Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
a585bbf
feat(server): 곡 상세에 Musixmatch·Genius 링크
countnine Aug 1, 2026
9dce220
feat(server): 곡의 의미 — 외부 자료 수집 + 한국어 요약
countnine Aug 1, 2026
4bbca56
fix(server): 쿼타 초과를 영구 실패로 저장하던 문제
countnine Aug 1, 2026
1c1dfc3
fix(core): 실제 데이터에서 소스가 통째로 비던 두 원인
countnine Aug 1, 2026
04e4571
fix(core): Genius가 아무 곡이나 물어 오던 것을 막는다
countnine Aug 1, 2026
23df726
fix(core): 출력 상한을 안 보내 잔액이 적으면 생성이 거절되던 문제
countnine Aug 2, 2026
8d35627
fix: Musixmatch 링크 403, 스니펫 HTML, 인용문을 사실처럼 옮기던 요약
countnine Aug 2, 2026
d95c655
feat(server): 관리자 화면 정리 + Musixmatch 곡 페이지 연동
countnine Aug 2, 2026
34157ce
feat(server): 곡 상세에 자료원 체크박스 + 생성 중 스피너
countnine Aug 2, 2026
01f51b6
fix(server): 생성 후 뒤로 가기가 "생성 전 같은 곡"으로 가던 문제
countnine Aug 2, 2026
0fce9d4
fix(core): "자료가 부족하다"는 답을 의미 있음으로 세던 문제
countnine Aug 2, 2026
a87adcc
feat(server): 관리자 로그인에 아이디·비밀번호
countnine Aug 2, 2026
f98d803
feat(apps): Windows·Android에서 곡의 의미 보기
countnine Aug 3, 2026
e6f6cfd
fix: 표기가 갈려 의미를 못 찾던 문제 + 안드로이드 팝업 두 번
countnine Aug 3, 2026
e04c880
chore: 릴리스 버전 — windows 0.18.0 / android 0.6.0
countnine Aug 3, 2026
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
27 changes: 27 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,33 @@
- **삼성 One UI는 서드파티 앱 로그를 막는다** — `adb shell setprop log.tag.Musebase VERBOSE` 없이는 `Musebase` 태그가 logcat에 한 줄도 안 나와 앱이 죽은 것처럼 보인다. `dumpsys media_session`은 metadata를 제목/아티스트/앨범 3개로만 덤프해서 광고 플래그가 안 보이므로, 앱이 찍는 `ad-signals` 로그가 사실상 유일한 프로브다.

