Music — 음원 파일 업로드 + ID3 메타 재기록 (#041) · 카탈로그 조회·소프트삭제 (#042) · 운영사 카탈로그 UI (#052) · 음원 타입 enforcement (#059)
SPEC #041/#042 정합. BE 계약 + /sync-api 타입 생성. SPEC #052 — 운영사 백오피스(apps/admin)
/music 카탈로그 화면(목록·검색·페이지네이션·미리듣기·업로드·교체·삭제)이 라이브. SPEC #059 — 음원
타입(musicSource AI/TRUST)을 업로드 입력·목록 배지·타입 필터로 노출하고, 라이브러리 추가 시 타입
일치를 강제한다(불일치 400 LIBRARY_TYPE_MISMATCH).
Overview
OPERATOR(본사 운영자)가 음원 파일(MP3)을 업로드하면, 서버가 표준 ID3v2.4 태그(브랜드 메타 +
워터마크 + 기본 커버)를 메모리에서 재기록한 뒤 Azure Blob(또는 Local 어댑터)에 저장하고 음원
row 를 생성한다(#041). 기존 음원의 파일 교체(덮어쓰기)도 지원한다. #042 가 음원 도메인 CRUD 를 닫아
목록(페이지네이션·제목검색·최신순)·상세 조회와 소프트삭제(deleted_at)를 제공한다. 업로드·교체·
삭제는 OperatorAuditLog 에 남는다(MUSIC_UPLOADED·MUSIC_FILE_REPLACED·MUSIC_DELETED).
운영사 카탈로그 UI 는 #052 로 라이브.
/music(apps/admin)은 ComingSoon placeholder 를 해소하고 목록(제목·길이·업로드일)·제목검색·페이지네이션·상단 미리듣기 플레이어·업로드·교체·삭제를 제공한다(아래 운영사 카탈로그 UI 참조). 제외 명시: AI 커버 생성· Cyanite 분석은 이번 범위가 아니다(다른 도메인).
핵심 동작 — 통합 단일 업로드
업로드된 음원 파일을 메모리에서 ID3v2.4 태그 재기록(파일 변경) 후 blob 에 1회 업로드한다. 임시 업로드·재업로드·다운로드 응답이 없다.
multipart 수신 → 메모리에서 Id3v24Codec.rewrite → StoragePort.upload(blob 1회) → 음원 row insert → audit 기록admin-was 는 업로드(원본 저장)와 rewrite(다운로드 전용)가 분리돼 있으나 우리는 통합한다 — 임시 업로드·재업로드·다운로드 단계 없음.
식별자 통일 (A안)
음원 PK = blob 파일명. 앱에서 시간순 UUID 를 선생성(이 엔티티만 @UuidGenerator 자동부여 미사용,
id 선할당)해 key = {prefix}/{id}.mp3 로 저장한다. blob ↔ row 가 1:1 이라 매핑 컬럼이 불필요하다.
파일 교체 = 덮어쓰기
key={id}.mp3 가 고정이라 교체(replaceMusicFile) 시 동일 blob 을 덮어쓴다(파일 이력 없음).
이력은 OperatorAuditLog row(MUSIC_FILE_REPLACED)로만 보존한다(F4 — 버전 보관이 필요해지면
{id}/{ver}.mp3 도입 검토).
ID3v2.4 태그 셋 (축소)
서버가 재기록하는 태그. 사실상 클라이언트가 제공하는 값은 title 하나다.
| 프레임 | 값 | 출처 |
|---|---|---|
TIT2 | 곡 제목 | 클라이언트 추출 제공(title) |
TPE1 · TALB · TPE2 | 정적 "Linkmusic"(소문자 m) | 서버 상수 |
TDRC | 업로드 년도 | 서버 |
TXXX:linkmusic | 재기록 시각(yyyy-MM-dd HH:mm:ss) | 서버 (워터마크) |
APIC | 기본 커버 이미지 | 서버 번들 classpath 리소스(최초 1회 로드·메모리 캐시) |
제외:
genre(TCON)·track(TRCK)·TLEN은 기록하지 않는다.durationSeconds는 DB 컬럼만 (ID3 미기록) — 클라이언트가 추출해 전달하고MusicResponse.durationSeconds로 응답된다.
카탈로그 조회 (#042)
#041 이 만든 음원 row 를 운영자가 목록·상세 조회한다. 감사(#034) 페이지네이션 패턴을 재사용한다.
- 목록(
listMusic) — queryq?(제목 부분검색, 대소문자 무시 ILIKE, 0..100자)·page(0-base, 기본 0)·size(기본 20, 1..100 으로 clamp) →MusicListResponse{ items, page, size, total }. - 정렬 고정 —
created_at DESC, id DESC(최신순, id 는 tiebreaker). 클라이언트 정렬 옵션 없음. - 활성만 —
deleted_at IS NULL행만 노출.total은 활성 음원 전체 건수. - 목록 항목 =
MusicListItem—{ id, title, durationSeconds, musicSource, createdAt, updatedAt }.musicSource(AI/TRUST, #059)는 행 배지로 노출.audioUrl제외: 목록은 브라우징용이고 재생 url 은 상세에서만 노출한다(불필요한 url 누출·payload 절감). - 상세(
getMusic) — pathid→MusicResponse(audioUrl포함, 상세 전용). soft-deleted·미존재는 모두 404MUSIC_NOT_FOUND(존재 은닉).
소프트삭제 (#042)
운영자가 음원을 삭제하면 하드삭제하지 않고 deleted_at 만 채운다(deleteMusic → 204 No Content).
- 원자적 UPDATE 가드 —
UPDATE music SET deleted_at=:now WHERE id=:id AND deleted_at IS NULL의 affected row 로 판정. affected=0(미존재·이미 soft-deleted) → 404MUSIC_NOT_FOUND. 재삭제·미존재· soft-deleted 를 모두 같은 코드로 통일(존재 은닉, 상세 조회와 일관). - blob 유지 — 소프트삭제는 복구 가능·90일 보관(BaseEntity 정합)이라 blob 을 지우지 않는다
(
StoragePort.delete미호출). 조회·삭제가 storage 미의존 → Azure 미설정에서도 완전 동작. - hard-purge 는 후속 — 만료(
deleted_at < now-90d) blob+row 영구삭제 배치는 별 SPEC(F1). - 음원이
deleted_at을 실제 쓰는 첫 도메인 — 기존 “회수”는status=WITHDRAWN(점장 등)이지deleted_at아님. status 축 없이 단일 soft-delete 축으로 동작한다(Data Schema). - 삭제 감사 — 같은 트랜잭션 내
MUSIC_DELETED기록(아래 감사 표).
BE 계약
| operationId | 메서드 · 경로 | 요청 | 성공 |
|---|---|---|---|
listMusic | GET /api/v1/admin/music | query q?(max 100) · musicSource?(AI/TRUST, #059) · page(=0) · size(=20, 1..100 clamp) | 200 MusicListResponse |
getMusic | GET /api/v1/admin/music/:id | path id | 200 MusicResponse |
deleteMusic | DELETE /api/v1/admin/music/:id | path id | 204 No Content |
uploadMusic | POST /api/v1/admin/music | multipart 파트 file(MP3) + query title · durationSeconds · musicSource(AI/TRUST, 필수·불변, #059) | 201 MusicResponse |
replaceMusicFile | PUT /api/v1/admin/music/:id/file | multipart 파트 file(MP3) + query title? | 200 MusicResponse |
- 인가: OPERATOR-only(
/api/v1/admin/**=hasRole("OPERATOR")). - 요청은
multipart/form-data— 파일 파트(file)와 메타(title·durationSeconds)는 query parameter. generated 는 파일 파트를UploadMusicBody/ReplaceMusicFileBody({ file: Blob }), 메타를UploadMusicParams/ReplaceMusicFileParams로 emit 한다. - 음원 메타 추출 endpoint 는 없다 —
title·durationSeconds는 클라이언트가 파일에서 추출해 전달. - DTO 상세는 DTOs · Music · endpoint 표는 Endpoints · Admin Music 참조.
검증 / 제한
- MP3 만 허용(
audio/mpeg+ ID3 파싱 성공). 최대 20MB. - 비-MP3/손상 ID3 → 400
METADATA_PARSE_FAILED. title누락 · 빈 파일 · 20MB 초과 · (#059)musicSource누락/오값 → 400MUSIC_INVALID_FIELD.- (#059) 음원을 라이브러리에 추가할 때
musicSource≠ 라이브러리libraryType→ 400LIBRARY_TYPE_MISMATCH(위반 musicId 를fields.violatingMusicIds에 노출·전체 reject). Library 참조. - 교체 대상 미존재 → 404
MUSIC_NOT_FOUND. - (#042) 목록
q100자 초과 → 400. 상세·삭제 대상 미존재·soft-deleted → 404MUSIC_NOT_FOUND. - 에러 카탈로그: Error Codes.
감사 (OperatorAuditLog)
업로드·교체·삭제 성공 지점에서 같은 트랜잭션 내 감사 row 를 기록한다(액션-감사 원자성). MUSIC
targetType 은 재사용한다(#042 신규 타입 없음).
| action | targetType | targetId | targetLabel | detail |
|---|---|---|---|---|
MUSIC_UPLOADED | MUSIC | 음원 PK | 곡 제목 스냅샷 | 원본 파일명·크기 |
MUSIC_FILE_REPLACED | MUSIC | 음원 PK | 곡 제목 스냅샷 | 원본 파일명·크기 |
MUSIC_DELETED | MUSIC | 음원 PK | 곡 제목 스냅샷 | — (소프트삭제, blob 유지) |
actor 는 SecurityContext(JWT)에서 도출한다(클라 body 아님 — 위조 방지). 현 endpoint 는 항상 OPERATOR 라 actor 도 OPERATOR. 상세는 Operator Action Audit.
스토리지 추상화
StoragePort 인터페이스로 어댑터를 추상화한다.
- Local 어댑터(기본) — 시크릿 없이 부팅 가능.
AzureBlobStorageAdapter—storage.azure.connection-string(envSTORAGE_AZURE_CONNECTION_STRING) 이 있을 때만@ConditionalOnProperty로 활성화. 없으면 Local 어댑터로 부팅(blob 미저장).- 배포·env 상세는 Deployment · Azure Blob 참조.
운영사 카탈로그 UI (#052)
운영사(OPERATOR) 백오피스(apps/admin) /music 화면. 직전 ComingSoon placeholder 를 해소하고
#041/#042 계약 위에 카탈로그 화면을 올린다. 시안 출처: workspace parent dir
design_handoff_linkmusic/design/screens/ops-music-library.jsx.
- 목록·검색·필터·페이지네이션 — generated
useListMusic(react-query, client query) 로 fetch. 제목 검색(q, max 100)·타입 필터(musicSourceAI/TRUST, #059)·page(0-base)·size(20) 를 client state 로 보유하고, 업로드/교체/삭제 mutation 성공 시 현재 params 의getListMusicQueryKey를 invalidate 해 즉시 갱신한다(운영자 #043·매장 #044 client-query 패턴 미러). 공용ListToolbar(검색 + 타입 select 필터)·ListPagination·ListNoResults재사용. - 목록 컬럼 — 제목 · 타입 배지(AI/TRUST, #059) · 길이(
m:ss) · 업로드일(KST) · 행 액션(재생· 라이브러리에 추가·파일 교체·삭제). 타입 배지는MUSIC_SOURCE_META(라이브러리 타입 배지와 동일 표현 — 색만으로 구분하지 않고 라벨 병기) 단일 소스로 렌더한다. [라이브러리에 추가]는 #053 라이브러리 도메인 도착으로 활성화됐다(아래 참조). 무드/BPM/사용처 컬럼·필터 사이드바·다중선택 일괄작업·CSV 일괄 메타·표/그리드 토글은 여전히 데이터 부재로 생략한다(후속 — 음원 모델 enrich 후, [[feedback_design_only_from_handoff]]). - 라이브러리에 추가(#053·#059) — 행 [라이브러리에 추가] →
MusicAddToLibraryDialog가useListLibraries(검색 포함,libraryType = 음원 musicSource로 일치 후보만, #059) 로 대상 라이브러리를 고르게 하고 generateduseAddLibraryMusic({musicIds:[id]}) 로 추가한다(멱등 — 중복 추가도 성공). picker 필터로 불일치 선택을 차단하고, 그래도 발생하는 400LIBRARY_TYPE_MISMATCH(목록 stale 등)는 “라이브러리 타입과 다른 음원은 담을 수 없습니다” 로 매핑한다(fields.violatingMusicIds활용). 상세는 Library 참조. - 미리듣기(상단 플레이어) — 목록 행은 audioUrl 미포함(#042 D2) → 재생 클릭 시
getMusic(상세, audioUrl 포함) 을 호출해 상단 고정<audio controls autoPlay>에 물린다. Azure public blob URL 이라 직접 재생 가능. 재생 중 행은 강조 + “재생 중” 배지. - 업로드 — 파일 선택 시 브라우저에서 title(파일명 확장자 제거)·durationSeconds(
<audio>loadedmetadata) 를 추출(메타 추출 endpoint 없음)하고, 음원 타입(AI/TRUST)을 라디오로 필수 선택 (#059 — 미선택 시 제출 불가, 업로드 후 불변)한 뒤uploadMusic(multipart 파트 file + query title·durationSeconds·musicSource) 호출. 추출 실패·빈 파일·20MB 초과는 호출 전 인라인 안내(불필요한 backend 호출 절약). 성공 시 목록 invalidate. - 교체 —
replaceMusicFile(multipart file + query title?). 제목을 비우면 기존 유지. - 삭제 —
deleteMusic(204 소프트삭제). 파괴적 액션이라 2-step 확인(계속 → 삭제) 다이얼로그. 재생 중 음원을 삭제하면 플레이어도 닫는다.
BFF multipart 통과: 파일 바이너리는 catch-all
/api/backend/[...path]가arrayBuffer()로 버퍼링·재생(refresh 재시도 대비)하며 그대로 backend 로 전달한다. Content-Type(boundary)은 fetch 가 FormData body 에 대해 자동 설정한다(BFF 가 강제 주입하지 않음).
영향 파일: apps/admin/src/app/(protected)/music/{page,music-catalog-client,music-upload-dialog, music-replace-dialog,music-delete-dialog,music-metadata,music-meta,music-add-to-library-dialog}.tsx
(music-meta.ts = 음원 타입 표시 메타 단일 소스, #059) · apps/admin/src/lib/{backend,error}.ts
(backendListMusic·backendGetMusic·backendUploadMusic·backendReplaceMusicFile·backendDeleteMusic
server-side helper + 타입 alias · extractFieldValue = fields.violatingMusicIds 추출 helper, #059).
Not in scope / Followups
- 음원 모델 enrich (시안 리치 메타) — 타입(AI/TRUST)은 #059 로 해소(업로드 입력·배지·필터· 라이브러리 enforcement). 나머지 무드·BPM·태그·아티스트 등 시안 필터/컬럼이 전제하는 리치 메타는 BE 후속(#052 F1 잔여). 도착 전까지 그 컬럼·필터는 minimal 유지.
- 라이브러리에 추가 — #053 라이브러리 도메인 도착으로 행 단위 추가는 라이브(위 참조). 다중선택 일괄 추가·CSV 일괄 메타는 후속(음원 모델 enrich·시안 일괄작업 도착 후).
- hard-purge 배치 — 만료(
deleted_at < now-90d) blob+row 영구삭제는 후속 SPEC(F1). - 검색 인덱스 — 베타 규모는 ILIKE 순차 스캔 충분. 수만 row 시
pg_trgmGIN 검토(F2). - 삭제 vs 비활성 구분 — 현재 soft-delete 단일 축. “숨김(비활성)” 분리 필요 시
MusicStatusenum 도입(F5). - AI 커버 생성 · Cyanite 분석 — 별 도메인(이번 범위 아님).
- 파일 버전 이력 — 덮어쓰기로 blob 이력 없음(audit row 로만 보존). 필요 시
{id}/{ver}.mp3검토(F4). - TTS 멘트 송출 — 별 도메인(후속).
References
- SPEC #041 — 음원 파일 업로드 + ID3v2.4 메타 태그 재기록
- SPEC #042 — 음원 카탈로그 조회·관리(목록/상세/소프트삭제)
- SPEC #059 — 음원 타입(
musicSourceAI/TRUST) enforcement·업로드 입력·배지·필터·라이브러리 일치 강제 - 설계: workspace parent dir
docs/research/02-music-upload-metadata-tagging.md linkmusic-frontend-space/packages/api-client/src/generated/endpoints/music/·schemas/musicResponse.ts등- Endpoints · DTOs · Data Schema · Deployment