Playlist — 음원→라이브러리→플레이리스트 2계층의 최상위 층 · 운영사 편집 (#054·#058)
SPEC #054·#058 정합. BE 계약 + /sync-api 타입 생성. 운영사 백오피스(apps/admin) /playlists
화면(목록·생성·상세 라이브러리 편집·이름수정·삭제·기본 PL 지정/해제)이 라이브.
Overview
플레이리스트 = 라이브러리 묶음(OPERATOR 소유, hqId 본사별). 음악 2계층(음원→라이브러리→
플레이리스트)의 최상위 층으로, 운영사(OPERATOR)가 본사별 플레이리스트를 만들고 라이브러리를
순서대로 담는다. 곡(music)을 직접 담지 않고 라이브러리 단위로 담는다. 이 슬라이스는 플레이리스트
CRUD + 라이브러리 담기/제거/순서까지다. 매장 적용은 도착(#055, F1) — Store 상세
활성 PL 섹션. 상태 파생 4종(#060) 도착 — EMPTY/UNUSED/ACTIVE/FALLBACK 배지(MISMATCH 만 플랜 게이팅 후속). 본사 조회·점장 큐는 후속.
- 플레이리스트 = 라이브러리 묶음 —
playlist(name·hqId소유·soft-delete).playlist_library(playlistId·libraryId·position). 곡 직접 안 담음 — 라이브러리 경유. - hqId 소유 — 운영사가 hqId 를 지정해 본사별 플레이리스트를 생성/편집한다(핸드오프 §8-5 “운영사가
본사들의 PL 관리”).
hqId는 생성 시 고정(이후 불변 — 수정은 이름만). - 라이브러리 담기 = position 순서(M:N) — 담기는 멱등(이미 담긴 라이브러리 무시, 끝에 append —
unique(playlist,library)), 제거도 멱등(담겨 있지 않아도 성공). 라이브러리 자체는 유지. 순서 변경
(reorder)은 현재 담긴 라이브러리와 정확히 일치하는 전체
libraryIds[]를 받는다. - 소프트삭제 — 플레이리스트 삭제(
deletePlaylist→ 204)는deleted_at만 채운다. - 셔플 컬럼 없음 — 셔플은 점장 큐 생성 시 서버(기획서 §4-4). PL 엔티티엔 라이브러리 순서(position)만.
- 기본 PL = 본사당 1개(#058) —
playlist.is_default(V19, partial unique index(hq_id) WHERE is_default AND deleted_at IS NULL). 운영사가 본사별 기본 PL 을 지정/해제한다. 매장에 활성 PL 이 없을 때 점장 큐가 그 본사 기본 PL 로 fallback 하는 기준(기획서 §4-5).PlaylistResponse·PlaylistListItem·HqPlaylist*에isDefault노출(본사·운영사가 어느 PL 이 기본인지 확인). - 상태 파생 4종(#060) —
PlaylistStatus{EMPTY,UNUSED,ACTIVE,FALLBACK}. DB 저장 없는 응답 계산값으로derivePlaylistStatus(libraryCount, appliedStoreCount, isDefault)(BE 단일 함수, 운영사·본사 공유)가 배타적 우선순위 EMPTY > FALLBACK > ACTIVE > UNUSED 로 판정한다. 운영사 응답엔 #060 부터appliedStoreCount(그 PL 의 hqId 매장 중 active=PL 수)·status가 추가됐다. 본사 응답은 기보유 집계로 동일 파생. FE 는 표시만(서버 파생값 신뢰). 상태 배지 라벨/톤:ACTIVE → "사용 중"(success) ·FALLBACK → "기본"(info) ·UNUSED → "미사용"(muted) ·EMPTY → "비어있음"(warn). MISMATCH(플랜 불일치)는 플랜 게이팅 후속(#060 F1).
BE 계약
| operationId | 메서드 · 경로 | 요청 | 성공 |
|---|---|---|---|
createPlaylist | POST /api/v1/admin/playlists | {hqId, name(1..255)} | 201 PlaylistResponse(라이브러리 수 0) |
listPlaylists | GET /api/v1/admin/playlists | query q?(max 100) · hqId? · page(=0) · size(=20, 1..100 clamp) | 200 PlaylistListResponse |
getPlaylist | GET /api/v1/admin/playlists/:id | path id | 200 PlaylistResponse(라이브러리 수 포함) |
updatePlaylist | PATCH /api/v1/admin/playlists/:id | {name} (hqId 불변) | 200 PlaylistResponse |
deletePlaylist | DELETE /api/v1/admin/playlists/:id | path id | 204 No Content (소프트삭제) |
listPlaylistLibraries | GET /api/v1/admin/playlists/:id/libraries | path id | 200 PlaylistLibraryListResponse(position 순서) |
addPlaylistLibrary | POST /api/v1/admin/playlists/:id/libraries | {libraryId} (멱등, 끝에 append) | 200/204 |
removePlaylistLibrary | DELETE /api/v1/admin/playlists/:id/libraries/:libraryId | path id·libraryId (멱등) | 204 No Content |
reorderPlaylistLibraries | PUT /api/v1/admin/playlists/:id/libraries/order | {libraryIds[](0..500)} (현재 담긴 것과 정확히 일치) | 200 |
setDefaultPlaylist (#058) | PUT /api/v1/admin/playlists/:id/default | — (본문 없음) | 200 PlaylistResponse(isDefault=true) |
clearDefaultPlaylist (#058) | DELETE /api/v1/admin/playlists/:id/default | — (본문 없음) | 204 No Content (멱등) |
- 인가: OPERATOR-only(
/api/v1/admin/**=hasRole("OPERATOR")). - 정렬 고정 — 플레이리스트 목록
created_at DESC, 담긴 라이브러리 목록position ASC. 클라이언트 정렬 없음. - 활성만 —
deleted_at IS NULL플레이리스트만 노출.total은 활성 전체 건수. - 목록 항목(
PlaylistListItem)은 라이브러리 수·본사명 미포함 — 라이브러리 수(libraryCount)는 상세 (getPlaylist) 응답에만 포함. 본사명은 UI 가 dropdown 본사 목록(listHqs)으로 hqId→name 해석한다. - DTO 상세는 DTOs · Playlist · endpoint 표는 Endpoints · Admin Playlist 참조.
에러 / 제한
- 플레이리스트 미존재·soft-deleted → 404
PLAYLIST_NOT_FOUND(존재 은닉). addPlaylistLibrary라이브러리 미존재 → 404LIBRARY_NOT_FOUND.createPlaylist본사 미존재 → 404HQ_NOT_FOUND.- 이름 1..255 위반 → 400
PLAYLIST_INVALID_FIELD/VALIDATION_ERROR. - 에러 카탈로그: Error Codes.
운영사 플레이리스트 UI (apps/admin)
목록/생성 관용구는 라이브러리(#053)·본사 목록(#045)을 미러한다(ops-playlists.jsx 의 본사 그룹·
커버리지·미리듣기·최종편집·본사시점·mismatch 는 후속). 상세·편집은 전용 시안 정합
(design_handoff_linkmusic_7/design/screens/ops-playlist-detail.jsx)으로 교체됐다 — 아이콘 칩 + 제목 +
상태/기본 배지 + 지표 한눈(담긴 라이브러리·적용 매장) 헤더, EMPTY 강조 배너, 상태 4종 범례 스트립
(mismatch 확장 슬롯 주석만), 순서 컬럼이 좌측인 라이브러리 표 + 순서 변경 즉시저장 footer.
시안의 곡수·재생시간·커버·미리듣기·본사시점·mismatch 상태는 BE 데이터에 없어 표시하지 않는다(상태 4종만).
사이드바 /libraries 다음에 /playlists 항목 추가.
- 목록(
/playlists) — generateduseListPlaylists(react-query, client query) fetch. 컬럼: 이름(+ 기본 배지/상태 배지) · 상태(#060) · 소속 본사명 · 적용 매장 수(#060appliedStoreCount) · 생성일(KST) · 행 액션(상세·기본으로 지정/기본 해제·이름수정·삭제). 이름 검색(q, max 100)·본사 필터·페이지네이션을 client state 로 보유하고(applied/draft 분리), 생성/수정/삭제·기본 지정/해제 mutation 성공 시 현재 params 의getListPlaylistsQueryKey를 invalidate 해 즉시 갱신한다. 소속 본사명은 dropdown 전용useListHqs로 hqId→name 맵을 만들어 표기하고(본사 필터 옵션도 공유). 공용ListToolbar·ListPagination·ListNoResults재사용. - 기본 지정/해제(#058) — 행·상세 헤더의 [기본으로 지정]/[기본 해제] 토글이
PlaylistDefaultDialog(1-step 확인 — 가역적이라 삭제 같은 2-step 없음)를 열어useSetDefaultPlaylist(PUT, set)·useClearDefaultPlaylist(DELETE, clear)를 호출한다.isDefault로 토글 라벨·다이얼로그 모드를 결정한다. 본사당 1개라 다른 PL 을 지정하면 서버가 이전 기본을 원자적 해제 → 목록 invalidate 로 한 행만 기본 배지가 남는다. 상세는 server-fetch 한detail.isDefault갱신을 위해 성공 시router.refresh(). 기본 배지는 공용StatusPill(tone=info, dot). - 생성 — 헤더 [플레이리스트 생성] → 다이얼로그(본사 dropdown + 이름) →
createPlaylist. 본사는 생성 시 고정(이후 불변). 빈 플레이리스트가 만들어지고 라이브러리는 상세에서 담는다. - 상세(
/playlists/[id], 전용 시안 정합) — server component 가getPlaylist(개요+라이브러리 수)를 fetch 해 client 에 내려준다(refresh-aware, 404 → notFound). 헤더는 아이콘 칩 + 제목 + 상태/기본 배지 + 지표 한눈(담긴 라이브러리 N개 · 적용 매장 N개) + 액션(담기·기본 토글·이름수정·삭제). status=EMPTY 면 헤더 아래 강조 배너(재생 불가 — 기본 PL 이면 fallback 누락 함의 추가)와 [라이브러리 담기] 액션. 그 아래 상태 4종 범례 스트립(비어있음·기본·사용 중·미사용 + 향후 ‘플랜 불일치’ 확장 슬롯 주석 — 미구현). 담긴 라이브러리 목록(순서)은 generateduseListPlaylistLibrariesclient query 로 fetch — 순서 컬럼이 좌측인 표(순서·라이브러리·타입·추가일·관리) + “순서 변경은 즉시 저장됩니다” footer.- 담기 — [라이브러리 담기] → 다이얼로그가
useListLibraries(검색)로 보유 라이브러리를 고르게 하고(이미 담긴 것 제외)addPlaylistLibrary({libraryId})로 끝에 append. 곡이 아니라 라이브러리 단위. - 제거 — 행 [제거] → 1-step 확인 다이얼로그(info 배너: 라이브러리 자체는 보존, 담기만 해제) →
removePlaylistLibrary({id, libraryId}, 멱등). 라이브러리 영구 삭제가 아니므로 PL 삭제(2-step)와 파급 차등. - 순서 변경 — 행 [위로]/[아래로] 칩 버튼 → 현재 순서에서 한 칸 swap 한 전체
libraryIds[]를reorderPlaylistLibraries로 일괄 전송(부분 swap 아님 — 계약은 전체 순서를 받는다). - 담기/제거/순서 성공 시
getListPlaylistLibrariesQueryKeyinvalidate. 헤더에서 이름수정 (updatePlaylist, 성공 시router.refresh(); 다이얼로그에 소속 본사 잠금 표식 — 생성 시 고정·불변)· 삭제(deletePlaylist, 2-step warn→danger 배너, 성공 시/playlists복귀).
- 담기 — [라이브러리 담기] → 다이얼로그가
- 라이브러리 타입 배지는 색만으로 구분하지 않는다 — 담긴 라이브러리 행의
AI/TRUST배지 옆에 한국어 부가 라벨을 병기한다(라이브러리library-meta.ts단일 소스 재사용). - 상태 배지(#060) — 목록 행·상세 헤더에 파생 상태 배지를 공용
StatusPill로 노출한다(사용 중/기본/미사용/비어있음— 색맹 가드로 라벨 항상 병기). 라벨·톤·도트는 단일 소스playlist-status-meta.ts+PlaylistStatusBadge. 미매핑 enum 값은 중립 톤 + 원문(미래 enum 가드). FALLBACK 과 “기본” 배지 중복 정리:status=FALLBACK이면 상태 배지가 “기본” 을 표현하므로 별도isDefault“기본” 배지를 숨긴다. 단EMPTY우선으로status≠FALLBACK인 기본 PL(라이브러리 0)은 “비어있음” 상태 배지 + “기본” 배지를 함께 노출해 “기본인데 비어있음” 을 명확히 한다. 상세 헤더엔 적용 매장 수(appliedStoreCount)도 함께 표기.
영향 파일: apps/admin/src/app/(protected)/playlists/{page,playlist-list-client,playlist-create-dialog, playlist-rename-dialog,playlist-delete-dialog,playlist-default-dialog(#058),playlist-status-meta(#060), playlist-status-badge(#060)}.tsx · playlists/[id]/{page, playlist-detail-client,playlist-library-add-dialog,playlist-library-remove-dialog}.tsx ·
components/shell/sidebar.tsx(nav 항목)
· apps/admin/src/lib/backend.ts(backendListPlaylists·backendCreatePlaylist·backendGetPlaylist·
backendUpdatePlaylist·backendDeletePlaylist·backendListPlaylistLibraries·backendAddPlaylistLibrary·
backendRemovePlaylistLibrary·backendReorderPlaylistLibraries server-side helper + 타입 alias).
Not in scope / Followups
- F1 매장 적용(StoreActivePlaylist) ✅ 완료(#055) — 매장 단일 활성 PL(공유).
store.active_playlist_id(V18 nullable FK·partial index). 적용/해제/조회 + hqId 일치 검증(409STORE_PLAYLIST_HQ_MISMATCH) + PL 소프트삭제 시 참조 매장 자동 해제(D7). 운영사 UI 는 Store 상세 활성 PL 섹션. - F2 본사 조회 — hqId 스코프 read(
/api/v1/hq/playlists). 본사 직원은 조회만(편집은 운영사). - F2 본사 조회 ✅ 완료(#057) —
apps/space본사 모드/admin/playlists목록·상세(read-only). 본사가 어느 PL 이 기본인지 read-only “기본” 배지로 확인(#058). - 기본 PL ✅ 완료(#058) — 운영사 본사당 1개 기본 PL 지정/해제(
is_default)·운영사·본사 기본 배지. 점장 큐(#056)는 매장 활성 PL 부재 시 그 본사 기본 PL 로 fallback(source=DEFAULT, 비파괴·조회 시점). - F3 status 파생 4종 ✅ 완료(#060) —
PlaylistStatus{EMPTY,UNUSED,ACTIVE,FALLBACK}파생값(저장 안 함)· 운영사 응답appliedStoreCount/status추가·운영사·본사 상태 배지(read-only 포함). MISMATCH(플랜 불일치) 만 잔여(#060 F1) — 라이브러리 타입 enforcement(플랜 게이팅) 도착 후. 우선순위에서 EMPTY 다음, FALLBACK 앞. 시안ops-playlists.jsx의 본사 그룹·커버리지는 후속. - F4 점장 큐 BE ✅ 완료(#056) — 적용 PL→라이브러리→음원 병합·셔플(
/api/v1/store/playback-queue). 점장 player UI(source안내 포함)는 후속(#058 F2).
References
- SPEC #054 — 플레이리스트 도메인(라이브러리를 담음, 운영사 편집)
- SPEC #058 — 본사 기본 플레이리스트(
is_default, 본사당 1개) + 점장 큐 fallback(source) - 기획서
21-policy-music §4(2계층·셔플은 큐) · 핸드오프ops-playlists.jsx(목록 — 본사 그룹·상태) ·design_handoff_linkmusic_7/design/screens/ops-playlist-detail.jsx(상세·편집 단일 소스) ·hq-playlist.jsx linkmusic-frontend-space/packages/api-client/src/generated/endpoints/playlist/·schemas/playlistResponse.ts등- Endpoints · DTOs · Library · Data Schema