## 미배포 (서버 쪽 작업 — 앱 릴리스와 무관하게 이미 운영 중)
- **곡의 의미(서버)** — 곡이 무엇에 대한 노래인지 한 문단으로. 관리자 곡 상세의 가사 **위**에 카드로 뜨고, 앱용 `GET /v1/meaning`도 열어 뒀다(앱 표시는 다음 작업). 배경은 `docs/adr/0007-song-meaning.md`.
- **Musixmatch는 링크만** — 공개 API에 meaning 엔드포인트가 없고(그 섹션은 사용자 기여 웹 콘텐츠) 크롤링은 약관 위반이다. 자동 수집은 **Genius**(`/songs/{id}`의 `description`, 무료 토큰) + **Last.fm**(`track.getInfo`의 wiki, 무료 키) + **Wikipedia**(키 불필요) 셋을 병렬로 겹친다.
- **엔진은 갈아끼운다** — `IMeaningWriter` + `MeaningWriterRegistry`(기존 `ITranslator`/`TranslatorRegistry`와 같은 모양). 기본은 **Gemini Developer API 직결**(API 키 한 줄, 무료 티어로 보유 곡 전체를 0원에 채운다 — Vertex AI는 서비스 계정·IAM 배선이 개인 프로젝트엔 과하다), 비교·전환용으로 **OpenRouter**(OpenAI 호환, `model` 문자열만 바꾸면 Claude·GPT·Gemini). 둘 다 순수 HttpClient라 SDK 의존성 0.
- **생성은 사람이 누를 때만** — 곡 상세 [의미 가져오기] + 대시보드 [의미 일괄 생성]. 자동 생성을 두지 않은 이유는 쿼타·비용이 예측 가능해야 하고 실패가 조용히 쌓이면 안 되기 때문. 실패·자료없음도 행으로 남겨 백필이 같은 곡을 무한 재시도하지 않는다.
- **환각 방어 둘** — ① 소스가 하나도 없으면 LLM을 아예 호출하지 않는다 ② Wikipedia 문서 선택은 제목 일치 + 아티스트 확인을 필수 조건으로 걸고 못 채우면 포기한다. 실측 함정: "(song)" 제목을 무조건 우선했더니 `Kids/MGMT`에서 정답 `Kids (MGMT song)`을 제치고 `Pursuit of Happiness (song)`이 뽑혔다 — 엉뚱한 문서는 자료가 없는 것보다 나쁘다(그럴듯하고 완전히 틀린 의미가 나온다).
- **실측 함정 하나 더**: Wikimedia는 User-Agent가 없으면 **403**을 준다. .NET `HttpClient`는 기본 UA를 안 보내므로 그대로 두면 위키피디아 소스가 항상 조용히 빈다 — 가사 제공자들의 검증된 동작을 건드리지 않도록 의미 전용 `MeaningHttp`에만 UA를 붙였다.
- **출처 표기 의무**(Wikipedia CC BY-SA, Genius·Last.fm 링크)를 화면과 `/v1/meaning`의 `attribution`에 함께 싣는다. 저장은 새 `meanings` 테이블(`user_version=2`), 조회 키는 **가사와 같은 해석기** — 가사가 느슨한 키로 맞는 곡은 의미도 맞아야 한다.
- 키를 하나도 넣지 않으면 기능이 통째로 꺼지고 외부 링크만 남는다(가사 기능 영향 0).
- **배포 후 실측으로 잡은 것들** — 스텁 테스트로는 절대 안 나오는 종류였다.
- **429를 영구 실패로 저장**하고 있었다. 백필은 행이 있는 곡을 건너뛰므로 쿼타가 회복돼도 그 곡은 영영 안 만들어진다. 이제 엔진이 영구/일시적 실패를 구분해(`MeaningWriteResult`) 일시적이면 **아무것도 저장하지 않고** 백필이 그 자리에서 멈춘다. 402(잔액)도 같은 취급.
- **출력 상한을 안 보내** OpenRouter가 모델 최대치(65,535토큰)를 예약하려다 402로 거절했다. 정작 쓰는 건 몇백 토큰이다. `max_tokens`/`maxOutputTokens`를 명시(1200) — 비용 폭주 방지이기도 하다.
- **아티스트를 통째로 비교**해 좋은 곡부터 버려졌다. `Lady Gaga/Bradley Cooper`는 문서 제목 `…Lady Gaga **and** Bradley Cooper…`와 영영 안 맞는다. 앨범 꼬리표(`harry styles — harry's house`)도 같은 이유. 이제 `ArtistNames`로 나눠 **한 명만 맞아도** 받아들이되 아무도 안 맞으면 여전히 버린다.
- **Genius에 관련성 검사가 없었다.** 유튜브 영상 제목으로 검색했더니 무관한 `119 REMIX`가 첫 히트로 나왔고 그대로 받았을 것이다. 제목 일치 + 아티스트 확인을 필수로 걸었다(`MeaningMatch`).
- **Genius 타임아웃이 2단 호출에 빠듯**했다(2.5초 예산, `Shallow`는 2.95초). 설명이 길고 좋은 곡일수록 먼저 잘려 나갔다 — 8초로.
- **스니펫의 HTML을 몰랐다.** `Belle & Sebastian`이 `belleampsebastian`이 되어 `The Boy with the Arab Strap`이 자료가 있는데도 비었다. 태그·엔티티를 처리하고 `&`와 `and`를 같은 말로 본다.
- **인용문이 객관적 서술로 둔갑**했다(`Even Flow`의 Genius 설명은 맷 캐머런의 인용문이다). 프롬프트에 못을 박았다.
- **Musixmatch 링크의 경로형 URL이 403**이었다 — `?query=` 형식으로 교정.
- **곡 페이지 링크는 공식 API로만** — `track.search`의 `track_share_url`. 주소를 규칙으로 만들면 안 된다: `/lyrics/Pearl-Jam/Even-Flow`가 오류 없이 200을 주면서 조용히 `/lyrics/Pearl-Jam/Alive`(**다른 곡**)로 넘어간다. 검색 결과를 서버가 긁는 길은 익명 요청이 로그인 페이지로 리다이렉트돼 막혀 있다.
- **자료원을 고를 수 있다** — `MUSEBASE_MEANING_SOURCES`(기본 `genius,lastfm,wikipedia`). Musixmatch의 "Meaning"은 자료로 쓸 수 있게 열어 뒀지만 **기본은 꺼져 있다**: 그 텍스트는 사람이 쓴 해설이 아니라 가사를 기계로 분석한 결과(같은 블록에 무드·테마·콘텐츠 등급이 함께 온다)라, 넣으면 LLM이 쓴 글을 다시 LLM에 넣어 요약하는 셈이 된다. 켜면 출처가 `Musixmatch (AI 분석)`으로 표시되고 프롬프트가 다른 자료를 우선한다. 켜져 있는 자료원은 대시보드에 그대로 보여 준다.
- **관리자 화면 정리** — 대시보드를 가사 중심 순서로(최근 올라온 가사 → 최근 조회 → …), 각 섹션 10행 + `전체 보기 →`(`/admin/list?view=` 하나로 처리). 가사 검색에 **의미 필터**(전체·있음·아직 없음)와 의미 열. **미스로 기록된 조회·미스 상위도 지금 서버에 있으면 곡으로 바로 간다**(기록의 `result`는 그대로 둔다 — 그때 미스였던 것은 사실이다).
- **곡 상세에서 자료원을 그 자리에서 고른다** — [의미 가져오기] 옆 체크박스. 설정을 건드리지 않고 한 곡으로 조합을 시험해 볼 수 있다(키가 있는 소스만 보여 준다 — 못 쓰는 걸 체크박스로 두면 눌러도 아무 일이 안 일어나 헷갈린다). 제출하면 버튼이 잠기고 **스피너**가 돈다: 외부 API를 여러 번 부르느라 수 초 걸리는데 반응이 없으면 사람이 다시 눌러 같은 곡을 두 번 만든다. 이 스피너가 관리자 화면의 **유일한 JS**이고, CSP는 느슨하게 푸는 대신 **그 스크립트의 sha256만 허용**한다(스크립트가 늘거나 바뀌면 테스트가 먼저 걸린다).
- **Musixmatch 무료 개발자 플랜은 사라진 것으로 보인다** — `developer.musixmatch.com/plans`는 상업용 Pro 요금제로 리다이렉트되고 `/signup`은 403, 공식 문서의 "Get API Key"도 같은 곳으로 간다. 그래서 곡 페이지 링크는 검색 폴백으로 두고, Musixmatch를 자료원으로 쓰는 것도 사실상 불가능하다(정확한 주소를 아는 길이 API뿐이라서). 코드는 남겨 뒀고 키가 생기면 그때 켜진다.
- **"자료가 부족하다"는 답을 의미로 세지 않는다** — 프롬프트가 근거 없는 창작을 막으려고 "부족하면 부족하다고 쓰라"고 시키므로 그런 답은 정상 동작인데, 글자가 있다는 이유만으로 `ok`로 저장하고 있었다. 통계가 부풀고 앱에는 "파악하기 어렵다"가 곡 해설이라며 뜬다. `insufficient` 상태를 새로 두고(문단은 남겨 사람이 판단하게), `/v1/meaning`은 404를 준다. 판정은 ① 프롬프트가 쓰게 한 `[자료부족]` 표식 ② 자료를 주어로 삼는 문구("자료만으로는", "파악하기 어렵다") 두 겹이고, "답을 알 수 없는 질문을 반복한다" 같은 진짜 의미는 통과한다(양쪽 다 테스트로 고정). 이미 쌓인 행도 다시 갈라 준다(`user_version=4`) — 실제로 3곡이 재분류됐다.
- **뒤로 가기 두 가지** — ① 관리자 응답에 `Cache-Control`이 없어 뒤로 가기가 캐시에서 그려졌다. 방금 만든 의미·방금 고친 가사가 사라진 것처럼 보인다. `no-store`로 못 박았다. ② 폼 제출이 히스토리 칸을 하나 더 만들어 뒤로 가기가 "생성 전의 같은 곡"으로 갔다. HTTP로는 못 고쳐(히스토리를 지우는 방법이 없다) 스피너 스크립트에서 `fetch` + `location.replace`로 지금 칸을 덮어쓴다. 리다이렉트는 302→**303**.
- **관리자 로그인에 아이디·비밀번호** — 기기마다 긴 토큰을 붙여 넣는 불편을 없앤다. `MUSEBASE_ADMIN_USER`/`MUSEBASE_ADMIN_PASSWORD`, 저장은 PBKDF2-SHA256(210k)이고 `--hash-password`로 해시를 만든다. **토큰 로그인은 비상구로 남겨 둔다** — 비밀번호를 잊으면 들어갈 길이 없어지기 때문. 실패 시 0.7초 지연.
- **앱에서 의미 보기(Windows·Android)** — 서버가 미리 만들어 둔 문단을 **읽기만 한다**(생성은 관리자 화면에서만, 쿼타·비용을 사람이 통제). Windows는 트레이 메뉴 [이 곡의 의미…] → 작은 창, Android는 상단 아이콘 → 다이얼로그. **출처 표기를 본문과 함께** 싣는다(Wikipedia CC BY-SA 등 — 계약상 의무). 서버가 없거나 그 곡에 의미가 없으면 안내 한 줄로 끝난다. 의미 조회 실패는 **서킷 브레이커에 세지 않는다** — 부가 기능 때문에 가사 조회까지 막히면 손해가 크다.
- 테스트 108건 추가(282개 통과).
- **가사 서버 백업 강화 + 컨테이너화** — 앱에는 영향 없음(서버 운영용).
- 백업: `sqlite3 .backup` → **`PRAGMA integrity_check` 검증**(깨지면 폐기) → gzip(2MB→660KB) → 보존 정리. `MUSEBASE_BACKUP_REMOTE`를 넣으면 매일 오프사이트 사본까지. 복구 절차 문서화.
- `Dockerfile`(멀티스테이지, tzdata·sqlite3·curl 포함, 비루트 실행, `/data` 볼륨, HEALTHCHECK) + `docker-compose.yml`(루프백 바인딩, 이름 있는 볼륨). WSL 도커에서 빌드·기동·백업·재시작 지속성·헬스체크까지 실측(이미지 372MB).
Expand Down
39 changes: 39 additions & 0 deletions contracts/lyrics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Authorization: Bearer <서버가 발급한 임의 문자열>
| GET | `/v1/lyrics?title=&artist=` | 가사 1건 조회. 히트 `200 LyricsEntry`, 미스 `404` |
| PUT | `/v1/lyrics` | 가사 1건 업서트. 본문 `LyricsEntry`(요청 필드만) |
| GET | `/v1/stats` | 곡 수·최근 갱신(검증·디버깅용) |
| GET | `/v1/meaning?title=&artist=` | 곡의 의미 1건. 히트 `200 MeaningEntry`, 없으면 `404` |

