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.getInfowiki(곡 해설)를 가져온다. 무료 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">와 + /// &amp;를 그대로 담아 온다. 지우지 않으면 Belle &amp; 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_SOURCESmusixmatch를 명시해야 쓰인다. +/// +/// 이 텍스트는 사람이 쓴 해설이 아니다. 페이지 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}} + """; diff --git a/src/Musebase.Server/Admin/AdminModels.cs b/src/Musebase.Server/Admin/AdminModels.cs index 2e572ca..94ed4ec 100644 --- a/src/Musebase.Server/Admin/AdminModels.cs +++ b/src/Musebase.Server/Admin/AdminModels.cs @@ -4,7 +4,9 @@ namespace Musebase.Server; public sealed record LookupRow(string At, string Title, string Artist, string Result, string? Key, string Device); /// 미스 상위 1건 — 서버에 없어서 각 기기가 직접 검색해야 했던 곡. -public sealed record MissRow(string Title, string Artist, int Count, string LastAt, int Devices); +/// 그 뒤에 곡이 올라왔으면 그 키(없으면 null) — 화면에서 가사로 넘어가기 위한 것. +public sealed record MissRow( + string Title, string Artist, int Count, string LastAt, int Devices, string? Key = null); /// 기기별 활동. public sealed record DeviceRow(string Device, int Lookups, int Hits, string LastAt); @@ -13,9 +15,11 @@ public sealed record DeviceRow(string Device, int Lookups, int Hits, string Last public sealed record DailyRow(string Day, int Hits, int Misses); /// 곡 목록 1행(LRC 본문 제외 — 목록은 가볍게). +/// `ok` | `no-source` | `failed`, 아직 해 본 적 없으면 null. public sealed record SongRow( string Key, string LooseKey, string Title, string Artist, string? Service, string Origin, - string[] Langs, int LineCount, bool HasInlineTimeTags, int Revision, string UpdatedAt, string? UpdatedBy); + string[] Langs, int LineCount, bool HasInlineTimeTags, int Revision, string UpdatedAt, string? UpdatedBy, + string? MeaningStatus = null); /// 기간 내 조회 결과 집계. public sealed record HitRate(int Exact, int Cleaned, int Miss) @@ -45,7 +49,15 @@ public sealed record DashboardModel( IReadOnlyList WithoutTranslation, IReadOnlyList DuplicateCandidates, ServerHealth Health, - IReadOnlyList<(string Name, string Value)> Diagnostics); + IReadOnlyList<(string Name, string Value)> Diagnostics, + MeaningSummary Meanings, + /// 지금 켜져 있는 의미 자료원 이름 — 무엇에 근거해 만들어지는지 화면에 드러낸다. + IReadOnlyList MeaningSources, + string Csrf); + +/// 대시보드의 "곡의 의미" 타일 — 만든 것 / 자료 없음 / 자료 부족 / 실패 + 아직 안 해 본 곡 수. +public sealed record MeaningSummary( + int Ok, int NoSource, int Failed, int Pending, bool Enabled, int Insufficient = 0); /// 서버 상태(작은 인스턴스라 실제로 쓸모 있다). public sealed record ServerHealth(TimeSpan Uptime, long WorkingSetBytes, long DiskFreeBytes, int RetentionDays); diff --git a/src/Musebase.Server/Admin/AdminPages.cs b/src/Musebase.Server/Admin/AdminPages.cs index 0f49992..3b20997 100644 --- a/src/Musebase.Server/Admin/AdminPages.cs +++ b/src/Musebase.Server/Admin/AdminPages.cs @@ -8,19 +8,46 @@ namespace Musebase.Server; /// public static class AdminPages { - /// 토큰 입력 폼(쿠키가 없을 때). - public static string Login(string? error = null) => Layout("로그인", $""" + /// + /// 로그인 폼(쿠키가 없을 때). + /// + /// 비밀번호를 정해 뒀으면 아이디·비밀번호를 먼저 보여 준다 — 기기마다 긴 토큰을 주소창에 + /// 붙여 넣는 것이 이 화면의 가장 큰 불편이었다. 토큰 입력은 비상구로 남겨 둔다 + /// (비밀번호를 잊거나 해시를 잘못 넣어도 들어갈 수 있어야 한다). + /// + public static string Login(string? error = null, bool passwordEnabled = false) => Layout("로그인", $""" + {(error is null ? "" : $"

{Esc(error)}

")} + {(passwordEnabled ? $""" +

로그인

+
+ + + +
+
+ 토큰으로 들어가기 +

비밀번호를 잊었을 때 쓰는 비상구입니다 — + 서버의 /etc/musebase/server.env에 있는 MUSEBASE_TOKEN 값입니다.

+ {TokenForm} +
+ """ : $"""

관리자 토큰

서버의 /etc/musebase/server.env에 있는 토큰을 넣으세요. (/admin?token=…로 열어도 됩니다 — 주소창은 자동으로 정리됩니다.)

