FeaturesPlaylist (플레이리스트 — 라이브러리 묶음)Playlist (플레이리스트 — 라이브러리 묶음)

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메서드 · 경로요청성공
createPlaylistPOST /api/v1/admin/playlists{hqId, name(1..255)}201 PlaylistResponse(라이브러리 수 0)
listPlaylistsGET /api/v1/admin/playlistsquery q?(max 100) · hqId? · page(=0) · size(=20, 1..100 clamp)200 PlaylistListResponse
getPlaylistGET /api/v1/admin/playlists/:idpath id200 PlaylistResponse(라이브러리 수 포함)
updatePlaylistPATCH /api/v1/admin/playlists/:id{name} (hqId 불변)200 PlaylistResponse
deletePlaylistDELETE /api/v1/admin/playlists/:idpath id204 No Content (소프트삭제)
listPlaylistLibrariesGET /api/v1/admin/playlists/:id/librariespath id200 PlaylistLibraryListResponse(position 순서)
addPlaylistLibraryPOST /api/v1/admin/playlists/:id/libraries{libraryId} (멱등, 끝에 append)200/204
removePlaylistLibraryDELETE /api/v1/admin/playlists/:id/libraries/:libraryIdpath id·libraryId (멱등)204 No Content
reorderPlaylistLibrariesPUT /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 라이브러리 미존재 → 404 LIBRARY_NOT_FOUND. createPlaylist 본사 미존재 → 404 HQ_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) — generated useListPlaylists(react-query, client query) fetch. 컬럼: 이름(+ 기본 배지/상태 배지) · 상태(#060) · 소속 본사명 · 적용 매장 수(#060 appliedStoreCount) · 생성일(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종 범례 스트립(비어있음·기본·사용 중·미사용 + 향후 ‘플랜 불일치’ 확장 슬롯 주석 — 미구현). 담긴 라이브러리 목록(순서)은 generated useListPlaylistLibraries client query 로 fetch — 순서 컬럼이 좌측인 표(순서·라이브러리·타입·추가일·관리) + “순서 변경은 즉시 저장됩니다” footer.
    • 담기 — [라이브러리 담기] → 다이얼로그가 useListLibraries(검색)로 보유 라이브러리를 고르게 하고(이미 담긴 것 제외) addPlaylistLibrary({libraryId})로 끝에 append. 곡이 아니라 라이브러리 단위.
    • 제거 — 행 [제거] → 1-step 확인 다이얼로그(info 배너: 라이브러리 자체는 보존, 담기만 해제) → removePlaylistLibrary({id, libraryId}, 멱등). 라이브러리 영구 삭제가 아니므로 PL 삭제(2-step)와 파급 차등.
    • 순서 변경 — 행 [위로]/[아래로] 칩 버튼 → 현재 순서에서 한 칸 swap 한 전체 libraryIds[]reorderPlaylistLibraries 로 일괄 전송(부분 swap 아님 — 계약은 전체 순서를 받는다).
    • 담기/제거/순서 성공 시 getListPlaylistLibrariesQueryKey invalidate. 헤더에서 이름수정 (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 일치 검증(409 STORE_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