FeaturesMusic Upload (음원 업로드)Music (음원 업로드·카탈로그 조회·삭제)

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) — query q?(제목 부분검색, 대소문자 무시 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) — path idMusicResponse(audioUrl 포함, 상세 전용). soft-deleted·미존재는 모두 404 MUSIC_NOT_FOUND(존재 은닉).

소프트삭제 (#042)

운영자가 음원을 삭제하면 하드삭제하지 않고 deleted_at 만 채운다(deleteMusic204 No Content).

  • 원자적 UPDATE 가드UPDATE music SET deleted_at=:now WHERE id=:id AND deleted_at IS NULL 의 affected row 로 판정. affected=0(미존재·이미 soft-deleted) → 404 MUSIC_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메서드 · 경로요청성공
listMusicGET /api/v1/admin/musicquery q?(max 100) · musicSource?(AI/TRUST, #059) · page(=0) · size(=20, 1..100 clamp)200 MusicListResponse
getMusicGET /api/v1/admin/music/:idpath id200 MusicResponse
deleteMusicDELETE /api/v1/admin/music/:idpath id204 No Content
uploadMusicPOST /api/v1/admin/musicmultipart 파트 file(MP3) + query title · durationSeconds · musicSource(AI/TRUST, 필수·불변, #059)201 MusicResponse
replaceMusicFilePUT /api/v1/admin/music/:id/filemultipart 파트 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 누락/오값 → 400 MUSIC_INVALID_FIELD.
  • (#059) 음원을 라이브러리에 추가할 때 musicSource ≠ 라이브러리 libraryType → 400 LIBRARY_TYPE_MISMATCH (위반 musicId 를 fields.violatingMusicIds 에 노출·전체 reject). Library 참조.
  • 교체 대상 미존재 → 404 MUSIC_NOT_FOUND.
  • (#042) 목록 q 100자 초과 → 400. 상세·삭제 대상 미존재·soft-deleted → 404 MUSIC_NOT_FOUND.
  • 에러 카탈로그: Error Codes.

감사 (OperatorAuditLog)

업로드·교체·삭제 성공 지점에서 같은 트랜잭션 내 감사 row 를 기록한다(액션-감사 원자성). MUSIC targetType 은 재사용한다(#042 신규 타입 없음).

actiontargetTypetargetIdtargetLabeldetail
MUSIC_UPLOADEDMUSIC음원 PK곡 제목 스냅샷원본 파일명·크기
MUSIC_FILE_REPLACEDMUSIC음원 PK곡 제목 스냅샷원본 파일명·크기
MUSIC_DELETEDMUSIC음원 PK곡 제목 스냅샷— (소프트삭제, blob 유지)

actor 는 SecurityContext(JWT)에서 도출한다(클라 body 아님 — 위조 방지). 현 endpoint 는 항상 OPERATOR 라 actor 도 OPERATOR. 상세는 Operator Action Audit.

스토리지 추상화

StoragePort 인터페이스로 어댑터를 추상화한다.

  • Local 어댑터(기본) — 시크릿 없이 부팅 가능.
  • AzureBlobStorageAdapterstorage.azure.connection-string(env STORAGE_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)·타입 필터(musicSource AI/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) — 행 [라이브러리에 추가] → MusicAddToLibraryDialoguseListLibraries(검색 포함, libraryType = 음원 musicSource 로 일치 후보만, #059) 로 대상 라이브러리를 고르게 하고 generated useAddLibraryMusic({musicIds:[id]}) 로 추가한다(멱등 — 중복 추가도 성공). picker 필터로 불일치 선택을 차단하고, 그래도 발생하는 400 LIBRARY_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_trgm GIN 검토(F2).
  • 삭제 vs 비활성 구분 — 현재 soft-delete 단일 축. “숨김(비활성)” 분리 필요 시 MusicStatus enum 도입(F5).
  • AI 커버 생성 · Cyanite 분석 — 별 도메인(이번 범위 아님).
  • 파일 버전 이력 — 덮어쓰기로 blob 이력 없음(audit row 로만 보존). 필요 시 {id}/{ver}.mp3 검토(F4).
  • TTS 멘트 송출 — 별 도메인(후속).

References

  • SPEC #041 — 음원 파일 업로드 + ID3v2.4 메타 태그 재기록
  • SPEC #042 — 음원 카탈로그 조회·관리(목록/상세/소프트삭제)
  • SPEC #059 — 음원 타입(musicSource AI/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