diff --git a/docs/exec-plans/active/2026-09-02-youtube-playlist-import/delivery-state.md b/docs/exec-plans/active/2026-09-02-youtube-playlist-import/delivery-state.md new file mode 100644 index 00000000..acda058b --- /dev/null +++ b/docs/exec-plans/active/2026-09-02-youtube-playlist-import/delivery-state.md @@ -0,0 +1,12 @@ +# Delivery State + +- status: ci-pending +- branch: dev +- base: main +- issue: +- pr: https://github.com/Queuing-org/frontend/pull/60 +- selected_skills: queuing-feature-delivery, queuing-pr-review-cycle, queuing-api-boundary, queuing-ui-flow, frontend-architecture-guardrails, queuing-qa-reviewer +- local_qa: pass — follow-up targeted 5 files/39 tests, lint, full test 155 files/688 tests, build, diff-check, structured QA pass +- ci: previous head pass; follow-up head pending +- review_threads: none — 사용자 후속 actionable 요구 구현 완료, GitHub review threads 없음 +- next_action: Draft PR #60의 새 CI를 확인한다. diff --git a/docs/exec-plans/active/2026-09-02-youtube-playlist-import/plan.md b/docs/exec-plans/active/2026-09-02-youtube-playlist-import/plan.md new file mode 100644 index 00000000..4db95f72 --- /dev/null +++ b/docs/exec-plans/active/2026-09-02-youtube-playlist-import/plan.md @@ -0,0 +1,89 @@ +# YouTube 재생목록 신청 연결 + +## Scope + +- 노래 신청 URL을 단일 영상과 YouTube 재생목록으로 구분한다. +- 재생목록 URL은 원본 URL과 `youtubePlaylist: true`를 WebSocket payload로 전달한다. +- 단일 영상은 기존처럼 영상 ID와 `youtubePlaylist: false`를 전달한다. +- 노래 신청 UI에서 영상과 재생목록을 모두 지원한다고 안내한다. + +## Acceptance Criteria + +- `watch?v=...&list=...`는 현재 영상 한 곡이 아니라 재생목록으로 요청한다. +- `playlist?list=...`와 `youtu.be/...?...list=...`도 재생목록으로 요청한다. +- `list`가 없는 `watch?v=...`와 `youtu.be/...`는 단일 영상으로 유지한다. +- 비 YouTube URL과 비어 있거나 잘못된 `list`는 제출하지 않는다. +- 기존 성공·오류·캐시 갱신 흐름은 유지한다. + +## Selected Skills + +- queuing-feature-delivery +- queuing-pr-review-cycle +- queuing-api-boundary +- queuing-ui-flow +- frontend-architecture-guardrails +- queuing-qa-reviewer + +## API Contract + +- destination: `/app/room/{roomSlug}/playlist` +- 단일 영상: `{ videoId: string, youtubePlaylist: false, story: string | null }` +- 재생목록: `{ videoId: fullYoutubeUrl, youtubePlaylist: true, story: string | null }` +- backend source: `QueueVideoRequest.youtubePlaylist`, `YoutubePlaylistUrlParser` + +## Commit Slices + +1. `fix(playlist): 유튜브 재생목록 신청을 백엔드에 전달` +2. `docs(delivery): 재생목록 신청 게시 결과를 기록` + +## Progress + +- [x] frontend 입력 파서와 WebSocket payload 확인 +- [x] backend `youtubePlaylist` 계약 확인 +- [x] URL 판별·payload·안내 문구 구현 +- [x] targeted/full local verification +- [x] fresh read-only QA +- [x] feature commit (`d0915a2`) +- [x] docs commit, push, Draft PR #60 게시 + +## Verification + +- add-track model/hook/API targeted Vitest +- `npm run lint` +- `npm run test` +- `npm run build` +- `git diff --check` + +결과: + +- targeted Vitest: 5 files / 28 tests pass +- lint: pass +- full test: 155 files / 677 tests pass +- build: pass +- git diff --check: pass +- fresh read-only QA: pass, blocker 없음 + +## Residual Risk + +- 실제 YouTube 재생목록 조회 성공 여부와 최대 수집 범위는 backend와 YouTube 응답에 의존한다. +- `watch&list=...`처럼 `?`가 없는 비표준 URL과 `/shorts/...?...list=...`는 지원하지 않는다. + +## Follow-up: explicit playlist scope + +- [x] 사용자 후속 요구를 actionable finding으로 분류 +- [x] 재생목록 감지 시 현재 영상/재생목록 노래 함께 추가 선택 UI 제공 +- [x] 순수 재생목록 URL의 노래 추가 명시 선택과 제약 안내 +- [x] targeted/full verification 및 structured QA +- [x] 후속 feature commit (`5b21b8c`) +- [x] delivery docs commit과 Draft PR #60 push + +후속 검증 결과: + +- targeted Vitest: 5 files / 39 tests pass +- lint: pass +- full test 1차: 687/688 pass, 범위 밖 queue virtualization test 5초 timeout +- queue virtualization isolated rerun: 7/7 pass +- full test 2차: 155 files / 688 tests pass +- build: pass +- git diff --check: pass +- structured QA: pass, blocker 없음 diff --git a/docs/exec-plans/active/2026-09-02-youtube-playlist-import/qa-report.md b/docs/exec-plans/active/2026-09-02-youtube-playlist-import/qa-report.md new file mode 100644 index 00000000..e17e6d89 --- /dev/null +++ b/docs/exec-plans/active/2026-09-02-youtube-playlist-import/qa-report.md @@ -0,0 +1,41 @@ +# QA Report + +## Result + +- classification: pass +- blocking findings: none + +## Boundary Review + +- `watch?v=...&list=...`, `/playlist?list=...`, `youtu.be/...?...list=...`는 전체 URL과 `youtubePlaylist: true`로 변환한다. +- `list`가 없는 `watch?v=...`, `youtu.be/...`는 영상 ID와 `youtubePlaylist: false`를 유지한다. +- `list`가 존재하지만 값이나 경로가 잘못된 URL은 단일 영상으로 조용히 폴백하지 않는다. +- `useAddTrackForm`의 로컬 상태가 파싱 결과와 제출 가능 여부를 소유한다. +- `publishAddTrack`의 실제 STOMP body가 backend `QueueVideoRequest` 계약과 일치한다. +- 성공 후 room queue/playback invalidation과 오류 처리 흐름은 기존 동작을 유지한다. + +## Verification + +- targeted Vitest: 5 files / 28 tests pass +- `npm run lint`: pass +- `npm run test`: 155 files / 677 tests pass +- `npm run build`: pass +- `git diff --check`: pass +- fresh read-only reviewer: pass + +## Residual Risk + +- backend와 실제 YouTube API를 연결한 E2E는 수행하지 않았다. +- `https://www.youtube.com/watch&list=...`처럼 query 시작 문자인 `?`가 없는 URL은 표준 URL이 아니므로 거부한다. +- `/shorts/...?...list=...`와 embed/videoseries URL은 backend parser 계약 밖이라 지원하지 않는다. + +## Follow-up QA: explicit playlist scope + +- classification: pass +- blocking findings: none +- `watch?v=...&list=...`는 선택 전 request를 만들지 않고, 현재 영상 또는 재생목록 모드 선택을 payload에 반영한다. +- 순수 `/playlist?list=...`는 현재 영상 선택을 비활성화하고 재생목록 노래 추가를 명시적으로 선택해야 한다. +- URL 변경과 modal reset은 이전 선택을 초기화한다. +- `전체`라는 표현은 backend의 최대 두 페이지 계약과 다를 수 있어 `재생목록 노래도 함께 추가`로 교정했다. +- targeted 5 files / 39 tests, lint, full test 155 files / 688 tests, build, diff-check가 통과했다. +- 첫 full test의 범위 밖 virtualization timeout은 isolated 7/7과 두 번째 full 688/688 통과로 flaky임을 확인했다. diff --git a/docs/exec-plans/active/2026-09-02-youtube-playlist-import/review-findings.md b/docs/exec-plans/active/2026-09-02-youtube-playlist-import/review-findings.md new file mode 100644 index 00000000..f439c81a --- /dev/null +++ b/docs/exec-plans/active/2026-09-02-youtube-playlist-import/review-findings.md @@ -0,0 +1,17 @@ +# Review Findings + +## Actionable + +- [x] 사용자 후속 요구: 재생목록 URL을 입력했을 때 자동으로 여러 곡을 넣지 말고, `현재 영상만 추가`와 `재생목록 노래도 함께 추가` 중 하나를 사용자가 명시적으로 선택해야 한다. +- [x] 순수 `/playlist?list=...` URL은 현재 영상 ID가 없으므로 단일 영상 선택을 제공할 수 없다. 재생목록 노래 추가 선택을 명시적으로 요구하고 이 제약을 UI에 안내한다. + +## Resolved + +- PR #60 이전 head의 GitHub CI, Vercel, CodeRabbit check는 모두 통과했다. +- 기존 GitHub 리뷰와 인라인 요청 사항은 없다. +- URL 변경 시 이전 범위 선택을 초기화하고, 선택 전 제출을 차단하는 테스트를 추가했다. +- 후속 targeted/full test, lint, build, diff-check와 structured QA가 통과했다. + +## Conflict + +- 없음. diff --git a/docs/exec-plans/active/README.md b/docs/exec-plans/active/README.md index a6bab59e..d705c17b 100644 --- a/docs/exec-plans/active/README.md +++ b/docs/exec-plans/active/README.md @@ -1,5 +1,7 @@ # Active Execution Plans +- [2026-09-02-youtube-playlist-import](./2026-09-02-youtube-playlist-import/plan.md): ci-pending — Draft PR #60, 재생목록 감지 시 현재 영상/재생목록 노래 추가 선택을 요구하고 후속 로컬 QA 통과 + - [2026-09-01-room-self-participant-menu](./2026-09-01-room-self-participant-menu/plan.md): ci-pending — Draft PR #59, 내 참가자 첫 행·`(나)`·Settings/Friends modal과 reconnect identity QA 통과 - [2026-09-01-profile-room-request-polish](./2026-09-01-profile-room-request-polish/plan.md): ready — Draft PR #59, 최애곡 두 줄 표시·방 편집 치수·노래 신청 문구와 hover/타이포 구현 및 로컬·CI QA 통과 diff --git a/src/features/playlist/add-track/hooks/useAddTrackAction.test.tsx b/src/features/playlist/add-track/hooks/useAddTrackAction.test.tsx index 7cf28903..dfa425ef 100644 --- a/src/features/playlist/add-track/hooks/useAddTrackAction.test.tsx +++ b/src/features/playlist/add-track/hooks/useAddTrackAction.test.tsx @@ -69,7 +69,14 @@ describe("useAddTrackAction", () => { setErrorMessage: mocks.setErrorMessage, setIsSubmitting: mocks.setIsSubmitting, storyValue: "사연", - videoId: "video-id", + queueSource: { + kind: "video", + videoId: "video-id", + }, + queueRequest: { + videoId: "video-id", + youtubePlaylist: false, + }, } as unknown as ReturnType); vi.mocked(subscribeRoomEvents).mockReturnValue({ id: "room-events", @@ -109,6 +116,7 @@ describe("useAddTrackAction", () => { expect(publishAddTrack).toHaveBeenCalledWith("room", { story: "사연", videoId: "video-id", + youtubePlaylist: false, }); expect(result.current.isModalOpen).toBe(false); expect(mocks.reset).toHaveBeenCalled(); @@ -152,7 +160,8 @@ describe("useAddTrackAction", () => { setErrorMessage: mocks.setErrorMessage, setIsSubmitting: mocks.setIsSubmitting, storyValue: "", - videoId: null, + queueSource: null, + queueRequest: null, } as unknown as ReturnType); const { result } = renderAddTrackAction(); @@ -160,11 +169,11 @@ describe("useAddTrackAction", () => { expect(mocks.setError).toHaveBeenCalledWith( "url", - "올바른 유튜브 링크를 입력해주세요.", + "올바른 유튜브 영상 또는 재생목록 링크를 입력해주세요.", ); expect(mocks.notify).toHaveBeenCalledWith({ dedupeKey: "add-track:room:url", - message: "올바른 유튜브 링크를 입력해주세요.", + message: "올바른 유튜브 영상 또는 재생목록 링크를 입력해주세요.", tone: "error", }); }); @@ -176,7 +185,14 @@ describe("useAddTrackAction", () => { setErrorMessage: mocks.setErrorMessage, setIsSubmitting: mocks.setIsSubmitting, storyValue: "가".repeat(31), - videoId: "video-id", + queueSource: { + kind: "video", + videoId: "video-id", + }, + queueRequest: { + videoId: "video-id", + youtubePlaylist: false, + }, } as unknown as ReturnType); const { result } = renderAddTrackAction(); @@ -193,6 +209,90 @@ describe("useAddTrackAction", () => { }); }); + it("재생목록 URL은 원본 URL과 youtubePlaylist 옵션을 함께 보낸다", () => { + const playlistUrl = + "https://www.youtube.com/watch?v=current&list=PL_playlist-1"; + vi.mocked(useAddTrackForm).mockReturnValue({ + reset: mocks.reset, + setError: mocks.setError, + setErrorMessage: mocks.setErrorMessage, + setIsSubmitting: mocks.setIsSubmitting, + storyValue: "사연", + queueSource: { + currentVideoId: "current", + kind: "playlist", + playlistUrl, + }, + queueRequest: { + videoId: playlistUrl, + youtubePlaylist: true, + }, + } as unknown as ReturnType); + const { result } = renderAddTrackAction(); + + act(() => result.current.submit()); + + expect(publishAddTrack).toHaveBeenCalledWith("room", { + story: "사연", + videoId: playlistUrl, + youtubePlaylist: true, + }); + }); + + it("재생목록 추가 범위를 선택하지 않으면 요청하지 않고 선택 오류를 표시한다", () => { + const playlistUrl = + "https://www.youtube.com/watch?v=current&list=PL_playlist-1"; + vi.mocked(useAddTrackForm).mockReturnValue({ + reset: mocks.reset, + setError: mocks.setError, + setErrorMessage: mocks.setErrorMessage, + setIsSubmitting: mocks.setIsSubmitting, + storyValue: "", + queueSource: { + currentVideoId: "current", + kind: "playlist", + playlistUrl, + }, + queueRequest: null, + } as unknown as ReturnType); + const { result } = renderAddTrackAction(); + + act(() => result.current.submit()); + + expect(publishAddTrack).not.toHaveBeenCalled(); + expect(mocks.setError).toHaveBeenCalledWith( + "queueMode", + "현재 영상만 추가할지 재생목록 노래도 함께 추가할지 선택해주세요.", + ); + }); + + it("현재 영상이 없는 재생목록은 전체 추가 선택을 요구한다", () => { + const playlistUrl = + "https://www.youtube.com/playlist?list=PL_playlist-1"; + vi.mocked(useAddTrackForm).mockReturnValue({ + reset: mocks.reset, + setError: mocks.setError, + setErrorMessage: mocks.setErrorMessage, + setIsSubmitting: mocks.setIsSubmitting, + storyValue: "", + queueSource: { + currentVideoId: null, + kind: "playlist", + playlistUrl, + }, + queueRequest: null, + } as unknown as ReturnType); + const { result } = renderAddTrackAction(); + + act(() => result.current.submit()); + + expect(publishAddTrack).not.toHaveBeenCalled(); + expect(mocks.setError).toHaveBeenCalledWith( + "queueMode", + "재생목록 노래 추가 여부를 선택해주세요.", + ); + }); + it("곡 길이 제한 WebSocket 오류는 URL 필드와 빨간 알림에 연결한다", () => { let userEventHandler: ((message: { body: string }) => void) | undefined; vi.mocked(subscribeUserRoomEvents).mockImplementation((handler) => { diff --git a/src/features/playlist/add-track/hooks/useAddTrackAction.ts b/src/features/playlist/add-track/hooks/useAddTrackAction.ts index 58bbef87..5cb3f8af 100644 --- a/src/features/playlist/add-track/hooks/useAddTrackAction.ts +++ b/src/features/playlist/add-track/hooks/useAddTrackAction.ts @@ -174,7 +174,7 @@ export function useAddTrackAction(slug: string, roomAccessToken: string) { }; const showError = ( - field: "url" | "story" | "form", + field: "url" | "queueMode" | "story" | "form", message: string, ) => { form.setError(field, message); @@ -186,11 +186,29 @@ export function useAddTrackAction(slug: string, roomAccessToken: string) { }; const submit = () => { - if (!form.videoId) { - showError("url", "올바른 유튜브 링크를 입력해주세요."); + if (!form.queueSource) { + showError( + "url", + "올바른 유튜브 영상 또는 재생목록 링크를 입력해주세요.", + ); + return; + } + + if (!form.queueRequest) { + const hasCurrentVideo = + form.queueSource.kind === "playlist" && + Boolean(form.queueSource.currentVideoId); + showError( + "queueMode", + hasCurrentVideo + ? "현재 영상만 추가할지 재생목록 노래도 함께 추가할지 선택해주세요." + : "재생목록 노래 추가 여부를 선택해주세요.", + ); return; } + const queueRequest = form.queueRequest; + const story = form.storyValue.trim(); if (story.length > ADD_TRACK_STORY_MAX_LENGTH) { showError("story", "노래 선정 이유는 30자 이하로 입력해주세요."); @@ -271,12 +289,13 @@ export function useAddTrackAction(slug: string, roomAccessToken: string) { pendingAddTrackRef.current = { timeoutId, - videoId: form.videoId, + videoId: queueRequest.videoId, }; publishAddTrack(normalizedSlug, { story: story ? story : null, - videoId: form.videoId, + videoId: queueRequest.videoId, + youtubePlaylist: queueRequest.youtubePlaylist, }); } catch (error) { clearPendingAddTrack(); diff --git a/src/features/playlist/add-track/hooks/useAddTrackForm.test.tsx b/src/features/playlist/add-track/hooks/useAddTrackForm.test.tsx index 8af9eec1..1ab9b47f 100644 --- a/src/features/playlist/add-track/hooks/useAddTrackForm.test.tsx +++ b/src/features/playlist/add-track/hooks/useAddTrackForm.test.tsx @@ -6,6 +6,80 @@ import { } from "./useAddTrackForm"; describe("useAddTrackForm", () => { + it("재생목록 URL은 추가 범위를 선택하기 전까지 제출 요청을 만들지 않는다", () => { + const { result } = renderHook(() => useAddTrackForm()); + const playlistUrl = + "https://www.youtube.com/watch?v=current&list=PL_playlist-1"; + + act(() => result.current.updateInputValue(playlistUrl)); + + expect(result.current.canSubmit).toBe(false); + expect(result.current.queueSource).toEqual({ + currentVideoId: "current", + kind: "playlist", + playlistUrl, + }); + expect(result.current.queueMode).toBeNull(); + expect(result.current.queueRequest).toBeNull(); + }); + + it("시청 재생목록은 현재 영상 또는 전체 재생목록을 명시적으로 선택한다", () => { + const { result } = renderHook(() => useAddTrackForm()); + const playlistUrl = + "https://www.youtube.com/watch?v=current&list=PL_playlist-1"; + + act(() => result.current.updateInputValue(playlistUrl)); + act(() => result.current.updateQueueMode("single")); + + expect(result.current.queueRequest).toEqual({ + videoId: "current", + youtubePlaylist: false, + }); + + act(() => result.current.updateQueueMode("playlist")); + + expect(result.current.queueRequest).toEqual({ + videoId: playlistUrl, + youtubePlaylist: true, + }); + }); + + it("순수 재생목록 URL은 현재 영상 선택을 거부하고 전체 추가만 허용한다", () => { + const { result } = renderHook(() => useAddTrackForm()); + const playlistUrl = + "https://www.youtube.com/playlist?list=PL_playlist-1"; + + act(() => result.current.updateInputValue(playlistUrl)); + act(() => result.current.updateQueueMode("single")); + expect(result.current.queueMode).toBeNull(); + expect(result.current.queueRequest).toBeNull(); + + act(() => result.current.updateQueueMode("playlist")); + expect(result.current.queueRequest).toEqual({ + videoId: playlistUrl, + youtubePlaylist: true, + }); + }); + + it("URL을 바꾸면 이전 재생목록 범위 선택을 초기화한다", () => { + const { result } = renderHook(() => useAddTrackForm()); + + act(() => + result.current.updateInputValue( + "https://www.youtube.com/watch?v=first&list=PL_playlist-1", + ), + ); + act(() => result.current.updateQueueMode("playlist")); + act(() => + result.current.updateInputValue( + "https://www.youtube.com/watch?v=second&list=PL_playlist-2", + ), + ); + + expect(result.current.queueMode).toBeNull(); + expect(result.current.queueRequest).toBeNull(); + }); + it("30자를 넘긴 사연을 유지해 제출 검증이 오류를 표시할 수 있게 한다", () => { const { result } = renderHook(() => useAddTrackForm()); diff --git a/src/features/playlist/add-track/hooks/useAddTrackForm.ts b/src/features/playlist/add-track/hooks/useAddTrackForm.ts index 2cb74bb9..cfddea1d 100644 --- a/src/features/playlist/add-track/hooks/useAddTrackForm.ts +++ b/src/features/playlist/add-track/hooks/useAddTrackForm.ts @@ -1,10 +1,19 @@ "use client"; import { useMemo, useState } from "react"; -import { extractYouTubeVideoId } from "../model/extractYouTubeVideoId"; +import { + createYouTubeQueueRequest, + parseYouTubeQueueSource, + type YouTubeQueueMode, +} from "../model/parseYouTubeQueueSource"; export const ADD_TRACK_STORY_MAX_LENGTH = 30; -export type AddTrackErrorField = "url" | "story" | "form" | null; +export type AddTrackErrorField = + | "url" + | "queueMode" + | "story" + | "form" + | null; export function useAddTrackForm() { const [inputValue, setInputValue] = useState(""); @@ -12,14 +21,40 @@ export function useAddTrackForm() { const [errorMessage, setErrorMessage] = useState(""); const [errorField, setErrorField] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const videoId = useMemo( - () => extractYouTubeVideoId(inputValue), + const [queueMode, setQueueMode] = useState(null); + const queueSource = useMemo( + () => parseYouTubeQueueSource(inputValue), [inputValue], ); + const queueRequest = useMemo( + () => createYouTubeQueueRequest(queueSource, queueMode), + [queueMode, queueSource], + ); const updateInputValue = (value: string) => { setInputValue(value); - if (errorField === "url" || errorField === "form") { + setQueueMode(null); + if ( + errorField === "url" || + errorField === "queueMode" || + errorField === "form" + ) { + setErrorMessage(""); + setErrorField(null); + } + }; + + const updateQueueMode = (mode: YouTubeQueueMode) => { + if ( + mode === "single" && + queueSource?.kind === "playlist" && + !queueSource.currentVideoId + ) { + return; + } + + setQueueMode(mode); + if (errorField === "queueMode" || errorField === "form") { setErrorMessage(""); setErrorField(null); } @@ -39,14 +74,17 @@ export function useAddTrackForm() { setErrorMessage(""); setErrorField(null); setIsSubmitting(false); + setQueueMode(null); }; return { - canSubmit: Boolean(videoId), + canSubmit: Boolean(queueRequest), errorMessage, errorField, inputValue, isSubmitting, + queueMode, + queueRequest, reset, setErrorMessage, setError: (field: AddTrackErrorField, message: string) => { @@ -58,7 +96,8 @@ export function useAddTrackForm() { storyMaxLength: ADD_TRACK_STORY_MAX_LENGTH, storyValue, updateInputValue, + updateQueueMode, updateStoryValue, - videoId, + queueSource, }; } diff --git a/src/features/playlist/add-track/model/extractYouTubeVideoId.ts b/src/features/playlist/add-track/model/extractYouTubeVideoId.ts deleted file mode 100644 index 7f57bc91..00000000 --- a/src/features/playlist/add-track/model/extractYouTubeVideoId.ts +++ /dev/null @@ -1,37 +0,0 @@ -function parseUrl(input: string) { - const trimmedInput = input.trim(); - if (!trimmedInput) { - return null; - } - - try { - return new URL(trimmedInput); - } catch { - try { - return new URL(`https://${trimmedInput}`); - } catch { - return null; - } - } -} - -export function extractYouTubeVideoId(input: string): string | null { - const parsedUrl = parseUrl(input); - if (!parsedUrl) { - return null; - } - - const hostname = parsedUrl.hostname.replace(/^www\./, ""); - - if (hostname === "youtube.com" && parsedUrl.pathname === "/watch") { - const videoId = parsedUrl.searchParams.get("v")?.trim(); - return videoId || null; - } - - if (hostname === "youtu.be") { - const videoId = parsedUrl.pathname.split("/").filter(Boolean)[0]?.trim(); - return videoId || null; - } - - return null; -} diff --git a/src/features/playlist/add-track/model/parseYouTubeQueueSource.test.ts b/src/features/playlist/add-track/model/parseYouTubeQueueSource.test.ts new file mode 100644 index 00000000..6dac487c --- /dev/null +++ b/src/features/playlist/add-track/model/parseYouTubeQueueSource.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + createYouTubeQueueRequest, + parseYouTubeQueueSource, +} from "./parseYouTubeQueueSource"; + +describe("parseYouTubeQueueSource", () => { + it.each([ + ["https://www.youtube.com/watch?v=video-id", "video-id"], + ["https://youtu.be/short-video", "short-video"], + ["m.youtube.com/watch?v=mobile-video", "mobile-video"], + ])("단일 영상 URL은 영상 ID로 변환한다", (input, videoId) => { + expect(parseYouTubeQueueSource(input)).toEqual({ + kind: "video", + videoId, + }); + }); + + it.each([ + "https://www.youtube.com/watch?v=current-video&list=PL_playlist-1&index=4", + "https://www.youtube.com/playlist?list=PL_playlist-1", + "https://youtu.be/current-video?list=PL_playlist-1", + ])("list가 있는 URL은 선택 가능한 재생목록으로 해석한다", (input) => { + const source = parseYouTubeQueueSource(input); + + expect(source).toMatchObject({ + kind: "playlist", + playlistUrl: input, + }); + }); + + it("시청 URL의 현재 영상 ID를 재생목록과 함께 유지한다", () => { + expect( + parseYouTubeQueueSource( + "https://www.youtube.com/watch?v=current-video&list=PL_playlist-1", + ), + ).toEqual({ + currentVideoId: "current-video", + kind: "playlist", + playlistUrl: + "https://www.youtube.com/watch?v=current-video&list=PL_playlist-1", + }); + }); + + it("순수 재생목록 URL은 현재 영상이 없다고 표시한다", () => { + expect( + parseYouTubeQueueSource( + "https://www.youtube.com/playlist?list=PL_playlist-1", + ), + ).toEqual({ + currentVideoId: null, + kind: "playlist", + playlistUrl: "https://www.youtube.com/playlist?list=PL_playlist-1", + }); + }); + + it("스킴이 없는 재생목록 URL은 backend가 해석할 수 있는 URL로 정규화한다", () => { + expect( + parseYouTubeQueueSource("youtube.com/playlist?list=PL_playlist-1"), + ).toEqual({ + currentVideoId: null, + kind: "playlist", + playlistUrl: "https://youtube.com/playlist?list=PL_playlist-1", + }); + }); + + it("시청 재생목록은 선택에 따라 현재 영상 또는 전체 URL 요청을 만든다", () => { + const source = parseYouTubeQueueSource( + "https://www.youtube.com/watch?v=current-video&list=PL_playlist-1", + ); + + expect(createYouTubeQueueRequest(source, null)).toBeNull(); + expect(createYouTubeQueueRequest(source, "single")).toEqual({ + videoId: "current-video", + youtubePlaylist: false, + }); + expect(createYouTubeQueueRequest(source, "playlist")).toEqual({ + videoId: + "https://www.youtube.com/watch?v=current-video&list=PL_playlist-1", + youtubePlaylist: true, + }); + }); + + it("현재 영상이 없는 재생목록은 단일 영상 요청을 만들지 않는다", () => { + const source = parseYouTubeQueueSource( + "https://www.youtube.com/playlist?list=PL_playlist-1", + ); + + expect(createYouTubeQueueRequest(source, "single")).toBeNull(); + expect(createYouTubeQueueRequest(source, "playlist")).toEqual({ + videoId: "https://www.youtube.com/playlist?list=PL_playlist-1", + youtubePlaylist: true, + }); + }); + + it.each([ + "https://example.com/watch?v=video-id", + "https://www.youtube.com/playlist", + "https://www.youtube.com/watch?v=current-video&list=", + "https://www.youtube.com/shorts/current-video?list=PL_playlist-1", + "https://www.youtube.com/watch&list=PL_playlist-1", + ])("지원하지 않거나 잘못된 URL은 거부한다", (input) => { + expect(parseYouTubeQueueSource(input)).toBeNull(); + }); +}); diff --git a/src/features/playlist/add-track/model/parseYouTubeQueueSource.ts b/src/features/playlist/add-track/model/parseYouTubeQueueSource.ts new file mode 100644 index 00000000..17c522d9 --- /dev/null +++ b/src/features/playlist/add-track/model/parseYouTubeQueueSource.ts @@ -0,0 +1,123 @@ +export type YouTubeQueueMode = "single" | "playlist"; + +export type YouTubeQueueSource = + | { + kind: "video"; + videoId: string; + } + | { + currentVideoId: string | null; + kind: "playlist"; + playlistUrl: string; + }; + +export type YouTubeQueueRequest = { + videoId: string; + youtubePlaylist: boolean; +}; + +const YOUTUBE_PLAYLIST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +function parseUrl(input: string) { + const trimmedInput = input.trim(); + if (!trimmedInput) { + return null; + } + + try { + return new URL(trimmedInput); + } catch { + try { + return new URL(`https://${trimmedInput}`); + } catch { + return null; + } + } +} + +function isYouTubeHost(hostname: string) { + return hostname === "youtube.com" || hostname.endsWith(".youtube.com"); +} + +function isPlaylistPath(hostname: string, pathname: string) { + return ( + hostname === "youtu.be" || pathname === "/watch" || pathname === "/playlist" + ); +} + +function getCurrentVideoId(parsedUrl: URL, hostname: string) { + if (isYouTubeHost(hostname) && parsedUrl.pathname === "/watch") { + return parsedUrl.searchParams.get("v")?.trim() || null; + } + + if (hostname === "youtu.be") { + return parsedUrl.pathname.split("/").filter(Boolean)[0]?.trim() || null; + } + + return null; +} + +export function parseYouTubeQueueSource( + input: string, +): YouTubeQueueSource | null { + const parsedUrl = parseUrl(input); + if (!parsedUrl) { + return null; + } + + const hostname = parsedUrl.hostname.toLowerCase(); + if (!isYouTubeHost(hostname) && hostname !== "youtu.be") { + return null; + } + + if (parsedUrl.searchParams.has("list")) { + const playlistId = parsedUrl.searchParams.get("list")?.trim() ?? ""; + if ( + !isPlaylistPath(hostname, parsedUrl.pathname) || + !YOUTUBE_PLAYLIST_ID_PATTERN.test(playlistId) + ) { + return null; + } + + return { + currentVideoId: getCurrentVideoId(parsedUrl, hostname), + kind: "playlist", + playlistUrl: parsedUrl.toString(), + }; + } + + const videoId = getCurrentVideoId(parsedUrl, hostname); + return videoId ? { kind: "video", videoId } : null; +} + +export function createYouTubeQueueRequest( + source: YouTubeQueueSource | null, + mode: YouTubeQueueMode | null, +): YouTubeQueueRequest | null { + if (!source) { + return null; + } + + if (source.kind === "video") { + return { + videoId: source.videoId, + youtubePlaylist: false, + }; + } + + if (mode === "playlist") { + return { + videoId: source.playlistUrl, + youtubePlaylist: true, + }; + } + + if (mode === "single" && source.currentVideoId) { + return { + videoId: source.currentVideoId, + youtubePlaylist: false, + }; + } + + return null; +} diff --git a/src/features/playlist/add-track/ui/AddTrackAction.tsx b/src/features/playlist/add-track/ui/AddTrackAction.tsx index 8061b72f..5b8ad01a 100644 --- a/src/features/playlist/add-track/ui/AddTrackAction.tsx +++ b/src/features/playlist/add-track/ui/AddTrackAction.tsx @@ -68,8 +68,15 @@ export default function AddTrackAction({ storyValue={form.storyValue} errorMessage={form.errorMessage} errorField={form.errorField} + playlistDetected={form.queueSource?.kind === "playlist"} + queueMode={form.queueMode} + singleTrackAvailable={ + form.queueSource?.kind === "playlist" && + Boolean(form.queueSource.currentVideoId) + } onChange={form.updateInputValue} onClose={action.closeModal} + onQueueModeChange={form.updateQueueMode} onStoryChange={form.updateStoryValue} onSubmit={action.submit} /> diff --git a/src/features/playlist/add-track/ui/AddTrackFormFields.test.tsx b/src/features/playlist/add-track/ui/AddTrackFormFields.test.tsx index 6cc0bc8f..3ec5df5c 100644 --- a/src/features/playlist/add-track/ui/AddTrackFormFields.test.tsx +++ b/src/features/playlist/add-track/ui/AddTrackFormFields.test.tsx @@ -1,14 +1,25 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import AddTrackFormFields from "./AddTrackFormFields"; -function renderFields(errorField: "url" | "story") { +function renderFields( + errorField: "url" | "queueMode" | "story", + options: { + playlistDetected?: boolean; + singleTrackAvailable?: boolean; + } = {}, +) { + const onQueueModeChange = vi.fn(); render( , ); + return { onQueueModeChange }; } describe("AddTrackFormFields", () => { @@ -30,11 +42,21 @@ describe("AddTrackFormFields", () => { ).toBeInTheDocument(); }); + it("영상과 재생목록 URL을 모두 지원한다고 안내한다", () => { + renderFields("url"); + + expect( + screen.getByPlaceholderText( + "함께 듣고 싶은 영상 또는 재생목록 URL을 붙여넣으세요", + ), + ).toBeInTheDocument(); + }); + it("URL 오류를 URL 입력의 aria-invalid와 SR 설명에 연결한다", () => { renderFields("url"); const input = screen.getByPlaceholderText( - "함께 듣고 싶은 영상의 URL을 붙여넣으세요", + "함께 듣고 싶은 영상 또는 재생목록 URL을 붙여넣으세요", ); expect(input).toHaveAttribute("aria-invalid", "true"); expect(input).toHaveAttribute("aria-describedby", "add-track-error"); @@ -49,4 +71,43 @@ describe("AddTrackFormFields", () => { expect(textarea).toHaveAttribute("aria-invalid", "true"); expect(textarea).toHaveAttribute("aria-describedby", "add-track-error"); }); + + it("시청 재생목록은 현재 영상과 전체 재생목록 선택을 표시한다", () => { + const { onQueueModeChange } = renderFields("queueMode", { + playlistDetected: true, + singleTrackAvailable: true, + }); + + const currentVideo = screen.getByRole("radio", { + name: "현재 영상만 추가", + }); + const wholePlaylist = screen.getByRole("radio", { + name: /재생목록 노래도 함께 추가/, + }); + expect(currentVideo).toBeEnabled(); + expect(wholePlaylist).toBeEnabled(); + + fireEvent.click(currentVideo); + expect(onQueueModeChange).toHaveBeenCalledWith("single"); + fireEvent.click(wholePlaylist); + expect(onQueueModeChange).toHaveBeenCalledWith("playlist"); + }); + + it("현재 영상이 없는 재생목록은 단일 영상 선택을 비활성화한다", () => { + renderFields("queueMode", { + playlistDetected: true, + singleTrackAvailable: false, + }); + + expect( + screen.getByRole("radio", { name: /현재 영상만 추가/ }), + ).toBeDisabled(); + expect( + screen.getByText("현재 영상 정보가 없는 링크입니다."), + ).toBeInTheDocument(); + expect(screen.getByRole("group")).toHaveAttribute( + "aria-describedby", + "add-track-error", + ); + }); }); diff --git a/src/features/playlist/add-track/ui/AddTrackFormFields.tsx b/src/features/playlist/add-track/ui/AddTrackFormFields.tsx index 8fa863b3..43a1c96e 100644 --- a/src/features/playlist/add-track/ui/AddTrackFormFields.tsx +++ b/src/features/playlist/add-track/ui/AddTrackFormFields.tsx @@ -2,28 +2,37 @@ import styles from "./AddTrackModal.module.css"; import type { AddTrackErrorField } from "../hooks/useAddTrackForm"; +import type { YouTubeQueueMode } from "../model/parseYouTubeQueueSource"; type AddTrackFormFieldsProps = { errorMessage: string; errorField: AddTrackErrorField; + playlistDetected: boolean; + queueMode: YouTubeQueueMode | null; + singleTrackAvailable: boolean; storyLength: number; storyMaxLength: number; storyValue: string; submitting: boolean; value: string; onChange: (value: string) => void; + onQueueModeChange: (mode: YouTubeQueueMode) => void; onStoryChange: (value: string) => void; }; export default function AddTrackFormFields({ errorMessage, errorField, + playlistDetected, + queueMode, + singleTrackAvailable, storyLength, storyMaxLength, storyValue, submitting, value, onChange, + onQueueModeChange, onStoryChange, }: AddTrackFormFieldsProps) { return ( @@ -44,7 +53,7 @@ export default function AddTrackFormFields({ type="url" value={value} onChange={(event) => onChange(event.target.value)} - placeholder="함께 듣고 싶은 영상의 URL을 붙여넣으세요" + placeholder="함께 듣고 싶은 영상 또는 재생목록 URL을 붙여넣으세요" className={styles.input} disabled={submitting} autoFocus @@ -53,6 +62,61 @@ export default function AddTrackFormFields({ /> + {playlistDetected ? ( +
+ 어떻게 추가할까요? +
+ + +
+
+ ) : null} +