Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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를 확인한다.
89 changes: 89 additions & 0 deletions docs/exec-plans/active/2026-09-02-youtube-playlist-import/plan.md
Original file line number Diff line number Diff line change
@@ -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 없음
Original file line number Diff line number Diff line change
@@ -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임을 확인했다.
Original file line number Diff line number Diff line change
@@ -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

- 없음.
2 changes: 2 additions & 0 deletions docs/exec-plans/active/README.md
Original file line number Diff line number Diff line change
@@ -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 통과
Expand Down
110 changes: 105 additions & 5 deletions src/features/playlist/add-track/hooks/useAddTrackAction.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useAddTrackForm>);
vi.mocked(subscribeRoomEvents).mockReturnValue({
id: "room-events",
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -152,19 +160,20 @@ describe("useAddTrackAction", () => {
setErrorMessage: mocks.setErrorMessage,
setIsSubmitting: mocks.setIsSubmitting,
storyValue: "",
videoId: null,
queueSource: null,
queueRequest: null,
} as unknown as ReturnType<typeof useAddTrackForm>);
const { result } = renderAddTrackAction();

act(() => result.current.submit());

expect(mocks.setError).toHaveBeenCalledWith(
"url",
"올바른 유튜브 링크를 입력해주세요.",
"올바른 유튜브 영상 또는 재생목록 링크를 입력해주세요.",
);
expect(mocks.notify).toHaveBeenCalledWith({
dedupeKey: "add-track:room:url",
message: "올바른 유튜브 링크를 입력해주세요.",
message: "올바른 유튜브 영상 또는 재생목록 링크를 입력해주세요.",
tone: "error",
});
});
Expand All @@ -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<typeof useAddTrackForm>);
const { result } = renderAddTrackAction();

Expand All @@ -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<typeof useAddTrackForm>);
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<typeof useAddTrackForm>);
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<typeof useAddTrackForm>);
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) => {
Expand Down
29 changes: 24 additions & 5 deletions src/features/playlist/add-track/hooks/useAddTrackAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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자 이하로 입력해주세요.");
Expand Down Expand Up @@ -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();
Expand Down
Loading