요청 본문 상한은 256KB, `Content-Type: application/json`이 아니면 `415`.

Expand Down Expand Up @@ -129,6 +130,44 @@ Authorization: Bearer <서버가 발급한 임의 문자열>
만료(TTL)는 두지 않는다. 삭제 API도 없다 — 한 기기의 "틀린 가사" 판정이 모든 기기의 캐시를
지우지 않도록, 억제는 로컬에만 남긴다. (사람이 확인하고 지우는 경로는 관리자 UI에만 있다. 아래 참고.)

## 곡의 의미 (`GET /v1/meaning`)

곡이 무엇에 대한 노래인지 한 문단으로 알려 준다. 서버가 외부 자료(Genius·Last.fm·Wikipedia)를
모아 요약해 저장해 둔 것을 읽기만 한다 — **클라이언트는 생성하지 않는다.** 생성은 관리자 화면에서
사람이 눌러야 일어난다(쿼타·비용을 사람이 통제한다).

키 정규화 규칙은 가사와 **완전히 같다**(정확 키 → 느슨한 키). 가사가 맞는 곡은 의미도 맞는다.

| 필드 | 형 | 설명 |
|---|---|---|
| `key` | string | 가사와 같은 키 |
| `title` / `artist` | string | 저장된 표기 |
| `summary` | string | **본문** — 대상 언어 한 문단 |
| `lang` | string | 요약 언어(`ko` 등) |
| `geniusUrl` | string? | 정확한 Genius 곡 페이지(있으면) |
| `musixmatchUrl` | string? | **공식 API로 확인한** Musixmatch 곡 페이지(있으면) — 아래 주의 |
| `engine` / `model` | string? | 생성에 쓴 엔진·모델 |
| `attribution` | `[{name, url}]` | **출처 목록 — 표시 의무가 있다(아래)** |
| `updatedAt` | string | ISO-8601 UTC |