- {(error is null ? "" : $"

{Esc(error)}

")} +

아이디·비밀번호로 들어오려면 MUSEBASE_ADMIN_PASSWORD를 설정하세요.

+ {TokenForm} + """)} + """); + + private const string TokenForm = """
- +
- """); + """; - public static string Dashboard(DashboardModel m, DateTimeOffset nowUtc, TimeZoneInfo tz) + public static string Dashboard( + DashboardModel m, DateTimeOffset nowUtc, TimeZoneInfo tz, string? notice = null) { var last = m.Recent.Count > 0 ? m.Recent[0] : null; @@ -36,7 +63,18 @@ public static string Dashboard(DashboardModel m, DateTimeOffset nowUtc, TimeZone $"{m.Week.Hits}/{m.Week.Total} · 느슨한 매치 {m.Week.Cleaned}"), Tile("보관 중인 가사", $"{m.Stats.Songs}곡", - $"번역 {m.Stats.WithTranslation}곡 · DB {Bytes(m.DatabaseSizeBytes)}")); + $"번역 {m.Stats.WithTranslation}곡 · DB {Bytes(m.DatabaseSizeBytes)}"), + Tile("곡의 의미", + $"{m.Meanings.Ok}곡", + m.Meanings.Enabled + ? $"자료 부족 {m.Meanings.Insufficient} · 자료 없음 {m.Meanings.NoSource}" + + $" · 실패 {m.Meanings.Failed} · 남은 {m.Meanings.Pending}" + : "엔진 미구성")); + + // 무엇에 근거해 만들어지는지는 화면에서 보여야 한다 — 설정에만 있으면 나중에 아무도 모른다. + var sourceLine = m.MeaningSources.Count == 0 + ? "" + : $"

의미 자료: {Esc(string.Join(" · ", m.MeaningSources))}

"; var recent = Table( ["시각", "곡", "아티스트", "결과", "기기"], @@ -50,11 +88,7 @@ public static string Dashboard(DashboardModel m, DateTimeOffset nowUtc, TimeZone var misses = Table( ["곡", "아티스트", "횟수", "기기 수", "마지막", ""], - m.TopMisses.Select(r => $""" - {Esc(r.Title)}{Esc(r.Artist)}{r.Count}{r.Devices} - {Esc(AdminTime.ToLocal(r.LastAt, tz))} - 검색 - """), + m.TopMisses.Select(MissRowHtml(tz)), "미스 없음 — 요청한 곡이 전부 서버에 있었습니다."); var devices = Table( @@ -78,18 +112,13 @@ public static string Dashboard(DashboardModel m, DateTimeOffset nowUtc, TimeZone """; })); - var uploads = Table( - ["곡", "아티스트", "출처", "줄", "번역", "올린 기기", "갱신"], - m.RecentUploads.Select(SongRowHtml(tz))); + var uploads = Table(SongHeaders, m.RecentUploads.Select(SongRowHtml(tz)), + "아직 올라온 가사가 없습니다."); - var noTranslation = Table( - ["곡", "아티스트", "출처", "줄", "번역", "올린 기기", "갱신"], - m.WithoutTranslation.Select(SongRowHtml(tz)), + var noTranslation = Table(SongHeaders, m.WithoutTranslation.Select(SongRowHtml(tz)), "모든 곡에 번역이 있습니다."); - var duplicates = Table( - ["곡", "아티스트", "출처", "줄", "번역", "올린 기기", "갱신"], - m.DuplicateCandidates.Select(SongRowHtml(tz)), + var duplicates = Table(SongHeaders, m.DuplicateCandidates.Select(SongRowHtml(tz)), "표기 차이로 갈린 곡이 없습니다 — 키 정규화가 잘 먹고 있습니다."); var cleaned = Table( @@ -104,19 +133,32 @@ public static string Dashboard(DashboardModel m, DateTimeOffset nowUtc, TimeZone var diagnostics = string.Join("", m.Diagnostics.Select(d => $"{Esc(d.Name)}{Esc(d.Value)}")); + var backfill = !m.Meanings.Enabled || m.Meanings.Pending == 0 + ? "" + : $""" +
+ + +
+ 한 번에 처리할 곡 수는 MUSEBASE_MEANING_BACKFILL_LIMIT로 정합니다. + """; + return Layout("대시보드", $""" + {(notice is null ? "" : $"

{Esc(notice)}

")}
{tiles}
+ {sourceLine}

각 기기의 로컬 캐시에 없는 곡만 서버로 옵니다 — 같은 곡을 반복 재생해도 조회 수는 늘지 않습니다(로컬 캐시 → 서버 → 제공자 검색 순).

+ {backfill} -

최근 조회

{recent} -

미스 상위 (7일) — 서버에 없어 각 기기가 직접 찾은 곡

{misses} +

최근 올라온 가사{More("/admin/search")}

{uploads} +

최근 조회{More("/admin/list?view=lookups")}

{recent} +

미스 상위 (7일) — 서버에 없어 각 기기가 직접 찾은 곡{More("/admin/list?view=misses")}

{misses} +

번역 없는 곡 — 일괄 사전번역 대상{More("/admin/list?view=untranslated")}

{noTranslation}

기기별 (7일)

{devices}

일별 (7일)

{daily} -

최근 올라온 가사

{uploads} -

번역 없는 곡 — 일괄 사전번역 대상

{noTranslation} -

표기 차이로 갈린 곡 후보 (같은 느슨한 키)

{duplicates} -

느슨한 키로 맞은 조회 (7일)

{cleaned} +

표기 차이로 갈린 곡 후보 (같은 느슨한 키){More("/admin/list?view=duplicates")}

{duplicates} +

느슨한 키로 맞은 조회 (7일){More("/admin/list?view=cleaned")}

{cleaned}
진단 — 현재 요청 헤더 · 서버 상태 @@ -133,26 +175,106 @@ public static string Dashboard(DashboardModel m, DateTimeOffset nowUtc, TimeZone """, "home"); } - public static string SearchPage(string? query, IReadOnlyList results, TimeZoneInfo tz) + public static string SearchPage( + string? query, IReadOnlyList results, TimeZoneInfo tz, string? meaning = null) { - var table = Table( - ["곡", "아티스트", "출처", "줄", "번역", "올린 기기", "갱신"], - results.Select(SongRowHtml(tz)), - string.IsNullOrWhiteSpace(query) ? "저장된 가사가 없습니다." : "검색 결과가 없습니다."); + var empty = (string.IsNullOrWhiteSpace(query), meaning) switch + { + (true, LyricsStore.MeaningFilterOk) => "의미가 만들어진 곡이 아직 없습니다.", + (true, LyricsStore.MeaningFilterNone) => "모든 곡에 의미가 있습니다.", + (true, _) => "저장된 가사가 없습니다.", + _ => "검색 결과가 없습니다.", + }; + var table = Table(SongHeaders, results.Select(SongRowHtml(tz)), empty); + + string Option(string value, string label) => + $""; + + var filterLabel = meaning switch + { + LyricsStore.MeaningFilterOk => " · 의미 있음", + LyricsStore.MeaningFilterNone => " · 의미 아직 없음", + _ => "", + }; return Layout("가사 검색", $"""
+
-

{(string.IsNullOrWhiteSpace(query) ? "최근 갱신순" : $"\"{Esc(query)}\" 검색")} · {results.Count}건

+

{(string.IsNullOrWhiteSpace(query) ? "최근 갱신순" : $"\"{Esc(query)}\" 검색")}{filterLabel} · {results.Count}건

{table} """, "search"); } + /// + /// 대시보드의 한 섹션을 전부 보여 주는 페이지. 섹션마다 라우트를 파지 않고 + /// ?view= 하나로 처리한다 — 표를 만드는 방법은 대시보드와 완전히 같다. + /// + public static string ListPage(string heading, string tableHtml, int count, string? note = null) => + Layout(heading, $""" +

{Esc(heading)}

+

{count}건{(note is null ? "" : $" · {Esc(note)}")}

+ {tableHtml} +

← 대시보드

+ """, "home"); + + /// `/admin/list?view=` 가 받는 값과 화면 제목. 여기 없는 값은 거절한다. + public static readonly IReadOnlyDictionary ListViews = + new Dictionary(StringComparer.Ordinal) + { + ["lookups"] = "최근 조회", + ["misses"] = "미스 상위 (7일)", + ["untranslated"] = "번역 없는 곡", + ["duplicates"] = "표기 차이로 갈린 곡 후보", + ["cleaned"] = "느슨한 키로 맞은 조회 (7일)", + }; + + /// `/admin/list` 의 표 — 뷰마다 열이 달라 여기서 만든다. + public static string ListTable( + string view, DashboardModel m, TimeZoneInfo tz) => view switch + { + "lookups" => Table( + ["시각", "곡", "아티스트", "결과", "기기"], + m.Recent.Select(r => $""" + {Esc(AdminTime.ToLocal(r.At, tz))} + {SongLink(r.Key, r.Title)}{Esc(r.Artist)} + {Esc(ResultText(r.Result))} + {Esc(r.Device)} + """), + "아직 조회가 없습니다."), + + "misses" => Table( + ["곡", "아티스트", "횟수", "기기 수", "마지막", ""], + m.TopMisses.Select(MissRowHtml(tz)), + "미스 없음 — 요청한 곡이 전부 서버에 있었습니다."), + + "untranslated" => Table(SongHeaders, m.WithoutTranslation.Select(SongRowHtml(tz)), + "모든 곡에 번역이 있습니다."), + + "duplicates" => Table(SongHeaders, m.DuplicateCandidates.Select(SongRowHtml(tz)), + "표기 차이로 갈린 곡이 없습니다."), + + _ => Table( + ["시각", "요청한 곡", "요청한 아티스트", "맞은 곡", "기기"], + m.CleanedMatches.Select(r => $""" + {Esc(AdminTime.ToLocal(r.At, tz))} + {Esc(r.Title)}{Esc(r.Artist)} + {SongLink(r.Key, r.Key ?? "")}{Esc(r.Device)} + """), + "느슨한 매치가 아직 없습니다."), + }; + public static string SongPage( LyricsEntry entry, IReadOnlyList lines, IReadOnlyList langs, - string? selectedLang, bool showTags, string csrf, TimeZoneInfo tz, string? notice = null) + string? selectedLang, bool showTags, string csrf, TimeZoneInfo tz, string? notice = null, + MeaningEntry? meaning = null, bool meaningEnabled = false, + IReadOnlyList<(string Id, string Label, bool Checked)>? meaningSources = null) { var key = entry.Key ?? ""; var langLinks = langs.Count == 0 @@ -184,6 +306,7 @@ public static string SongPage( · 타임태그 {(showTags ? "숨기기" : "보기")} · 원문(.lrc)

+ {MeaningCard(entry, meaning, csrf, meaningEnabled, meaningSources ?? [])} {body}

편집

@@ -207,12 +330,88 @@ public static string SongPage( """, "search"); } + /// + /// 가사 위에 붙는 "이 곡의 의미" 카드. 의미가 없으면 외부 링크와 생성 버튼만 보인다. + /// + /// 출처 표기는 의무다 — Wikipedia 본문은 CC BY-SA고 Genius·Last.fm도 링크 표기를 + /// 요구하므로 요약과 항상 함께 렌더한다. + /// + private static string MeaningCard( + LyricsEntry entry, MeaningEntry? meaning, string csrf, bool enabled, + IReadOnlyList<(string Id, string Label, bool Checked)> sources) + { + var key = entry.Key ?? ""; + var geniusUrl = MeaningLinks.Genius(entry.Title, entry.Artist, meaning?.GeniusUrl); + + var musixmatchUrl = MeaningLinks.Musixmatch(entry.Title, entry.Artist, meaning?.MusixmatchUrl); + var links = $""" + Musixmatch + · Genius + """; + + // 어떤 자료로 만들지 그 자리에서 고른다 — 한 곡으로 소스를 바꿔 가며 시험해 볼 수 있다. + var picker = sources.Count == 0 ? "" : $""" + {string.Concat(sources.Select(s => $""" + + """))} + """; + + // data-busy: 제출하면 버튼이 잠기고 스피너가 돈다(외부 API를 여러 번 부르므로 수 초 걸린다). + var button = !enabled + ? "의미 엔진이 구성되지 않았습니다." + : $""" +
+ + + + {picker} +
+ """; + + var bodyHtml = meaning?.Status switch + { + MeaningEntry.StatusOk => $"

{Esc(meaning.Summary)}

", + // 문단은 보여 준다(사람이 판단할 수 있게) — 다만 의미가 아니라는 것을 앞에 밝힌다. + MeaningEntry.StatusInsufficient => + "

자료 부족 — 모은 자료만으로는 곡의 의미를 판단하지 못했습니다.

" + + $"

{Esc(meaning.Summary)}

", + MeaningEntry.StatusNoSource => + "

외부 자료를 찾지 못했습니다 — 위 링크에서 직접 확인해 보세요.

", + MeaningEntry.StatusFailed => + "

생성에 실패했습니다(키·쿼타·네트워크).

", + _ => "

아직 만들지 않았습니다.

", + }; + + var attribution = MeaningMapper.Attribution(meaning?.Sources); + var credit = attribution.Count == 0 + ? "" + : "

출처: " + string.Join(" · ", attribution.Select(a => + string.IsNullOrWhiteSpace(a.Url) + ? Esc(a.Name) + : $"{Esc(a.Name)}")) + + (attribution.Any(a => a.Name == "Wikipedia") ? " (CC BY-SA)" : "") + + $" · {Esc(meaning?.Engine ?? "-")}/{Esc(meaning?.Model ?? "-")}

"; + + return $""" +

이 곡의 의미

+ {bodyHtml} + {credit} +

{links}

+ {button} + """; + } + // ---- 조각 ---- + /// 곡 목록 표의 열 이름 — 표를 만드는 곳이 여럿이라 한 군데서 정한다. + private static readonly string[] SongHeaders = + ["곡", "아티스트", "출처", "줄", "번역", "의미", "올린 기기", "갱신"]; + private static Func SongRowHtml(TimeZoneInfo tz) => r => $""" {SongLink(r.Key, r.Title)}{Esc(r.Artist)}{Esc(r.Service ?? "-")} {r.LineCount}{(r.HasInlineTimeTags ? " ●" : "")} {Esc(r.Langs.Length == 0 ? "-" : string.Join(",", r.Langs))} + {MeaningCell(r.MeaningStatus)} {Esc(r.UpdatedBy ?? "-")} {Esc(AdminTime.ToLocal(r.UpdatedAt, tz))} """; @@ -220,6 +419,38 @@ private static Func SongRowHtml(TimeZoneInfo tz) => r => $""" private static string SongLink(string? key, string text) => string.IsNullOrEmpty(key) ? Esc(text) : $"{Esc(text)}"; + /// + /// 미스 행 한 줄. 그때는 없었어도 지금은 서버에 있을 수 있어, 있으면 곡으로 바로 간다 + /// (없으면 예전처럼 검색으로 보낸다). + /// + private static Func MissRowHtml(TimeZoneInfo tz) => r => + { + var action = string.IsNullOrEmpty(r.Key) + ? $"검색" + : $"가사 보기"; + return $""" + {SongLink(r.Key, r.Title)}{Esc(r.Artist)}{r.Count}{r.Devices} + {Esc(AdminTime.ToLocal(r.LastAt, tz))} + {action} + """; + }; + + /// 대시보드의 각 목록이 보여 주는 행 수 — 나머지는 "전체 보기"로 넘긴다. + public const int DashboardRows = 10; + + /// 섹션 제목 옆의 "전체 보기" 링크. + private static string More(string href) => + $" · 전체 보기 →"; + + private static string MeaningCell(string? status) => status switch + { + MeaningEntry.StatusOk => "있음", + MeaningEntry.StatusInsufficient => "자료 부족", + MeaningEntry.StatusNoSource => "자료 없음", + MeaningEntry.StatusFailed => "실패", + _ => "-", + }; + private static string ResultText(string result) => result switch { LyricsEntry.MatchExact => "히트", diff --git a/src/Musebase.Server/Admin/AdminSupport.cs b/src/Musebase.Server/Admin/AdminSupport.cs index 6d653ab..ea00db0 100644 --- a/src/Musebase.Server/Admin/AdminSupport.cs +++ b/src/Musebase.Server/Admin/AdminSupport.cs @@ -46,6 +46,57 @@ public static bool VerifyCsrf(string? provided, string secret, string cookieValu } } +/// +/// 관리자 비밀번호. 긴 토큰을 주소창에 붙여 넣는 대신 사람이 외울 수 있는 값으로 들어오게 한다. +/// +/// 저장은 PBKDF2-SHA256 해시다 — 비밀번호는 다른 서비스와 돌려 쓰이기 쉬워, 설정 파일이 +/// 한 번 새면 피해가 이 서버에서 끝나지 않는다. 평문도 받아 주긴 하지만(개인 서버의 편의) +/// 해시를 권한다. 형식은 pbkdf2$반복수$소금(base64)$해시(base64). +/// +public static class AdminPassword +{ + /// 느리게 만드는 것이 목적이다 — 로그인은 사람이 가끔 하는 일이라 비싸도 된다. + public const int Iterations = 210_000; + + private const string Prefix = "pbkdf2$"; + + public static string Hash(string password, byte[]? salt = null) + { + salt ??= RandomNumberGenerator.GetBytes(16); + var key = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, 32); + return $"{Prefix}{Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(key)}"; + } + + /// + /// 설정값과 대조한다. 설정이 해시면 해시로, 평문이면 그대로 비교한다(둘 다 고정시간 비교). + /// 설정이 비어 있으면 항상 실패 — 비밀번호를 안 정했는데 아무 값으로나 들어오면 안 된다. + /// + public static bool Verify(string? password, string? configured) + { + if (string.IsNullOrEmpty(password) || string.IsNullOrWhiteSpace(configured)) return false; + + if (!configured!.StartsWith(Prefix, StringComparison.Ordinal)) + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(password!), Encoding.UTF8.GetBytes(configured)); + + var parts = configured.Split('$'); + if (parts.Length != 4 || !int.TryParse(parts[1], out var iterations)) return false; + try + { + var salt = Convert.FromBase64String(parts[2]); + var expected = Convert.FromBase64String(parts[3]); + var actual = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password!), salt, iterations, HashAlgorithmName.SHA256, expected.Length); + return CryptographicOperations.FixedTimeEquals(expected, actual); + } + catch (FormatException) + { + return false; // 설정이 깨졌다 — 통과시키지 않는다 + } + } +} + /// 검색어 → SQL LIKE 패턴. public static class AdminQuery { diff --git a/src/Musebase.Server/Admin/MeaningLinks.cs b/src/Musebase.Server/Admin/MeaningLinks.cs new file mode 100644 index 0000000..38ea967 --- /dev/null +++ b/src/Musebase.Server/Admin/MeaningLinks.cs @@ -0,0 +1,46 @@ +namespace Musebase.Server; + +/// +/// 곡의 배경·의미를 사람이 직접 읽으러 갈 외부 사이트 링크. +/// +/// Musixmatch의 "Meaning" 섹션은 **API로 가져올 수 없다** — 공개 API에는 meaning 엔드포인트가 +/// 없고(그 섹션은 사용자 기여 웹 콘텐츠다), 크롤링은 약관 위반이라 링크만 건다. +/// Genius는 공식 API로 곡 설명을 받아올 수 있으므로, 수집이 끝난 곡은 검색 링크 대신 +/// 정확한 곡 페이지(song.url)로 승격한다. +/// +/// 순수 함수라 유닛 테스트 대상이다. 관리자 페이지의 CSP(default-src 'none')는 +/// 링크 이동에 관여하지 않으므로 <a href>는 그대로 동작한다. +/// +public static class MeaningLinks +{ + /// 검색어 — "아티스트 제목". 아티스트가 없으면 제목만. + public static string Query(string title, string artist) + { + var t = (title ?? "").Trim(); + var a = (artist ?? "").Trim(); + return a.Length == 0 ? t : $"{a} {t}"; + } + + /// + /// 반드시 ?query= 형식이어야 한다. 경로형(/search/{검색어})은 실측에서 + /// 403을 준다 — 계획 단계에서는 Cloudflare 때문에 형식을 미리 확인할 수 없어 + /// 경로형으로 넣어 뒀다가, 배포 후 실제로 눌러 보고 잡았다. + /// + public static string MusixmatchSearch(string title, string artist) => + "https://www.musixmatch.com/search?query=" + Uri.EscapeDataString(Query(title, artist)); + + public static string GeniusSearch(string title, string artist) => + "https://genius.com/search?q=" + Uri.EscapeDataString(Query(title, artist)); + + /// 수집으로 알아낸 정확한 Genius 곡 페이지가 있으면 그것을, 없으면 검색 링크를 준다. + public static string Genius(string title, string artist, string? knownUrl) => + string.IsNullOrWhiteSpace(knownUrl) ? GeniusSearch(title, artist) : knownUrl!; + + /// + /// 공식 API로 확인한 곡 페이지가 있으면 그것을, 없으면 검색 링크를 준다. + /// 주소를 규칙으로 만들어 보내지 않는다 — 실측에서 /lyrics/Pearl-Jam/Even-Flow가 + /// 오류 없이 /lyrics/Pearl-Jam/Alive(다른 곡!)로 넘어갔다. + /// + public static string Musixmatch(string title, string artist, string? knownUrl) => + string.IsNullOrWhiteSpace(knownUrl) ? MusixmatchSearch(title, artist) : knownUrl!; +} diff --git a/src/Musebase.Server/ApiModels.cs b/src/Musebase.Server/ApiModels.cs index fceb385..52088a1 100644 --- a/src/Musebase.Server/ApiModels.cs +++ b/src/Musebase.Server/ApiModels.cs @@ -31,6 +31,8 @@ public sealed record LyricsEntry public const string OriginUser = "user"; public const string MatchExact = "exact"; public const string MatchCleaned = "cleaned"; + /// 서버에 없었다. 조회 기록에만 쓰이는 값이다(항목 자체에는 실리지 않는다). + public const string MatchMiss = "miss"; } /// PUT이 병합 정책으로 거부됐을 때의 응답(202). @@ -43,6 +45,45 @@ public sealed record PutRejected(bool Accepted, string Reason) /// GET /v1/stats — 검증·디버깅용 요약. public sealed record ServerStats(int Songs, int WithTranslation, string? LastUpdatedAt); +/// +/// 곡의 의미 1건. 앱은 만 보면 되고, +/// (원문 JSON)는 관리자 화면·재생성 판단용이다. +/// +/// 출처 표기는 선택이 아니다 — Wikipedia 본문은 CC BY-SA고 Genius·Last.fm도 링크 표기를 +/// 요구하므로, 요약을 보여 주는 화면은 을 함께 렌더해야 한다. +/// +public sealed record MeaningEntry +{ + public string Key { get; init; } = ""; + public required string Title { get; init; } + public required string Artist { get; init; } + /// 생성된 대상 언어 문단. `status`가 `ok`가 아니면 null. + public string? Summary { get; init; } + public string Lang { get; init; } = "ko"; + /// 근거로 쓴 원문들(JSON 배열 `[{name,url,text}]`). + public string Sources { get; init; } = "[]"; + public string? GeniusUrl { get; init; } + /// 공식 API로 확인한 Musixmatch 곡 페이지. 규칙으로 만든 주소는 다른 곡으로 갈 수 있어 쓰지 않는다. + public string? MusixmatchUrl { get; init; } + public string? Engine { get; init; } + public string? Model { get; init; } + /// `ok` | `no-source` | `failed`. + public string Status { get; init; } = StatusFailed; + public string UpdatedAt { get; init; } = ""; + + /// 화면에 그대로 붙이는 출처 문구(이름·링크 쌍). 응답에 계산해 싣는다. + public IReadOnlyList? Attribution { get; init; } + + public const string StatusOk = "ok"; + public const string StatusNoSource = "no-source"; + public const string StatusFailed = "failed"; + /// 자료는 있었지만 그것만으로는 의미를 말할 수 없었다 — 문단은 남되 의미로 세지 않는다. + public const string StatusInsufficient = "insufficient"; +} + +/// 출처 한 건 — 이름과 원문 주소. +public sealed record MeaningAttribution(string Name, string? Url); + /// JSON 오류 본문. public sealed record ApiError([property: JsonPropertyName("error")] string Error); diff --git a/src/Musebase.Server/LyricsStore.cs b/src/Musebase.Server/LyricsStore.cs index b75f85e..1da60c8 100644 --- a/src/Musebase.Server/LyricsStore.cs +++ b/src/Musebase.Server/LyricsStore.cs @@ -56,27 +56,153 @@ updated_by TEXT /// 스키마 버전 마이그레이션. PRAGMA user_version으로 관리한다 — /// CREATE TABLE IF NOT EXISTS와 달리 ALTER TABLE은 재실행하면 실패하므로, /// 컬럼을 더할 일이 생기기 전에 버전 관리를 들여 둔다. - /// 0 = lyrics만(v1 배포본), 1 = lookups(조회 기록) 추가. + /// 0 = lyrics만(v1 배포본), 1 = lookups(조회 기록), 2 = meanings(곡의 의미). /// private void Migrate() { - if (ScalarInt("PRAGMA user_version;") >= 1) return; + var version = ScalarInt("PRAGMA user_version;"); - Execute(""" - CREATE TABLE IF NOT EXISTS lookups ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - at TEXT NOT NULL, -- ISO-8601 UTC (lyrics.updated_at과 같은 포맷) - title TEXT NOT NULL, - artist TEXT NOT NULL, - result TEXT NOT NULL, -- 'exact' | 'cleaned' | 'miss' - key TEXT, -- 히트 시 맞은 행의 key, 미스면 NULL - device TEXT NOT NULL, - client TEXT -- User-Agent 원문(진단용) - ); - CREATE INDEX IF NOT EXISTS ix_lookups_at ON lookups(at); - CREATE INDEX IF NOT EXISTS ix_lookups_result_at ON lookups(result, at); - """); - Execute("PRAGMA user_version = 1;"); + if (version < 1) + { + Execute(""" + CREATE TABLE IF NOT EXISTS lookups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, -- ISO-8601 UTC (lyrics.updated_at과 같은 포맷) + title TEXT NOT NULL, + artist TEXT NOT NULL, + result TEXT NOT NULL, -- 'exact' | 'cleaned' | 'miss' + key TEXT, -- 히트 시 맞은 행의 key, 미스면 NULL + device TEXT NOT NULL, + client TEXT -- User-Agent 원문(진단용) + ); + CREATE INDEX IF NOT EXISTS ix_lookups_at ON lookups(at); + CREATE INDEX IF NOT EXISTS ix_lookups_result_at ON lookups(result, at); + """); + Execute("PRAGMA user_version = 1;"); + } + + if (version < 2) + { + // 가사와 1:1(같은 key). 별도 테이블인 이유는 의미가 없어도 가사는 멀쩡해야 하고, + // 재생성이 가사 revision을 건드리면 안 되기 때문이다. + Execute(""" + CREATE TABLE IF NOT EXISTS meanings ( + key TEXT PRIMARY KEY, -- lyrics.key와 같은 규칙 + title TEXT NOT NULL, + artist TEXT NOT NULL, + summary TEXT, -- 생성된 대상 언어 문단 + lang TEXT NOT NULL, + sources TEXT NOT NULL, -- JSON 배열: [{name,url,text}] + genius_url TEXT, + engine TEXT, + model TEXT, -- 재생성 판단용 + status TEXT NOT NULL, -- 'ok' | 'no-source' | 'failed' + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_meanings_status ON meanings(status); + """); + Execute("PRAGMA user_version = 2;"); + } + + if (version < 3) + { + // 곡 페이지 주소는 공식 API로 확인한 것만 저장한다(규칙으로 만든 주소는 다른 곡으로 간다). + Execute("ALTER TABLE meanings ADD COLUMN musixmatch_url TEXT;"); + Execute("PRAGMA user_version = 3;"); + } + + if (version < 4) + { + // "자료가 부족해 파악하기 어렵다"는 답도 글자가 있다는 이유로 ok로 저장돼 있었다. + // 이미 쌓인 것까지 다시 갈라 준다 — 안 그러면 통계가 계속 부풀어 있고, 앱에는 + // 그 문장이 곡 해설이라며 뜬다. 판정은 생성 때와 **같은 함수**를 쓴다. + ReclassifyInsufficient(); + Execute("PRAGMA user_version = 4;"); + } + + if (version < 5) + { + // 느슨한 키 규칙이 바뀌었다(공동 아티스트를 대표 한 명으로 줄인다). + // 기존 행을 다시 계산하지 않으면 예전에 갈린 곡들이 영영 서로를 못 찾는다. + RecomputeLooseKeys(); + Execute("PRAGMA user_version = 5;"); + } + } + + /// 모든 행의 loose_key를 지금 규칙으로 다시 계산한다(행은 합치지 않는다). + private void RecomputeLooseKeys() + { + var rows = new List<(string Key, string Title, string Artist)>(); + using (var read = _conn.CreateCommand()) + { + read.CommandText = "SELECT key, title, artist FROM lyrics;"; + using var reader = read.ExecuteReader(); + while (reader.Read()) + rows.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2))); + } + + foreach (var (key, title, artist) in rows) + { + using var update = _conn.CreateCommand(); + update.CommandText = "UPDATE lyrics SET loose_key = $loose WHERE key = $k;"; + update.Parameters.AddWithValue("$loose", PrimaryLooseKey(title, artist)); + update.Parameters.AddWithValue("$k", key); + update.ExecuteNonQuery(); + } + } + + /// + /// 병합 규칙을 우회해 행을 그대로 넣는다 — 테스트 전용. + /// 예전 규칙으로 갈려 저장된 형제 행을 재현할 때 쓴다(운영 경로에서는 쓰지 않는다). + /// + public void UpsertRawForTest(string key, string looseKey, string title, string artist, string lrc) + { + lock (_lock) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + INSERT OR REPLACE INTO lyrics + (key, loose_key, title, artist, lrc, service, origin, langs, + line_count, has_inline, revision, updated_at, updated_by) + VALUES ($key, $loose, $title, $artist, $lrc, 'LRCLIB', 'provider', '', + 2, 0, 1, $at, 'test'); + """; + cmd.Parameters.AddWithValue("$key", key); + cmd.Parameters.AddWithValue("$loose", looseKey); + cmd.Parameters.AddWithValue("$title", title); + cmd.Parameters.AddWithValue("$artist", artist); + cmd.Parameters.AddWithValue("$lrc", lrc); + cmd.Parameters.AddWithValue("$at", UtcNow()); + cmd.ExecuteNonQuery(); + } + } + + /// 테스트에서 마이그레이션을 다시 돌려 보기 위한 것. 운영 경로에서는 쓰지 않는다. + public void SetUserVersionForTest(int version) + { + lock (_lock) Execute($"PRAGMA user_version = {version};"); + } + + /// 이미 저장된 `ok` 행 중 "자료 부족" 고백을 골라 상태를 고친다. + private void ReclassifyInsufficient() + { + var targets = new List(); + using (var read = _conn.CreateCommand()) + { + read.CommandText = "SELECT key, summary FROM meanings WHERE status = 'ok' AND summary IS NOT NULL;"; + using var reader = read.ExecuteReader(); + while (reader.Read()) + if (Musebase.Core.Meaning.MeaningVerdict.IsInsufficient(reader.GetString(1))) + targets.Add(reader.GetString(0)); + } + + foreach (var key in targets) + { + using var update = _conn.CreateCommand(); + update.CommandText = "UPDATE meanings SET status = 'insufficient' WHERE key = $k;"; + update.Parameters.AddWithValue("$k", key); + update.ExecuteNonQuery(); + } } // ---- 키 계산 (클라이언트와 같은 코드를 쓴다) ---- @@ -107,11 +233,12 @@ public static IReadOnlyList LooseKeys(string title, string artist) if (variant.Artist is { } a && !artists.Contains(a, StringComparer.OrdinalIgnoreCase)) artists.Add(a); } - // 아티스트에서 앨범 꼬리를 떼어 낸 형태도 후보에 넣는다. + // 앨범 꼬리를 뗀 형태와, 공동 아티스트를 대표 한 명으로 줄인 형태도 후보에 넣는다 + // (구분자만 다른 표기 — "A/B" ↔ "A, B" — 를 흡수한다). foreach (var a in artists.ToArray()) { - var stripped = StripAlbumSuffix(a); - if (!stripped.Equals(a, StringComparison.OrdinalIgnoreCase)) artists.Add(stripped); + foreach (var candidate in new[] { StripAlbumSuffix(a), LeadArtist(a) }) + if (!artists.Contains(candidate, StringComparer.OrdinalIgnoreCase)) artists.Add(candidate); } var keys = new List(); @@ -139,7 +266,23 @@ public static string PrimaryLooseKey(string title, string artist) if (variant.Artist is { Length: > 0 } a) cleanArtist = a; break; // 첫 변형이 가장 정제된 형태다 } - return LyricsCacheStore.MakeKey(cleanTitle, StripAlbumSuffix(cleanArtist)); + return LyricsCacheStore.MakeKey(cleanTitle, LeadArtist(cleanArtist)); + } + + /// + /// 느슨한 키에 쓸 대표 아티스트 한 명. 앨범 꼬리를 떼고 공동 아티스트도 첫 명만 남긴다. + /// + /// 실측으로 걸린 문제: 같은 폰이 같은 곡을 어떤 날은 + /// "Lady Gaga/Bradley Cooper", 어떤 날은 "Lady Gaga, Bradley Cooper"로 보고했다. + /// 구분자 하나가 달라 두 행으로 갈렸고, 한쪽에만 붙은 의미가 다른 쪽에서는 보이지 않았다. + /// 제목이 같고 대표 아티스트가 같으면 사실상 같은 곡이므로, 여기까지 줄여 흡수한다 + /// (정확 키가 먼저 시도되므로 이건 어디까지나 폴백이다). + /// + public static string LeadArtist(string artist) + { + var stripped = StripAlbumSuffix(artist); + var names = Musebase.Core.Meaning.ArtistNames.All(stripped); + return names.Count > 0 ? names[0] : stripped; } /// @@ -317,6 +460,162 @@ ON CONFLICT(key) DO UPDATE SET } } + // ---- 곡의 의미 ---- + + /// + /// 저장된 의미를 찾는다. **가사와 같은 해석기()로 키를 정한다** — + /// 가사가 느슨한 키로 맞는 곡은 의미도 같이 맞아야 한다. + /// + public MeaningEntry? GetMeaning(string title, string artist) + { + lock (_lock) + { + var found = Locate(title, artist); + var key = found?.Key ?? ExactKey(title, artist); + if (ReadMeaning(key) is { } direct) return direct; + + // 같은 곡이 표기 차이로 두 행에 갈려 있고 의미가 **한쪽에만** 붙어 있을 수 있다 + // (실측: "Lady Gaga/Bradley Cooper"와 "Lady Gaga, Bradley Cooper"). 가사가 맞았는데 + // 의미만 비는 상태는 만들지 않는다 — 같은 느슨한 키를 쓰는 형제 행까지 살펴본다. + return ReadMeaningByLooseGroup(PrimaryLooseKey(title, artist)) + ?? (found is null ? null : ReadMeaningByLooseGroup(PrimaryLooseKey(found.Title, found.Artist))); + } + } + + /// 관리자 화면처럼 이미 key를 아는 곳에서 쓴다. + public MeaningEntry? GetMeaningByKey(string key) + { + lock (_lock) return ReadMeaning(key); + } + + /// + /// 같은 느슨한 키를 쓰는 행들 중 의미가 붙은 것을 찾는다. + /// 쓸 수 있는 의미(ok)를 먼저 고른다 — "자료 부족" 행이 진짜 의미를 가릴 이유가 없다. + /// + private MeaningEntry? ReadMeaningByLooseGroup(string looseKey) + { + if (looseKey.Length == 0) return null; + + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + SELECT m.key FROM meanings m + JOIN lyrics l ON l.key = m.key + WHERE l.loose_key = $loose + ORDER BY CASE WHEN m.status = 'ok' THEN 0 ELSE 1 END, m.updated_at DESC + LIMIT 1; + """; + cmd.Parameters.AddWithValue("$loose", looseKey); + return cmd.ExecuteScalar() is string key ? ReadMeaning(key) : null; + } + + private MeaningEntry? ReadMeaning(string key) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + SELECT key, title, artist, summary, lang, sources, genius_url, engine, model, status, + updated_at, musixmatch_url + FROM meanings WHERE key = $k LIMIT 1; + """; + cmd.Parameters.AddWithValue("$k", key); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) return null; + + return new MeaningEntry + { + Key = reader.GetString(0), + Title = reader.GetString(1), + Artist = reader.GetString(2), + Summary = reader.IsDBNull(3) ? null : reader.GetString(3), + Lang = reader.GetString(4), + Sources = reader.GetString(5), + GeniusUrl = reader.IsDBNull(6) ? null : reader.GetString(6), + Engine = reader.IsDBNull(7) ? null : reader.GetString(7), + Model = reader.IsDBNull(8) ? null : reader.GetString(8), + Status = reader.GetString(9), + UpdatedAt = reader.GetString(10), + MusixmatchUrl = reader.IsDBNull(11) ? null : reader.GetString(11), + }; + } + + /// 의미를 저장한다(같은 key면 덮어쓴다 — 재생성이 정상 경로다). + public void UpsertMeaning(MeaningEntry entry) + { + lock (_lock) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO meanings (key, title, artist, summary, lang, sources, genius_url, + engine, model, status, updated_at, musixmatch_url) + VALUES ($key, $title, $artist, $summary, $lang, $sources, $genius, + $engine, $model, $status, $at, $mxm) + ON CONFLICT(key) DO UPDATE SET + title = $title, artist = $artist, summary = $summary, lang = $lang, + sources = $sources, genius_url = $genius, engine = $engine, model = $model, + status = $status, updated_at = $at, + -- 이번에 못 찾았다고 지난번에 확인한 주소를 지우지 않는다. + musixmatch_url = COALESCE($mxm, musixmatch_url); + """; + cmd.Parameters.AddWithValue("$key", entry.Key); + cmd.Parameters.AddWithValue("$title", entry.Title); + cmd.Parameters.AddWithValue("$artist", entry.Artist); + cmd.Parameters.AddWithValue("$summary", (object?)entry.Summary ?? DBNull.Value); + cmd.Parameters.AddWithValue("$lang", entry.Lang); + cmd.Parameters.AddWithValue("$sources", entry.Sources); + cmd.Parameters.AddWithValue("$genius", (object?)entry.GeniusUrl ?? DBNull.Value); + cmd.Parameters.AddWithValue("$engine", (object?)entry.Engine ?? DBNull.Value); + cmd.Parameters.AddWithValue("$model", (object?)entry.Model ?? DBNull.Value); + cmd.Parameters.AddWithValue("$status", entry.Status); + cmd.Parameters.AddWithValue("$at", entry.UpdatedAt); + cmd.Parameters.AddWithValue("$mxm", (object?)entry.MusixmatchUrl ?? DBNull.Value); + cmd.ExecuteNonQuery(); + } + } + + /// + /// 아직 의미가 없는 곡(백필 대상). 이미 시도해 본 곡은 제외한다 — + /// 실패·자료없음도 행이 남으므로 백필을 다시 눌러도 같은 곡을 무한히 재시도하지 않는다. + /// + public IReadOnlyList<(string Key, string Title, string Artist)> SongsWithoutMeaning(int limit) + { + lock (_lock) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + SELECT l.key, l.title, l.artist FROM lyrics l + LEFT JOIN meanings m ON m.key = l.key + WHERE m.key IS NULL + ORDER BY l.updated_at DESC + LIMIT $limit; + """; + cmd.Parameters.AddWithValue("$limit", Math.Clamp(limit, 1, 1000)); + using var reader = cmd.ExecuteReader(); + var rows = new List<(string, string, string)>(); + while (reader.Read()) rows.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2))); + return rows; + } + } + + /// 대시보드 타일용. `자료 부족`은 글자는 있지만 의미가 아니므로 따로 센다. + public (int WithMeaning, int NoSource, int Failed, int Insufficient) MeaningStats() + { + lock (_lock) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + SELECT + SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'no-source' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'insufficient' THEN 1 ELSE 0 END) + FROM meanings; + """; + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) return (0, 0, 0, 0); + int At(int i) => reader.IsDBNull(i) ? 0 : reader.GetInt32(i); + return (At(0), At(1), At(2), At(3)); + } + } + // ---- 부가 ---- public ServerStats Stats() @@ -467,7 +766,13 @@ public HitRate HitRateSince(string sinceUtc) return new HitRate(exact, cleaned, miss); } - /// 최근 조회 기록(최신순). + /// + /// 최근 조회 기록(최신순). + /// + /// 미스였던 행은 key가 비어 있지만 그 뒤에 곡이 올라왔을 수 있다. + /// 그래서 표시 시점에 다시 찾아 키를 채운다 — 화면에서 곡으로 넘어갈 수 있게 하기 위한 것이고, + /// result는 그대로 둔다(그때 미스였던 것은 사실이므로 기록을 바꾸면 안 된다). + /// public IReadOnlyList RecentLookups(int limit = 50) { var rows = new List(); @@ -483,10 +788,15 @@ public IReadOnlyList RecentLookups(int limit = 50) rows.Add(new LookupRow( reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.IsDBNull(4) ? null : reader.GetString(4), reader.GetString(5))); + + for (var i = 0; i < rows.Count; i++) + if (string.IsNullOrEmpty(rows[i].Key)) + rows[i] = rows[i] with { Key = Locate(rows[i].Title, rows[i].Artist)?.Key }; } return rows; } + /// 기간 내 미스 상위 — 서버에 없는 곡(=채울 후보). public IReadOnlyList TopMisses(string sinceUtc, int limit = 50) { @@ -507,6 +817,10 @@ GROUP BY lower(title), lower(artist) rows.Add(new MissRow( reader.GetString(0), reader.GetString(1), reader.GetInt32(2), reader.GetString(3), reader.GetInt32(4))); + + // 미스로 기록됐어도 지금은 서버에 있을 수 있다 — 있으면 바로 열어 볼 수 있게 키를 붙인다. + for (var i = 0; i < rows.Count; i++) + rows[i] = rows[i] with { Key = Locate(rows[i].Title, rows[i].Artist)?.Key }; } return rows; } @@ -579,23 +893,47 @@ public IReadOnlyList CleanedMatches(string sinceUtc, int limit = 50) private const string SongColumns = "key, loose_key, title, artist, service, origin, langs, line_count, has_inline, revision, updated_at, updated_by"; + /// + /// 목록 조회는 의미 상태를 항상 함께 읽는다 — 검색 결과에 "의미" 열을 보여 주기 위해서다. + /// meanings.key는 저장할 때 가 정한 키(=lyrics.key)라 + /// 별도 해석 없이 그대로 조인하면 된다. + /// + private const string SongSelect = + "SELECT l.key, l.loose_key, l.title, l.artist, l.service, l.origin, l.langs, l.line_count, " + + "l.has_inline, l.revision, l.updated_at, l.updated_by, m.status " + + "FROM lyrics l LEFT JOIN meanings m ON m.key = l.key"; + + /// 검색 화면의 의미 필터. + public const string MeaningFilterOk = "ok"; + public const string MeaningFilterNone = "none"; + + private static string MeaningWhere(string? filter) => filter switch + { + MeaningFilterOk => " m.status = 'ok' ", + MeaningFilterNone => " (m.status IS NULL OR m.status <> 'ok') ", + _ => "", + }; + /// /// 제목·아티스트 부분 일치 검색(대소문자 무시). 질의가 비면 최근 갱신순 목록. /// 곡 수가 수백 규모라 LIKE 풀스캔으로 충분하다(`%…%`는 어차피 인덱스를 못 탄다). /// - public IReadOnlyList Search(string? query, int limit = 100, int offset = 0) + public IReadOnlyList Search( + string? query, int limit = 100, int offset = 0, string? meaning = null) { var like = AdminQuery.ToLikePattern(query); + var conditions = new List(); + if (like is not null) + conditions.Add(@" (lower(l.title) LIKE $like ESCAPE '\' OR lower(l.artist) LIKE $like ESCAPE '\') "); + if (MeaningWhere(meaning) is { Length: > 0 } meaningWhere) conditions.Add(meaningWhere); + + var where = conditions.Count == 0 ? "" : " WHERE " + string.Join(" AND ", conditions); + lock (_lock) { using var cmd = _conn.CreateCommand(); - cmd.CommandText = like is null - ? $"SELECT {SongColumns} FROM lyrics ORDER BY updated_at DESC LIMIT $limit OFFSET $offset;" - : $""" - SELECT {SongColumns} FROM lyrics - WHERE lower(title) LIKE $like ESCAPE '\' OR lower(artist) LIKE $like ESCAPE '\' - ORDER BY updated_at DESC LIMIT $limit OFFSET $offset; - """; + cmd.CommandText = + $"{SongSelect}{where} ORDER BY l.updated_at DESC LIMIT $limit OFFSET $offset;"; if (like is not null) cmd.Parameters.AddWithValue("$like", like); cmd.Parameters.AddWithValue("$limit", limit); cmd.Parameters.AddWithValue("$offset", offset); @@ -609,7 +947,7 @@ public IReadOnlyList RecentUploads(int limit = 20) lock (_lock) { using var cmd = _conn.CreateCommand(); - cmd.CommandText = $"SELECT {SongColumns} FROM lyrics ORDER BY updated_at DESC LIMIT $limit;"; + cmd.CommandText = $"{SongSelect} ORDER BY l.updated_at DESC LIMIT $limit;"; cmd.Parameters.AddWithValue("$limit", limit); return ReadSongs(cmd); } @@ -621,7 +959,7 @@ public IReadOnlyList WithoutTranslation(int limit = 200) lock (_lock) { using var cmd = _conn.CreateCommand(); - cmd.CommandText = $"SELECT {SongColumns} FROM lyrics WHERE langs = '' ORDER BY updated_at DESC LIMIT $limit;"; + cmd.CommandText = $"{SongSelect} WHERE l.langs = '' ORDER BY l.updated_at DESC LIMIT $limit;"; cmd.Parameters.AddWithValue("$limit", limit); return ReadSongs(cmd); } @@ -637,9 +975,9 @@ public IReadOnlyList DuplicateCandidates(int limit = 100) { using var cmd = _conn.CreateCommand(); cmd.CommandText = $""" - SELECT {SongColumns} FROM lyrics - WHERE loose_key IN (SELECT loose_key FROM lyrics GROUP BY loose_key HAVING COUNT(*) > 1) - ORDER BY loose_key, updated_at DESC LIMIT $limit; + {SongSelect} + WHERE l.loose_key IN (SELECT loose_key FROM lyrics GROUP BY loose_key HAVING COUNT(*) > 1) + ORDER BY l.loose_key, l.updated_at DESC LIMIT $limit; """; cmd.Parameters.AddWithValue("$limit", limit); return ReadSongs(cmd); @@ -658,7 +996,8 @@ private static List ReadSongs(SqliteCommand cmd) reader.IsDBNull(4) ? null : reader.GetString(4), reader.GetString(5), langs.Length == 0 ? Array.Empty() : langs.Split(','), reader.GetInt32(7), reader.GetInt32(8) != 0, reader.GetInt32(9), - reader.GetString(10), reader.IsDBNull(11) ? null : reader.GetString(11))); + reader.GetString(10), reader.IsDBNull(11) ? null : reader.GetString(11), + reader.IsDBNull(12) ? null : reader.GetString(12))); } return rows; } diff --git a/src/Musebase.Server/MeaningOptions.cs b/src/Musebase.Server/MeaningOptions.cs new file mode 100644 index 0000000..e37f4e0 --- /dev/null +++ b/src/Musebase.Server/MeaningOptions.cs @@ -0,0 +1,200 @@ +using System.Text.Json; +using Musebase.Core.Meaning; + +namespace Musebase.Server; + +/// +/// 곡 의미 기능의 서버 구성. 전부 환경변수에서 읽고, **키가 없으면 그냥 꺼진다** — +/// 가사 기능에는 어떤 영향도 주지 않는다. +/// +public sealed record MeaningOptions( + string Engine, + string Lang, + string? GeminiApiKey, + string? GeminiModel, + string? OpenRouterApiKey, + string? OpenRouterModel, + string? GeniusToken, + string? LastFmKey, + string? MusixmatchKey, + IReadOnlyList Sources, + int BackfillLimit, + int BackfillDelayMs) +{ + /// + /// 소스 id. 기본값에 musixmatch는 없다 — 그 자료는 사람이 쓴 해설이 아니라 + /// 기계가 가사를 분석한 결과라( 참고) 켤지 말지를 + /// 운영자가 직접 정해야 한다. + /// + public static readonly string[] DefaultSources = ["genius", "lastfm", "wikipedia"]; + + /// + /// `MUSEBASE_MEANING_ENGINE`(gemini|openrouter|none, 기본 none), + /// `MUSEBASE_MEANING_LANG`(기본 ko), `MUSEBASE_GEMINI_API_KEY` / `MUSEBASE_GEMINI_MODEL`, + /// `MUSEBASE_OPENROUTER_API_KEY` / `MUSEBASE_OPENROUTER_MODEL`, + /// `MUSEBASE_GENIUS_TOKEN`, `MUSEBASE_LASTFM_KEY`, `MUSEBASE_MUSIXMATCH_KEY`, + /// `MUSEBASE_MEANING_SOURCES`(쉼표 구분, 기본 `genius,lastfm,wikipedia`), + /// `MUSEBASE_MEANING_WIKIPEDIA`(0이면 끔 — 예전 변수, 아래 설명), + /// `MUSEBASE_MEANING_BACKFILL_LIMIT`(기본 50), + /// `MUSEBASE_MEANING_BACKFILL_DELAY_MS`(기본 0 — 아래 설명). + /// + /// `MUSEBASE_MEANING_WIKIPEDIA=0`은 소스 목록이 생기기 전부터 쓰던 변수라 계속 받아 준다 — + /// 목록을 직접 지정하지 않은 경우에만 기본값에서 위키피디아를 뺀다(직접 지정이 항상 이긴다). + /// + /// 백필 간격이 기본 0인 이유: 유료 티어는 분당 한도가 넉넉해 일부러 느리게 돌 이유가 없고, + /// 429가 나더라도 백필이 그 자리에서 멈추고 **아무것도 저장하지 않으므로** 망가지지 않는다. + /// Gemini 무료 티어(15 RPM)처럼 빡빡한 한도에서 끝까지 한 번에 돌리고 싶으면 4500 정도를 준다. + /// + public static MeaningOptions FromEnvironment() + { + static string? Env(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : null; + + var limit = int.TryParse(Env("MUSEBASE_MEANING_BACKFILL_LIMIT"), out var n) + ? Math.Clamp(n, 1, 500) : 50; + var delay = int.TryParse(Env("MUSEBASE_MEANING_BACKFILL_DELAY_MS"), out var d) + ? Math.Clamp(d, 0, 60_000) : 0; + + var sources = ParseSources(Env("MUSEBASE_MEANING_SOURCES"), Env("MUSEBASE_MEANING_WIKIPEDIA")); + + return new MeaningOptions( + Engine: Env("MUSEBASE_MEANING_ENGINE") ?? MeaningWriterRegistry.None, + Lang: Env("MUSEBASE_MEANING_LANG") ?? "ko", + GeminiApiKey: Env("MUSEBASE_GEMINI_API_KEY"), + GeminiModel: Env("MUSEBASE_GEMINI_MODEL"), + OpenRouterApiKey: Env("MUSEBASE_OPENROUTER_API_KEY"), + OpenRouterModel: Env("MUSEBASE_OPENROUTER_MODEL"), + GeniusToken: Env("MUSEBASE_GENIUS_TOKEN"), + LastFmKey: Env("MUSEBASE_LASTFM_KEY"), + MusixmatchKey: Env("MUSEBASE_MUSIXMATCH_KEY"), + Sources: sources, + BackfillLimit: limit, + BackfillDelayMs: delay); + } + + /// 설정 문자열 → 소스 id 목록. 알 수 없는 이름은 무시한다(오타로 서버가 죽지 않게). + public static IReadOnlyList ParseSources(string? configured, string? legacyWikipedia) + { + if (!string.IsNullOrWhiteSpace(configured)) + return configured!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => s.ToLowerInvariant()) + .Where(s => DefaultSources.Contains(s) || s == "musixmatch") + .Distinct(StringComparer.Ordinal) + .ToList(); + + return legacyWikipedia == "0" + ? DefaultSources.Where(s => s != "wikipedia").ToList() + : DefaultSources.ToList(); + } + + /// Musixmatch 곡 페이지 주소를 찾아 주는 클라이언트(키가 없으면 꺼진 상태로 동작). + public MusixmatchApi MusixmatchApi() => new(MusixmatchKey ?? ""); + + /// 화면에 보여 줄 소스 이름. + public static string SourceLabel(string id) => id switch + { + "genius" => "Genius", + "lastfm" => "Last.fm", + "wikipedia" => "Wikipedia", + "musixmatch" => "Musixmatch (AI 분석)", + _ => id, + }; + + /// + /// **키가 있어 실제로 쓸 수 있는** 소스들 — 곡 상세의 체크박스 목록이 된다. + /// 쓸 수 없는 소스를 체크박스로 보여 주면 눌러도 아무 일이 안 일어나 사람을 헷갈리게 한다. + /// 두 번째 값은 "설정상 기본으로 켜져 있는가"다(체크 상태). + /// + public IReadOnlyList<(string Id, bool Default)> SelectableSources() + { + var all = new (string Id, bool Available)[] + { + ("genius", !string.IsNullOrWhiteSpace(GeniusToken)), + ("lastfm", !string.IsNullOrWhiteSpace(LastFmKey)), + ("wikipedia", true), + ("musixmatch", !string.IsNullOrWhiteSpace(MusixmatchKey)), + }; + return all.Where(s => s.Available) + .Select(s => (s.Id, Sources.Contains(s.Id, StringComparer.Ordinal))) + .ToList(); + } + + /// + /// 고른 소스 중 **키까지 있는 것만** 골라 서비스를 만든다. + /// 하나도 남지 않으면 소스가 비어 기능이 꺼진 상태가 된다. + /// + /// + /// 이번 한 번만 쓸 소스 목록(곡 상세에서 체크박스로 고른 경우). 비우면 설정값을 쓴다. + /// 설정에 없는 소스도 **키만 있으면** 여기서 켤 수 있다 — 한 곡으로 시험해 보라고 둔 문이다. + /// + public SongMeaningService BuildService(IReadOnlyList? only = null) + { + var sources = new List(); + foreach (var id in only is { Count: > 0 } ? only : Sources) + { + switch (id) + { + case "genius" when !string.IsNullOrWhiteSpace(GeniusToken): + sources.Add(new GeniusSource(GeniusToken!)); break; + case "lastfm" when !string.IsNullOrWhiteSpace(LastFmKey): + sources.Add(new LastFmSource(LastFmKey!)); break; + case "wikipedia": + sources.Add(new WikipediaSource()); break; + case "musixmatch" when !string.IsNullOrWhiteSpace(MusixmatchKey): + sources.Add(new MusixmatchMeaningSource(MusixmatchApi())); break; + } + } + + var writer = MeaningWriterRegistry.Build(Engine, new MeaningWriterOptions + { + GeminiApiKey = GeminiApiKey, + GeminiModel = GeminiModel, + OpenRouterApiKey = OpenRouterApiKey, + OpenRouterModel = OpenRouterModel, + }); + + return new SongMeaningService(sources, writer); + } +} + +/// 생성 결과를 저장 행으로 옮기고, 저장 행에서 출처 목록을 되꺼내는 변환. +public static class MeaningMapper +{ + private static readonly JsonSerializerOptions Json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public static MeaningEntry ToEntry(string key, string title, string artist, string lang, SongMeaning result) => + new() + { + Key = key, + Title = title, + Artist = artist, + Summary = result.Summary, + Lang = lang, + Sources = JsonSerializer.Serialize(result.Sources, Json), + GeniusUrl = result.GeniusUrl, + Engine = result.Engine, + Model = result.Model, + Status = result.Status, + UpdatedAt = LyricsStore.UtcNow(), + }; + + /// 저장된 원문 JSON에서 출처(이름·주소)만 뽑는다. 깨져 있으면 빈 목록. + public static IReadOnlyList Attribution(string? sourcesJson) + { + if (string.IsNullOrWhiteSpace(sourcesJson)) return []; + try + { + var sources = JsonSerializer.Deserialize(sourcesJson!, Json); + return sources is null + ? [] + : sources.Select(s => new MeaningAttribution(s.Name, s.Url)).ToList(); + } + catch (JsonException) + { + return []; + } + } +} diff --git a/src/Musebase.Server/Program.cs b/src/Musebase.Server/Program.cs index 8fd8be4..81e5374 100644 --- a/src/Musebase.Server/Program.cs +++ b/src/Musebase.Server/Program.cs @@ -11,11 +11,25 @@ // MUSEBASE_DB 선택 — SQLite 경로(기본 ./lyrics.db) // CLI: // --import 기존 클라이언트 캐시를 흡수하고 종료(시드용) +// --hash-password <비밀번호> MUSEBASE_ADMIN_PASSWORD에 넣을 해시를 찍고 종료 const int MaxBodyBytes = 256 * 1024; // 양보 힌트에 실어 보내는 재조회 간격. 클라이언트는 이 값을 자기 상한으로 clamp한다. const int YieldRetryAfterMs = 3000; +// --hash-password: 설정 파일에 평문을 두지 않아도 되도록 해시를 만들어 준다. +var hashIndex = Array.IndexOf(args, "--hash-password"); +if (hashIndex >= 0) +{ + if (hashIndex + 1 >= args.Length) + { + Console.Error.WriteLine("사용법: Musebase.Server --hash-password <비밀번호>"); + return 2; + } + Console.WriteLine(AdminPassword.Hash(args[hashIndex + 1])); + return 0; +} + var dbPath = Environment.GetEnvironmentVariable("MUSEBASE_DB") ?? "lyrics.db"; // --import 모드: 서버를 띄우지 않고 시드만 하고 끝낸다. @@ -55,7 +69,12 @@ var app = builder.Build(); using var store = new LyricsStore(dbPath); var admin = AdminOptions.FromEnvironment(token!); -app.MapAdmin(store, admin); + +// 곡의 의미 — 키가 없으면 서비스가 꺼진 상태로 만들어지고 아무 데도 영향을 주지 않는다. +var meaningOptions = MeaningOptions.FromEnvironment(); +var meanings = meaningOptions.BuildService(); + +app.MapAdmin(store, admin, meanings, meaningOptions); // 보존 기간이 지난 조회 기록 정리 — 시작 시 1회 + 하루 1회. _ = Task.Run(async () => @@ -109,7 +128,7 @@ bool Authorized(HttpRequest request) { try { - store.LogLookup(title!, artist ?? "", found?.Match ?? "miss", found?.Key, + store.LogLookup(title!, artist ?? "", found?.Match ?? LyricsEntry.MatchMiss, found?.Key, device, request.Headers.UserAgent.ToString()); } catch (Exception e) { app.Logger.LogWarning("조회 기록 실패: {Message}", e.Message); } @@ -153,5 +172,23 @@ bool Authorized(HttpRequest request) app.MapGet("/v1/stats", (HttpRequest request) => !Authorized(request) ? Unauthorized() : Results.Ok(store.Stats())); +// 곡의 의미 — 앱은 조회만 한다. 생성은 관리자 화면에서만 일어난다(쿼타·비용을 사람이 통제). +app.MapGet("/v1/meaning", (HttpRequest request, string? title, string? artist) => +{ + if (!Authorized(request)) return Unauthorized(); + if (string.IsNullOrWhiteSpace(title)) return Results.Json(new ApiError("title required"), statusCode: 400); + + // `insufficient`도 404다 — 문단은 있지만 "파악하기 어렵다"는 고백이라 곡 해설로 띄우면 안 된다. + var found = store.GetMeaning(title!, artist ?? ""); + if (found is null || found.Status != MeaningEntry.StatusOk) return Results.NotFound(); + + // 원문 전체(sources)는 무겁고 앱에 필요 없다 — 출처 표기만 계산해 싣는다. + return Results.Ok(found with + { + Sources = "", + Attribution = MeaningMapper.Attribution(found.Sources), + }); +}); + app.Run(); return 0; diff --git a/src/Musebase.Server/deploy/README.md b/src/Musebase.Server/deploy/README.md index 6058c32..76e18e7 100644 --- a/src/Musebase.Server/deploy/README.md +++ b/src/Musebase.Server/deploy/README.md @@ -28,10 +28,38 @@ MUSEBASE_DB=/var/lib/musebase/lyrics.db # MUSEBASE_LOG_LOOKUPS=0 # 조회 기록을 남기지 않으려면 # MUSEBASE_LOOKUP_RETENTION_DAYS=90 # 조회 기록 보존 기간(기본 90일) # MUSEBASE_YIELD_WINDOW_SECONDS=30 # 번역 양보 판정 창(0이면 끔) — 아래 참고 +# --- 곡의 의미(선택) — 11절 참고 --- +# MUSEBASE_MEANING_ENGINE=gemini # gemini | openrouter | none(기본) +# MUSEBASE_GEMINI_API_KEY=... +# MUSEBASE_GENIUS_TOKEN=... +# MUSEBASE_LASTFM_KEY=... EOF sudo chmod 600 /etc/musebase/server.env # 토큰 파일은 절대 저장소에 커밋하지 않는다 ``` +### 관리자 로그인 — 아이디·비밀번호 + +기본은 토큰 로그인이다(주소창에 `?token=…`). 기기가 여러 대면 긴 토큰을 매번 붙여 넣어야 해 +불편하므로, 아이디·비밀번호를 정할 수 있다. + +```bash +# 설정 파일에 평문을 두지 않도록 해시를 만든다 +/opt/musebase/Musebase.Server --hash-password '정할비밀번호' +# → pbkdf2$210000$…$… +``` + +``` +MUSEBASE_ADMIN_USER=admin # 생략하면 admin +MUSEBASE_ADMIN_PASSWORD=pbkdf2$210000$…$… +``` + +- 값이 `pbkdf2$`로 시작하면 해시로, 아니면 **평문 그대로** 비교한다. 평문도 동작하지만 + 비밀번호는 다른 서비스와 돌려 쓰이기 쉬워, 설정 파일이 한 번 새면 피해가 여기서 끝나지 않는다 + — 해시를 권한다. +- **토큰 로그인은 계속 살아 있다.** 비밀번호를 잊거나 해시를 잘못 넣어도 들어갈 수 있어야 하기 + 때문이다(로그인 화면의 "토큰으로 들어가기"). 토큰은 어차피 앱이 API에 쓰는 값이라 새 비밀이 늘지 않는다. +- 비밀번호를 지우고 재시작하면 예전처럼 토큰 화면만 나온다. + ## 3. 빌드 · 전송 (개발 PC) Oracle 무료 티어는 보통 **Ampere A1(ARM64)** 이다. x86 인스턴스면 `linux-x64`로 바꾼다. @@ -163,7 +191,95 @@ Spotify Connect처럼 **PC에서 재생하고 폰에서 조작**하면 두 기 두 앱 모두 **저장 즉시 반영**되며(재시작 불필요), 서버에 못 붙으면 조용히 기존 동작 (로컬 캐시 → 제공자 검색)으로 강등된다. +## 11. 곡의 의미 (선택) + +곡이 무엇에 대한 노래인지 한 문단으로 만들어 관리자 화면과 `/v1/meaning`에 실어 준다. +**키를 넣지 않으면 통째로 꺼지고** 곡 상세에 Musixmatch·Genius 링크만 남는다(가사 기능엔 영향 없음). + +### 키 발급 + +| 키 | 어디서 | 비고 | +|---|---|---| +| `MUSEBASE_GEMINI_API_KEY` | | 요금은 아래 "무료로 쓰려면" 참고 | +| `MUSEBASE_GENIUS_TOKEN` | → New API Client → **Generate Access Token** | 무료. OAuth 사용자 플로우 불필요 | +| `MUSEBASE_LASTFM_KEY` | | 선택. Genius에 설명이 없는 곡을 메워 준다 | +| `MUSEBASE_MUSIXMATCH_KEY` | | 선택. **곡 페이지 링크를 정확히** 만드는 데 쓴다(아래) | + +Wikipedia는 키가 필요 없고 기본으로 켜져 있다(`MUSEBASE_MEANING_WIKIPEDIA=0`으로 끔). + +``` +MUSEBASE_MEANING_ENGINE=gemini # gemini | openrouter | none(기본) +MUSEBASE_MEANING_LANG=ko +MUSEBASE_GEMINI_API_KEY=... +MUSEBASE_GEMINI_MODEL=gemini-2.5-flash-lite # 생략 가능 +MUSEBASE_GENIUS_TOKEN=... +MUSEBASE_LASTFM_KEY=... +MUSEBASE_MUSIXMATCH_KEY=... # 선택 — 곡 페이지 링크 정확도 +MUSEBASE_MEANING_SOURCES=genius,lastfm,wikipedia # 기본값. musixmatch는 빠져 있다 +MUSEBASE_MEANING_BACKFILL_LIMIT=50 # 일괄 생성 1회 처리량 +MUSEBASE_MEANING_BACKFILL_DELAY_MS=0 # 호출 간 간격 — 무료 티어면 4500 +``` + +### 자료원을 고른다 — `MUSEBASE_MEANING_SOURCES` + +쉼표로 나열한다. 목록에 있고 **키까지 있는** 소스만 실제로 쓰인다(위키피디아만 키가 필요 없다). +지금 켜져 있는 자료원은 관리자 대시보드에 그대로 표시된다. + +`musixmatch`는 **기본값에 없다.** 그 사이트의 "Meaning"은 사람이 쓴 해설이 아니라 가사를 기계로 +분석한 결과이고(같은 블록에 무드·테마·콘텐츠 등급이 함께 온다), 자료로 넣으면 LLM이 쓴 글을 다시 +LLM에 넣어 요약하는 셈이 된다. 켜면 출처가 `Musixmatch (AI 분석)`으로 표시되고, 프롬프트가 +"다른 자료와 어긋나면 다른 자료를 따른다"로 취급한다. 스크래핑이라 약관 위험도 함께 진다 — +**켜는 판단은 운영자 몫이다.** + +``` +MUSEBASE_MEANING_SOURCES=genius,lastfm,wikipedia,musixmatch +``` + +### Musixmatch 링크 + +키를 넣으면 공식 API(`track.search`)로 확인한 **그 곡의 페이지**로 링크가 걸린다. 키가 없으면 +검색 링크로 물러난다. 주소를 규칙으로 만들지 않는 이유는 실측 때문이다 — +`/lyrics/Pearl-Jam/Even-Flow`가 오류 없이 `/lyrics/Pearl-Jam/Alive`(**다른 곡**)로 넘어갔다. + +### 무료로 쓰려면 — 헷갈리는 지점 + +**"$300 무료 체험 크레딧"과 "Gemini API 무료 티어"는 다른 제도다.** 크레딧은 Gemini API에 +**쓸 수 없다**(Google 공식 문서의 명시적 제외 항목). 무료로 쓰는 길은 무료 티어 하나뿐이고, +그건 **결제 계정이 연결되지 않은 프로젝트에만** 적용된다. + +여기서 함정: 결제를 연결하는 순간 그 프로젝트는 즉시 **Tier 1(유료)** 이 되고 무료 티어는 +사라진다. 크레딧은 안 먹히므로 카드에서 실제로 청구된다. 되돌리려면 결제를 명시적으로 해제해야 한다. + +- **무료로 가려면**: 결제가 없는 **별도 프로젝트**를 만들어 그 안에서 키를 발급한다. + 가사 번역용 프로젝트(Cloud Translation)는 결제가 필요하므로 **그쪽 결제를 끄면 안 된다.** + 무료 티어는 15 RPM이라 백필을 한 번에 돌리려면 `MUSEBASE_MEANING_BACKFILL_DELAY_MS=4500`을 준다. +- **유료(Tier 1)로 가도 된다**: 곡당 사실상 0원이라 보유 곡 전체를 채워도 몇백 원 수준이고, + 분당 한도가 넉넉해 간격이 필요 없다. 무료 티어와 달리 **보낸 내용이 학습에 쓰이지 않는다.** + +쿼타에 걸려도 안전하다 — 429·5xx는 저장하지 않고 백필이 그 자리에서 멈춘다. 남은 곡은 +손대지 않으므로 나중에 다시 누르면 이어서 진행된다(영구 실패만 행으로 남아 건너뛰어진다). + +### 모델을 바꿔 보고 싶다면 + +`MUSEBASE_MEANING_ENGINE=openrouter` + `MUSEBASE_OPENROUTER_API_KEY`로 바꾸고 +`MUSEBASE_OPENROUTER_MODEL`에 모델 문자열만 넣으면 된다(`anthropic/claude-opus-5`, +`google/gemini-2.5-flash` …). 같은 곡을 [다시 생성]으로 만들어 문장을 비교할 수 있다. +OpenRouter는 Google Cloud 프로젝트가 아예 필요 없어, 프로젝트 한도에 막혔을 때의 우회로이기도 하다. + +### 쓰는 법 + +- 곡 상세 → **[의미 가져오기]** (다시 누르면 재생성) +- 대시보드 → **[의미 일괄 생성]** — 아직 안 해 본 곡을 상한까지 처리 +- **생성은 사람이 누를 때만 일어난다.** 자동 생성은 두지 않았다 — 쿼타·비용이 예측 가능해야 하고 + 실패가 조용히 쌓이면 안 되기 때문이다. + +> **출처 표기 의무**: Wikipedia 본문은 CC BY-SA, Genius·Last.fm도 링크 표기를 요구한다. +> 관리자 화면과 `/v1/meaning`의 `attribution`이 이를 담고 있으므로, 요약을 보여 주는 화면은 +> 출처를 함께 표시해야 한다. + ## 업데이트 3~4단계를 반복하면 된다(`systemctl restart musebase-server`). DB는 `/var/lib/musebase`에 -따로 있으므로 배포로 지워지지 않는다. +따로 있으므로 배포로 지워지지 않는다. 스키마는 `PRAGMA user_version`으로 자동 이행된다 +(현재 3 — `meanings` 테이블과 `musixmatch_url` 컬럼까지) +(현재 2 = `lyrics` + `lookups` + `meanings`). diff --git a/src/Musebase.Windows/MeaningWindow.cs b/src/Musebase.Windows/MeaningWindow.cs new file mode 100644 index 0000000..f295e57 --- /dev/null +++ b/src/Musebase.Windows/MeaningWindow.cs @@ -0,0 +1,152 @@ +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Navigation; +using Musebase.Core.Search; +using Musebase.Engine; +using Musebase.Windows.Services; + +namespace Musebase.Windows; + +/// +/// "이 곡의 의미" 창. 가사 서버가 미리 만들어 둔 문단을 **읽기만 한다** — +/// 생성은 서버 관리자 화면에서만 일어나므로(쿼타·비용을 사람이 통제) 앱은 조회 전용이다. +/// +/// 서버가 없거나 그 곡에 의미가 없으면 그냥 안내 한 줄로 끝난다 — 가사 기능에는 아무 영향이 없다. +/// 출처 표기는 의무다(Wikipedia CC BY-SA 등) — 본문만 떼어 보여 주지 않는다. +/// +public sealed class MeaningWindow : Window +{ + private readonly LyricsCoordinator _coordinator; + private readonly TextBlock _header; + private readonly TextBlock _body; + private readonly TextBlock _credit; + private CancellationTokenSource? _cts; + + public MeaningWindow(LyricsCoordinator coordinator) + { + _coordinator = coordinator; + + Title = Loc.T("meaning.title"); + Width = 520; + Height = 360; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + + _header = new TextBlock + { + FontSize = 15, + FontWeight = FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 0, 0, 10), + }; + + _body = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + LineHeight = 22, + Text = Loc.T("meaning.loading"), + }; + + _credit = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + Opacity = 0.7, + FontSize = 11, + Margin = new Thickness(0, 14, 0, 0), + }; + + var stack = new StackPanel { Margin = new Thickness(16) }; + stack.Children.Add(_header); + stack.Children.Add(_body); + stack.Children.Add(_credit); + + Content = new ScrollViewer + { + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Content = stack, + }; + + Loaded += async (_, _) => await LoadAsync(); + Closed += (_, _) => _cts?.Cancel(); + } + + private async Task LoadAsync() + { + _cts?.Cancel(); + _cts = new CancellationTokenSource(); + var ct = _cts.Token; + + if (_coordinator.CurrentTrack is not { } track) + { + _body.Text = Loc.T("meaning.noTrack"); + return; + } + + _header.Text = $"{track.Title} — {track.Artist}"; + + if (_coordinator.RemoteCache is not { } remote) + { + _body.Text = Loc.T("meaning.noServer"); + return; + } + + var meaning = await remote.GetMeaningAsync(track.Title, track.Artist, ct).ConfigureAwait(true); + if (ct.IsCancellationRequested) return; + + if (meaning is null) + { + // 대부분의 곡에는 아직 의미가 없다 — 실패가 아니라 정상이다. + _body.Text = Loc.T("meaning.none"); + return; + } + + _body.Text = meaning.Summary; + ShowCredits(meaning); + } + + /// 출처를 이름·링크로 붙인다. 링크가 있으면 눌러서 원문으로 갈 수 있게 한다. + private void ShowCredits(SongMeaningView meaning) + { + if (meaning.Attribution.Count == 0) return; + + _credit.Inlines.Clear(); + _credit.Inlines.Add(new Run(Loc.T("meaning.credit") + " ")); + + var first = true; + foreach (var credit in meaning.Attribution) + { + if (!first) _credit.Inlines.Add(new Run(" · ")); + first = false; + + if (Uri.TryCreate(credit.Url, UriKind.Absolute, out var uri) + && uri.Scheme is "http" or "https") + { + var link = new Hyperlink(new Run(credit.Name)) { NavigateUri = uri }; + link.RequestNavigate += OpenExternal; + _credit.Inlines.Add(link); + } + else + { + _credit.Inlines.Add(new Run(credit.Name)); + } + } + + // Wikipedia 본문은 CC BY-SA다 — 이름만으로는 부족하고 라이선스를 함께 밝혀야 한다. + if (meaning.Attribution.Any(a => a.Name == "Wikipedia")) + _credit.Inlines.Add(new Run(" (CC BY-SA)")); + } + + private static void OpenExternal(object sender, RequestNavigateEventArgs e) + { + try + { + Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true }); + } + catch (Exception ex) + { + Log.Write($"[meaning] 링크 열기 실패: {ex.Message}"); + } + e.Handled = true; + } +} diff --git a/src/Musebase.Windows/Musebase.Windows.csproj b/src/Musebase.Windows/Musebase.Windows.csproj index d0372a3..d2ca78e 100644 --- a/src/Musebase.Windows/Musebase.Windows.csproj +++ b/src/Musebase.Windows/Musebase.Windows.csproj @@ -12,7 +12,7 @@ app.manifest assets\app.ico - 0.17.0 + 0.18.0 diff --git a/src/Musebase.Windows/Program.cs b/src/Musebase.Windows/Program.cs index 1a5bea0..3f25257 100644 --- a/src/Musebase.Windows/Program.cs +++ b/src/Musebase.Windows/Program.cs @@ -290,6 +290,7 @@ void RebuildSourceMenu() }; // ---- 트레이·미니창 공유 동작(중복 구현 방지: 같은 로컬 함수를 호출) ---- LyricsEditorWindow? editorWindow = null; + MeaningWindow? meaningWindow = null; void MediaPrevious() { telemetry.CountFeature("mediaControls"); _ = nowPlaying.SkipPreviousAsync(); } void MediaPlayPause() { telemetry.CountFeature("mediaControls"); _ = nowPlaying.TogglePlayPauseAsync(); } void MediaNext() { telemetry.CountFeature("mediaControls"); _ = nowPlaying.SkipNextAsync(); } @@ -314,6 +315,18 @@ void OpenLyricsEditor() editorWindow.Show(); } void MarkWrong() => coordinator.MarkWrongLyrics(); + void OpenMeaning() + { + if (coordinator.CurrentTrack is null) return; + telemetry.CountFeature("meaning"); + if (meaningWindow is { IsLoaded: true }) + { + meaningWindow.Activate(); + return; + } + meaningWindow = new MeaningWindow(coordinator); + meaningWindow.Show(); + } var searchItem = new MenuItem { Header = Loc.T("tray.search") }; searchItem.Click += (_, _) => OpenSearch(); @@ -348,6 +361,10 @@ void OpenLyricsEditor() } }; + // 곡의 의미 — 서버가 미리 만들어 둔 문단을 읽기만 한다(생성은 서버 관리자 화면에서). + var meaningItem = new MenuItem { Header = Loc.T("tray.meaning") }; + meaningItem.Click += (_, _) => OpenMeaning(); + // 현재 가사가 틀렸을 때: 표시 중단 + 캐시 제거 + 재검색 억제 var wrongItem = new MenuItem { Header = Loc.T("tray.wrong") }; wrongItem.Click += (_, _) => MarkWrong(); @@ -609,12 +626,15 @@ void AdjustOffset(double? delta) editItem.IsEnabled = hasLyrics; exportItem.IsEnabled = hasLyrics; wrongItem.IsEnabled = hasLyrics; + // 의미는 가사가 없어도 볼 수 있다(서버에 곡만 있으면 된다). + meaningItem.IsEnabled = coordinator.CurrentTrack is not null; RebuildSourceMenu(); }; menu.Items.Add(trackItem); menu.Items.Add(searchItem); menu.Items.Add(editItem); menu.Items.Add(exportItem); + menu.Items.Add(meaningItem); menu.Items.Add(wrongItem); menu.Items.Add(new Separator()); menu.Items.Add(overlayToggle); diff --git a/src/Musebase.Windows/i18n/en.json b/src/Musebase.Windows/i18n/en.json index d8fad81..a0b0977 100644 --- a/src/Musebase.Windows/i18n/en.json +++ b/src/Musebase.Windows/i18n/en.json @@ -91,6 +91,7 @@ "tray.search": "Search lyrics…", "tray.edit": "Edit current lyrics…", "tray.export": "Export lyrics (.lrc)…", + "tray.meaning": "About this song…", "tray.wrong": "Mark as no lyrics (wrong lyrics)", "tray.settings": "Settings…", "tray.exit": "Exit", @@ -168,6 +169,13 @@ "translation.status.disabled": "API translation off", "translation.status.disabledCache": "cached (API off)", + "meaning.title": "About this song", + "meaning.loading": "Loading…", + "meaning.noTrack": "Nothing is playing.", + "meaning.noServer": "No lyrics server configured — add the server address in Settings.", + "meaning.none": "No description for this song yet.", + "meaning.credit": "Sources:", + "search.title": "Search lyrics", "search.button": "Search", "search.label.title": "Title:", diff --git a/src/Musebase.Windows/i18n/ko.json b/src/Musebase.Windows/i18n/ko.json index 183fe4b..49b1e72 100644 --- a/src/Musebase.Windows/i18n/ko.json +++ b/src/Musebase.Windows/i18n/ko.json @@ -91,6 +91,7 @@ "tray.search": "가사 검색…", "tray.edit": "현재 가사 편집…", "tray.export": "가사 내보내기 (.lrc)…", + "tray.meaning": "이 곡의 의미…", "tray.wrong": "가사 없음으로 표시 (틀린 가사)", "tray.settings": "설정…", "tray.exit": "종료", @@ -168,6 +169,13 @@ "translation.status.disabled": "API 번역 꺼짐", "translation.status.disabledCache": "캐시 이용 (API 꺼짐)", + "meaning.title": "이 곡의 의미", + "meaning.loading": "불러오는 중…", + "meaning.noTrack": "재생 중인 곡이 없습니다.", + "meaning.noServer": "가사 서버가 설정되지 않았습니다 — 설정에서 서버 주소를 넣으세요.", + "meaning.none": "이 곡의 의미는 아직 없습니다.", + "meaning.credit": "출처:", + "search.title": "가사 검색", "search.button": "검색", "search.label.title": "제목:", diff --git a/tests/Musebase.Core.Tests/AdminPageTests.cs b/tests/Musebase.Core.Tests/AdminPageTests.cs index 943ccfa..a55f001 100644 --- a/tests/Musebase.Core.Tests/AdminPageTests.cs +++ b/tests/Musebase.Core.Tests/AdminPageTests.cs @@ -228,15 +228,268 @@ public class AdminPageTests [Fact] public void 조회_기록이_비면_안내행이_렌더된다() { - var model = new DashboardModel( - new ServerStats(0, 0, null), 0, new HitRate(0, 0, 0), new HitRate(0, 0, 0), - [], [], [], [], [], [], [], [], - new ServerHealth(TimeSpan.FromHours(1), 0, 0, 90), []); - - var html = AdminPages.Dashboard(model, DateTimeOffset.UtcNow, Kst); + var html = AdminPages.Dashboard(EmptyDashboard(), DateTimeOffset.UtcNow, Kst); Assert.Contains("아직 조회가 없습니다", html); Assert.Contains("colspan", html); - Assert.DoesNotContain("{AdminHtml.BusyScript}", html); + } + + [Fact] + public void 제출은_히스토리를_늘리지_않는다() + { + // 평범한 폼 제출은 [검색 → 곡 → 곡(생성 후)]을 만들어 뒤로 가기가 "생성 전의 같은 곡"으로 + // 간다. fetch로 보내고 location.replace로 지금 칸을 덮어써야 한 번에 그 앞 화면으로 간다. + Assert.Contains("fetch(", AdminHtml.BusyScript); + Assert.Contains("location.replace", AdminHtml.BusyScript); + Assert.DoesNotContain("history.pushState", AdminHtml.BusyScript); + + // fetch가 없는 브라우저에서는 평소대로 제출돼야 한다. + Assert.Contains("if(!window.fetch", AdminHtml.BusyScript); + Assert.Contains("f.submit()", AdminHtml.BusyScript); + } + + [Fact] + public void CSP는_그_스크립트의_해시만_허용한다() + { + Assert.StartsWith("'sha256-", AdminHtml.ScriptCsp); + Assert.DoesNotContain("unsafe-inline", AdminHtml.ScriptCsp); + + // 스크립트를 고치면 해시도 따라 바뀌어야 한다(상수로 박아 두면 조용히 안 돈다). + var expected = Convert.ToBase64String( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(AdminHtml.BusyScript))); + Assert.Equal($"'sha256-{expected}'", AdminHtml.ScriptCsp); + } + + [Fact] + public void 생성_폼은_스피너_표시_대상이다() + { + // data-busy가 없으면 눌러도 아무 반응이 없어 사람이 다시 누른다(같은 곡을 두 번 만든다). + var model = EmptyDashboard() with + { + Meanings = new MeaningSummary(0, 0, 0, Pending: 3, Enabled: true), + }; + + Assert.Contains("data-busy", AdminPages.Dashboard(model, DateTimeOffset.UtcNow, Kst)); + } + + // ---- 곡의 의미 ---- + + [Fact] + public void 의미_엔진이_없으면_일괄_생성_버튼이_뜨지_않는다() + { + var model = EmptyDashboard() with + { + Meanings = new MeaningSummary(0, 0, 0, Pending: 30, Enabled: false), + }; + + var html = AdminPages.Dashboard(model, DateTimeOffset.UtcNow, Kst); + + Assert.Contains("엔진 미구성", html); + Assert.DoesNotContain("/admin/meanings/backfill", html); + } + + [Fact] + public void 처리할_곡이_있으면_일괄_생성_버튼에_곡_수가_보인다() + { + var model = EmptyDashboard() with + { + Meanings = new MeaningSummary(5, 1, 0, Pending: 30, Enabled: true), + }; + + var html = AdminPages.Dashboard(model, DateTimeOffset.UtcNow, Kst); + + Assert.Contains("/admin/meanings/backfill", html); + Assert.Contains("의미 일괄 생성 (30곡)", html); + } + + [Fact] + public void 처리할_곡이_없으면_버튼을_숨긴다() + { + var model = EmptyDashboard() with + { + Meanings = new MeaningSummary(5, 1, 0, Pending: 0, Enabled: true), + }; + + Assert.DoesNotContain("/admin/meanings/backfill", + AdminPages.Dashboard(model, DateTimeOffset.UtcNow, Kst)); + } + + // ---- 로그인 ---- + + [Fact] + public void 비밀번호를_정하면_아이디_칸이_먼저_나온다() + { + var html = AdminPages.Login(passwordEnabled: true); + + Assert.Contains("name=\"user\"", html); + Assert.Contains("name=\"password\"", html); + // 토큰은 사라지지 않는다 — 비밀번호를 잊었을 때의 비상구다. + Assert.Contains("name=\"token\"", html); + Assert.Contains("토큰으로 들어가기", html); + } + + [Fact] + public void 비밀번호가_없으면_토큰_화면_그대로다() + { + var html = AdminPages.Login(); + + Assert.Contains("name=\"token\"", html); + Assert.DoesNotContain("name=\"password\"", html); + } + + [Fact] + public void 비밀번호는_해시로_검증된다() + { + var stored = AdminPassword.Hash("여기는비밀번호"); + + Assert.StartsWith("pbkdf2$", stored); + Assert.DoesNotContain("여기는비밀번호", stored); // 평문이 남지 않는다 + Assert.True(AdminPassword.Verify("여기는비밀번호", stored)); + Assert.False(AdminPassword.Verify("여기는비밀번회", stored)); + } + + [Fact] + public void 소금이_매번_달라_같은_비밀번호도_다른_해시가_된다() + { + Assert.NotEqual(AdminPassword.Hash("같은값"), AdminPassword.Hash("같은값")); + } + + [Fact] + public void 평문_설정도_받아_주되_그대로_비교한다() + { + // 개인 서버의 편의 — 해시를 만들기 귀찮을 때. 문서에서는 해시를 권한다. + Assert.True(AdminPassword.Verify("평문암호", "평문암호")); + Assert.False(AdminPassword.Verify("다른암호", "평문암호")); + } + + [Fact] + public void 비밀번호를_안_정했으면_무엇을_넣어도_통과하지_못한다() + { + // 설정이 비었을 때 빈 비밀번호로 들어가지는 사고를 막는다. + Assert.False(AdminPassword.Verify("", null)); + Assert.False(AdminPassword.Verify("아무거나", null)); + Assert.False(AdminPassword.Verify("아무거나", " ")); + Assert.False(AdminPassword.Verify("", "")); + } + + [Fact] + public void 해시가_깨져_있으면_통과시키지_않는다() + { + Assert.False(AdminPassword.Verify("x", "pbkdf2$210000$짧은소금")); + Assert.False(AdminPassword.Verify("x", "pbkdf2$abc$c2FsdA==$aGFzaA==")); + Assert.False(AdminPassword.Verify("x", "pbkdf2$210000$!!!$!!!")); + } + + // ---- 대시보드 구성 ---- + + [Fact] + public void 대시보드는_최근_올라온_가사를_맨_위에_둔다() + { + // 가사 서버의 정체성은 "무슨 가사가 들어와 있는가"다 — 조회 통계보다 앞에 온다. + var html = AdminPages.Dashboard(EmptyDashboard(), DateTimeOffset.UtcNow, Kst); + + var uploads = html.IndexOf("최근 올라온 가사", StringComparison.Ordinal); + var lookups = html.IndexOf("최근 조회", StringComparison.Ordinal); + + Assert.True(uploads > 0 && lookups > 0); + Assert.True(uploads < lookups, "최근 올라온 가사가 최근 조회보다 위여야 한다"); + } + + [Fact] + public void 각_섹션에_전체_보기_링크가_있다() + { + var html = AdminPages.Dashboard(EmptyDashboard(), DateTimeOffset.UtcNow, Kst); + + Assert.Contains("/admin/search\">전체 보기", html); // 최근 올라온 가사 = 질의 없는 검색 화면 + foreach (var view in AdminPages.ListViews.Keys) + Assert.Contains($"/admin/list?view={view}", html); + } + + [Fact] + public void 켜져_있는_의미_자료원을_화면에_보여_준다() + { + // 무엇에 근거해 만들어지는지가 설정에만 있으면 나중에 아무도 모른다. + var model = EmptyDashboard() with { MeaningSources = ["Genius", "Musixmatch (AI 분석)"] }; + + var html = AdminPages.Dashboard(model, DateTimeOffset.UtcNow, Kst); + + Assert.Contains("의미 자료:", html); + Assert.Contains("Musixmatch (AI 분석)", html); + } + + // ---- 미스 행에서 곡으로 ---- + + [Fact] + public void 미스여도_지금_서버에_있으면_가사로_가는_링크가_생긴다() + { + var model = EmptyDashboard() with + { + TopMisses = [new MissRow("Kids", "MGMT", 3, "2026-08-01T00:00:00Z", 2, "kids|mgmt")], + }; + + var html = AdminPages.Dashboard(model, DateTimeOffset.UtcNow, Kst); + + Assert.Contains("/admin/song?key=kids%7Cmgmt", html); + Assert.Contains("가사 보기", html); + } + + [Fact] + public void 정말_없는_곡은_검색으로만_보낸다() + { + var model = EmptyDashboard() with + { + TopMisses = [new MissRow("Kids", "MGMT", 3, "2026-08-01T00:00:00Z", 2)], + }; + + var html = AdminPages.Dashboard(model, DateTimeOffset.UtcNow, Kst); + + Assert.DoesNotContain("가사 보기", html); + Assert.Contains("/admin/search?q=Kids", html); + } + + // ---- 검색 화면의 의미 필터 ---- + + [Fact] + public void 검색_결과에_의미_열이_있다() + { + var withMeaning = Song("Kids", MeaningEntry.StatusOk); + var without = Song("Go!", null); + + var html = AdminPages.SearchPage(null, [withMeaning, without], Kst); + + Assert.Contains("의미", html); + Assert.Contains("있음", html); + } + + [Fact] + public void 고른_필터가_폼에_남아_있다() + { + var html = AdminPages.SearchPage(null, [], Kst, LyricsStore.MeaningFilterOk); + + Assert.Contains($"value=\"{LyricsStore.MeaningFilterOk}\" selected", html); + Assert.Contains("의미 있음", html); + } + + private static SongRow Song(string title, string? meaning) => + new("k-" + title, "k", title, "아티스트", "LRCLIB", "provider", + ["ko"], 10, false, 1, "2026-07-29T00:00:00Z", "거실PC", meaning); + + private static DashboardModel EmptyDashboard() => new( + new ServerStats(0, 0, null), 0, new HitRate(0, 0, 0), new HitRate(0, 0, 0), + [], [], [], [], [], [], [], [], + new ServerHealth(TimeSpan.FromHours(1), 0, 0, 90), [], + new MeaningSummary(0, 0, 0, 0, false), [], "csrf-token"); } diff --git a/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs b/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs index 2c79eaf..a70b44d 100644 --- a/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs +++ b/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs @@ -102,6 +102,235 @@ private static LyricsEntry Entry(string title, string artist, string lrc, string Assert.Equal(2, store.Stats().Songs); } + /// + /// 의미는 가사와 **같은 해석기**로 찾아야 한다 — 가사가 느슨한 키로 맞는 곡은 + /// 의미도 같이 맞지 않으면 앱에서 가사는 뜨는데 의미만 비는 일이 생긴다. + /// + [Fact] + public void 의미는_가사와_같은_키_해석기로_찾는다() + { + using var store = NewStore(); + store.Upsert(Entry("Kids", "MGMT", Plain), "윈도우PC", out _); + + store.UpsertMeaning(new MeaningEntry + { + Key = "kids|mgmt", + Title = "Kids", + Artist = "MGMT", + Summary = "성장의 불안에 대한 곡이다.", + Lang = "ko", + Sources = "[]", + Status = MeaningEntry.StatusOk, + UpdatedAt = "2026-08-01T00:00:00Z", + }); + + // 정확 키 + Assert.NotNull(store.GetMeaning("Kids", "MGMT")); + // 꼬리표가 붙은 표기(느슨한 키)로도 같은 의미가 나와야 한다 + Assert.NotNull(store.GetMeaning("Kids", "MGMT • 스마트셔플 추천")); + Assert.NotNull(store.GetMeaning("Kids", "MGMT — Oracular Spectacular")); + // 다른 곡은 없다 + Assert.Null(store.GetMeaning("Time to Pretend", "MGMT")); + } + + [Fact] + public void 이미_시도한_곡은_백필_대상에서_빠진다() + { + using var store = NewStore(); + store.Upsert(Entry("Kids", "MGMT", Plain), "윈도우PC", out _); + store.Upsert(Entry("Go!", "M83", Plain), "윈도우PC", out _); + + Assert.Equal(2, store.SongsWithoutMeaning(50).Count); + + // 자료를 못 찾은 곡도 행으로 남는다 — 백필을 다시 눌러도 무한 재시도하지 않는다. + store.UpsertMeaning(new MeaningEntry + { + Key = "kids|mgmt", + Title = "Kids", + Artist = "MGMT", + Lang = "ko", + Sources = "[]", + Status = MeaningEntry.StatusNoSource, + UpdatedAt = "2026-08-01T00:00:00Z", + }); + + var remaining = store.SongsWithoutMeaning(50); + Assert.Single(remaining); + Assert.Equal("Go!", remaining[0].Title); + + var (ok, none, failed, insufficient) = store.MeaningStats(); + Assert.Equal(0, ok); + Assert.Equal(1, none); + Assert.Equal(0, failed); + Assert.Equal(0, insufficient); + } + + // ---- 관리자 화면이 기대는 조회 ---- + + [Fact] + public void 의미_필터가_상태별로_갈라_준다() + { + using var store = NewStore(); + store.Upsert(Entry("Kids", "MGMT", Plain), "윈도우PC", out _); + store.Upsert(Entry("Go!", "M83", Plain), "윈도우PC", out _); + + store.UpsertMeaning(new MeaningEntry + { + Key = "kids|mgmt", Title = "Kids", Artist = "MGMT", Lang = "ko", Sources = "[]", + Summary = "성장의 불안에 대한 곡이다.", + Status = MeaningEntry.StatusOk, UpdatedAt = "2026-08-01T00:00:00Z", + }); + + Assert.Equal(2, store.Search(null).Count); + + var withMeaning = store.Search(null, meaning: LyricsStore.MeaningFilterOk); + Assert.Single(withMeaning); + Assert.Equal("Kids", withMeaning[0].Title); + Assert.Equal(MeaningEntry.StatusOk, withMeaning[0].MeaningStatus); + + var without = store.Search(null, meaning: LyricsStore.MeaningFilterNone); + Assert.Single(without); + Assert.Equal("Go!", without[0].Title); + Assert.Null(without[0].MeaningStatus); + } + + [Fact] + public void 자료를_못_찾은_곡은_의미_있음에_들지_않는다() + { + using var store = NewStore(); + store.Upsert(Entry("Kids", "MGMT", Plain), "윈도우PC", out _); + store.UpsertMeaning(new MeaningEntry + { + Key = "kids|mgmt", Title = "Kids", Artist = "MGMT", Lang = "ko", Sources = "[]", + Status = MeaningEntry.StatusNoSource, UpdatedAt = "2026-08-01T00:00:00Z", + }); + + Assert.Empty(store.Search(null, meaning: LyricsStore.MeaningFilterOk)); + Assert.Single(store.Search(null, meaning: LyricsStore.MeaningFilterNone)); + } + + [Theory] + // 같은 폰이 같은 곡을 날마다 다르게 보고한다 — 구분자 하나로 곡이 갈리면 안 된다. + [InlineData("Lady Gaga/Bradley Cooper")] + [InlineData("Lady Gaga, Bradley Cooper")] + [InlineData("Lady Gaga & Bradley Cooper")] + [InlineData("Lady Gaga feat. Bradley Cooper")] + [InlineData("Lady Gaga — A Star Is Born")] + public void 공동_아티스트_표기가_달라도_같은_곡으로_본다(string artist) + { + using var store = NewStore(); + store.Upsert(Entry("Shallow", "Lady Gaga/Bradley Cooper", Plain), "s26", out _); + + Assert.NotNull(store.Get("Shallow", artist)); + Assert.Equal(1, store.Stats().Songs); // 새 행이 생기지 않는다 + + store.Upsert(Entry("Shallow", artist, Translated), "윈도우PC", out _); + Assert.Equal(1, store.Stats().Songs); + } + + [Fact] + public void 표기가_갈려_이미_두_행이_됐어도_의미를_찾아낸다() + { + // 실측 상황: 의미는 슬래시 표기 행에만 붙어 있는데 폰은 쉼표 표기로 물어봤다. + using var store = NewStore(); + store.Upsert(Entry("Shallow", "Lady Gaga/Bradley Cooper", Plain), "s26", out _); + + // 예전 규칙으로 갈려 저장된 형제 행을 흉내낸다. + store.UpsertRawForTest("shallow|lady gaga, bradley cooper", "shallow|lady gaga, bradley cooper", + "Shallow", "Lady Gaga, Bradley Cooper", Plain); + + store.UpsertMeaning(new MeaningEntry + { + Key = "shallow|lady gaga/bradley cooper", Title = "Shallow", Artist = "Lady Gaga/Bradley Cooper", + Lang = "ko", Sources = "[]", Summary = "영화 속 두 사람의 대화를 담은 곡이다.", + Status = MeaningEntry.StatusOk, UpdatedAt = "2026-08-03T00:00:00Z", + }); + + Assert.NotNull(store.GetMeaning("Shallow", "Lady Gaga, Bradley Cooper")); + Assert.NotNull(store.GetMeaning("Shallow", "Lady Gaga")); + } + + [Fact] + public void 자료부족은_의미_있음에서_빠진다() + { + using var store = NewStore(); + store.Upsert(Entry("Kids", "MGMT", Plain), "윈도우PC", out _); + store.UpsertMeaning(new MeaningEntry + { + Key = "kids|mgmt", Title = "Kids", Artist = "MGMT", Lang = "ko", Sources = "[]", + Summary = "제시된 자료만으로는 파악하기 어렵다.", + Status = MeaningEntry.StatusInsufficient, UpdatedAt = "2026-08-02T00:00:00Z", + }); + + Assert.Empty(store.Search(null, meaning: LyricsStore.MeaningFilterOk)); + Assert.Single(store.Search(null, meaning: LyricsStore.MeaningFilterNone)); + + var (ok, _, _, insufficient) = store.MeaningStats(); + Assert.Equal(0, ok); + Assert.Equal(1, insufficient); + } + + [Fact] + public void 이미_ok로_저장된_자료부족_행을_다시_갈라_준다() + { + // 이 판정이 생기기 전에 쌓인 행들 — 그대로 두면 통계가 부풀고 앱에 그 문장이 뜬다. + using (var store = NewStore()) + { + store.Upsert(Entry("Kids", "MGMT", Plain), "윈도우PC", out _); + store.Upsert(Entry("Go!", "M83", Plain), "윈도우PC", out _); + + foreach (var (key, title, artist, summary) in new[] + { + ("kids|mgmt", "Kids", "MGMT", "제시된 자료만으로는 이 곡이 무엇에 대한 노래인지 파악하기 어렵다."), + ("go!|m83", "Go!", "M83", "이 곡은 질주하는 청춘의 감각을 다룬다."), + }) + { + store.UpsertMeaning(new MeaningEntry + { + Key = key, Title = title, Artist = artist, Lang = "ko", Sources = "[]", + Summary = summary, Status = MeaningEntry.StatusOk, UpdatedAt = "2026-08-02T00:00:00Z", + }); + } + + // 마이그레이션이 다시 돌도록 되돌린다. + store.SetUserVersionForTest(3); + } + + using var reopened = NewStore(); // 여는 순간 마이그레이션이 돈다 + Assert.Equal(MeaningEntry.StatusInsufficient, reopened.GetMeaningByKey("kids|mgmt")!.Status); + Assert.Equal(MeaningEntry.StatusOk, reopened.GetMeaningByKey("go!|m83")!.Status); + } + + [Fact] + public void 미스로_기록된_조회도_나중에_올라온_가사를_찾아낸다() + { + using var store = NewStore(); + store.LogLookup("Kids", "MGMT", LyricsEntry.MatchMiss, null, "안드로이드", null); + + // 그때는 없었다. + Assert.Null(store.RecentLookups(10)[0].Key); + + // 나중에 (표기가 조금 다른 채로) 올라왔다. + store.Upsert(Entry("Kids", "MGMT — Oracular Spectacular", Plain), "윈도우PC", out _); + + var row = store.RecentLookups(10)[0]; + Assert.NotNull(row.Key); // 이제 곡으로 갈 수 있다 + Assert.Equal(LyricsEntry.MatchMiss, row.Result); // 기록 자체는 바꾸지 않는다 + } + + [Fact] + public void 미스_상위도_지금_서버에_있으면_키를_붙인다() + { + using var store = NewStore(); + store.LogLookup("Kids", "MGMT", LyricsEntry.MatchMiss, null, "안드로이드", null); + store.LogLookup("Go!", "M83", LyricsEntry.MatchMiss, null, "안드로이드", null); + store.Upsert(Entry("Kids", "MGMT", Plain), "윈도우PC", out _); + + var misses = store.TopMisses("2000-01-01T00:00:00Z"); + Assert.Equal("kids|mgmt", misses.Single(m => m.Title == "Kids").Key); + Assert.Null(misses.Single(m => m.Title == "Go!").Key); // 정말 없는 곡은 그대로 null + } + public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); diff --git a/tests/Musebase.Core.Tests/MeaningLinksTests.cs b/tests/Musebase.Core.Tests/MeaningLinksTests.cs new file mode 100644 index 0000000..105296f --- /dev/null +++ b/tests/Musebase.Core.Tests/MeaningLinksTests.cs @@ -0,0 +1,65 @@ +using Musebase.Server; +using Xunit; + +namespace Musebase.Core.Tests; + +/// +/// 곡 배경·의미를 읽으러 가는 외부 링크 조립. 관리자 화면에 그대로 박히므로 +/// 이스케이프가 틀리면 링크가 깨지거나 HTML이 샌다. +/// +public class MeaningLinksTests +{ + [Fact] + public void 검색어는_아티스트_다음에_제목이다() + { + Assert.Equal("MGMT Kids", MeaningLinks.Query("Kids", "MGMT")); + } + + [Fact] + public void 아티스트가_없으면_제목만_쓴다() + { + Assert.Equal("Kids", MeaningLinks.Query("Kids", "")); + Assert.Equal("Kids", MeaningLinks.Query("Kids", " ")); + } + + [Fact] + public void 공백과_특수문자는_URL로_이스케이프된다() + { + var url = MeaningLinks.MusixmatchSearch("Don't Delete The Kisses", "Wolf Alice"); + // 경로형(/search/{검색어})은 실측에서 403이다 — 쿼리 형식이어야 한다. + Assert.StartsWith("https://www.musixmatch.com/search?query=", url); + Assert.DoesNotContain(" ", url); + Assert.Contains("%20", url); + Assert.Contains("%27", url); // 작은따옴표 + } + + [Fact] + public void 한글_제목도_깨지지_않는다() + { + var url = MeaningLinks.GeniusSearch("우리 그럼 앞으로", "Kim Mok In"); + Assert.StartsWith("https://genius.com/search?q=", url); + Assert.DoesNotContain(" ", url); + Assert.Contains("%EC%9A%B0", url); // "우" + } + + /// + /// Spotify가 붙이는 꼬리표가 그대로 들어와도 링크 자체는 유효해야 한다 + /// (검색 품질은 소스 수집 단계에서 SearchTermCleaner가 다룬다). + /// + [Fact] + public void 불릿_꼬리표가_붙어도_링크가_깨지지_않는다() + { + var url = MeaningLinks.GeniusSearch("Go!", "M83 • 스마트셔플 추천"); + Assert.StartsWith("https://genius.com/search?q=", url); + Assert.DoesNotContain(" ", url); + } + + [Fact] + public void 정확한_Genius_주소를_알면_검색_대신_그것을_쓴다() + { + var known = "https://genius.com/Mgmt-kids-lyrics"; + Assert.Equal(known, MeaningLinks.Genius("Kids", "MGMT", known)); + Assert.StartsWith("https://genius.com/search?q=", MeaningLinks.Genius("Kids", "MGMT", null)); + Assert.StartsWith("https://genius.com/search?q=", MeaningLinks.Genius("Kids", "MGMT", " ")); + } +} diff --git a/tests/Musebase.Core.Tests/MeaningOptionsTests.cs b/tests/Musebase.Core.Tests/MeaningOptionsTests.cs new file mode 100644 index 0000000..453e558 --- /dev/null +++ b/tests/Musebase.Core.Tests/MeaningOptionsTests.cs @@ -0,0 +1,72 @@ +using Musebase.Server; +using Xunit; + +namespace Musebase.Core.Tests; + +/// +/// 의미 자료원 선택. Musixmatch 자료는 사람이 쓴 해설이 아니라 기계가 가사를 분석한 결과라 +/// **기본으로 켜지지 않아야 한다** — 켤지 말지는 운영자가 정한다. +/// +public class MeaningOptionsTests +{ + [Fact] + public void 기본_소스에_musixmatch는_없다() + { + var sources = MeaningOptions.ParseSources(null, null); + + Assert.Equal(["genius", "lastfm", "wikipedia"], sources); + Assert.DoesNotContain("musixmatch", sources); + } + + [Fact] + public void 설정한_소스만_구성된다() + { + Assert.Equal(["wikipedia"], MeaningOptions.ParseSources("wikipedia", null)); + Assert.Equal(["genius", "musixmatch"], MeaningOptions.ParseSources("genius, musixmatch", null)); + Assert.Equal(["genius"], MeaningOptions.ParseSources("GENIUS", null)); // 대소문자 무시 + } + + [Fact] + public void 모르는_이름은_무시한다() + { + // 오타 하나로 서버가 죽으면 안 된다 — 조용히 빼고 나머지로 돌린다. + Assert.Equal(["genius"], MeaningOptions.ParseSources("genius,geniuss,songfacts", null)); + Assert.Empty(MeaningOptions.ParseSources("nonsense", null)); + } + + [Fact] + public void 예전_위키피디아_스위치를_계속_받아_준다() + { + // 소스 목록이 생기기 전부터 쓰던 변수라, 목록을 직접 지정하지 않은 경우에만 적용한다. + Assert.Equal(["genius", "lastfm"], MeaningOptions.ParseSources(null, "0")); + + // 직접 지정이 항상 이긴다. + Assert.Equal(["wikipedia"], MeaningOptions.ParseSources("wikipedia", "0")); + } + + [Fact] + public void 키가_없는_소스는_구성에서_빠진다() + { + var options = Empty with { Sources = ["genius", "lastfm", "wikipedia", "musixmatch"] }; + + // 위키피디아만 키가 필요 없다. + Assert.Equal(["Wikipedia"], options.BuildService().SourceNames); + } + + [Fact] + public void musixmatch는_고르고_키가_있을_때만_붙는다() + { + var keyed = Empty with { MusixmatchKey = "k", Sources = ["musixmatch"] }; + Assert.Single(keyed.BuildService().SourceNames); + Assert.Contains("AI 분석", keyed.BuildService().SourceNames[0]); // 출처에 성격이 드러난다 + + var notChosen = Empty with { MusixmatchKey = "k", Sources = ["wikipedia"] }; + Assert.DoesNotContain(notChosen.BuildService().SourceNames, n => n.Contains("Musixmatch")); + } + + private static readonly MeaningOptions Empty = new( + Engine: "none", Lang: "ko", + GeminiApiKey: null, GeminiModel: null, OpenRouterApiKey: null, OpenRouterModel: null, + GeniusToken: null, LastFmKey: null, MusixmatchKey: null, + Sources: [], BackfillLimit: 50, BackfillDelayMs: 0); +} diff --git a/tests/Musebase.Core.Tests/MeaningTests.cs b/tests/Musebase.Core.Tests/MeaningTests.cs new file mode 100644 index 0000000..72fc33c --- /dev/null +++ b/tests/Musebase.Core.Tests/MeaningTests.cs @@ -0,0 +1,601 @@ +using System.Net; +using System.Text; +using Musebase.Core.Meaning; +using Xunit; + +namespace Musebase.Core.Tests; + +/// +/// 곡 의미 수집·생성. 두 가지를 특히 본다 — +/// ① 소스가 죽어도 **예외가 아니라 null**이라 다른 소스가 채운다, +/// ② 자료가 하나도 없으면 **LLM을 아예 부르지 않는다**(곡 해설은 창작이 쉬운 영역이라 +/// 근거 없이 부르면 모델이 지어낸다). +/// +public class MeaningTests +{ + // ---- 소스 ---- + + [Fact] + public async Task Genius_응답에서_설명과_곡_주소를_뽑는다() + { + var handler = new StubHandler(req => + { + var path = req.RequestUri!.PathAndQuery; + if (path.StartsWith("/search")) + return Json(""" + {"response":{"hits":[ + {"type":"song","result":{"id":378195,"title":"Kids","artist_names":"MGMT", + "url":"https://genius.com/Mgmt-kids-lyrics"}}]}} + """); + return Json(""" + {"response":{"song":{"url":"https://genius.com/Mgmt-kids-lyrics", + "description":{"plain":"Kids is about the loss of innocence and the anxieties of growing up, written while the duo were students."}}}} + """); + }); + + var source = new GeniusSource("token", handler.Client); + var result = await source.FetchAsync("Kids", "MGMT"); + + Assert.NotNull(result); + Assert.Equal("Genius", result!.Name); + Assert.Equal("https://genius.com/Mgmt-kids-lyrics", result.Url); + Assert.Contains("loss of innocence", result.Text); + } + + [Fact] + public async Task Genius_설명이_비면_소스가_없는_것으로_본다() + { + // 대부분의 곡에는 About이 없다 — 빈 문자열을 근거로 넘기면 모델이 지어낸다. + var handler = new StubHandler(req => + req.RequestUri!.PathAndQuery.StartsWith("/search") + ? Json(""" + {"response":{"hits":[{"type":"song", + "result":{"id":1,"title":"Kids","artist_names":"MGMT","url":"u"}}]}} + """) + : Json("""{"response":{"song":{"url":"u","description":{"plain":"?"}}}}""")); + + Assert.Null(await new GeniusSource("token", handler.Client).FetchAsync("X", "Y")); + } + + [Fact] + public async Task 토큰이_없으면_네트워크를_건드리지_않는다() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("불려선 안 된다")); + Assert.Null(await new GeniusSource("", handler.Client).FetchAsync("X", "Y")); + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task 소스가_죽어도_예외_대신_null() + { + var handler = new StubHandler(_ => throw new HttpRequestException("down")); + Assert.Null(await new GeniusSource("token", handler.Client).FetchAsync("X", "Y")); + Assert.Null(await new LastFmSource("key", handler.Client).FetchAsync("X", "Y")); + Assert.Null(await new WikipediaSource("en", handler.Client).FetchAsync("X", "Y")); + } + + [Fact] + public async Task LastFm_본문에서_HTML과_꼬리표를_걷어_낸다() + { + var handler = new StubHandler(_ => Json(""" + {"track":{"url":"https://last.fm/x","wiki":{ + "content":"Wonderwall was written by Noel Gallagher about an imaginary friend who saves him from himself. Read more on Last.fm. User-contributed text..."}}} + """)); + + var result = await new LastFmSource("key", handler.Client).FetchAsync("Wonderwall", "Oasis"); + + Assert.NotNull(result); + Assert.DoesNotContain("", result!.Text); + Assert.DoesNotContain("Read more on Last.fm", result.Text); + Assert.Contains("imaginary friend", result.Text); + } + + // ---- Wikipedia 문서 선택 ---- + // + // 여기서 고른 문서가 그대로 LLM의 근거가 된다. 엉뚱한 문서를 넘기면 그럴듯하고 완전히 + // 틀린 "의미"가 만들어지므로, 확신이 없으면 포기하는 쪽이 옳다. + + [Fact] + public void 아티스트가_제목에_든_곡_문서를_고른다() + { + // 실측 함정: "(song)"이 붙은 제목을 무조건 우선하면 정답인 "Kids (MGMT song)" + // ("(song)"이 아니라 "(MGMT song)"이다)를 제치고 엉뚱한 문서가 뽑혔다. + WikipediaSource.SearchHit[] hits = + [ + new("Kids (MGMT song)", "\"Kids\" is a song by American rock band MGMT."), + new("Pursuit of Happiness (song)", "a song by Kid Cudi"), + new("MGMT", "MGMT is an American rock band"), + ]; + + Assert.Equal("Kids (MGMT song)", WikipediaSource.PickPage(hits, "Kids", "MGMT")); + } + + [Fact] + public void 아티스트가_스니펫에만_있어도_받아들인다() + { + WikipediaSource.SearchHit[] hits = + [ + new("Wonderwall", "\"Wonderwall\" is a song by the English rock band Oasis."), + ]; + + Assert.Equal("Wonderwall", WikipediaSource.PickPage(hits, "Wonderwall", "Oasis")); + } + + [Fact] + public void 제목이_맞아도_아티스트_확인이_안_되면_버린다() + { + // 동명이곡 — 근거로 쓰면 다른 곡의 이야기를 이 곡의 의미로 쓰게 된다. + WikipediaSource.SearchHit[] hits = + [ + new("Kids (song)", "a 2011 single by Sleigh Bells"), + ]; + + Assert.Null(WikipediaSource.PickPage(hits, "Kids", "MGMT")); + } + + [Fact] + public void 제목이_아예_다르면_고르지_않는다() + { + WikipediaSource.SearchHit[] hits = + [ + new("Oracular Spectacular", "the debut album by MGMT"), + ]; + + Assert.Null(WikipediaSource.PickPage(hits, "Kids", "MGMT")); + } + + [Fact] + public void 괄호와_구두점은_비교에서_무시한다() + { + WikipediaSource.SearchHit[] hits = + [ + new("Don't Delete the Kisses", "a song by Wolf Alice"), + ]; + + Assert.Equal("Don't Delete the Kisses", + WikipediaSource.PickPage(hits, "Don’t Delete The Kisses", "Wolf Alice")); + } + + [Fact] + public void 합작곡은_아티스트_한_명만_맞아도_받아들인다() + { + // 실측 함정: 재생 메타데이터는 "Lady Gaga/Bradley Cooper"로 오는데 문서 제목은 + // "…(Lady Gaga and Bradley Cooper song)"이다. 구두점을 지우고 통째로 포함 검사를 하면 + // 가운데 "and" 때문에 영영 일치하지 않아, 자료가 가장 좋은 곡이 조용히 버려졌다. + WikipediaSource.SearchHit[] hits = + [ + new("Shallow (Lady Gaga and Bradley Cooper song)", "from A Star Is Born"), + ]; + + Assert.Equal("Shallow (Lady Gaga and Bradley Cooper song)", + WikipediaSource.PickPage(hits, "Shallow", "Lady Gaga/Bradley Cooper")); + } + + [Fact] + public void 아티스트에_앨범_꼬리표가_붙어도_찾는다() + { + // 재생 메타데이터의 아티스트에 앨범이 " — "로 붙어 오는 경우가 흔하다. + WikipediaSource.SearchHit[] hits = + [ + new("As It Was", "a song by English singer Harry Styles"), + ]; + + Assert.Equal("As It Was", + WikipediaSource.PickPage(hits, "As It Was", "harry styles — harry's house")); + } + + [Fact] + public void 한_명만_맞으면_되지만_아무도_안_맞으면_여전히_버린다() + { + // 완화가 "아무나 통과"가 되면 안 된다 — 동명이곡 방어는 그대로여야 한다. + WikipediaSource.SearchHit[] hits = + [ + new("Shallow (song)", "a 2016 single by Porcupine Tree"), + ]; + + Assert.Null(WikipediaSource.PickPage(hits, "Shallow", "Lady Gaga/Bradley Cooper")); + } + + // ---- Genius 검색 결과 확인 ---- + + [Fact] + public void Genius가_무관한_곡을_돌려주면_거른다() + { + // 실측: 음악이 아닌 유튜브 제목으로 검색했더니 "119 REMIX"가 첫 히트로 나왔다. + // 확인 없이 받으면 남의 곡 해설이 이 트랙의 "의미"가 된다. + Assert.False(GeniusSource.Matches( + "119 REMIX", "GRAY", "해외에서 화제라는 한국의 지하철 문화", "여기는한국")); + } + + [Fact] + public void Genius의_제목_꼬리표는_허용한다() + { + Assert.True(GeniusSource.Matches("Shallow", "Lady Gaga & Bradley Cooper", + "Shallow", "Lady Gaga/Bradley Cooper")); + Assert.True(GeniusSource.Matches("Wonderwall (Live)", "Oasis", "Wonderwall", "Oasis")); + } + + [Fact] + public void Genius에서_제목이_같아도_아티스트가_다르면_거른다() + { + // 동명이곡 — 가장 위험한 오염원이다. + Assert.False(GeniusSource.Matches("Shallow", "Porcupine Tree", + "Shallow", "Lady Gaga/Bradley Cooper")); + } + + [Fact] + public void Genius에서_아티스트를_모르면_제목만으로_받아들인다() + { + Assert.True(GeniusSource.Matches("Wonderwall", "Oasis", "Wonderwall", "")); + } + + [Fact] + public void 스니펫의_HTML과_앰퍼샌드를_이해한다() + { + // 실측: 위키피디아 스니펫은 "Belle & Sebastian"과 를 + // 그대로 담아 온다. 처리하지 않으면 "amp"가 글자로 섞여 아티스트가 영영 안 맞는다. + WikipediaSource.SearchHit[] hits = + [ + new("The Boy with the Arab Strap", + "the third studio album by Scottish indie pop band Belle & Sebastian"), + ]; + + Assert.Equal("The Boy with the Arab Strap", + WikipediaSource.PickPage(hits, "The Boy with the Arab Strap", "Belle and Sebastian")); + } + + [Fact] + public void 앰퍼샌드와_and는_같은_말로_본다() + { + Assert.Equal(MeaningText.Normalize("Belle & Sebastian"), MeaningText.Normalize("Belle and Sebastian")); + Assert.Equal("belleandsebastian", MeaningText.Normalize("Belle & Sebastian")); + } + + // ---- 아티스트 표기 정리 ---- + + [Theory] + [InlineData("harry styles — harry's house", "harry styles")] + [InlineData("요네즈 켄시 — 1991 - single", "요네즈 켄시")] + [InlineData("westside cowboy • it goes on", "westside cowboy")] + [InlineData("Lady Gaga", "Lady Gaga")] + public void 앨범_꼬리표를_떼어_낸다(string raw, string expected) + { + Assert.Equal(expected, ArtistNames.StripAlbumSuffix(raw)); + } + + [Fact] + public void 여러_아티스트를_나눈다() + { + Assert.Equal(["Lady Gaga", "Bradley Cooper"], ArtistNames.All("Lady Gaga/Bradley Cooper")); + Assert.Equal(["Calvin Harris", "Dua Lipa"], ArtistNames.All("Calvin Harris & Dua Lipa")); + Assert.Equal(["Drake", "Rihanna"], ArtistNames.All("Drake feat. Rihanna")); + } + + [Fact] + public void 이름_자체에_든_기호는_자르지_않는다() + { + // 공백 없는 하이픈·앰퍼샌드는 이름의 일부다. + Assert.Equal(["Jay-Z"], ArtistNames.All("Jay-Z")); + Assert.Equal("Jay-Z", ArtistNames.Primary("Jay-Z")); + } + + [Fact] + public void 검색어에는_대표_이름만_쓴다() + { + Assert.Equal("harry styles", ArtistNames.Primary("harry styles — harry's house")); + Assert.Equal("Lady Gaga", ArtistNames.Primary("Lady Gaga/Bradley Cooper")); + } + + // ---- 생성 ---- + + [Fact] + public async Task 자료가_하나도_없으면_LLM을_부르지_않는다() + { + var writer = new CountingWriter(); + var service = new SongMeaningService([new EmptySource()], writer); + + var result = await service.BuildAsync("X", "Y", "ko"); + + Assert.Equal(SongMeaning.NoSource, result.Status); + Assert.Null(result.Summary); + Assert.Equal(0, writer.Calls); + } + + [Fact] + public async Task 소스_하나만_살아_있어도_의미를_만든다() + { + var service = new SongMeaningService( + [new EmptySource(), new FixedSource("Genius", "이 곡은 성장의 불안에 대한 것이다.")], + new CountingWriter()); + + var result = await service.BuildAsync("Kids", "MGMT", "ko"); + + Assert.Equal(SongMeaning.Ok, result.Status); + Assert.Single(result.Sources); + Assert.Equal("gemini", result.Engine); + } + + [Fact] + public async Task 엔진이_실패하면_자료는_남기고_failed로_기록한다() + { + var service = new SongMeaningService( + [new FixedSource("Genius", "설명")], new FailingWriter(MeaningWriteResult.Failed)); + + var result = await service.BuildAsync("Kids", "MGMT", "ko"); + + Assert.Equal(SongMeaning.Failed, result.Status); + Assert.Single(result.Sources); // 다시 시도할 때 재수집하지 않아도 되도록 남긴다 + } + + [Fact] + public async Task 쿼타_초과는_failed가_아니라_retry다() + { + // 429를 영구 실패로 굳히면 한도가 회복된 뒤에도 백필이 이 곡을 영영 건너뛴다. + var service = new SongMeaningService( + [new FixedSource("Genius", "설명")], new FailingWriter(MeaningWriteResult.Transient)); + + var result = await service.BuildAsync("Kids", "MGMT", "ko"); + + Assert.Equal(SongMeaning.Retry, result.Status); + } + + [Theory] + [InlineData(HttpStatusCode.TooManyRequests, true)] // 쿼타 — 기다리면 풀린다 + [InlineData(HttpStatusCode.ServiceUnavailable, true)] + [InlineData(HttpStatusCode.InternalServerError, true)] + [InlineData(HttpStatusCode.PaymentRequired, true)] // 잔액 — 충전하면 풀린다 + [InlineData(HttpStatusCode.Unauthorized, false)] // 키가 틀렸다 — 다시 불러도 같다 + [InlineData(HttpStatusCode.BadRequest, false)] + public void 다시_시도할_가치가_있는_응답만_retryable이다(HttpStatusCode code, bool retryable) + { + Assert.Equal(retryable, MeaningWriteResult.FromStatus(code).Retryable); + } + + [Fact] + public async Task 엔진이_429를_주면_두_엔진_모두_일시적_실패로_본다() + { + var busy = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)); + var sources = new[] { new MeaningSource("Genius", null, "text") }; + + Assert.True((await new GeminiMeaningWriter("k", null, busy.Client) + .WriteAsync("T", "A", sources, "ko")).Retryable); + Assert.True((await new OpenRouterMeaningWriter("k", null, busy.Client) + .WriteAsync("T", "A", sources, "ko")).Retryable); + } + + // ---- "자료 부족"은 의미가 아니다 ---- + + [Fact] + public async Task 표식이_붙으면_자료부족으로_기록하고_표식은_지운다() + { + var service = new SongMeaningService( + [new FixedSource("Genius", "설명")], + new FixedWriter($"{MeaningVerdict.Marker} 자료에는 앨범 정보뿐이다.")); + + var result = await service.BuildAsync("T", "A", "ko"); + + Assert.Equal(SongMeaning.Insufficient, result.Status); + Assert.Equal("자료에는 앨범 정보뿐이다.", result.Summary); // 표식은 화면에 나가지 않는다 + } + + [Fact] + public async Task 곡_이야기를_하면_의미로_기록한다() + { + var service = new SongMeaningService( + [new FixedSource("Genius", "설명")], + new FixedWriter("이 곡은 성장의 불안을 다룬다.")); + + Assert.Equal(SongMeaning.Ok, (await service.BuildAsync("T", "A", "ko")).Status); + } + + [Theory] + // 실측으로 나온 문장(Arab Strap) — 글자는 있지만 곡 이야기가 아니다. + [InlineData("제시된 자료만으로는 이 곡이 무엇에 대한 노래인지 파악하기 어렵다.")] + [InlineData("자료가 부족해 의미를 말하기 어렵습니다.")] + [InlineData("주어진 정보에는 이 곡에 대한 설명이 포함되어 있지 않다.")] + [InlineData("")] + public void 표식이_없어도_자료를_두고_하는_말은_걸러낸다(string text) + { + Assert.True(MeaningVerdict.IsInsufficient(text)); + } + + [Theory] + // 진짜 의미. "어렵다"·"없다" 같은 낱말이 있어도 곡 이야기면 통과해야 한다. + [InlineData("이 곡은 성장의 불안과 상실을 다룬다. 가사는 어린 시절의 기억을 되짚는다.")] + [InlineData("사랑을 잃은 뒤의 공허를 노래한다. 화자는 답을 알 수 없는 질문을 반복한다.")] + [InlineData("전쟁으로 가족을 잃은 사람의 이야기이며, 돌아갈 집이 없다는 심상이 반복된다.")] + public void 진짜_의미는_자료부족으로_보지_않는다(string text) + { + Assert.False(MeaningVerdict.IsInsufficient(text)); + } + + [Fact] + public void 프롬프트는_부족하면_표식을_쓰라고_지시한다() + { + var prompt = MeaningPrompt.Build("T", "A", [new MeaningSource("Genius", null, "x")], "ko"); + Assert.Contains(MeaningVerdict.Marker, prompt); + } + + [Fact] + public void 프롬프트는_지어내지_말라고_못을_박는다() + { + var prompt = MeaningPrompt.Build("Kids", "MGMT", + [new MeaningSource("Genius", "u", "about growing up")], "ko"); + + Assert.Contains("한국어", prompt); + Assert.Contains("지어내지 않는다", prompt); + Assert.Contains("about growing up", prompt); + } + + [Fact] + public void 원문이_길어도_프롬프트_예산을_넘기지_않는다() + { + var huge = new string('가', MeaningPrompt.MaxSourceChars * 3); + var prompt = MeaningPrompt.Build("T", "A", + [new MeaningSource("Genius", null, huge), new MeaningSource("Wikipedia", null, huge)], "ko"); + + // 예산 + 지시문·머리말 몫의 여유를 봐도 폭주하지 않아야 한다. + Assert.True(prompt.Length < MeaningPrompt.MaxSourceChars + 2000, $"길이 {prompt.Length}"); + } + + // ---- 레지스트리 ---- + + [Fact] + public void 키가_없으면_엔진이_만들어지지_않는다() + { + Assert.Null(MeaningWriterRegistry.Build("gemini", new MeaningWriterOptions())); + Assert.Null(MeaningWriterRegistry.Build("openrouter", new MeaningWriterOptions())); + Assert.Null(MeaningWriterRegistry.Build("none", new MeaningWriterOptions { GeminiApiKey = "k" })); + Assert.Null(MeaningWriterRegistry.Build(null, new MeaningWriterOptions { GeminiApiKey = "k" })); + } + + [Fact] + public void 엔진은_설정으로_갈아끼운다() + { + var options = new MeaningWriterOptions + { + GeminiApiKey = "g", + OpenRouterApiKey = "o", + OpenRouterModel = "anthropic/claude-opus-5", + }; + + Assert.Equal("gemini", MeaningWriterRegistry.Build("gemini", options)!.EngineId); + var openRouter = MeaningWriterRegistry.Build("openrouter", options)!; + Assert.Equal("openrouter", openRouter.EngineId); + Assert.Equal("anthropic/claude-opus-5", openRouter.Model); + } + + [Fact] + public async Task Gemini_응답에서_본문을_뽑는다() + { + var handler = new StubHandler(_ => Json(""" + {"candidates":[{"content":{"parts":[{"text":"이 곡은 성장의 불안을 다룬다."}]}}]} + """)); + + var result = await new GeminiMeaningWriter("key", null, handler.Client) + .WriteAsync("Kids", "MGMT", [new MeaningSource("Genius", null, "about growing up")], "ko"); + + Assert.Equal("이 곡은 성장의 불안을 다룬다.", result.Text); + } + + [Fact] + public async Task OpenRouter_응답에서_본문을_뽑는다() + { + var handler = new StubHandler(_ => Json(""" + {"choices":[{"message":{"role":"assistant","content":"이 곡은 이별을 다룬다."}}]} + """)); + + var result = await new OpenRouterMeaningWriter("key", "anthropic/claude-opus-5", handler.Client) + .WriteAsync("X", "Y", [new MeaningSource("Genius", null, "about a breakup")], "ko"); + + Assert.Equal("이 곡은 이별을 다룬다.", result.Text); + } + + [Fact] + public async Task 출력_상한을_반드시_요청에_싣는다() + { + // 상한을 안 보내면 공급자가 모델 최대치를 예약하려 들어 잔액 적은 계정에서 402가 난다. + string? geminiBody = null, openRouterBody = null; + var gemini = new StubHandler(req => + { + geminiBody = req.Content!.ReadAsStringAsync().Result; + return Json("""{"candidates":[{"content":{"parts":[{"text":"요약"}]}}]}"""); + }); + var openRouter = new StubHandler(req => + { + openRouterBody = req.Content!.ReadAsStringAsync().Result; + return Json("""{"choices":[{"message":{"content":"요약"}}]}"""); + }); + var sources = new[] { new MeaningSource("Genius", null, "text") }; + + await new GeminiMeaningWriter("k", null, gemini.Client).WriteAsync("T", "A", sources, "ko"); + await new OpenRouterMeaningWriter("k", null, openRouter.Client).WriteAsync("T", "A", sources, "ko"); + + Assert.Contains($"\"maxOutputTokens\":{MeaningPrompt.MaxOutputTokens}", geminiBody); + Assert.Contains($"\"max_tokens\":{MeaningPrompt.MaxOutputTokens}", openRouterBody); + } + + [Fact] + public async Task 엔진_오류는_예외_대신_빈_결과() + { + var denied = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized)); + var sources = new[] { new MeaningSource("Genius", null, "text") }; + + var gemini = await new GeminiMeaningWriter("k", null, denied.Client).WriteAsync("T", "A", sources, "ko"); + var openRouter = await new OpenRouterMeaningWriter("k", null, denied.Client).WriteAsync("T", "A", sources, "ko"); + + Assert.Null(gemini.Text); + Assert.Null(openRouter.Text); + Assert.False(gemini.Retryable); // 키가 틀린 건 다시 눌러도 같다 + Assert.False(openRouter.Retryable); + } + + // ---- 테스트 더블 ---- + + private sealed class EmptySource : ISongMeaningSource + { + public string Name => "Empty"; + public Task FetchAsync(string t, string a, CancellationToken ct = default) => + Task.FromResult(null); + } + + private sealed class FixedSource(string name, string text) : ISongMeaningSource + { + public string Name => name; + public Task FetchAsync(string t, string a, CancellationToken ct = default) => + Task.FromResult(new MeaningSource(name, "https://example/x", text)); + } + + private sealed class CountingWriter : IMeaningWriter + { + public int Calls { get; private set; } + public string EngineId => "gemini"; + public string Model => "test-model"; + + public Task WriteAsync( + string title, string artist, IReadOnlyList sources, + string targetLang, CancellationToken ct = default) + { + Calls++; + return Task.FromResult(MeaningWriteResult.Written("생성된 한국어 문단")); + } + } + + /// 정해진 문단을 돌려주는 엔진. + private sealed class FixedWriter(string text) : IMeaningWriter + { + public string EngineId => "gemini"; + public string Model => "test-model"; + public Task WriteAsync( + string title, string artist, IReadOnlyList sources, + string targetLang, CancellationToken ct = default) => + Task.FromResult(MeaningWriteResult.Written(text)); + } + + /// 정해진 실패를 돌려주는 엔진(영구 실패 / 일시적 실패를 갈라 보기 위한 것). + private sealed class FailingWriter(MeaningWriteResult result) : IMeaningWriter + { + public string EngineId => "gemini"; + public string Model => "test-model"; + public Task WriteAsync( + string title, string artist, IReadOnlyList sources, + string targetLang, CancellationToken ct = default) => Task.FromResult(result); + } + + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + public int Calls { get; private set; } + public HttpClient Client => new(this); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Calls++; + return Task.FromResult(responder(request)); + } + } +} diff --git a/tests/Musebase.Core.Tests/MusixmatchTests.cs b/tests/Musebase.Core.Tests/MusixmatchTests.cs new file mode 100644 index 0000000..0a832f9 --- /dev/null +++ b/tests/Musebase.Core.Tests/MusixmatchTests.cs @@ -0,0 +1,113 @@ +using Musebase.Core.Meaning; +using Xunit; + +namespace Musebase.Core.Tests; + +/// +/// Musixmatch 연동 — 공식 API로 **확인한** 주소만 쓰고, 검색 결과는 그대로 믿지 않는다. +/// +public class MusixmatchTests +{ + private const string SearchJson = """ + {"message":{"header":{"status_code":200},"body":{"track_list":[ + {"track":{"track_id":15445219,"track_name":"Even Flow","artist_name":"Pearl Jam", + "track_share_url":"https://www.musixmatch.com/lyrics/Pearl-Jam/Even-Flow-2"}}]}}} + """; + + [Fact] + public void 곡_페이지_주소를_뽑는다() + { + var track = MusixmatchApi.Pick(SearchJson, "Even Flow", "Pearl Jam"); + + Assert.NotNull(track); + Assert.Equal("https://www.musixmatch.com/lyrics/Pearl-Jam/Even-Flow-2", track!.ShareUrl); + Assert.Equal(15445219, track.TrackId); + } + + [Fact] + public void 본문_status_code가_실패면_결과를_쓰지_않는다() + { + // HTTP는 200이어도 Musixmatch는 성공/실패를 본문에 싣는다(키 오류 401, 플랜 초과 402 …). + var denied = """ + {"message":{"header":{"status_code":401},"body":{"track_list":[ + {"track":{"track_id":1,"track_name":"Even Flow","artist_name":"Pearl Jam", + "track_share_url":"https://example/x"}}]}}} + """; + + Assert.Null(MusixmatchApi.Pick(denied, "Even Flow", "Pearl Jam")); + } + + [Fact] + public void 결과가_없어_body가_빈_배열로_와도_죽지_않는다() + { + // 실제로 이렇게 오는 경우가 있어 레코드 역직렬화 대신 방어적으로 읽는다. + Assert.Null(MusixmatchApi.Pick("""{"message":{"header":{"status_code":200},"body":[]}}""", "T", "A")); + } + + [Fact] + public void 무관한_곡은_거른다() + { + // 검색 API는 무엇을 넣든 뭔가를 돌려준다 — Genius에서 겪은 것과 같은 함정이다. + Assert.Null(MusixmatchApi.Pick(SearchJson, "해외에서 화제라는 한국의 지하철 문화", "여기는한국")); + } + + [Fact] + public void 합작곡_표기가_달라도_찾는다() + { + var json = """ + {"message":{"header":{"status_code":200},"body":{"track_list":[ + {"track":{"track_id":7,"track_name":"Shallow","artist_name":"Lady Gaga & Bradley Cooper", + "track_share_url":"https://example/shallow"}}]}}} + """; + + Assert.NotNull(MusixmatchApi.Pick(json, "Shallow", "Lady Gaga/Bradley Cooper")); + } + + // ---- 곡 페이지에서 의미 꺼내기 ---- + + [Fact] + public void lens의_의미_문단을_찾는다() + { + var html = """ + + """; + + Assert.Contains("고립과 어울리지 못하는", MusixmatchMeaningSource.Explanation(html)); + } + + [Fact] + public void 구조가_바뀌어_lens가_없으면_null이다() + { + // 경로를 고정하지 않고 재귀로 찾되, 없으면 조용히 포기한다(지어내지 않는다). + var html = """ + + """; + + Assert.Null(MusixmatchMeaningSource.Explanation(html)); + } + + [Fact] + public void 페이지에_데이터_블록이_없으면_null이다() + { + Assert.Null(MusixmatchMeaningSource.Explanation("로그인이 필요합니다")); + Assert.Null(MusixmatchMeaningSource.Explanation("")); + } + + [Fact] + public void 중첩이_깊어도_찾아낸다() + { + var html = """ + + """; + + Assert.Equal("깊은 곳에 있는 설명 문장이다.", MusixmatchMeaningSource.Explanation(html)); + } +} diff --git a/tests/Musebase.Core.Tests/RemoteLyricsCacheTests.cs b/tests/Musebase.Core.Tests/RemoteLyricsCacheTests.cs index d225892..f46589d 100644 --- a/tests/Musebase.Core.Tests/RemoteLyricsCacheTests.cs +++ b/tests/Musebase.Core.Tests/RemoteLyricsCacheTests.cs @@ -109,6 +109,50 @@ public class RemoteLyricsCacheTests await cache.SetAsync("T", "A", Lyrics.Parse(Lrc)!); // 예외가 새어 나오면 실패 } + // ---- 곡의 의미(앱은 읽기만 한다) ---- + + [Fact] + public async Task 의미와_출처를_읽는다() + { + var cache = Create(new StubHandler(_ => Task.FromResult(Json(HttpStatusCode.OK, """ + {"summary":"이 곡은 성장의 불안을 다룬다.","lang":"ko", + "attribution":[{"name":"Wikipedia","url":"https://en.wikipedia.org/wiki/Kids"}, + {"name":"Genius","url":null}]} + """)))); + + var meaning = await cache.GetMeaningAsync("Kids", "MGMT"); + + Assert.NotNull(meaning); + Assert.Equal("이 곡은 성장의 불안을 다룬다.", meaning!.Summary); + Assert.Equal(2, meaning.Attribution.Count); + // 출처 표기는 의무다 — 라이선스까지 붙는다. + Assert.Contains("Wikipedia", meaning.CreditLine); + Assert.Contains("CC BY-SA", meaning.CreditLine); + } + + [Fact] + public async Task 의미가_없으면_404이고_그것은_정상이다() + { + var cache = Create(new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)))); + Assert.Null(await cache.GetMeaningAsync("Kids", "MGMT")); + } + + [Fact] + public async Task 의미_조회_실패는_가사_조회를_막지_않는다() + { + // 부가 기능이라 실패를 서킷 브레이커에 세지 않는다 — 여기서 회로가 열리면 손해가 크다. + var handler = new StubHandler(req => + req.RequestUri!.AbsolutePath.Contains("meaning") + ? Task.FromException(new HttpRequestException("down")) + : Task.FromResult(Json(HttpStatusCode.OK, + $$"""{"title":"Kids","artist":"MGMT","lrc":{{System.Text.Json.JsonSerializer.Serialize(Lrc)}},"service":"LRCLIB"}"""))); + var cache = Create(handler); + + for (var i = 0; i < 5; i++) Assert.Null(await cache.GetMeaningAsync("Kids", "MGMT")); + + Assert.NotNull((await cache.GetAsync("Kids", "MGMT")).Lyrics); + } + private static HttpRemoteLyricsCache Create(StubHandler handler) => new("http://localhost:9/", "token", timeoutMs: 500, log: null, handler: handler); diff --git a/tests/Musebase.Core.Tests/TranslationSharingTests.cs b/tests/Musebase.Core.Tests/TranslationSharingTests.cs index d0231a8..f79dd70 100644 --- a/tests/Musebase.Core.Tests/TranslationSharingTests.cs +++ b/tests/Musebase.Core.Tests/TranslationSharingTests.cs @@ -194,6 +194,11 @@ private sealed class FakeRemoteCache(string? lrc) : IRemoteLyricsCache public string? ArrivingLrc { get; init; } public int ArriveAfter { get; init; } = int.MaxValue; + /// 의미는 이 테스트의 관심사가 아니다 — 항상 없음(가사 흐름에 영향이 없어야 한다). + public Task GetMeaningAsync( + string title, string artist, CancellationToken ct = default) => + Task.FromResult(null); + public Task GetAsync(string title, string artist, CancellationToken ct = default) { int n;