diff --git a/PROGRESS.md b/PROGRESS.md
index daf3c60..c2f9925 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -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).
diff --git a/contracts/lyrics-api.md b/contracts/lyrics-api.md
index 0fe6862..25f4731 100644
--- a/contracts/lyrics-api.md
+++ b/contracts/lyrics-api.md
@@ -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`.
@@ -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)을
diff --git a/docs/adr/0007-song-meaning.md b/docs/adr/0007-song-meaning.md
new file mode 100644
index 0000000..3c9f3b6
--- /dev/null
+++ b/docs/adr/0007-song-meaning.md
@@ -0,0 +1,123 @@
+# ADR-0007: 곡의 의미 — 외부 자료 수집 + 요약
+
+- 상태: 채택 (2026-08-01)
+- 관련: ADR-0005(개인 가사 서버), `contracts/lyrics-api.md`
+
+## 배경
+
+가사는 있는데 **그 곡이 무슨 이야기인지**는 어디에도 없다. 관리자 화면에서 곡을 열었을 때
+가사 위에 "이 곡의 의미"를 한국어로 보여 주고, 나중에 Windows·Android 앱에서도 확인할 수
+있게 하려 한다.
+
+## 결정
+
+### 1. Musixmatch의 "Meaning"은 링크가 기본, 자료로 쓰려면 켜야 한다
+
+공개 API(`track.search` / `matcher.track.get` / `track.lyrics.get` / `track.snippet.get` /
+`artist.search` …)에 **meaning 엔드포인트가 없다.** 그래서 처음에는 링크만 걸었다.
+
+**2026-08-02 변경**: 곡 페이지에서 그 텍스트를 꺼낼 수 있음을 확인해 **선택적 자료원**으로 넣되,
+**기본은 꺼 둔다**(`MUSEBASE_MEANING_SOURCES`에 `musixmatch`를 명시해야 쓰인다). 그렇게 한 이유:
+
+- **그 텍스트는 사람이 쓴 해설이 아니다.** 페이지 HTML의 `__NEXT_DATA__` 안 `lens` 블록에 있고,
+ 같은 블록에 `moods`·`themes`·콘텐츠 등급이 함께 들어 있다 — 가사를 기계로 분석한 묶음이다.
+ 이걸 자료로 넣으면 **LLM이 쓴 글을 다시 LLM에 넣어 요약**하는 셈이라, "근거에 묶어 둔다"는
+ 이 기능의 전제가 약해지고 무엇에 근거했는지 추적할 수 없다.
+- 그래서 소스 이름을 `Musixmatch (AI 분석)`으로 두어 출처 표기에 성격이 그대로 드러나게 하고,
+ 프롬프트에도 "AI 분석 자료는 사실 근거가 약하니 다른 자료와 어긋나면 다른 자료를 따른다"를 넣었다.
+- 약관상 스크래핑 금지라는 점은 그대로다. **켜는 판단과 그 위험은 운영자의 몫**이고, 기본값이
+ 꺼져 있으므로 배포본이 저절로 그 상태가 되지는 않는다.
+
+**곡 페이지 주소는 공식 API(`track.search`의 `track_share_url`)로만 얻는다.** 규칙으로 만들면
+안 된다 — 실측에서 `/lyrics/Pearl-Jam/Even-Flow`가 오류 없이 200을 주면서 조용히
+`/lyrics/Pearl-Jam/Alive`(다른 곡)로 넘어갔다. 검색 결과 페이지를 서버가 긁는 길도 익명 요청이
+로그인 페이지로 리다이렉트되어 막혀 있다.
+
+### 2. 소스는 셋을 겹친다 — Genius · Last.fm · Wikipedia
+
+| 소스 | 인증 | 얻는 것 |
+|---|---|---|
+| Genius | 무료 Client Access Token(OAuth 플로우 불필요) | `/songs/{id}?text_format=plain`의 `description`(About) |
+| Last.fm | 무료 API 키 | `track.getInfo`의 `wiki.content` |
+| Wikipedia | **없음** | 곡 문서 도입부(`prop=extracts`) |
+
+하나가 비어도 나머지가 채운다. 셋을 **병렬로** 부르고 실패는 무시한다 —
+`HttpRemoteLyricsCache`의 조용한 강등과 같은 원칙이다. Songfacts는 API가 없어 제외했다.
+
+### 3. 번역이 아니라 요약이다 — LLM을 쓴다
+
+세 소스 모두 영어 산문이다. DeepL은 번역만 하므로 그대로 넣으면 "의미"가 아니라 긴 영어
+문서의 긴 한국어판이 나온다. 그래서 요약이 가능한 LLM을 쓰되, 엔진을 **갈아끼울 수 있게**
+`IMeaningWriter` + `MeaningWriterRegistry`로 감쌌다(기존 `ITranslator`/`TranslatorRegistry`와 같은 모양).
+
+- **기본은 Google Gemini Developer API 직결.** Vertex AI가 아닌 이유는 인증이 API 키 한 줄이라
+ 이미 쓰는 `GoogleTranslateTranslator`와 패턴이 같아서다(서비스 계정·ADC 불필요).
+ IAM·데이터 레지던시가 필요해지면 Vertex로 옮긴다.
+ 요금은 어느 쪽이든 부담이 없다 — 보유 곡 전체를 채워도 유료 기준 몇백 원이다.
+ 다만 **"$300 무료 체험 크레딧"은 Gemini API에 쓸 수 없다**(공식 문서의 명시적 제외 항목).
+ 진짜 무료로 가려면 별개 제도인 "무료 티어"를 써야 하고, 그건 **결제가 연결되지 않은
+ 프로젝트에만** 적용된다 — 결제를 붙이는 순간 Tier 1(유료)이 되고 무료 티어는 사라진다.
+ 가사 번역용 프로젝트는 Cloud Translation 때문에 결제가 필요하므로 **그 프로젝트를 그대로
+ 쓰면 유료다.** 무료를 원하면 결제 없는 별도 프로젝트가 필요하다(계정당 프로젝트 수 한도에
+ 걸릴 수 있다). 유료 티어는 대신 보낸 내용이 학습에 쓰이지 않는다.
+- **OpenRouter를 함께 둔다.** OpenAI 호환 엔드포인트라 키 하나로 Claude·GPT·Gemini·Llama를
+ `model` 문자열만 바꿔 부를 수 있다. 같은 곡을 여러 모델로 만들어 문장 품질을 비교할 때 쓴다.
+- 둘 다 순수 HttpClient + System.Text.Json — SDK 의존성을 늘리지 않는다.
+
+### 4. 생성은 사람이 누를 때만 — 자동 생성을 두지 않는다
+
+새 가사가 올라올 때 자동으로 만들지 않는다. 관리자 화면의 단건 버튼과 일괄 백필만 둔다.
+
+- 쿼타·비용이 예측 가능하다(무료 티어 한도를 모르게 긁지 않는다).
+- 실패가 조용히 쌓이지 않는다.
+- 광고·오인식 트랙까지 토큰을 쓰지 않는다.
+
+결과는 실패·자료없음도 행으로 남긴다 — 백필을 다시 눌러도 같은 곡을 무한히 재시도하지 않는다.
+
+**단 일시적 실패는 남기지 않는다.** 429(쿼타)와 5xx·타임아웃은 시간이 지나면 풀리는데, 이걸
+`failed` 행으로 굳히면 한도가 회복된 뒤에도 그 곡은 영영 건너뛰어진다. 그래서 엔진은
+"영구 실패"와 "일시적 실패"를 갈라 돌려주고(`MeaningWriteResult.Retryable`), 후자는 **아무것도
+저장하지 않고** 백필이 그 자리에서 멈춘다 — 계속 돌아 봐야 남은 곡도 같은 벽에 부딪힐 뿐이고,
+멈춰도 망가지는 것이 없다. 무료 티어처럼 분당 한도가 빡빡한 환경에서는
+`MUSEBASE_MEANING_BACKFILL_DELAY_MS`로 호출 간격을 줄 수 있다(유료 티어는 필요 없어 기본 0).
+
+### 5. 근거가 없으면 부르지 않고, 확신이 없으면 포기한다
+
+곡 해설은 **그럴듯한 창작이 특히 쉬운 영역**이다. 두 가지 방어를 뒀다.
+
+- 소스가 하나도 없으면 LLM을 **아예 호출하지 않는다**(`status='no-source'`).
+- Wikipedia 문서 선택은 제목 일치와 아티스트 확인을 **필수 조건**으로 걸고, 못 채우면 포기한다.
+ 실측으로 걸린 함정: "(song)"이 붙은 제목을 무조건 우선했더니 `Kids / MGMT`에서 정답인
+ `Kids (MGMT song)`("(song)"이 아니라 "(MGMT song)"이다)을 제치고 상위에 섞여 있던
+ `Pursuit of Happiness (song)`이 뽑혔다. **엉뚱한 문서는 자료가 없는 것보다 나쁘다** —
+ 그럴듯하고 완전히 틀린 의미가 만들어지기 때문이다.
+- 프롬프트도 "자료에 없는 내용은 지어내지 않는다 / 부족하면 부족하다고 쓴다"로 못을 박는다.
+
+### 6. 출처 표기는 의무다
+
+Wikipedia 본문은 CC BY-SA이고 Genius·Last.fm도 링크 표기를 요구한다. 요약을 보여 주는 화면은
+출처 이름·링크를 함께 렌더해야 하며, 이를 위해 `/v1/meaning` 응답에 `attribution`을 싣는다
+(원문 전체는 무거워 응답에서 비운다).
+
+### 7. 저장은 별도 테이블
+
+`meanings` 테이블을 새로 만들고(`PRAGMA user_version = 2`) 가사 테이블은 건드리지 않는다.
+의미가 없어도 가사는 멀쩡해야 하고, 재생성이 가사 `revision`을 올리면 안 된다.
+조회 키는 **가사와 같은 해석기**(`Locate`)를 쓴다 — 가사가 느슨한 키로 맞는 곡은 의미도 맞아야
+앱에서 "가사는 뜨는데 의미만 빈" 상태가 생기지 않는다.
+
+## 결과
+
+- 앱에는 LLM 키를 심지 않는다. 서버가 대행하고 앱은 `/v1/meaning`을 읽기만 한다 —
+ ADR-0005가 v2로 예고한 "서버가 번역 대행"과 같은 방향이다.
+- 키를 하나도 넣지 않으면 기능이 통째로 꺼지고 외부 링크만 남는다. 가사 기능에는 영향이 없다.
+- 실측으로 잡은 함정 하나 더: **Wikimedia는 User-Agent가 없으면 403을 준다.**
+ .NET `HttpClient`는 기본 User-Agent를 보내지 않으므로 그대로 두면 위키피디아 소스가 항상
+ 조용히 빈다. 가사 제공자들의 검증된 동작을 건드리지 않도록 의미 전용 `MeaningHttp`에만 붙였다.
+
+## 대안 (기각)
+
+- **DeepL로 번역만** — 새 키가 필요 없지만 요약이 안 돼 긴 영어 bio가 긴 한국어 글이 될 뿐이고,
+ 가사 번역용 무료 할당까지 함께 먹는다.
+- **Musixmatch 크롤링** — 약관 위반이고 Cloudflare로 막혀 있다.
+- **Vertex AI** — 거버넌스가 필요할 때의 선택지다. 개인 프로젝트에는 서비스 계정·IAM 배선이 과하다.
diff --git a/src/Musebase.Android/MainActivity.cs b/src/Musebase.Android/MainActivity.cs
index 7a1826e..64eace2 100644
--- a/src/Musebase.Android/MainActivity.cs
+++ b/src/Musebase.Android/MainActivity.cs
@@ -114,11 +114,13 @@ protected override void OnCreate(Bundle? savedInstanceState)
_moveButton = IconButton(global::Android.Resource.Drawable.IcMenuCompass, "오버레이 위치 이동", ToggleOverlayMoveMode);
var searchButton = IconButton(global::Android.Resource.Drawable.IcMenuSearch, "가사 검색",
() => StartActivity(new Intent(this, typeof(SearchActivity))));
+ var meaningButton = IconButton(global::Android.Resource.Drawable.IcMenuInfoDetails, "이 곡의 의미", ShowMeaning);
var wrongButton = IconButton(global::Android.Resource.Drawable.IcMenuDelete, "틀린 가사로 표시", ConfirmMarkWrong);
var settingsButton = IconButton(global::Android.Resource.Drawable.IcMenuPreferences, "설정",
() => StartActivity(new Intent(this, typeof(SettingsActivity))));
var quitButton = IconButton(global::Android.Resource.Drawable.IcMenuCloseClearCancel, "앱 종료", ConfirmQuit);
iconBar.AddView(searchButton);
+ iconBar.AddView(meaningButton);
iconBar.AddView(wrongButton);
iconBar.AddView(_overlayButton);
iconBar.AddView(_moveButton);
@@ -541,6 +543,46 @@ private void StartOverlayService(string? action)
/// 현재 곡을 "틀린 가사"로 표시한다(Windows 트레이의 같은 기능) — 이 곡은 이후 가사를 찾지 않고,
/// 캐시에 저장된 잘못된 가사도 지운다. 되돌리려면 가사 검색에서 직접 골라 적용하면 된다.
///
+ ///
+ /// "이 곡의 의미" — 가사 서버가 미리 만들어 둔 문단을 읽기만 한다.
+ /// 생성은 서버 관리자 화면에서만 일어나므로 앱은 조회 전용이고, 없으면 그렇게 알려 줄 뿐이다.
+ ///
+ /// 출처 표기는 의무라(Wikipedia CC BY-SA 등) 본문과 함께 반드시 붙인다.
+ ///
+ private async void ShowMeaning()
+ {
+ if (MusebaseApp.Instance is not { } app || app.Source.CurrentTrack is not { } track)
+ {
+ Toast.MakeText(this, "재생 중인 곡이 없습니다.", ToastLength.Short)?.Show();
+ return;
+ }
+
+ if (app.Coordinator.RemoteCache is not { } remote)
+ {
+ Toast.MakeText(this, "가사 서버가 설정되지 않았습니다.", ToastLength.Short)?.Show();
+ return;
+ }
+
+ // 창은 **하나**만 띄우고 내용만 바꾼다. 불러오기용과 결과용을 따로 띄우면
+ // 팝업이 두 번 깜빡여 부자연스럽다(실측으로 지적받은 부분).
+ var dialog = new AlertDialog.Builder(this)
+ .SetTitle($"{track.Title} — {track.Artist}")!
+ .SetMessage("불러오는 중…")!
+ .SetPositiveButton("닫기", (_, _) => { })!
+ .Show();
+
+ Musebase.Core.Search.SongMeaningView? meaning = null;
+ try { meaning = await remote.GetMeaningAsync(track.Title, track.Artist); }
+ catch (Exception) { /* 조용한 강등 — 부가 기능이다 */ }
+
+ if (IsFinishing || IsDestroyed || dialog is null || !dialog.IsShowing) return;
+
+ dialog.SetMessage(meaning is null
+ // 대부분의 곡에는 아직 의미가 없다 — 실패가 아니라 정상이다.
+ ? "이 곡의 의미는 아직 없습니다."
+ : meaning.Summary + "\n\n" + meaning.CreditLine);
+ }
+
private void ConfirmMarkWrong()
{
if (MusebaseApp.Instance?.Source.CurrentTrack is null)
diff --git a/src/Musebase.Android/Musebase.Android.csproj b/src/Musebase.Android/Musebase.Android.csproj
index f5e514a..bc88ea5 100644
--- a/src/Musebase.Android/Musebase.Android.csproj
+++ b/src/Musebase.Android/Musebase.Android.csproj
@@ -14,8 +14,8 @@
26.0
com.countnine.musebase
- 5
- 0.5.0
+ 6
+ 0.6.0
apk
diff --git a/src/Musebase.Core/Meaning/ArtistNames.cs b/src/Musebase.Core/Meaning/ArtistNames.cs
new file mode 100644
index 0000000..bb1c7c1
--- /dev/null
+++ b/src/Musebase.Core/Meaning/ArtistNames.cs
@@ -0,0 +1,58 @@
+namespace Musebase.Core.Meaning;
+
+///
+/// 아티스트 표기에서 **비교에 쓸 이름 후보들**을 뽑는다.
+///
+/// 재생 메타데이터의 아티스트 필드는 생각보다 지저분해서, 통째로 비교하면 어떤 문서와도
+/// 맞지 않는다. 실측으로 두 가지가 걸렸다.
+///
+/// ① **앨범이 꼬리표로 붙어 온다** — "harry styles — harry's house".
+/// ② **합작곡은 구분자로 이어 온다** — "Lady Gaga/Bradley Cooper". 그런데 위키피디아
+/// 문서 제목은 "Shallow (Lady Gaga and Bradley Cooper song)"라서, 구두점을 지우고
+/// 통째로 포함 검사를 하면 가운데 "and" 때문에 절대 일치하지 않는다.
+/// **한 명만 확인돼도** 동명이곡을 거르는 목적은 달성된다.
+///
+/// 구분자는 **앞뒤 공백이 있는 것만** 본다 — 이름 자체에 기호가 든 경우
+/// (Jay-Z, AC/DC)를 자르면 안 되기 때문이다. 그래도 AC/DC처럼 쪼개지는
+/// 이름이 남는데, 호출자가 "쓸 만한 이름이 없으면 원본 전체로 되돌리는" 방식으로 받아 준다.
+///
+public static class ArtistNames
+{
+ /// 아티스트 뒤에 붙는 앨범 꼬리표 구분자(공백 포함).
+ private static readonly string[] AlbumSeparators = [" — ", " – ", " • ", " · "];
+
+ /// 여러 아티스트를 잇는 구분자. 슬래시만 공백 없이도 흔해 예외로 둔다.
+ private static readonly string[] ArtistSeparators =
+ ["/", " & ", ", ", " feat. ", " feat ", " featuring ", " ft. ", " ft ", " with ", " x "];
+
+ /// " — 앨범명" 같은 꼬리표를 떼어 낸다.
+ public static string StripAlbumSuffix(string artist)
+ {
+ if (string.IsNullOrWhiteSpace(artist)) return "";
+ var value = artist.Trim();
+ foreach (var separator in AlbumSeparators)
+ {
+ var at = value.IndexOf(separator, StringComparison.Ordinal);
+ if (at > 0) value = value[..at].Trim();
+ }
+ return value;
+ }
+
+ /// 앨범 꼬리표를 떼고 여러 아티스트로 나눈 이름들(등장 순서 유지).
+ public static IReadOnlyList All(string artist)
+ {
+ var value = StripAlbumSuffix(artist);
+ if (value.Length == 0) return [];
+
+ var parts = value.Split(ArtistSeparators, StringSplitOptions.RemoveEmptyEntries
+ | StringSplitOptions.TrimEntries);
+ return parts.Length == 0 ? [value] : parts;
+ }
+
+ /// 대표 이름 하나 — 검색어를 만들 때 쓴다(꼬리표·공동 아티스트는 잡음이다).
+ public static string Primary(string artist)
+ {
+ var all = All(artist);
+ return all.Count > 0 ? all[0] : artist.Trim();
+ }
+}
diff --git a/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs b/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs
new file mode 100644
index 0000000..c76fe1e
--- /dev/null
+++ b/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs
@@ -0,0 +1,130 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Musebase.Core.Search;
+
+namespace Musebase.Core.Meaning;
+
+///
+/// Google Gemini Developer API(generativelanguage.googleapis.com)로 의미를 쓴다.
+///
+/// **Vertex AI가 아니라 Developer API를 쓰는 이유**: 인증이 API 키 한 줄이라
+/// 와 완전히 같은 패턴이고(서비스 계정·ADC 불필요),
+/// 무료 티어가 있어 보유 곡 전체를 0원에 채울 수 있다. IAM·데이터 레지던시 같은 거버넌스가
+/// 필요해지면 그때 Vertex로 옮기면 된다.
+///
+/// 키는 에서 만든다. **$300 무료 체험
+/// 크레딧은 Gemini API에 쓸 수 없다**(공식 문서에 명시된 제외 항목) — 별개인 "Gemini API
+/// 무료 티어"가 있고, 그건 **결제를 연결하지 않은 프로젝트에만** 적용된다. 결제를 붙이는
+/// 순간 그 프로젝트는 Tier 1(유료)이 되고 무료 티어는 사라지므로, 무료로 쓰려면 결제가
+/// 없는 프로젝트에서 키를 만들어야 한다. 다만 요약 한 번은 매우 싸서(곡당 사실상 0원)
+/// 유료 티어로 두는 선택도 합리적이다 — 그쪽은 보낸 내용이 학습에 쓰이지 않는다.
+///
+public sealed class GeminiMeaningWriter : IMeaningWriter
+{
+ /// 무료 티어 한도가 가장 넉넉한 모델. 요약 작업엔 충분하다.
+ public const string DefaultModel = "gemini-2.5-flash-lite";
+
+ private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta/models";
+
+ private static readonly JsonSerializerOptions Json = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ private readonly HttpClient _http;
+ private readonly string _apiKey;
+ private readonly TimeSpan _timeout;
+
+ public GeminiMeaningWriter(string apiKey, string? model = null, HttpClient? http = null, int timeoutMs = 30_000)
+ {
+ _apiKey = apiKey.Trim();
+ Model = string.IsNullOrWhiteSpace(model) ? DefaultModel : model!.Trim();
+ _http = http ?? MeaningHttp.Client;
+ _timeout = TimeSpan.FromMilliseconds(Math.Clamp(timeoutMs, 1000, 120_000));
+ }
+
+ public string EngineId => "gemini";
+ public string Model { get; }
+
+ public async Task WriteAsync(
+ string title, string artist, IReadOnlyList sources,
+ string targetLang, CancellationToken ct = default)
+ {
+ if (_apiKey.Length == 0 || sources.Count == 0) return MeaningWriteResult.Failed;
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(_timeout);
+
+ var prompt = MeaningPrompt.Build(title, artist, sources, targetLang);
+ var payload = new GeminiRequest
+ {
+ Contents = [new GeminiContent { Parts = [new GeminiPart { Text = prompt }] }],
+ GenerationConfig = new GeminiGenerationConfig
+ {
+ MaxOutputTokens = MeaningPrompt.MaxOutputTokens,
+ },
+ };
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/{Model}:generateContent")
+ {
+ Content = JsonContent.Create(payload, options: Json),
+ };
+ request.Headers.Add("x-goog-api-key", _apiKey);
+
+ using var response = await _http.SendAsync(request, cts.Token).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode) return MeaningWriteResult.FromStatus(response.StatusCode);
+
+ var body = await response.Content.ReadFromJsonAsync(Json, cts.Token).ConfigureAwait(false);
+ var text = body?.Candidates?
+ .FirstOrDefault()?.Content?.Parts?
+ .FirstOrDefault(p => !string.IsNullOrWhiteSpace(p.Text))?.Text;
+ return string.IsNullOrWhiteSpace(text)
+ ? MeaningWriteResult.Failed
+ : MeaningWriteResult.Written(text!.Trim());
+ }
+ catch (OperationCanceledException)
+ {
+ // 타임아웃이든 호출자 취소든 "결과를 모른다"는 뜻이다 — 실패로 못 박지 않는다.
+ return MeaningWriteResult.Transient;
+ }
+ catch (HttpRequestException)
+ {
+ return MeaningWriteResult.Transient; // 네트워크는 다음에 될 수 있다
+ }
+ catch (Exception)
+ {
+ return MeaningWriteResult.Failed; // 의미는 부가 기능 — 가사에 영향이 없어야 한다
+ }
+ }
+
+ // ---- 요청/응답 모델(필요한 필드만) ----
+
+ private sealed record GeminiRequest
+ {
+ public GeminiContent[] Contents { get; init; } = [];
+ public GeminiGenerationConfig? GenerationConfig { get; init; }
+ }
+
+ /// 출력 상한만 쓴다( 참고).
+ private sealed record GeminiGenerationConfig
+ {
+ public int MaxOutputTokens { get; init; }
+ }
+
+ private sealed record GeminiContent
+ {
+ public GeminiPart[] Parts { get; init; } = [];
+ }
+
+ private sealed record GeminiPart
+ {
+ public string? Text { get; init; }
+ }
+
+ private sealed record GeminiResponse(GeminiCandidate[]? Candidates);
+ private sealed record GeminiCandidate(GeminiContentOut? Content);
+ private sealed record GeminiContentOut(GeminiPart[]? Parts);
+}
diff --git a/src/Musebase.Core/Meaning/GeniusSource.cs b/src/Musebase.Core/Meaning/GeniusSource.cs
new file mode 100644
index 0000000..f6668ac
--- /dev/null
+++ b/src/Musebase.Core/Meaning/GeniusSource.cs
@@ -0,0 +1,146 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Musebase.Core.Search;
+
+namespace Musebase.Core.Meaning;
+
+///
+/// Genius 공식 API로 곡의 "About"(description)을 가져온다.
+///
+/// 무료 Client Access Token만 있으면 되고 OAuth 사용자 플로우가 필요 없다
+/// (에서 발급). 두 번 호출한다:
+/// GET /search?q=로 곡 id를 찾고 → GET /songs/{id}?text_format=plain에서
+/// description과 정확한 곡 페이지 url을 읽는다.
+///
+/// **가사 본문은 이 API로 오지 않는다**(그건 스크래핑 영역) — 우리는 이미 가지고 있으므로 상관없다.
+///
+public sealed class GeniusSource : ISongMeaningSource
+{
+ private const string BaseUrl = "https://api.genius.com";
+
+ private static readonly JsonSerializerOptions Json = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ };
+
+ private readonly HttpClient _http;
+ private readonly string _token;
+ private readonly TimeSpan _timeout;
+
+ ///
+ /// **두 번의 순차 호출 전체**에 대한 예산이라 넉넉해야 한다. 실측에서 유명 곡일수록
+ /// 설명이 길어 느렸다 — Lady Gaga / Shallow는 검색 0.96초 + 상세 2.0초로 2.95초였고,
+ /// 예전 기본값 2.5초에서는 **정확히 그런 곡들만 조용히 잘려 나갔다**(자료가 가장 좋은 곡들이다).
+ /// 가사 검색과 달리 여기서는 사람이 버튼을 누르고 기다리므로 지연보다 누락이 훨씬 나쁘다.
+ ///
+ public GeniusSource(string token, HttpClient? http = null, int timeoutMs = 8000)
+ {
+ _token = token.Trim();
+ _http = http ?? MeaningHttp.Client;
+ _timeout = TimeSpan.FromMilliseconds(Math.Clamp(timeoutMs, 500, 30_000));
+ }
+
+ public string Name => "Genius";
+
+ public async Task FetchAsync(string title, string artist, CancellationToken ct = default)
+ {
+ if (_token.Length == 0) return null;
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(_timeout);
+
+ var hit = await SearchAsync(title, artist, cts.Token).ConfigureAwait(false);
+ if (hit is null) return null;
+
+ var song = await GetAsync(
+ $"/songs/{hit.Id}?text_format=plain", cts.Token).ConfigureAwait(false);
+ var description = song?.Response?.Song?.Description?.Plain;
+ var url = song?.Response?.Song?.Url ?? hit.Url;
+
+ // 설명이 비어 있는 곡이 많다(대부분의 곡에 About이 없다). 그때는 소스가 없는 것으로 본다.
+ if (string.IsNullOrWhiteSpace(description) || description!.Trim().Length < 40)
+ return null;
+
+ return new MeaningSource(Name, url, description.Trim());
+ }
+ catch (Exception)
+ {
+ return null; // 조용한 강등 — 다른 소스가 채운다
+ }
+ }
+
+ ///
+ /// 원본 표기와 정제 표기를 순서대로 시도한다. 스트리밍 메타데이터의 잡음
+ /// (피처링·리마스터 표기, "• 스마트셔플 추천" 같은 꼬리표)은 Genius 검색을 그냥 실패시킨다.
+ ///
+ private async Task SearchAsync(string title, string artist, CancellationToken ct)
+ {
+ foreach (var term in Terms(title, artist))
+ {
+ var url = "/search?q=" + Uri.EscapeDataString(term);
+ var found = await GetAsync(url, ct).ConfigureAwait(false);
+ if (found?.Response?.Hits is not { Length: > 0 } hits) continue;
+
+ foreach (var wrapper in hits)
+ {
+ if (!string.Equals(wrapper.Type, "song", StringComparison.OrdinalIgnoreCase)) continue;
+ if (wrapper.Result is not { Id: > 0 } song) continue;
+ if (!Matches(song.Title, song.Artists, title, artist)) continue;
+ return song;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// 이 검색 결과가 정말 그 곡인지 확인한다. 판정 기준은 검색 기반 소스가 모두 공유한다
+ /// ( — 그쪽에 이유를 적어 뒀다).
+ ///
+ internal static bool Matches(string? hitTitle, string? hitArtists, string title, string artist) =>
+ MeaningMatch.IsSameSong(hitTitle, hitArtists, title, artist);
+
+ private static IEnumerable Terms(string title, string artist)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ string Compose(string t, string a) => string.IsNullOrWhiteSpace(a) ? t.Trim() : $"{a.Trim()} {t.Trim()}";
+
+ if (seen.Add(Compose(title, artist))) yield return Compose(title, artist);
+
+ foreach (var variant in SearchTermCleaner.Variants(new SearchTerm(title, artist)))
+ {
+ if (variant.IsKeyword) continue;
+ var term = Compose(variant.Title ?? title, variant.Artist ?? artist);
+ if (seen.Add(term)) yield return term;
+ }
+ }
+
+ private async Task GetAsync(string path, CancellationToken ct)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + path);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
+ using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode) return default;
+ return await response.Content.ReadFromJsonAsync(Json, ct).ConfigureAwait(false);
+ }
+
+ // ---- 응답 모델(필요한 필드만) ----
+
+ private sealed record GeniusSearchEnvelope(GeniusSearchResponse? Response);
+ private sealed record GeniusSearchResponse(GeniusHitWrapper[]? Hits);
+ private sealed record GeniusHitWrapper(string? Type, GeniusHit? Result);
+ private sealed record GeniusHit(
+ long Id,
+ string? Url,
+ string? Title,
+ [property: JsonPropertyName("artist_names")] string? Artists);
+
+ private sealed record GeniusSongEnvelope(GeniusSongResponse? Response);
+ private sealed record GeniusSongResponse(GeniusSong? Song);
+ private sealed record GeniusSong(string? Url, GeniusDescription? Description);
+
+ /// text_format=plain이면 설명이 {"plain": "..."}로 온다.
+ private sealed record GeniusDescription([property: JsonPropertyName("plain")] string? Plain);
+}
diff --git a/src/Musebase.Core/Meaning/IMeaningWriter.cs b/src/Musebase.Core/Meaning/IMeaningWriter.cs
new file mode 100644
index 0000000..53271d7
--- /dev/null
+++ b/src/Musebase.Core/Meaning/IMeaningWriter.cs
@@ -0,0 +1,90 @@
+using System.Text;
+
+namespace Musebase.Core.Meaning;
+
+///
+/// 수집한 영어 원문들을 읽고 "이 곡이 무엇에 대한 노래인지"를 대상 언어로 써 준다.
+///
+/// 번역이 아니라 **요약**이라 로는 안 된다 — DeepL에 긴 영어
+/// bio를 넣으면 긴 한국어 문서가 나올 뿐 "의미"가 되지 않는다. 구현은 순수 HTTP + JSON이며
+/// 엔진은 로 갈아끼운다(번역 엔진과 같은 구조).
+///
+/// 실패는 예외가 아니라 다 — 의미는 부가 기능이고, 없다고
+/// 가사가 안 뜨면 안 된다. 다만 "일시적 실패"만은 구분해서 돌려준다(그쪽 설명 참고).
+///
+public interface IMeaningWriter
+{
+ /// 레지스트리 id(예: "gemini"). 어떤 엔진으로 만들었는지 기록해 둔다.
+ string EngineId { get; }
+
+ /// 실제로 호출한 모델 이름(재생성 판단·기록용).
+ string Model { get; }
+
+ Task WriteAsync(
+ string title, string artist, IReadOnlyList sources,
+ string targetLang, CancellationToken ct = default);
+}
+
+///
+/// 엔진에 상관없이 같은 프롬프트를 쓴다 — 모델을 바꿔도 결과의 성격이 흔들리지 않게 하고,
+/// 두 엔진의 출력을 나란히 비교할 수 있게 하기 위해서다.
+///
+public static class MeaningPrompt
+{
+ /// 원문이 아무리 길어도 이 길이까지만 넣는다(토큰 폭주 방지).
+ public const int MaxSourceChars = 6000;
+
+ ///
+ /// 출력 상한. 3~5문장이면 충분하고, 넉넉히 잡아도 이 정도다.
+ /// **반드시 요청에 실어야 한다** — 상한을 안 보내면 공급자가 모델 최대치를 예약하려 들어
+ /// 잔액이 적은 계정에서 402로 거절당한다(OpenRouter 실측). 비용 폭주 방지이기도 하다.
+ ///
+ public const int MaxOutputTokens = 1200;
+
+ ///
+ /// 마지막 문장이 이 프롬프트의 핵심이다 — 자료가 부족할 때 모델이 지어내지 않고
+ /// "부족하다"고 쓰게 만든다. 곡 해설은 그럴듯한 창작이 특히 쉬운 영역이다.
+ ///
+ public static string Build(
+ string title, string artist, IReadOnlyList sources, string targetLang)
+ {
+ var language = LanguageName(targetLang);
+ var sb = new StringBuilder();
+ sb.Append("다음은 한 곡에 대해 여러 웹 자료에서 모은 설명이다.\n\n");
+ sb.Append($"곡: {title}\n아티스트: {artist}\n\n");
+
+ var budget = MaxSourceChars;
+ foreach (var source in sources)
+ {
+ if (budget <= 0) break;
+ var text = source.Text.Length > budget ? source.Text[..budget] : source.Text;
+ budget -= text.Length;
+ sb.Append($"[{source.Name}]\n{text}\n\n");
+ }
+
+ sb.Append($"""
+ 위 자료만 근거로, 이 곡이 무엇에 대한 노래인지 {language}로 3~5문장으로 써라.
+
+ - 작곡 배경, 가사가 다루는 주제, 알려진 해석을 중심으로 쓴다.
+ - 자료에 없는 내용은 절대 지어내지 않는다. 추측하지 않는다.
+ - 자료가 부족해 이 곡이 무엇에 대한 노래인지 말할 수 없으면, 첫 줄에 정확히
+ [자료부족] 이라고 쓰고 그 뒤에 이유를 한 문장으로만 쓴다. 억지로 채우지 않는다.
+ - 차트 성적·수상 이력 같은 곡의 의미와 무관한 사실은 넣지 않는다.
+ - 자료에 인용문이나 개인적 감상("내가 밴드에 들어온 뒤…", "정말 훌륭하다")이 섞여
+ 있으면 그것을 객관적 서술처럼 옮기지 않는다. 곡이 무엇에 대한 노래인지만 쓴다.
+ - 출처 이름에 "AI 분석"이 붙은 자료는 기계가 가사를 해석한 것이라 사실 근거가 약하다.
+ 다른 자료와 어긋나면 다른 자료를 따르고, 그것만 있을 때는 단정하지 않는다.
+ - 머리말 없이 본문만 쓴다.
+ """);
+ return sb.ToString();
+ }
+
+ private static string LanguageName(string code) => code.ToLowerInvariant() switch
+ {
+ "ko" => "한국어",
+ "ja" => "일본어",
+ "en" => "영어",
+ "zh" or "zh-hans" or "zh-hant" => "중국어",
+ _ => code,
+ };
+}
diff --git a/src/Musebase.Core/Meaning/ISongMeaningSource.cs b/src/Musebase.Core/Meaning/ISongMeaningSource.cs
new file mode 100644
index 0000000..46f12e1
--- /dev/null
+++ b/src/Musebase.Core/Meaning/ISongMeaningSource.cs
@@ -0,0 +1,25 @@
+namespace Musebase.Core.Meaning;
+
+///
+/// 한 소스에서 가져온 곡 배경 원문. 는 영어 산문인 경우가 대부분이라
+/// 그대로 보여 주지 않고 가 한국어로 요약한다.
+///
+/// 표시용 소스 이름(출처 표기 의무가 있다 — 화면에 그대로 렌더한다).
+/// 사람이 원문을 확인할 주소. 없으면 null.
+/// 수집한 본문.
+public sealed record MeaningSource(string Name, string? Url, string Text);
+
+///
+/// 곡 배경 원문을 한 곳에서 가져오는 계약.
+///
+/// **실패는 예외가 아니라 null이다** — 키 미설정·타임아웃·검색 실패·차단이 모두 같은 결과다
+/// (의 조용한 강등과 같은 원칙). 소스 하나가 죽어도
+/// 나머지가 채우고, 전부 비면 호출자가 LLM을 아예 부르지 않는다.
+///
+public interface ISongMeaningSource
+{
+ /// 표시·기록용 소스 id(예: "genius").
+ string Name { get; }
+
+ Task FetchAsync(string title, string artist, CancellationToken ct = default);
+}
diff --git a/src/Musebase.Core/Meaning/LastFmSource.cs b/src/Musebase.Core/Meaning/LastFmSource.cs
new file mode 100644
index 0000000..930c383
--- /dev/null
+++ b/src/Musebase.Core/Meaning/LastFmSource.cs
@@ -0,0 +1,105 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+using Musebase.Core.Search;
+
+namespace Musebase.Core.Meaning;
+
+///
+/// Last.fm track.getInfo의 wiki(곡 해설)를 가져온다. 무료 API 키만 있으면 되고
+/// 인증 플로우가 없다. Genius에 About이 없는 곡을 자주 메워 준다.
+///
+/// 본문 끝에는 항상 "Read more on Last.fm" 링크가 HTML로 붙어 오므로 잘라 낸다.
+///
+public sealed partial class LastFmSource : ISongMeaningSource
+{
+ private const string Endpoint = "https://ws.audioscrobbler.com/2.0/";
+
+ private static readonly JsonSerializerOptions Json = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private readonly HttpClient _http;
+ private readonly string _apiKey;
+ private readonly TimeSpan _timeout;
+
+ public LastFmSource(string apiKey, HttpClient? http = null, int timeoutMs = 6000)
+ {
+ _apiKey = apiKey.Trim();
+ _http = http ?? MeaningHttp.Client;
+ _timeout = TimeSpan.FromMilliseconds(Math.Clamp(timeoutMs, 500, 30_000));
+ }
+
+ public string Name => "Last.fm";
+
+ public async Task FetchAsync(string title, string artist, CancellationToken ct = default)
+ {
+ if (_apiKey.Length == 0 || string.IsNullOrWhiteSpace(artist)) return null;
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(_timeout);
+
+ foreach (var (t, a) in Variants(title, artist))
+ {
+ var url = $"{Endpoint}?method=track.getinfo&format=json"
+ + $"&api_key={Uri.EscapeDataString(_apiKey)}"
+ + $"&artist={Uri.EscapeDataString(a)}&track={Uri.EscapeDataString(t)}"
+ + "&autocorrect=1";
+
+ // Last.fm은 오류도 HTTP 200에 담아 보내므로 본문을 봐야 한다.
+ var body = await _http.GetFromJsonAsync(url, Json, cts.Token).ConfigureAwait(false);
+ var wiki = body?.Track?.Wiki;
+ var text = Clean(wiki?.Content) ?? Clean(wiki?.Summary);
+ if (text is { Length: >= 40 })
+ return new MeaningSource(Name, body!.Track!.Url, text);
+ }
+ return null;
+ }
+ catch (Exception)
+ {
+ return null; // 조용한 강등
+ }
+ }
+
+ private static IEnumerable<(string Title, string Artist)> Variants(string title, string artist)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (seen.Add($"{title}|{artist}")) yield return (title, artist);
+
+ foreach (var variant in SearchTermCleaner.Variants(new SearchTerm(title, artist)))
+ {
+ if (variant.IsKeyword) continue;
+ var t = variant.Title ?? title;
+ var a = variant.Artist ?? artist;
+ if (seen.Add($"{t}|{a}")) yield return (t, a);
+ }
+ }
+
+ /// HTML 태그와 꼬리표("Read more on Last.fm")를 걷어 낸다.
+ private static string? Clean(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) return null;
+ var text = TagRegex().Replace(raw, " ");
+ var marker = text.IndexOf("Read more on Last.fm", StringComparison.OrdinalIgnoreCase);
+ if (marker > 0) text = text[..marker];
+ text = WhitespaceRegex().Replace(text, " ").Trim();
+ return text.Length == 0 ? null : System.Net.WebUtility.HtmlDecode(text);
+ }
+
+ [GeneratedRegex("<[^>]+>")]
+ private static partial Regex TagRegex();
+
+ [GeneratedRegex(@"\s+")]
+ private static partial Regex WhitespaceRegex();
+
+ // ---- 응답 모델(필요한 필드만) ----
+
+ private sealed record LastFmEnvelope(LastFmTrack? Track);
+ private sealed record LastFmTrack(string? Url, LastFmWiki? Wiki);
+ private sealed record LastFmWiki(
+ [property: JsonPropertyName("summary")] string? Summary,
+ [property: JsonPropertyName("content")] string? Content);
+}
diff --git a/src/Musebase.Core/Meaning/MeaningHttp.cs b/src/Musebase.Core/Meaning/MeaningHttp.cs
new file mode 100644
index 0000000..f5a653c
--- /dev/null
+++ b/src/Musebase.Core/Meaning/MeaningHttp.cs
@@ -0,0 +1,34 @@
+using System.Net;
+
+namespace Musebase.Core.Meaning;
+
+///
+/// 의미 수집 전용 HttpClient.
+///
+/// **가사 제공자와 분리한 이유는 User-Agent다.** Wikimedia는 설명적인 User-Agent를 요구하고
+/// 없으면 **403을 돌려준다** — .NET의 는 기본 User-Agent를 보내지 않으므로
+/// 그대로 두면 위키피디아 소스가 항상 조용히 빈다(실측으로 확인). 가사 제공자 쪽
+/// LyricsHttp.Client에 헤더를 얹으면 이미 검증된 제공자들의 동작까지 건드리게 되므로
+/// 여기서만 붙인다.
+///
+public static class MeaningHttp
+{
+ /// Wikimedia User-Agent 정책이 요구하는 형식 — 앱 이름 + 연락 가능한 주소.
+ public const string UserAgent = "Musebase/1.0 (+https://github.com/countnine/musebase)";
+
+ public static readonly HttpClient Client = CreateClient();
+
+ private static HttpClient CreateClient()
+ {
+ var client = new HttpClient(new HttpClientHandler
+ {
+ AutomaticDecompression = DecompressionMethods.All,
+ UseCookies = false,
+ })
+ {
+ Timeout = TimeSpan.FromSeconds(30), // 실제 만료는 소스마다 링크된 CTS로 제어한다
+ };
+ client.DefaultRequestHeaders.Add("User-Agent", UserAgent);
+ return client;
+ }
+}
diff --git a/src/Musebase.Core/Meaning/MeaningMatch.cs b/src/Musebase.Core/Meaning/MeaningMatch.cs
new file mode 100644
index 0000000..cf5fec8
--- /dev/null
+++ b/src/Musebase.Core/Meaning/MeaningMatch.cs
@@ -0,0 +1,37 @@
+namespace Musebase.Core.Meaning;
+
+///
+/// 검색 결과가 정말 그 곡인지 판정한다.
+///
+/// Genius도 Musixmatch도 **무엇을 넣든 무언가를 돌려준다.** 실측에서 음악이 아닌 유튜브 제목
+/// ("해외에서 화제라는 한국의 지하철 문화")으로 검색했더니 전혀 무관한 곡이 첫 히트로 나왔고,
+/// 확인 없이 받았으면 그 곡의 해설이 이 트랙의 "의미"로 붙었을 것이다.
+/// 엉뚱한 근거는 자료가 없는 것보다 나쁘다 — 그럴듯하고 완전히 틀린 글이 만들어지기 때문이다.
+///
+/// 그래서 검색 기반 소스는 전부 이 하나의 기준을 통과해야 한다.
+///
+public static class MeaningMatch
+{
+ ///
+ /// 제목 일치는 필수, 아티스트는 아는 경우에만 확인을 요구한다.
+ /// 제목은 어느 쪽이 담아도 인정한다 — "(Remix)"·"(Live)" 같은 꼬리표가 흔하다.
+ ///
+ public static bool IsSameSong(string? hitTitle, string? hitArtists, string title, string artist)
+ {
+ var wanted = MeaningText.Normalize(title);
+ var got = MeaningText.Normalize(hitTitle ?? "");
+ if (wanted.Length == 0 || got.Length == 0) return false;
+
+ if (!got.Contains(wanted, StringComparison.Ordinal)
+ && !wanted.Contains(got, StringComparison.Ordinal)) return false;
+
+ var names = ArtistNames.All(artist)
+ .Select(MeaningText.Normalize)
+ .Where(n => n.Length >= 3)
+ .ToList();
+ if (names.Count == 0) return true; // 아티스트를 모르면 제목만으로 받아들인다
+
+ var haystack = MeaningText.Normalize(hitArtists ?? "");
+ return names.Any(n => haystack.Contains(n, StringComparison.Ordinal));
+ }
+}
diff --git a/src/Musebase.Core/Meaning/MeaningText.cs b/src/Musebase.Core/Meaning/MeaningText.cs
new file mode 100644
index 0000000..74e190f
--- /dev/null
+++ b/src/Musebase.Core/Meaning/MeaningText.cs
@@ -0,0 +1,37 @@
+using System.Net;
+using System.Text.RegularExpressions;
+
+namespace Musebase.Core.Meaning;
+
+/// 제목·아티스트를 견주기 위한 정규화. 소스 구현들이 같은 기준을 쓰게 한다.
+public static partial class MeaningText
+{
+ ///
+ /// 소문자 + 영숫자/한글만 남긴다(괄호·구두점·공백 제거).
+ ///
+ /// 그 전에 두 가지를 반드시 처리한다 — 실측으로 둘 다 곡을 통째로 놓치게 만들었다.
+ ///
+ /// ① HTML 태그와 엔티티. 위키피디아 검색 스니펫은 <span class="searchmatch">와
+ /// &를 그대로 담아 온다. 지우지 않으면 Belle & Sebastian이
+ /// belleampsebastian이 되어("amp"가 글자로 섞인다) 무엇과도 맞지 않는다.
+ /// ② &와 and는 같은 말이다. 우리가 받은 표기가 "Belle and Sebastian"인데 문서는
+ /// "Belle & Sebastian"으로 적는 식으로 흔히 갈린다.
+ ///
+ public static string Normalize(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return "";
+
+ var text = TagRegex().Replace(s, " ");
+ text = WebUtility.HtmlDecode(text);
+ text = text.Replace("&", " and ", StringComparison.Ordinal);
+
+ Span buffer = text.Length <= 256 ? stackalloc char[text.Length] : new char[text.Length];
+ var n = 0;
+ foreach (var c in text)
+ if (char.IsLetterOrDigit(c)) buffer[n++] = char.ToLowerInvariant(c);
+ return new string(buffer[..n]);
+ }
+
+ [GeneratedRegex("<[^>]*>")]
+ private static partial Regex TagRegex();
+}
diff --git a/src/Musebase.Core/Meaning/MeaningVerdict.cs b/src/Musebase.Core/Meaning/MeaningVerdict.cs
new file mode 100644
index 0000000..aec9293
--- /dev/null
+++ b/src/Musebase.Core/Meaning/MeaningVerdict.cs
@@ -0,0 +1,51 @@
+using System.Text.RegularExpressions;
+
+namespace Musebase.Core.Meaning;
+
+///
+/// 생성된 문단이 정말 곡의 의미인지, 아니면 "자료가 부족해 말할 수 없다"는 고백인지 가른다.
+///
+/// 프롬프트가 근거 없는 창작을 막으려고 "부족하면 부족하다고 쓰라"고 시키므로, 이 답은 정상 동작의
+/// 일부다. 문제는 글자가 있다는 이유만으로 "의미 있음"으로 세면 통계가 부풀고, 앱에는
+/// "파악하기 어렵다"는 문장이 곡 해설이라며 뜬다는 것이다. 별도 상태로 갈라 둔다.
+///
+/// 판정은 두 겹이다.
+/// ① 표식 — 프롬프트가 이 경우 첫 줄에 [자료부족]을 쓰게 한다(가장 확실하다).
+/// ② 문구 — 표식을 안 붙이는 모델도 있어, 자료 자체를 두고 하는 말("자료만으로는",
+/// "파악하기 어렵다")을 함께 본다. 곡 이야기를 하는 문장은 이런 표현을 쓰지 않는다.
+///
+public static partial class MeaningVerdict
+{
+ /// 프롬프트가 요구하는 표식. 응답에서는 지우고 저장한다.
+ public const string Marker = "[자료부족]";
+
+ /// 표식을 떼어 낸 본문(없으면 원문 그대로).
+ public static string Strip(string text)
+ {
+ var trimmed = (text ?? "").Trim();
+ return trimmed.StartsWith(Marker, StringComparison.Ordinal)
+ ? trimmed[Marker.Length..].Trim()
+ : trimmed;
+ }
+
+ /// 이 문단이 "자료가 부족하다"는 고백인가.
+ public static bool IsInsufficient(string? text)
+ {
+ var trimmed = (text ?? "").Trim();
+ if (trimmed.Length == 0) return true;
+ if (trimmed.StartsWith(Marker, StringComparison.Ordinal)) return true;
+ return ExcuseRegex().IsMatch(trimmed);
+ }
+
+ ///
+ /// 자료 자체를 두고 하는 말만 고른다. "이 곡은 상실을 다룬다" 같은 문장은 걸리지 않도록
+ /// 자료·정보를 주어로 삼는 표현과 말할 수 없다는 서술이 함께 있을 때만 본다.
+ ///
+ [GeneratedRegex(
+ @"(자료|정보)[^.!?\n]{0,40}(부족|없|담고 있지 않|포함되어 있지 않)" +
+ @"|(자료|정보)만으로는" +
+ @"|(파악|설명|말)하기\s*(가\s*)?(어렵|힘들)" +
+ @"|알\s*수\s*없(다|습니다)",
+ RegexOptions.CultureInvariant)]
+ private static partial Regex ExcuseRegex();
+}
diff --git a/src/Musebase.Core/Meaning/MeaningWriteResult.cs b/src/Musebase.Core/Meaning/MeaningWriteResult.cs
new file mode 100644
index 0000000..97e464d
--- /dev/null
+++ b/src/Musebase.Core/Meaning/MeaningWriteResult.cs
@@ -0,0 +1,37 @@
+using System.Net;
+
+namespace Musebase.Core.Meaning;
+
+///
+/// 의미 생성 한 번의 결과.
+///
+/// 성공/실패 두 갈래로는 부족해서 을 따로 둔다. 429(쿼타 초과)나
+/// 5xx는 **시간이 지나면 저절로 풀리는** 실패인데, 이걸 영구 실패로 저장해 버리면 그 곡은
+/// 다시 시도되지 않는다 — 백필은 이미 행이 있는 곡을 건너뛰기 때문이다. 쿼타는 회복되는데
+/// 기록만 남아 "이 곡은 의미를 만들 수 없다"가 되는 셈이라, 일시적 실패는 아무것도 남기지
+/// 않고 물러나는 편이 옳다.
+///
+/// 생성된 문단. null이면 실패다.
+/// 시간이 지나면 풀릴 실패인가 — 그렇다면 저장하지 않는다.
+public sealed record MeaningWriteResult(string? Text, bool Retryable)
+{
+ /// 영구 실패(키가 틀렸다, 응답이 비었다 등) — 저장해 두고 넘어간다.
+ public static readonly MeaningWriteResult Failed = new(null, false);
+
+ /// 일시적 실패(쿼타·서버·네트워크·타임아웃) — 저장하지 않는다.
+ public static readonly MeaningWriteResult Transient = new(null, true);
+
+ public static MeaningWriteResult Written(string text) => new(text, false);
+
+ ///
+ /// 429는 쿼타, 5xx는 상대 서버 문제, 402는 잔액 부족 — 셋 다 시간이나 충전으로 풀린다.
+ /// 402를 영구 실패로 굳히면 백필 도중 잔액이 떨어졌을 때 남은 곡이 전부 "의미 없음"으로
+ /// 박제된다(429에서 고친 것과 같은 병이다).
+ /// 나머지 4xx(키 오류·잘못된 요청)는 다시 불러도 같은 답이 오므로 영구 실패다.
+ ///
+ public static MeaningWriteResult FromStatus(HttpStatusCode code) =>
+ code is HttpStatusCode.TooManyRequests or HttpStatusCode.PaymentRequired
+ || (int)code >= 500
+ ? Transient
+ : Failed;
+}
diff --git a/src/Musebase.Core/Meaning/MeaningWriterRegistry.cs b/src/Musebase.Core/Meaning/MeaningWriterRegistry.cs
new file mode 100644
index 0000000..5288caa
--- /dev/null
+++ b/src/Musebase.Core/Meaning/MeaningWriterRegistry.cs
@@ -0,0 +1,52 @@
+namespace Musebase.Core.Meaning;
+
+/// 의미 생성 엔진 구성값. 키가 없는 엔진은 만들어지지 않는다(null).
+public sealed record MeaningWriterOptions
+{
+ public string? GeminiApiKey { get; init; }
+ public string? GeminiModel { get; init; }
+ public string? OpenRouterApiKey { get; init; }
+ public string? OpenRouterModel { get; init; }
+ public HttpClient? Http { get; init; }
+}
+
+/// 표시·선택용 엔진 서술자.
+public sealed record MeaningWriterDescriptor(
+ string Id,
+ string Display,
+ Func Factory);
+
+///
+/// 의미 생성 엔진 레지스트리. 와 같은 모양이라
+/// 설정 화면·환경변수 배선이 그대로 재사용된다.
+///
+/// 기본은 none이다 — 키를 넣기 전에는 아무것도 하지 않고, 관리자 화면에는 외부 링크만 뜬다.
+///
+public static class MeaningWriterRegistry
+{
+ public const string None = "none";
+
+ public static IReadOnlyList All { get; } = new MeaningWriterDescriptor[]
+ {
+ new("gemini", "Google Gemini (API 키·무료 티어 있음)",
+ o => string.IsNullOrWhiteSpace(o.GeminiApiKey)
+ ? null
+ : new GeminiMeaningWriter(o.GeminiApiKey!, o.GeminiModel, o.Http)),
+
+ new("openrouter", "OpenRouter (모델 자유 선택)",
+ o => string.IsNullOrWhiteSpace(o.OpenRouterApiKey)
+ ? null
+ : new OpenRouterMeaningWriter(o.OpenRouterApiKey!, o.OpenRouterModel, o.Http)),
+ };
+
+ public static MeaningWriterDescriptor? Find(string id) =>
+ All.FirstOrDefault(d => string.Equals(d.Id, id, StringComparison.OrdinalIgnoreCase));
+
+ /// 선택된 엔진을 만든다. "none"/미지원/키 부족이면 null.
+ public static IMeaningWriter? Build(string? id, MeaningWriterOptions options)
+ {
+ if (string.IsNullOrWhiteSpace(id) || string.Equals(id, None, StringComparison.OrdinalIgnoreCase))
+ return null;
+ return Find(id!)?.Factory(options);
+ }
+}
diff --git a/src/Musebase.Core/Meaning/MusixmatchApi.cs b/src/Musebase.Core/Meaning/MusixmatchApi.cs
new file mode 100644
index 0000000..881111c
--- /dev/null
+++ b/src/Musebase.Core/Meaning/MusixmatchApi.cs
@@ -0,0 +1,118 @@
+using System.Text.Json;
+using Musebase.Core.Search;
+
+namespace Musebase.Core.Meaning;
+
+/// Musixmatch가 알려 준 곡 하나. 이 그 곡의 공식 페이지 주소다.
+public sealed record MusixmatchTrack(long TrackId, string? ShareUrl, string? Name, string? Artist);
+
+///
+/// Musixmatch 공식 API(api.musixmatch.com/ws/1.1)로 곡을 찾아 **정확한 곡 페이지 주소**를 얻는다.
+///
+/// 주소를 규칙으로 만들면 안 되는 이유가 실측으로 확인됐다 — /lyrics/Pearl-Jam/Even-Flow는
+/// 오류 없이 200을 주면서 조용히 /lyrics/Pearl-Jam/Alive(다른 곡!)로 넘어간다.
+/// 사용자를 엉뚱한 곡으로 보내는 실패라 추측은 금지이고, 검색 결과 페이지를 서버가 긁는 길도
+/// 익명 요청이 로그인 페이지로 리다이렉트되어 막혀 있다. 남은 정당한 길이 이 API다.
+///
+/// 키는 에서 발급한다. 없으면 조용히 꺼진다.
+///
+public sealed class MusixmatchApi
+{
+ private const string BaseUrl = "https://api.musixmatch.com/ws/1.1";
+
+ private readonly HttpClient _http;
+ private readonly string _apiKey;
+ private readonly TimeSpan _timeout;
+
+ public MusixmatchApi(string apiKey, HttpClient? http = null, int timeoutMs = 6000)
+ {
+ _apiKey = (apiKey ?? "").Trim();
+ _http = http ?? MeaningHttp.Client;
+ _timeout = TimeSpan.FromMilliseconds(Math.Clamp(timeoutMs, 500, 30_000));
+ }
+
+ public bool IsConfigured => _apiKey.Length > 0;
+
+ public async Task FindAsync(string title, string artist, CancellationToken ct = default)
+ {
+ if (!IsConfigured) return null;
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(_timeout);
+
+ foreach (var (t, a) in Terms(title, artist))
+ {
+ var url = $"{BaseUrl}/track.search"
+ + $"?q_track={Uri.EscapeDataString(t)}"
+ + $"&q_artist={Uri.EscapeDataString(a)}"
+ + "&page_size=5&s_track_rating=desc&apikey=" + Uri.EscapeDataString(_apiKey);
+
+ var json = await _http.GetStringAsync(url, cts.Token).ConfigureAwait(false);
+ if (Pick(json, title, artist) is { } track) return track;
+ }
+ return null;
+ }
+ catch (Exception)
+ {
+ return null; // 소스·링크 모두 부가 기능 — 실패해도 가사에 영향이 없어야 한다
+ }
+ }
+
+ ///
+ /// 응답에서 이 곡을 고른다(순수 함수 — 테스트 대상).
+ ///
+ /// HTTP 200이어도 실패일 수 있다 — Musixmatch는 성공/실패를 본문의
+ /// message.header.status_code에 싣는다(키 오류 401, 플랜 초과 402 …).
+ /// 결과가 없을 때 body가 객체가 아니라 빈 배열로 오는 경우도 있어
+ /// 레코드 역직렬화 대신 로 방어적으로 읽는다.
+ ///
+ internal static MusixmatchTrack? Pick(string json, string title, string artist)
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (!doc.RootElement.TryGetProperty("message", out var message)) return null;
+
+ if (message.TryGetProperty("header", out var header)
+ && header.TryGetProperty("status_code", out var status)
+ && status.TryGetInt32(out var code) && code != 200) return null;
+
+ if (!message.TryGetProperty("body", out var body) || body.ValueKind != JsonValueKind.Object) return null;
+ if (!body.TryGetProperty("track_list", out var list) || list.ValueKind != JsonValueKind.Array) return null;
+
+ foreach (var wrapper in list.EnumerateArray())
+ {
+ if (!wrapper.TryGetProperty("track", out var track)) continue;
+
+ var name = Text(track, "track_name");
+ var by = Text(track, "artist_name");
+
+ // 검색 API는 무엇을 넣든 뭔가를 돌려준다 — 받아들이기 전에 확인한다.
+ if (!MeaningMatch.IsSameSong(name, by, title, artist)) continue;
+
+ var id = track.TryGetProperty("track_id", out var idEl) && idEl.TryGetInt64(out var v) ? v : 0;
+ return new MusixmatchTrack(id, Text(track, "track_share_url"), name, by);
+ }
+ return null;
+ }
+
+ private static string? Text(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+
+ /// 원본 표기 → 정제 표기 순으로 시도한다. 아티스트는 대표 이름 하나만 쓴다.
+ private static IEnumerable<(string Title, string Artist)> Terms(string title, string artist)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ (string, string) Make(string t, string a) => (t.Trim(), ArtistNames.Primary(a));
+
+ var first = Make(title, artist);
+ if (seen.Add($"{first.Item1}|{first.Item2}")) yield return first;
+
+ foreach (var variant in SearchTermCleaner.Variants(new SearchTerm(title, artist)))
+ {
+ if (variant.IsKeyword) continue;
+ var next = Make(variant.Title ?? title, variant.Artist ?? artist);
+ if (seen.Add($"{next.Item1}|{next.Item2}")) yield return next;
+ }
+ }
+}
diff --git a/src/Musebase.Core/Meaning/MusixmatchMeaningSource.cs b/src/Musebase.Core/Meaning/MusixmatchMeaningSource.cs
new file mode 100644
index 0000000..37241e4
--- /dev/null
+++ b/src/Musebase.Core/Meaning/MusixmatchMeaningSource.cs
@@ -0,0 +1,115 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace Musebase.Core.Meaning;
+
+///
+/// Musixmatch 곡 페이지의 "Meaning"을 가져온다. 기본으로 켜지지 않는다 —
+/// MUSEBASE_MEANING_SOURCES에 musixmatch를 명시해야 쓰인다.
+///
+/// 이 텍스트는 사람이 쓴 해설이 아니다. 페이지 HTML의 __NEXT_DATA__ 안 lens
+/// 블록에 들어 있고, 같은 블록에 moods·themes·콘텐츠 등급이 함께 있다 —
+/// 가사를 기계로 분석한 묶음이다. 즉 이걸 자료로 쓰면 LLM이 쓴 글을 다시 LLM에 넣어 요약하는
+/// 셈이라, 무엇에 근거했는지 추적할 수 없고 다른 소스와 같은 무게로 다루면 안 된다.
+/// 그래서 이름에 "(AI 분석)"을 박아 출처 표기에 그대로 드러나게 하고, 프롬프트에서도
+/// 다른 자료와 충돌하면 다른 자료를 따르도록 한다. 배경과 결정은 ADR-0007.
+///
+/// 주소는 가 준 track_share_url만 쓴다 — 규칙으로 만든 주소는
+/// 조용히 다른 곡으로 넘어간다(그쪽 설명 참고).
+///
+public sealed partial class MusixmatchMeaningSource : ISongMeaningSource
+{
+ /// 이보다 짧으면 근거로 삼지 않는다(Genius와 같은 기준).
+ private const int MinLength = 40;
+
+ private readonly MusixmatchApi _api;
+ private readonly HttpClient _http;
+ private readonly TimeSpan _timeout;
+
+ public MusixmatchMeaningSource(MusixmatchApi api, HttpClient? http = null, int timeoutMs = 8000)
+ {
+ _api = api;
+ _http = http ?? MeaningHttp.Client;
+ _timeout = TimeSpan.FromMilliseconds(Math.Clamp(timeoutMs, 500, 30_000));
+ }
+
+ /// 출처 표기에 그대로 나간다 — 사람이 쓴 해설처럼 보이면 안 된다.
+ public string Name => "Musixmatch (AI 분석)";
+
+ public async Task FetchAsync(string title, string artist, CancellationToken ct = default)
+ {
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(_timeout);
+
+ // 주소를 모르면 여기서 끝난다. 추측해서 받아 오지 않는다.
+ var track = await _api.FindAsync(title, artist, cts.Token).ConfigureAwait(false);
+ if (track?.ShareUrl is not { Length: > 0 } url) return null;
+
+ var html = await _http.GetStringAsync(url, cts.Token).ConfigureAwait(false);
+ var explanation = Explanation(html);
+ if (explanation is null || explanation.Length < MinLength) return null;
+
+ return new MeaningSource(Name, url, explanation);
+ }
+ catch (Exception)
+ {
+ return null; // 조용한 강등
+ }
+ }
+
+ ///
+ /// 페이지에서 의미 문단을 꺼낸다(순수 함수 — 테스트 대상).
+ ///
+ /// lens까지의 경로를 고정하지 않고 재귀로 찾는다 — Next.js 페이지의 데이터 구조는
+ /// 우리 사정과 무관하게 바뀌고, 경로를 박아 두면 바뀌는 순간 예외도 없이 조용히 비기 때문이다.
+ ///
+ internal static string? Explanation(string html)
+ {
+ var match = NextDataRegex().Match(html ?? "");
+ if (!match.Success) return null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(match.Groups[1].Value);
+ return FindLensMeaning(doc.RootElement)?.Trim();
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static string? FindLensMeaning(JsonElement element)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ foreach (var property in element.EnumerateObject())
+ {
+ if (property.NameEquals("lens")
+ && property.Value.ValueKind == JsonValueKind.Object
+ && property.Value.TryGetProperty("meaning", out var meaning)
+ && meaning.ValueKind == JsonValueKind.Object
+ && meaning.TryGetProperty("explanation", out var text)
+ && text.ValueKind == JsonValueKind.String)
+ return text.GetString();
+
+ if (FindLensMeaning(property.Value) is { } found) return found;
+ }
+ return null;
+
+ case JsonValueKind.Array:
+ foreach (var item in element.EnumerateArray())
+ if (FindLensMeaning(item) is { } found) return found;
+ return null;
+
+ default:
+ return null;
+ }
+ }
+
+ [GeneratedRegex("""""", RegexOptions.Singleline)]
+ private static partial Regex NextDataRegex();
+}
diff --git a/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs b/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs
new file mode 100644
index 0000000..2038b12
--- /dev/null
+++ b/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs
@@ -0,0 +1,126 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Musebase.Core.Search;
+
+namespace Musebase.Core.Meaning;
+
+///
+/// OpenRouter로 의미를 쓴다 — **모델을 갈아끼우기 위한 엔진**이다.
+///
+/// 엔드포인트가 OpenAI 호환(/api/v1/chat/completions)이라 키 하나로 Claude·Gemini·GPT·Llama를
+/// 모두 부를 수 있고, 바꾸는 것은 model 문자열 하나뿐이다(예: anthropic/claude-opus-5,
+/// google/gemini-2.5-flash). 같은 곡을 여러 모델로 만들어 문장 품질을 비교할 때 쓴다.
+///
+/// 대가로 플랫폼 수수료가 붙으므로, 대량 백필의 기본값은 무료 티어가 있는
+/// 쪽이 낫다.
+///
+public sealed class OpenRouterMeaningWriter : IMeaningWriter
+{
+ private const string Endpoint = "https://openrouter.ai/api/v1/chat/completions";
+
+ public const string DefaultModel = "google/gemini-2.5-flash";
+
+ private static readonly JsonSerializerOptions Json = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ private readonly HttpClient _http;
+ private readonly string _apiKey;
+ private readonly TimeSpan _timeout;
+
+ public OpenRouterMeaningWriter(string apiKey, string? model = null, HttpClient? http = null, int timeoutMs = 60_000)
+ {
+ _apiKey = apiKey.Trim();
+ Model = string.IsNullOrWhiteSpace(model) ? DefaultModel : model!.Trim();
+ _http = http ?? MeaningHttp.Client;
+ _timeout = TimeSpan.FromMilliseconds(Math.Clamp(timeoutMs, 1000, 180_000));
+ }
+
+ public string EngineId => "openrouter";
+ public string Model { get; }
+
+ public async Task WriteAsync(
+ string title, string artist, IReadOnlyList sources,
+ string targetLang, CancellationToken ct = default)
+ {
+ if (_apiKey.Length == 0 || sources.Count == 0) return MeaningWriteResult.Failed;
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(_timeout);
+
+ var payload = new ChatRequest
+ {
+ Model = Model,
+ Messages =
+ [
+ new ChatMessage
+ {
+ Role = "user",
+ Content = MeaningPrompt.Build(title, artist, sources, targetLang),
+ },
+ ],
+ };
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint)
+ {
+ Content = JsonContent.Create(payload, options: Json),
+ };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
+ // OpenRouter가 순위·통계에 쓰는 선택 헤더. 넣어 두면 대시보드에서 어떤 앱인지 보인다.
+ request.Headers.Add("X-Title", "Musebase");
+ request.Headers.Add("HTTP-Referer", "https://github.com/countnine/musebase");
+
+ using var response = await _http.SendAsync(request, cts.Token).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode) return MeaningWriteResult.FromStatus(response.StatusCode);
+
+ var body = await response.Content.ReadFromJsonAsync(Json, cts.Token).ConfigureAwait(false);
+ var text = body?.Choices?.FirstOrDefault()?.Message?.Content;
+ return string.IsNullOrWhiteSpace(text)
+ ? MeaningWriteResult.Failed
+ : MeaningWriteResult.Written(text!.Trim());
+ }
+ catch (OperationCanceledException)
+ {
+ return MeaningWriteResult.Transient; // 타임아웃·취소 — 결과를 모른다
+ }
+ catch (HttpRequestException)
+ {
+ return MeaningWriteResult.Transient; // 네트워크는 다음에 될 수 있다
+ }
+ catch (Exception)
+ {
+ return MeaningWriteResult.Failed; // 조용한 강등
+ }
+ }
+
+ // ---- 요청/응답 모델(OpenAI 호환, 필요한 필드만) ----
+
+ private sealed record ChatRequest
+ {
+ public string Model { get; init; } = "";
+ public ChatMessage[] Messages { get; init; } = [];
+
+ ///
+ /// 반드시 보낸다. 비워 두면 OpenRouter가 모델 최대치(실측 65,535)를 예약하려 들고,
+ /// 잔액이 그만큼을 감당 못 하면 402로 거절한다 — 정작 우리가 쓰는 건 몇백 토큰이다.
+ /// 상한을 두면 폭주 비용도 함께 막힌다.
+ ///
+ [property: JsonPropertyName("max_tokens")]
+ public int MaxTokens { get; init; } = MeaningPrompt.MaxOutputTokens;
+ }
+
+ private sealed record ChatMessage
+ {
+ public string Role { get; init; } = "user";
+ public string Content { get; init; } = "";
+ }
+
+ private sealed record ChatResponse(ChatChoice[]? Choices);
+ private sealed record ChatChoice(ChatMessageOut? Message);
+ private sealed record ChatMessageOut(string? Content);
+}
diff --git a/src/Musebase.Core/Meaning/SongMeaningService.cs b/src/Musebase.Core/Meaning/SongMeaningService.cs
new file mode 100644
index 0000000..ba93292
--- /dev/null
+++ b/src/Musebase.Core/Meaning/SongMeaningService.cs
@@ -0,0 +1,96 @@
+namespace Musebase.Core.Meaning;
+
+/// 한 곡에 대한 의미 생성 결과.
+/// `ok` | `no-source` | `failed` | `retry`.
+/// 생성된 대상 언어 문단. `ok`가 아니면 null.
+/// 근거로 쓴 원문들(출처 표기·재생성 판단용).
+public sealed record SongMeaning(
+ string Status,
+ string? Summary,
+ IReadOnlyList Sources,
+ string? Engine,
+ string? Model)
+{
+ public const string Ok = "ok";
+ /// 어느 소스에도 자료가 없었다 — LLM은 부르지 않았다.
+ public const string NoSource = "no-source";
+ /// 자료는 있었지만 생성이 영구적으로 실패했다(키가 틀렸다, 응답이 비었다).
+ public const string Failed = "failed";
+
+ ///
+ /// 일시적 실패(쿼타·서버·네트워크) — **저장하지 않는다.** 저장하면 쿼타가 풀린 뒤에도
+ /// 백필이 이 곡을 영영 건너뛴다. 이 상태는 DB에 들어가지 않는 값이다.
+ ///
+ public const string Retry = "retry";
+
+ ///
+ /// 자료는 찾았지만 그것만으로는 곡의 의미를 말할 수 없었다. 문단은 남기되(사람이 판단할 수
+ /// 있게) **"의미 있음"으로 세지 않는다** — 글자가 있다는 이유로 세면 통계가 부풀고,
+ /// 앱에는 "파악하기 어렵다"는 문장이 곡 해설이라며 뜬다.
+ ///
+ public const string Insufficient = "insufficient";
+
+ public string? GeniusUrl =>
+ Sources.FirstOrDefault(s => s.Name == "Genius")?.Url;
+}
+
+///
+/// 소스 수집 → 요약을 한 번에 수행한다.
+///
+/// 두 가지가 설계의 핵심이다.
+/// 1. **소스는 병렬로, 실패는 무시.** 하나가 죽어도 나머지가 채운다.
+/// 2. **자료가 하나도 없으면 LLM을 부르지 않는다.** 곡 해설은 그럴듯한 창작이 특히 쉬운
+/// 영역이라, 근거 없이 부르면 모델이 지어낸다. 토큰 낭비이기도 하다.
+///
+public sealed class SongMeaningService
+{
+ private readonly IReadOnlyList _sources;
+ private readonly IMeaningWriter? _writer;
+
+ public SongMeaningService(IReadOnlyList sources, IMeaningWriter? writer)
+ {
+ _sources = sources;
+ _writer = writer;
+ }
+
+ /// 소스도 엔진도 구성되지 않았으면 이 기능은 꺼진 것이다.
+ public bool IsEnabled => _writer is not null && _sources.Count > 0;
+
+ /// 켜져 있는 소스 이름들 — 무엇에 근거해 만들어지는지 화면에 보여 주기 위한 것.
+ public IReadOnlyList SourceNames => _sources.Select(s => s.Name).ToList();
+
+ public async Task BuildAsync(
+ string title, string artist, string targetLang, CancellationToken ct = default)
+ {
+ var collected = await CollectAsync(title, artist, ct).ConfigureAwait(false);
+ if (collected.Count == 0)
+ return new SongMeaning(SongMeaning.NoSource, null, collected, _writer?.EngineId, _writer?.Model);
+
+ if (_writer is null)
+ return new SongMeaning(SongMeaning.Failed, null, collected, null, null);
+
+ var written = await _writer.WriteAsync(title, artist, collected, targetLang, ct).ConfigureAwait(false);
+ if (written.Text is not null)
+ {
+ // "자료가 부족하다"는 답도 정상 동작이지만 의미는 아니다 — 갈라서 기록한다.
+ var verdict = MeaningVerdict.IsInsufficient(written.Text)
+ ? SongMeaning.Insufficient
+ : SongMeaning.Ok;
+ return new SongMeaning(
+ verdict, MeaningVerdict.Strip(written.Text), collected, _writer.EngineId, _writer.Model);
+ }
+
+ // 쿼타·네트워크처럼 시간이 풀어 줄 실패는 `failed`로 굳히지 않는다.
+ var status = written.Retryable ? SongMeaning.Retry : SongMeaning.Failed;
+ return new SongMeaning(status, null, collected, _writer.EngineId, _writer.Model);
+ }
+
+ /// 모든 소스를 동시에 부르고 성공한 것만 모은다(레지스트리 등록 순서 유지).
+ public async Task> CollectAsync(
+ string title, string artist, CancellationToken ct = default)
+ {
+ var tasks = _sources.Select(s => s.FetchAsync(title, artist, ct)).ToArray();
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ return results.Where(r => r is not null).Select(r => r!).ToList();
+ }
+}
diff --git a/src/Musebase.Core/Meaning/WikipediaSource.cs b/src/Musebase.Core/Meaning/WikipediaSource.cs
new file mode 100644
index 0000000..0238e22
--- /dev/null
+++ b/src/Musebase.Core/Meaning/WikipediaSource.cs
@@ -0,0 +1,171 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Musebase.Core.Search;
+
+namespace Musebase.Core.Meaning;
+
+///
+/// Wikipedia(MediaWiki API)에서 곡 문서의 도입부를 가져온다. **키가 필요 없다.**
+/// 유명 곡은 "Composition"·"Lyrics and interpretation" 같은 절이 통째로 있어 가장 밀도가 높다.
+///
+/// 검색은 list=search로 하되 **"(song)" 문서를 우선**한다 — 같은 제목의 앨범·영화
+/// 문서가 먼저 잡히는 일이 흔하다. 본문은 prop=extracts&exintro&explaintext로 받는다.
+///
+/// 문서 본문은 CC BY-SA다 — 출처와 라이선스를 화면에 함께 표기해야 한다(호출자 책임).
+///
+public sealed partial class WikipediaSource : ISongMeaningSource
+{
+ private readonly HttpClient _http;
+ private readonly string _endpoint;
+ private readonly TimeSpan _timeout;
+
+ private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
+
+ /// 위키 언어 코드. 기본 영어 — 곡 해설은 영어판이 압도적으로 두껍다.
+ /// 검색 + 본문 두 호출 전체의 예산( 참고).
+ public WikipediaSource(string language = "en", HttpClient? http = null, int timeoutMs = 6000)
+ {
+ _endpoint = $"https://{language}.wikipedia.org/w/api.php";
+ _http = http ?? MeaningHttp.Client;
+ _timeout = TimeSpan.FromMilliseconds(Math.Clamp(timeoutMs, 500, 30_000));
+ }
+
+ public string Name => "Wikipedia";
+
+ public async Task FetchAsync(string title, string artist, CancellationToken ct = default)
+ {
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(_timeout);
+
+ var pageTitle = await FindPageAsync(title, artist, cts.Token).ConfigureAwait(false);
+ if (pageTitle is null) return null;
+
+ var url = $"{_endpoint}?action=query&format=json&formatversion=2&redirects=1"
+ + "&prop=extracts&exintro=1&explaintext=1"
+ + $"&titles={Uri.EscapeDataString(pageTitle)}";
+ var body = await _http.GetFromJsonAsync(url, Json, cts.Token).ConfigureAwait(false);
+ var extract = body?.Query?.Pages?.FirstOrDefault()?.Extract;
+ if (string.IsNullOrWhiteSpace(extract) || extract!.Trim().Length < 60) return null;
+
+ var text = WhitespaceRegex().Replace(extract, " ").Trim();
+ var pageUrl = $"https://en.wikipedia.org/wiki/{Uri.EscapeDataString(pageTitle.Replace(' ', '_'))}";
+ return new MeaningSource(Name, pageUrl, text);
+ }
+ catch (Exception)
+ {
+ return null; // 조용한 강등
+ }
+ }
+
+ ///
+ /// 검색 결과 중 이 곡의 문서를 고른다. 확신이 없으면 포기한다(null) —
+ /// 여기서 고른 문서가 그대로 LLM의 근거가 되므로, 엉뚱한 문서를 넘기면 그럴듯하고 완전히
+ /// 틀린 "의미"가 만들어진다. 자료가 없는 것보다 나쁘다.
+ ///
+ /// 실측으로 걸린 함정: "(song)"이 붙은 제목을 무조건 우선하면 Kids / MGMT 검색에서
+ /// 정답인 Kids (MGMT song)("(song)"이 아니라 "(MGMT song)"이다)를 제치고
+ /// 상위에 섞여 있던 Pursuit of Happiness (song)가 뽑혔다. 그래서 제목 일치를 먼저 보고
+ /// 아티스트 확인을 요구한다.
+ ///
+ private async Task FindPageAsync(string title, string artist, CancellationToken ct)
+ {
+ var clean = SearchTermCleaner.Variants(new SearchTerm(title, artist))
+ .FirstOrDefault(v => !v.IsKeyword);
+ var t = clean?.Title ?? title;
+ var a = clean?.Artist ?? artist;
+
+ // 검색어에는 **대표 이름 하나만** 넣는다 — 앨범 꼬리표나 공동 아티스트가 그대로 들어가면
+ // 검색이 흐려진다("little freak harry styles — harry's house song").
+ var primary = ArtistNames.Primary(a);
+ var query = string.IsNullOrWhiteSpace(primary) ? $"{t} song" : $"{t} {primary} song";
+ var url = $"{_endpoint}?action=query&format=json&formatversion=2&list=search&srlimit=5"
+ + $"&srsearch={Uri.EscapeDataString(query)}";
+ var body = await _http.GetFromJsonAsync(url, Json, ct).ConfigureAwait(false);
+ var hits = body?.Query?.Search;
+ if (hits is not { Length: > 0 }) return null;
+
+ return PickPage(hits, t, a);
+ }
+
+ ///
+ /// 후보 중 가장 그럴듯한 곡 문서를 고른다(순수 함수 — 테스트 대상).
+ /// 조건을 못 채우면 null. 점수가 아니라 필수 조건으로 거른다.
+ ///
+ internal static string? PickPage(IReadOnlyList hits, string title, string artist)
+ {
+ var wantedTitle = Normalize(title);
+ if (wantedTitle.Length == 0) return null;
+ var wantedArtists = ArtistCandidates(artist);
+
+ SearchHit? best = null;
+ var bestScore = int.MinValue;
+
+ foreach (var hit in hits)
+ {
+ if (hit.Title is not { Length: > 0 } pageTitle) continue;
+
+ // 필수 ①: 문서 제목이 곡 제목을 담아야 한다.
+ var normalizedPage = Normalize(pageTitle);
+ if (!normalizedPage.Contains(wantedTitle, StringComparison.Ordinal)) continue;
+
+ // 이름 **하나라도** 걸리면 이 곡의 문서로 본다. 합작곡의 문서 제목은
+ // "Shallow (Lady Gaga and Bradley Cooper song)"처럼 우리가 받은 표기와 다르게 적히므로,
+ // 전원 일치를 요구하면 아무것도 통과하지 못한다.
+ var normalizedSnippet = Normalize(hit.Snippet ?? "");
+ var titleHasArtist = wantedArtists.Any(n => normalizedPage.Contains(n, StringComparison.Ordinal));
+ var snippetHasArtist = wantedArtists.Any(n => normalizedSnippet.Contains(n, StringComparison.Ordinal));
+
+ // 필수 ②: 아티스트를 아는데 제목에도 스니펫에도 없으면 동명이곡일 수 있다 — 버린다.
+ if (wantedArtists.Count > 0 && !titleHasArtist && !snippetHasArtist) continue;
+
+ var score =
+ (titleHasArtist ? 4 : 0) // "Kids (MGMT song)"
+ + (pageTitle.Contains("song", StringComparison.OrdinalIgnoreCase) ? 2 : 0) // 곡 문서 표식
+ + (snippetHasArtist ? 1 : 0)
+ + (normalizedPage == wantedTitle ? 1 : 0); // 제목이 정확히 일치
+
+ if (score > bestScore) (best, bestScore) = (hit, score);
+ }
+
+ return best?.Title;
+ }
+
+ ///
+ /// 비교에 쓸 아티스트 이름들. 너무 짧은 조각(AC/DC → "ac","dc")은 아무 데나 걸리므로
+ /// 버리고, 그렇게 다 버려지면 원본 전체로 되돌린다 — 확인 자체를 포기하는 것보다 낫다.
+ ///
+ private static IReadOnlyList ArtistCandidates(string artist)
+ {
+ var names = ArtistNames.All(artist)
+ .Select(Normalize)
+ .Where(n => n.Length >= 3)
+ .ToList();
+
+ if (names.Count == 0)
+ {
+ var whole = Normalize(artist);
+ if (whole.Length > 0) names.Add(whole);
+ }
+ return names;
+ }
+
+ private static string Normalize(string s) => MeaningText.Normalize(s);
+
+ [GeneratedRegex(@"\s+")]
+ private static partial Regex WhitespaceRegex();
+
+ // ---- 응답 모델(필요한 필드만) ----
+
+ private sealed record SearchEnvelope(SearchQuery? Query);
+ private sealed record SearchQuery(SearchHit[]? Search);
+
+ /// 검색 결과 한 건. 테스트를 위해 internal이다.
+ internal sealed record SearchHit(string? Title, string? Snippet);
+
+ private sealed record QueryEnvelope(PageQuery? Query);
+ private sealed record PageQuery(PageEntry[]? Pages);
+ private sealed record PageEntry(string? Title, string? Extract);
+}
diff --git a/src/Musebase.Core/Search/HttpRemoteLyricsCache.cs b/src/Musebase.Core/Search/HttpRemoteLyricsCache.cs
index c70ad56..8e9d64d 100644
--- a/src/Musebase.Core/Search/HttpRemoteLyricsCache.cs
+++ b/src/Musebase.Core/Search/HttpRemoteLyricsCache.cs
@@ -127,6 +127,44 @@ private async Task ReadMissAsync(HttpResponseMessage respons
}
}
+ ///
+ /// 곡의 의미. 서버에 없으면 404이고 그것은 정상이다 — 대부분의 곡에는 아직 의미가 없다.
+ ///
+ /// 회로 차단기와 실패 집계를 **가사와 공유한다**: 이건 부가 정보라 여기서 실패했다고
+ /// 가사 조회까지 막으면 손해가 크다. 그래서 실패해도 를 부르지 않고
+ /// 조용히 null만 돌려준다(회로가 이미 열려 있으면 아예 시도하지 않는다).
+ ///
+ public async Task GetMeaningAsync(
+ string title, string artist, CancellationToken ct = default)
+ {
+ if (IsCircuitOpen()) return null;
+
+ try
+ {
+ var url = new Uri(_baseUri,
+ $"v1/meaning?title={Uri.EscapeDataString(title)}&artist={Uri.EscapeDataString(artist)}");
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(_timeout);
+
+ using var response = await _http.GetAsync(url, cts.Token).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode) return null; // 404 = 아직 의미가 없다(정상)
+
+ var entry = await response.Content
+ .ReadFromJsonAsync(Json, cts.Token).ConfigureAwait(false);
+ if (entry?.Summary is not { Length: > 0 } summary) return null;
+
+ var credits = (entry.Attribution ?? [])
+ .Where(a => !string.IsNullOrWhiteSpace(a.Name))
+ .Select(a => new MeaningCredit(a.Name!, a.Url))
+ .ToList();
+ return new SongMeaningView(summary.Trim(), credits, entry.Lang ?? "ko");
+ }
+ catch (Exception)
+ {
+ return null; // 부가 기능 — 가사 조회에 영향을 주지 않는다
+ }
+ }
+
public async Task SetAsync(string title, string artist, Lyrics lyrics, CancellationToken ct = default)
{
if (IsCircuitOpen()) return;
@@ -210,6 +248,20 @@ private sealed record RemoteLyricsEntry
public string? Match { get; init; }
}
+ /// `GET /v1/meaning` 응답(필요한 필드만).
+ private sealed record RemoteMeaningEntry
+ {
+ public string? Summary { get; init; }
+ public string? Lang { get; init; }
+ public RemoteAttribution[]? Attribution { get; init; }
+ }
+
+ private sealed record RemoteAttribution
+ {
+ public string? Name { get; init; }
+ public string? Url { get; init; }
+ }
+
/// 404 본문(계약 v1의 "번역 양보"). 구버전 서버는 본문이 없다.
private sealed record MissBody
{
diff --git a/src/Musebase.Core/Search/IRemoteLyricsCache.cs b/src/Musebase.Core/Search/IRemoteLyricsCache.cs
index 29bcadb..e0474df 100644
--- a/src/Musebase.Core/Search/IRemoteLyricsCache.cs
+++ b/src/Musebase.Core/Search/IRemoteLyricsCache.cs
@@ -33,4 +33,35 @@ public interface IRemoteLyricsCache
/// 서버에 가사를 올린다(업서트). 실패는 조용히 무시하므로 호출자가 await하지 않아도 된다.
Task SetAsync(string title, string artist, Lyrics lyrics, CancellationToken ct = default);
+
+ ///
+ /// 곡의 의미를 가져온다. 없으면 null — 만들지는 않는다(생성은 서버 관리자만 한다).
+ /// 가사와 마찬가지로 실패·미접속도 조용히 null이다.
+ ///
+ Task GetMeaningAsync(string title, string artist, CancellationToken ct = default);
+}
+
+///
+/// 앱이 보여 줄 "이 곡의 의미" 한 건.
+///
+/// 은 표시 의무가 있다 — Wikipedia 본문은 CC BY-SA이고
+/// Genius·Last.fm도 링크 표기를 요구한다(`contracts/lyrics-api.md`). 본문만 떼어 보여 주면 안 된다.
+///
+/// 대상 언어 한 문단.
+/// 출처 이름·링크 쌍.
+/// 요약 언어(`ko` 등).
+public sealed record SongMeaningView(
+ string Summary,
+ IReadOnlyList Attribution,
+ string Lang)
+{
+ /// 화면 하단에 그대로 붙일 한 줄(링크가 없는 UI용).
+ public string CreditLine =>
+ Attribution.Count == 0
+ ? ""
+ : "출처: " + string.Join(" · ", Attribution.Select(a => a.Name))
+ + (Attribution.Any(a => a.Name == "Wikipedia") ? " (CC BY-SA)" : "");
}
+
+/// 출처 한 건.
+public sealed record MeaningCredit(string Name, string? Url);
diff --git a/src/Musebase.Server/Admin/AdminEndpoints.cs b/src/Musebase.Server/Admin/AdminEndpoints.cs
index 84fee06..4287ef6 100644
--- a/src/Musebase.Server/Admin/AdminEndpoints.cs
+++ b/src/Musebase.Server/Admin/AdminEndpoints.cs
@@ -9,12 +9,21 @@ public sealed record AdminOptions(
IReadOnlyDictionary DeviceLabels,
bool LogLookups,
int RetentionDays,
- int YieldWindowSeconds)
+ int YieldWindowSeconds,
+ string User = "admin",
+ string? Password = null)
{
+ /// 비밀번호를 정해 뒀는가 — 로그인 화면이 어떤 칸을 보여 줄지 가른다.
+ public bool HasPassword => !string.IsNullOrWhiteSpace(Password);
+
///
/// `MUSEBASE_ADMIN_TOKEN`(없으면 `MUSEBASE_TOKEN`), `MUSEBASE_TZ`(기본 Asia/Seoul),
/// `MUSEBASE_DEVICES`, `MUSEBASE_LOG_LOOKUPS`, `MUSEBASE_LOOKUP_RETENTION_DAYS`,
- /// `MUSEBASE_YIELD_WINDOW_SECONDS`(0이면 번역 양보 힌트를 주지 않는다).
+ /// `MUSEBASE_YIELD_WINDOW_SECONDS`(0이면 번역 양보 힌트를 주지 않는다),
+ /// `MUSEBASE_ADMIN_USER`(기본 admin), `MUSEBASE_ADMIN_PASSWORD`(해시 또는 평문).
+ ///
+ /// 비밀번호를 정해도 토큰 로그인은 계속 살려 둔다 — 비밀번호를 잊거나 해시를 잘못 넣으면
+ /// 들어갈 길이 없어지기 때문이다. 토큰은 어차피 앱이 API에 쓰는 값이라 새 비밀이 늘지도 않는다.
///
public static AdminOptions FromEnvironment(string apiToken)
{
@@ -35,7 +44,9 @@ public static AdminOptions FromEnvironment(string apiToken)
DeviceLabels: DeviceLabel.ParseLabels(Environment.GetEnvironmentVariable("MUSEBASE_DEVICES")),
LogLookups: Environment.GetEnvironmentVariable("MUSEBASE_LOG_LOOKUPS") != "0",
RetentionDays: retention,
- YieldWindowSeconds: yieldWindow);
+ YieldWindowSeconds: yieldWindow,
+ User: Environment.GetEnvironmentVariable("MUSEBASE_ADMIN_USER") is { Length: > 0 } u ? u : "admin",
+ Password: Environment.GetEnvironmentVariable("MUSEBASE_ADMIN_PASSWORD"));
}
}
@@ -49,14 +60,39 @@ public static class AdminEndpoints
private const string CookieName = "musebase_admin";
private static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(30);
- public static void MapAdmin(this WebApplication app, LyricsStore store, AdminOptions options)
+ /// 303 See Other — 이 프레임워크에 기본 헬퍼가 없어 직접 만든다.
+ private sealed class SeeOtherResult(string location) : IResult
+ {
+ public Task ExecuteAsync(HttpContext context)
+ {
+ context.Response.StatusCode = StatusCodes.Status303SeeOther;
+ context.Response.Headers.Location = location;
+ return Task.CompletedTask;
+ }
+ }
+
+ /// `/admin/list`가 한 번에 보여 주는 최대 행 수 — 넘으면 화면에 그렇게 밝힌다.
+ private const int FullRows = 200;
+
+ public static void MapAdmin(
+ this WebApplication app, LyricsStore store, AdminOptions options,
+ Musebase.Core.Meaning.SongMeaningService meanings, MeaningOptions meaningOptions)
{
- // JS가 없으므로 스크립트를 통째로 막는다(인라인 스타일만 허용).
- const string Csp = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'";
+ // 스크립트는 딱 하나(제출 스피너)뿐이라 'unsafe-inline' 대신 **그 해시만** 허용한다 —
+ // 다른 스크립트는 여전히 한 줄도 실행되지 않는다(AdminHtml.BusyScript 참고).
+ // connect-src가 필요한 이유: 그 스크립트가 폼을 fetch로 보낸다. 기본값 'none'이면
+ // 조용히 막혀 버튼만 잠긴 채 아무 일도 일어나지 않는다. 대상은 같은 출처뿐이다.
+ var Csp = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; "
+ + "connect-src 'self'; "
+ + $"script-src {AdminHtml.ScriptCsp}";
IResult Html(string html) =>
Results.Text(html, "text/html; charset=utf-8");
+ // POST 뒤에는 303이 맞다 — 302는 "다음 요청의 메서드"를 규정하지 않아 브라우저마다 다르다.
+ // 303은 반드시 GET으로 가라는 뜻이라 새로고침이 POST를 되풀이하지 않는다.
+ static IResult SeeOther(string location) => new SeeOtherResult(location);
+
string? Cookie(HttpRequest req) => req.Cookies.TryGetValue(CookieName, out var v) ? v : null;
bool LoggedIn(HttpRequest req) =>
@@ -79,7 +115,15 @@ void SetCookie(HttpResponse res)
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/admin"))
+ {
context.Response.Headers["Content-Security-Policy"] = Csp;
+
+ // **뒤로 가기로 낡은 화면이 되살아나면 안 된다.** 캐시 지시가 없으면 브라우저가
+ // 뒤로 가기를 캐시에서 그리는데, 방금 만든 의미나 방금 고친 가사가 사라진 것처럼
+ // 보인다 — 사람은 작업이 실패한 줄 알고 다시 누른다. 관리자 화면은 전부 지금
+ // 상태를 봐야 하는 화면이므로 저장하지 않는다.
+ context.Response.Headers.CacheControl = "no-store";
+ }
await next();
});
@@ -88,64 +132,89 @@ void SetCookie(HttpResponse res)
app.MapGet("/admin/logout", (HttpResponse res) =>
{
res.Cookies.Delete(CookieName, new CookieOptions { Path = "/admin" });
- return Html(AdminPages.Login("로그아웃했습니다."));
+ return Html(AdminPages.Login("로그아웃했습니다.", options.HasPassword));
});
app.MapPost("/admin/login", async (HttpRequest req, HttpResponse res) =>
{
var form = await req.ReadFormAsync();
- if (!TokenMatches(form["token"].ToString(), options.Token))
- return Html(AdminPages.Login("토큰이 맞지 않습니다."));
+
+ var token = form["token"].ToString();
+ var user = form["user"].ToString();
+ var password = form["password"].ToString();
+
+ var ok = token.Length > 0
+ ? TokenMatches(token, options.Token)
+ : string.Equals(user.Trim(), options.User, StringComparison.Ordinal)
+ && AdminPassword.Verify(password, options.Password);
+
+ if (!ok)
+ {
+ // 온라인 추측을 느리게 만든다. 테일넷 안이라 위험은 낮지만 값이 싸다.
+ await Task.Delay(700);
+ return Html(AdminPages.Login(
+ token.Length > 0 ? "토큰이 맞지 않습니다." : "아이디 또는 비밀번호가 맞지 않습니다.",
+ options.HasPassword));
+ }
+
SetCookie(res);
- return Results.Redirect("/admin");
+ return SeeOther("/admin");
});
// ---- 대시보드 ----
- app.MapGet("/admin", (HttpRequest req, HttpResponse res, string? token) =>
+ app.MapGet("/admin", (HttpRequest req, HttpResponse res, string? token, string? notice) =>
{
// ?token=…로 들어오면 쿠키를 굽고 주소창을 정리한다(토큰이 히스토리·로그에 남지 않도록).
if (!string.IsNullOrEmpty(token))
{
- if (!TokenMatches(token!, options.Token)) return Html(AdminPages.Login("토큰이 맞지 않습니다."));
+ if (!TokenMatches(token!, options.Token)) return Html(AdminPages.Login("토큰이 맞지 않습니다.", options.HasPassword));
SetCookie(res);
- return Results.Redirect("/admin");
+ return SeeOther("/admin");
}
- if (!LoggedIn(req)) return Html(AdminPages.Login());
+ if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword));
var now = DateTimeOffset.UtcNow;
- var todayStart = AdminTime.TodayStartUtc(now, options.TimeZone);
- var weekStart = AdminTime.DaysAgoUtc(now, 7);
+ return Html(AdminPages.Dashboard(
+ BuildDashboard(req, now, AdminPages.DashboardRows), now, options.TimeZone, notice));
+ });
- var model = new DashboardModel(
- Stats: store.Stats(),
- DatabaseSizeBytes: store.DatabaseSizeBytes(),
- Today: store.HitRateSince(todayStart),
- Week: store.HitRateSince(weekStart),
- Recent: store.RecentLookups(50),
- TopMisses: store.TopMisses(weekStart),
- Devices: store.DeviceActivity(weekStart),
- Daily: store.DailyHitRate(weekStart),
- CleanedMatches: store.CleanedMatches(weekStart, 30),
- RecentUploads: store.RecentUploads(20),
- WithoutTranslation: store.WithoutTranslation(100),
- DuplicateCandidates: store.DuplicateCandidates(),
- Health: Health(options.RetentionDays),
- Diagnostics: Diagnostics(req, options));
+ // 대시보드의 한 섹션을 전부 보여 준다. 섹션마다 라우트를 파지 않고 ?view= 하나로 받는다.
+ app.MapGet("/admin/list", (HttpRequest req, string? view) =>
+ {
+ if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword));
+ if (view is null || !AdminPages.ListViews.TryGetValue(view, out var heading))
+ return SeeOther("/admin");
- return Html(AdminPages.Dashboard(model, now, options.TimeZone));
+ var now = DateTimeOffset.UtcNow;
+ var model = BuildDashboard(req, now, FullRows);
+ var table = AdminPages.ListTable(view, model, options.TimeZone);
+ var count = view switch
+ {
+ "lookups" => model.Recent.Count,
+ "misses" => model.TopMisses.Count,
+ "untranslated" => model.WithoutTranslation.Count,
+ "duplicates" => model.DuplicateCandidates.Count,
+ _ => model.CleanedMatches.Count,
+ };
+ var note = count < FullRows ? null : $"최대 {FullRows}건까지 보여 줍니다";
+ return Html(AdminPages.ListPage(heading, table, count, note));
});
// ---- 검색·열람 ----
- app.MapGet("/admin/search", (HttpRequest req, string? q) =>
- !LoggedIn(req) ? Html(AdminPages.Login())
- : Html(AdminPages.SearchPage(q, store.Search(q, limit: 200), options.TimeZone)));
+ app.MapGet("/admin/search", (HttpRequest req, string? q, string? meaning) =>
+ {
+ if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword));
+ var filter = string.IsNullOrWhiteSpace(meaning) ? null : meaning;
+ return Html(AdminPages.SearchPage(
+ q, store.Search(q, limit: 200, meaning: filter), options.TimeZone, filter));
+ });
app.MapGet("/admin/song", (HttpRequest req, string? key, string? lang, string? tags, string? notice) =>
{
- if (!LoggedIn(req)) return Html(AdminPages.Login());
- if (string.IsNullOrWhiteSpace(key)) return Results.Redirect("/admin/search");
+ if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword));
+ if (string.IsNullOrWhiteSpace(key)) return SeeOther("/admin/search");
var entry = store.GetByKey(key!);
if (entry is null) return Html(AdminPages.SearchPage(key, Array.Empty(), options.TimeZone));
@@ -156,12 +225,16 @@ void SetCookie(HttpResponse res)
return Html(AdminPages.SongPage(
entry, AdminLrc.ToDisplayLines(entry.Lrc, selected), langs, selected, showTags,
- AdminAuth.Csrf(options.Token, Cookie(req) ?? ""), options.TimeZone, notice));
+ AdminAuth.Csrf(options.Token, Cookie(req) ?? ""), options.TimeZone, notice,
+ store.GetMeaningByKey(entry.Key ?? ""), meanings.IsEnabled,
+ meaningOptions.SelectableSources()
+ .Select(s => (s.Id, MeaningOptions.SourceLabel(s.Id), s.Default))
+ .ToList()));
});
app.MapGet("/admin/raw", (HttpRequest req, string? key) =>
{
- if (!LoggedIn(req)) return Html(AdminPages.Login());
+ if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword));
var entry = string.IsNullOrWhiteSpace(key) ? null : store.GetByKey(key!);
return entry is null ? Results.NotFound() : Results.Text(entry.Lrc, "text/plain; charset=utf-8");
});
@@ -170,7 +243,7 @@ void SetCookie(HttpResponse res)
app.MapPost("/admin/song/edit", async (HttpRequest req) =>
{
- if (!LoggedIn(req)) return Html(AdminPages.Login());
+ if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword));
var form = await req.ReadFormAsync();
if (!AdminAuth.VerifyCsrf(form["csrf"].ToString(), options.Token, Cookie(req) ?? ""))
return Results.Json(new ApiError("csrf"), statusCode: StatusCodes.Status400BadRequest);
@@ -178,25 +251,159 @@ void SetCookie(HttpResponse res)
var key = form["key"].ToString();
var lrc = form["lrc"].ToString();
var existing = store.GetByKey(key);
- if (existing is null || string.IsNullOrWhiteSpace(lrc)) return Results.Redirect("/admin/search");
+ if (existing is null || string.IsNullOrWhiteSpace(lrc)) return SeeOther("/admin/search");
// origin=user로 저장 → 병합 정책이 각 기기의 자동 검색 결과로부터 이 편집본을 보호한다.
store.Upsert(existing with { Lrc = lrc, Origin = LyricsEntry.OriginUser, Service = "사용자 편집" },
updatedBy: "admin", out _);
- return Results.Redirect($"/admin/song?key={Uri.EscapeDataString(key)}¬ice={Uri.EscapeDataString("저장했습니다.")}");
+ return SeeOther($"/admin/song?key={Uri.EscapeDataString(key)}¬ice={Uri.EscapeDataString("저장했습니다.")}");
});
app.MapPost("/admin/song/delete", async (HttpRequest req) =>
{
- if (!LoggedIn(req)) return Html(AdminPages.Login());
+ if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword));
var form = await req.ReadFormAsync();
if (!AdminAuth.VerifyCsrf(form["csrf"].ToString(), options.Token, Cookie(req) ?? ""))
return Results.Json(new ApiError("csrf"), statusCode: StatusCodes.Status400BadRequest);
var key = form["key"].ToString();
if (!string.IsNullOrWhiteSpace(key)) store.Delete(key);
- return Results.Redirect("/admin/search");
+ return SeeOther("/admin/search");
});
+
+ // ---- 곡의 의미 ----
+ // 생성은 **사람이 누를 때만** 일어난다. 자동 생성을 두지 않는 이유는 쿼타·비용이
+ // 예측 가능해야 하고, 실패가 조용히 쌓이면 안 되기 때문이다.
+
+ app.MapPost("/admin/song/meaning", async (HttpRequest req) =>
+ {
+ if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword));
+ var form = await req.ReadFormAsync();
+ if (!AdminAuth.VerifyCsrf(form["csrf"].ToString(), options.Token, Cookie(req) ?? ""))
+ return Results.Json(new ApiError("csrf"), statusCode: StatusCodes.Status400BadRequest);
+
+ var key = form["key"].ToString();
+ var entry = string.IsNullOrWhiteSpace(key) ? null : store.GetByKey(key);
+ if (entry is null) return SeeOther("/admin/search");
+
+ // 화면에서 고른 자료원(체크박스). 하나도 안 고르면 설정값으로 만든다.
+ var picked = form["src"].Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s!).ToList();
+ var notice = await GenerateMeaningAsync(entry.Key ?? key, entry.Title, entry.Artist, picked);
+ return SeeOther(
+ $"/admin/song?key={Uri.EscapeDataString(key)}¬ice={Uri.EscapeDataString(notice)}");
+ });
+
+ app.MapPost("/admin/meanings/backfill", async (HttpRequest req) =>
+ {
+ if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword));
+ var form = await req.ReadFormAsync();
+ if (!AdminAuth.VerifyCsrf(form["csrf"].ToString(), options.Token, Cookie(req) ?? ""))
+ return Results.Json(new ApiError("csrf"), statusCode: StatusCodes.Status400BadRequest);
+
+ if (!meanings.IsEnabled)
+ return SeeOther($"/admin?notice={Uri.EscapeDataString("의미 엔진이 구성되지 않았습니다.")}");
+
+ var targets = store.SongsWithoutMeaning(meaningOptions.BackfillLimit);
+ int ok = 0, none = 0, failed = 0, done = 0;
+ var stopped = false;
+ foreach (var (key, title, artist) in targets)
+ {
+ if (done > 0 && meaningOptions.BackfillDelayMs > 0)
+ await Task.Delay(meaningOptions.BackfillDelayMs);
+
+ var status = await GenerateStatusAsync(key, title, artist);
+
+ // 쿼타·네트워크 같은 일시적 실패면 여기서 멈춘다. 계속 돌아 봐야 남은 곡까지
+ // 같은 벽에 부딪힐 뿐이고, 중단해도 아무것도 망가지지 않는다 — 저장을 안 했으므로
+ // 다음에 다시 누르면 이 곡부터 그대로 이어진다.
+ if (status == Musebase.Core.Meaning.SongMeaning.Retry) { stopped = true; break; }
+
+ done++;
+ // 자료 부족은 "자료 없음"과 같은 칸에 센다 — 둘 다 "의미를 만들지 못함"이다.
+ if (status == Musebase.Core.Meaning.SongMeaning.Ok) ok++;
+ else if (status is Musebase.Core.Meaning.SongMeaning.NoSource
+ or Musebase.Core.Meaning.SongMeaning.Insufficient) none++;
+ else failed++;
+ }
+
+ var summary = stopped
+ ? $"{done}곡 처리 후 중단 — 생성 {ok} · 자료 없음 {none} · 실패 {failed}. "
+ + "쿼타·네트워크 문제로 보입니다. 남은 곡은 손대지 않았으니 잠시 후 다시 눌러 주세요."
+ : $"{targets.Count}곡 처리 — 생성 {ok} · 자료 없음 {none} · 실패 {failed}";
+ return SeeOther($"/admin?notice={Uri.EscapeDataString(summary)}");
+ });
+
+ // 단건 생성 후 사람에게 보여 줄 한 줄.
+ async Task GenerateMeaningAsync(
+ string key, string title, string artist, IReadOnlyList? only = null)
+ {
+ if (!meanings.IsEnabled) return "의미 엔진이 구성되지 않았습니다(키를 확인하세요).";
+ var status = await GenerateStatusAsync(key, title, artist, only);
+ return status switch
+ {
+ Musebase.Core.Meaning.SongMeaning.Ok => "의미를 만들었습니다.",
+ Musebase.Core.Meaning.SongMeaning.NoSource => "외부 자료를 찾지 못했습니다.",
+ Musebase.Core.Meaning.SongMeaning.Insufficient =>
+ "자료가 부족해 의미를 판단하지 못했습니다 — 자료원을 바꿔 다시 시도해 보세요.",
+ Musebase.Core.Meaning.SongMeaning.Retry =>
+ "일시적인 오류입니다(쿼타·네트워크). 저장하지 않았으니 잠시 후 다시 시도하세요.",
+ _ => "생성에 실패했습니다(키를 확인하세요).",
+ };
+ }
+
+ // 대시보드와 `/admin/list`가 같은 모델을 쓴다 — 행 수만 다르다.
+ DashboardModel BuildDashboard(HttpRequest req, DateTimeOffset now, int rows)
+ {
+ var todayStart = AdminTime.TodayStartUtc(now, options.TimeZone);
+ var weekStart = AdminTime.DaysAgoUtc(now, 7);
+
+ return new DashboardModel(
+ Stats: store.Stats(),
+ DatabaseSizeBytes: store.DatabaseSizeBytes(),
+ Today: store.HitRateSince(todayStart),
+ Week: store.HitRateSince(weekStart),
+ Recent: store.RecentLookups(rows),
+ TopMisses: store.TopMisses(weekStart, rows),
+ Devices: store.DeviceActivity(weekStart),
+ Daily: store.DailyHitRate(weekStart),
+ CleanedMatches: store.CleanedMatches(weekStart, rows),
+ RecentUploads: store.RecentUploads(rows),
+ WithoutTranslation: store.WithoutTranslation(rows),
+ DuplicateCandidates: store.DuplicateCandidates(rows),
+ Health: Health(options.RetentionDays),
+ Diagnostics: Diagnostics(req, options),
+ Meanings: MeaningSummaryOf(),
+ MeaningSources: meanings.SourceNames,
+ Csrf: AdminAuth.Csrf(options.Token, Cookie(req) ?? ""));
+ }
+
+ MeaningSummary MeaningSummaryOf()
+ {
+ var (ok, none, failed, insufficient) = store.MeaningStats();
+ // "아직 안 해 본 곡"은 백필 버튼이 실제로 처리할 대상 수다(상한까지만 센다).
+ var pending = store.SongsWithoutMeaning(meaningOptions.BackfillLimit).Count;
+ return new MeaningSummary(ok, none, failed, pending, meanings.IsEnabled, insufficient);
+ }
+
+ // 결과를 저장하고 status만 돌려준다. 실패·자료없음도 행으로 남겨 백필이 같은 곡을
+ // 무한히 재시도하지 않게 한다 — 단 **일시적 실패는 예외다.** 쿼타 초과를 행으로
+ // 남기면 한도가 회복된 뒤에도 그 곡은 영영 건너뛰어진다.
+ async Task GenerateStatusAsync(
+ string key, string title, string artist, IReadOnlyList? only = null)
+ {
+ // 소스를 골라 왔으면 이번 한 번만 그 조합으로 만든다(설정은 그대로 둔다).
+ var service = only is { Count: > 0 } ? meaningOptions.BuildService(only) : meanings;
+ var result = await service.BuildAsync(title, artist, meaningOptions.Lang);
+ if (result.Status == Musebase.Core.Meaning.SongMeaning.Retry) return result.Status;
+
+ // 곡 페이지 주소는 의미 소스와 별개다 — Musixmatch를 자료로 쓰지 않아도 링크는 정확해야 한다.
+ var musixmatch = await meaningOptions.MusixmatchApi().FindAsync(title, artist);
+
+ store.UpsertMeaning(
+ MeaningMapper.ToEntry(key, title, artist, meaningOptions.Lang, result)
+ with { MusixmatchUrl = musixmatch?.ShareUrl });
+ return result.Status;
+ }
}
/// 기기 라벨 계산(요청 헤더 → 이름). 조회 기록과 업로드 표기에 함께 쓴다.
diff --git a/src/Musebase.Server/Admin/AdminHtml.cs b/src/Musebase.Server/Admin/AdminHtml.cs
index 6bb7d95..1a68b43 100644
--- a/src/Musebase.Server/Admin/AdminHtml.cs
+++ b/src/Musebase.Server/Admin/AdminHtml.cs
@@ -1,3 +1,4 @@
+using System.Security.Cryptography;
using System.Text;
namespace Musebase.Server;
@@ -5,7 +6,10 @@ namespace Musebase.Server;
///
/// 관리자 페이지 HTML 조각(순수 함수 — DB도 HTTP도 모른다).
/// 텔레메트리 Worker의 관리자 리포트(`backend/telemetry/src/worker.js`)와 같은 문제·같은 모양이라
-/// 이스케이프 규칙과 다크 표 CSS를 그대로 이식했다. JS는 한 줄도 쓰지 않으므로 CSP를 강하게 잠근다.
+/// 이스케이프 규칙과 다크 표 CSS를 그대로 이식했다.
+///
+/// JS는 하나뿐이고, 그 대가로 CSP를 느슨하게 하는 대신
+/// **해시로 고정**한다() — 다른 스크립트는 여전히 실행되지 않는다.
///
public static class AdminHtml
{
@@ -35,6 +39,39 @@ public static string Esc(string? s)
/// 링크에 실을 쿼리 값(키 등) 인코딩.
public static string Url(string? s) => Uri.EscapeDataString(s ?? "");
+ ///
+ /// 이 페이지들의 유일한 스크립트. 두 가지를 한다.
+ ///
+ /// ① 스피너. 의미 생성은 외부 API를 여러 번 부르므로 수 초가 걸리는데, 눌러도 아무 반응이
+ /// 없으면 사람이 다시 누른다(그러면 같은 곡을 두 번 만든다).
+ /// ② 히스토리를 늘리지 않는다. 평범한 폼 제출은 [검색 → 곡 → 곡(생성 후)] 세 칸을 만들어,
+ /// 뒤로 가기가 생성 전의 같은 곡으로 간다. 사람이 원하는 곳은 그 곡에 들어오기 전 화면이다.
+ /// 그래서 fetch로 보내고(제출 자체가 히스토리를 만들지 않는다) 결과 주소로
+ /// location.replace한다 — 지금 칸을 덮어써서 [검색 → 곡(생성 후)]만 남는다.
+ /// 서버가 할 수 없는 일이라(HTTP에는 히스토리를 지우는 방법이 없다) 여기서 한다.
+ ///
+ /// CSP는 계속 잠가 둔다 — 'unsafe-inline'이 아니라 이 문자열의 해시만 허용하므로
+ /// 다른 스크립트는 여전히 한 줄도 실행되지 않는다().
+ ///
+ /// fetch가 없으면 평소대로 제출한다(그때는 버튼 잠금을 setTimeout으로 미룬다 —
+ /// 제출 전에 끄면 폼이 전송되지 않는 브라우저가 있다). 리다이렉트가 아니면(예: CSRF 실패)
+ /// 같은 자리를 다시 읽어 실제 상태를 보여 준다.
+ ///
+ public const string BusyScript =
+ "document.addEventListener('submit',function(e){" +
+ "var f=e.target;if(!f.hasAttribute('data-busy'))return;" +
+ "var b=f.querySelector('button[type=submit]');" +
+ "var busy=function(){if(b){b.disabled=true;b.classList.add('busy');}};" +
+ "if(!window.fetch||!window.FormData){setTimeout(busy,0);return;}" +
+ "e.preventDefault();busy();" +
+ "fetch(f.action,{method:'POST',body:new FormData(f),credentials:'same-origin'})" +
+ ".then(function(r){location.replace(r.redirected?r.url:location.href);})" +
+ ".catch(function(){f.submit();});},true);";
+
+ /// `script-src`에 넣을 해시 토큰. 스크립트를 고치면 자동으로 따라간다.
+ public static string ScriptCsp { get; } =
+ $"'sha256-{Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(BusyScript)))}'";
+
/// 공통 레이아웃 — 다크 표 스타일 + 상단 네비게이션.
public static string Layout(string title, string body, string? activeNav = null)
{
@@ -76,13 +113,21 @@ string Nav(string href, string label, string id) =>
.bar{background:#222;height:.55rem;border-radius:.3rem;overflow:hidden;min-width:6rem}
.bar>span{display:block;height:100%;background:var(--accent)}
form.inline{display:flex;gap:.5rem;margin:.5rem 0 1rem;flex-wrap:wrap}
- input[type=text],input[type=password],textarea{background:#0e0e0e;color:var(--text);
+ input[type=text],input[type=password],textarea,select{background:#0e0e0e;color:var(--text);
border:1px solid var(--line);border-radius:.35rem;padding:.4rem .55rem;font:inherit}
input[type=text]{min-width:18rem}
textarea{width:100%;min-height:22rem;font-family:ui-monospace,Consolas,monospace;font-size:.82rem}
button{background:#243447;color:var(--text);border:1px solid var(--line);border-radius:.35rem;
padding:.4rem .8rem;font:inherit;cursor:pointer}
button:hover{background:#2d4258} button.danger{background:#4a2020} button.danger:hover{background:#5e2727}
+ button[disabled]{opacity:.6;cursor:default}
+ button.busy::before{content:"";display:inline-block;width:.8em;height:.8em;
+ margin-right:.45em;vertical-align:-.08em;border:2px solid currentColor;
+ border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}
+ @keyframes spin{to{transform:rotate(360deg)} }
+ @media (prefers-reduced-motion:reduce){button.busy::before{animation-duration:2.5s} }
+ .srcpick{display:inline-flex;flex-wrap:wrap;gap:.15rem .8rem;align-items:center}
+ .srcpick label{color:var(--dim);font-size:.8rem;white-space:nowrap}
details{margin-top:2rem} summary{cursor:pointer;color:var(--dim)}
pre{background:var(--panel);border:1px solid var(--line);border-radius:.4rem;padding:.75rem;
overflow:auto;font-size:.8rem;white-space:pre-wrap}
@@ -97,6 +142,7 @@ string Nav(string href, string label, string id) =>
{{Nav("/admin/logout", "로그아웃", "logout")}}
{{body}}
+