의미가 없거나 만들다 실패한 곡은 `404`다 — 앱은 그냥 이 영역을 감추면 된다.
원문(`sources`)은 무겁고 앱에 필요 없어 응답에서 비운다.

> ⚠️ **출처 표기는 선택이 아니다.** Wikipedia 본문은 CC BY-SA이고 Genius·Last.fm도 링크 표기를
> 요구한다. `summary`를 보여 주는 화면은 `attribution`의 이름·링크를 함께 렌더해야 하며,
> 목록에 Wikipedia가 있으면 CC BY-SA 표기도 함께 붙인다.

### Musixmatch 주소를 직접 만들지 말 것

`musixmatchUrl`은 **공식 API가 알려 준 주소만** 담는다. 클라이언트가 제목·아티스트로 주소를
조립해서는 안 된다 — 실측에서 `/lyrics/Pearl-Jam/Even-Flow`가 오류 없이 200을 주면서 조용히
`/lyrics/Pearl-Jam/Alive`(**다른 곡**)로 넘어갔다. 값이 없으면 링크를 감추거나
검색(`https://www.musixmatch.com/search?query=…` — 경로형은 403이다)으로 보낸다.

Musixmatch의 "Meaning" 섹션 자체는 공개 API에 엔드포인트가 없다. 서버는 이를 **선택적** 자료원으로만
다루며 기본은 꺼져 있다(사람이 쓴 해설이 아니라 기계 분석 결과다 — ADR-0007). 켜져 있으면
`attribution`에 `Musixmatch (AI 분석)`이라는 이름으로 나타나므로, 화면은 그 이름을 그대로 보여 준다.

## 관리자 UI (`/admin/*`) — 계약 밖

서버는 사람이 보는 관리자 화면(`/admin`, `/admin/search`, `/admin/song`, `/admin/raw`, 편집·삭제 POST)을
Expand Down
Loading
Loading