From a585bbf9370e94bfc57184963a2516208cf9224d Mon Sep 17 00:00:00 2001 From: Jay Date: Sat, 1 Aug 2026 19:00:38 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat(server):=20=EA=B3=A1=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=97=90=20Musixmatch=C2=B7Genius=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가사는 있는데 그 곡이 무슨 이야기인지는 어디에도 없다. 첫 단계로 사람이 직접 읽으러 갈 링크를 곡 상세(가사 위)에 건다. API도 키도 필요 없어 바로 배포된다. Musixmatch의 "Meaning"은 API로 가져올 수 없다 — 공개 API에 meaning 엔드포인트가 없고(그 섹션은 사용자 기여 웹 콘텐츠다) 크롤링은 약관 위반이라 링크가 유일한 길이다. Genius는 공식 API로 곡 설명을 받아올 수 있으므로, 뒤이어 수집이 붙으면 검색 링크 대신 정확한 곡 페이지로 승격하도록 MeaningLinks.Genius를 미리 열어 뒀다. 관리자 CSP(default-src 'none')는 링크 이동에 관여하지 않으므로 는 그대로 동작한다. 외부 API 호출만 서버 쪽이어야 한다. 테스트 6건(이스케이프·한글·꼬리표·정확 주소 승격). Co-Authored-By: Claude Opus 5 (1M context) --- src/Musebase.Server/Admin/AdminPages.cs | 5 ++ src/Musebase.Server/Admin/MeaningLinks.cs | 33 ++++++++++ .../Musebase.Core.Tests/MeaningLinksTests.cs | 64 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 src/Musebase.Server/Admin/MeaningLinks.cs create mode 100644 tests/Musebase.Core.Tests/MeaningLinksTests.cs diff --git a/src/Musebase.Server/Admin/AdminPages.cs b/src/Musebase.Server/Admin/AdminPages.cs index 0f49992..d25adca 100644 --- a/src/Musebase.Server/Admin/AdminPages.cs +++ b/src/Musebase.Server/Admin/AdminPages.cs @@ -184,6 +184,11 @@ public static string SongPage( · 타임태그 {(showTags ? "숨기기" : "보기")} · 원문(.lrc)

+

곡의 배경·의미: + Musixmatch + · Genius

{body}

편집

diff --git a/src/Musebase.Server/Admin/MeaningLinks.cs b/src/Musebase.Server/Admin/MeaningLinks.cs new file mode 100644 index 0000000..2e259b5 --- /dev/null +++ b/src/Musebase.Server/Admin/MeaningLinks.cs @@ -0,0 +1,33 @@ +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}"; + } + + public static string MusixmatchSearch(string title, string artist) => + "https://www.musixmatch.com/search/" + 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!; +} diff --git a/tests/Musebase.Core.Tests/MeaningLinksTests.cs b/tests/Musebase.Core.Tests/MeaningLinksTests.cs new file mode 100644 index 0000000..efe1b3b --- /dev/null +++ b/tests/Musebase.Core.Tests/MeaningLinksTests.cs @@ -0,0 +1,64 @@ +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"); + Assert.StartsWith("https://www.musixmatch.com/search/", 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", " ")); + } +} From 9dce220772da66ea36c0d46b19a60495818846fb Mon Sep 17 00:00:00 2001 From: Jay Date: Sat, 1 Aug 2026 19:21:00 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(server):=20=EA=B3=A1=EC=9D=98=20?= =?UTF-8?q?=EC=9D=98=EB=AF=B8=20=E2=80=94=20=EC=99=B8=EB=B6=80=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=88=98=EC=A7=91=20+=20=ED=95=9C=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=20=EC=9A=94=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가사는 있는데 그 곡이 무슨 이야기인지는 어디에도 없었다. 관리자 곡 상세의 가사 위에 "이 곡의 의미" 카드를 띄우고, 앱이 나중에 그대로 쓸 GET /v1/meaning도 함께 연다. 배경과 기각한 대안은 docs/adr/0007-song-meaning.md. 소스 셋을 병렬로 겹친다: Genius(/songs/{id}의 description, 무료 토큰) + Last.fm(track.getInfo의 wiki, 무료 키) + Wikipedia(키 불필요). 하나가 죽어도 나머지가 채우고 실패는 예외가 아니라 null이다. Musixmatch의 "Meaning"은 공개 API에 엔드포인트가 없고 크롤링은 약관 위반이라 링크로만 제공한다. 엔진은 갈아끼운다 — IMeaningWriter + MeaningWriterRegistry로 기존 ITranslator/TranslatorRegistry와 같은 모양. 기본은 Gemini Developer API 직결 (API 키 한 줄이라 GoogleTranslateTranslator와 패턴이 같고, 무료 티어로 보유 곡 전체를 0원에 채운다 — Vertex AI는 서비스 계정·IAM 배선이 개인 프로젝트엔 과하다), 비교·전환용으로 OpenRouter(OpenAI 호환, model 문자열만 바꾸면 Claude·GPT·Gemini). 둘 다 순수 HttpClient라 SDK 의존성이 늘지 않는다. 생성은 사람이 누를 때만 일어난다(곡 상세 버튼 + 대시보드 일괄). 자동 생성을 두지 않은 이유는 쿼타·비용이 예측 가능해야 하고 실패가 조용히 쌓이면 안 되기 때문이다. 실패·자료없음도 행으로 남겨 백필이 같은 곡을 무한 재시도하지 않는다. 곡 해설은 그럴듯한 창작이 특히 쉬운 영역이라 환각 방어를 둘 뒀다. ① 소스가 하나도 없으면 LLM을 아예 호출하지 않는다. ② Wikipedia 문서 선택에 제목 일치 + 아티스트 확인을 필수 조건으로 걸고, 못 채우면 포기한다. 실측으로 "(song)" 제목을 무조건 우선했더니 Kids/MGMT에서 정답인 Kids (MGMT song)을 제치고 Pursuit of Happiness (song)이 뽑혔다 — 엉뚱한 문서는 자료가 없는 것보다 나쁘다. 프롬프트도 "자료에 없는 내용은 지어내지 않는다"로 못을 박는다. 실측으로 잡은 함정 하나 더: Wikimedia는 User-Agent가 없으면 403을 준다. .NET HttpClient는 기본 UA를 보내지 않으므로 그대로 두면 위키피디아 소스가 항상 조용히 빈다. 가사 제공자들의 검증된 동작을 건드리지 않도록 의미 전용 MeaningHttp에만 붙였다. 출처 표기는 의무다(Wikipedia CC BY-SA, Genius·Last.fm 링크) — 화면과 응답의 attribution에 함께 싣는다. 저장은 새 meanings 테이블(user_version=2)이고 조회 키는 가사와 같은 해석기를 쓴다(가사가 느슨한 키로 맞는 곡은 의미도 맞아야 한다). 키를 하나도 넣지 않으면 기능이 통째로 꺼지고 외부 링크만 남는다 — 가사 기능에는 영향이 없다. 로컬에서 위키피디아 실수집으로 Kids/MGMT·Wonderwall/Oasis는 정확한 문서를, 없는 곡은 포기를 확인했다. 테스트 205개 통과, 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- PROGRESS.md | 8 + contracts/lyrics-api.md | 30 ++ docs/adr/0007-song-meaning.md | 94 +++++ .../Meaning/GeminiMeaningWriter.cs | 104 ++++++ src/Musebase.Core/Meaning/GeniusSource.cs | 124 +++++++ src/Musebase.Core/Meaning/IMeaningWriter.cs | 77 ++++ .../Meaning/ISongMeaningSource.cs | 25 ++ src/Musebase.Core/Meaning/LastFmSource.cs | 105 ++++++ src/Musebase.Core/Meaning/MeaningHttp.cs | 34 ++ .../Meaning/MeaningWriterRegistry.cs | 52 +++ .../Meaning/OpenRouterMeaningWriter.cs | 108 ++++++ .../Meaning/SongMeaningService.cs | 70 ++++ src/Musebase.Core/Meaning/WikipediaSource.cs | 154 ++++++++ src/Musebase.Server/Admin/AdminEndpoints.cs | 89 ++++- src/Musebase.Server/Admin/AdminModels.cs | 7 +- src/Musebase.Server/Admin/AdminPages.cs | 88 ++++- src/Musebase.Server/ApiModels.cs | 35 ++ src/Musebase.Server/LyricsStore.cs | 185 +++++++++- src/Musebase.Server/MeaningOptions.cs | 110 ++++++ src/Musebase.Server/Program.cs | 24 +- src/Musebase.Server/deploy/README.md | 51 ++- tests/Musebase.Core.Tests/AdminPageTests.cs | 55 ++- .../LyricsStoreMergeTests.cs | 62 ++++ tests/Musebase.Core.Tests/MeaningTests.cs | 337 ++++++++++++++++++ 24 files changed, 1989 insertions(+), 39 deletions(-) create mode 100644 docs/adr/0007-song-meaning.md create mode 100644 src/Musebase.Core/Meaning/GeminiMeaningWriter.cs create mode 100644 src/Musebase.Core/Meaning/GeniusSource.cs create mode 100644 src/Musebase.Core/Meaning/IMeaningWriter.cs create mode 100644 src/Musebase.Core/Meaning/ISongMeaningSource.cs create mode 100644 src/Musebase.Core/Meaning/LastFmSource.cs create mode 100644 src/Musebase.Core/Meaning/MeaningHttp.cs create mode 100644 src/Musebase.Core/Meaning/MeaningWriterRegistry.cs create mode 100644 src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs create mode 100644 src/Musebase.Core/Meaning/SongMeaningService.cs create mode 100644 src/Musebase.Core/Meaning/WikipediaSource.cs create mode 100644 src/Musebase.Server/MeaningOptions.cs create mode 100644 tests/Musebase.Core.Tests/MeaningTests.cs diff --git a/PROGRESS.md b/PROGRESS.md index daf3c60..2f6b999 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -55,6 +55,14 @@ - **삼성 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). 테스트 31건 추가(205개 통과). - **가사 서버 백업 강화 + 컨테이너화** — 앱에는 영향 없음(서버 운영용). - 백업: `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..dc65102 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,35 @@ 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 곡 페이지(있으면) | +| `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는 이 API에 없다.** 사이트의 "Meaning" 섹션은 공개 API로 노출되지 않고(meaning +엔드포인트가 없다) 크롤링은 약관 위반이라, 사람이 직접 읽으러 가는 **링크로만** 제공한다. + ## 관리자 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..b143ca1 --- /dev/null +++ b/docs/adr/0007-song-meaning.md @@ -0,0 +1,94 @@ +# 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 엔드포인트가 없다** — 그 섹션은 사용자 기여 웹 콘텐츠다. +크롤링은 약관 위반이고 Cloudflare로 막혀 있다. 그래서 자동 수집 대상에서 제외하고, +사람이 직접 읽으러 가는 링크만 건다. + +### 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 불필요), 무료 티어가 + 있어 보유 곡 전체를 0원에 채울 수 있어서다. IAM·데이터 레지던시가 필요해지면 Vertex로 옮긴다. +- **OpenRouter를 함께 둔다.** OpenAI 호환 엔드포인트라 키 하나로 Claude·GPT·Gemini·Llama를 + `model` 문자열만 바꿔 부를 수 있다. 같은 곡을 여러 모델로 만들어 문장 품질을 비교할 때 쓴다. +- 둘 다 순수 HttpClient + System.Text.Json — SDK 의존성을 늘리지 않는다. + +### 4. 생성은 사람이 누를 때만 — 자동 생성을 두지 않는다 + +새 가사가 올라올 때 자동으로 만들지 않는다. 관리자 화면의 단건 버튼과 일괄 백필만 둔다. + +- 쿼타·비용이 예측 가능하다(무료 티어 한도를 모르게 긁지 않는다). +- 실패가 조용히 쌓이지 않는다. +- 광고·오인식 트랙까지 토큰을 쓰지 않는다. + +결과는 실패·자료없음도 행으로 남긴다 — 백필을 다시 눌러도 같은 곡을 무한히 재시도하지 않는다. + +### 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.Core/Meaning/GeminiMeaningWriter.cs b/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs new file mode 100644 index 0000000..2eee573 --- /dev/null +++ b/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs @@ -0,0 +1,104 @@ +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로 옮기면 된다. +/// +/// 키는 에서 만들고, 기존 GCP 프로젝트에 +/// 결제를 연결하면 무료 크레딧이 그대로 적용된다. +/// +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 null; + 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 }] }], + }; + + 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 null; + + 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) ? null : text!.Trim(); + } + catch (Exception) + { + return null; // 의미는 부가 기능 — 실패해도 가사에 영향이 없어야 한다 + } + } + + // ---- 요청/응답 모델(필요한 필드만) ---- + + private sealed record GeminiRequest + { + public GeminiContent[] Contents { 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..b347ae2 --- /dev/null +++ b/src/Musebase.Core/Meaning/GeniusSource.cs @@ -0,0 +1,124 @@ +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; + + public GeniusSource(string token, HttpClient? http = null, int timeoutMs = 2500) + { + _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); + var song = found?.Response?.Hits? + .FirstOrDefault(h => string.Equals(h.Type, "song", StringComparison.OrdinalIgnoreCase))? + .Result; + if (song is { Id: > 0 }) return song; + } + return null; + } + + 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); + + 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..5ed857f --- /dev/null +++ b/src/Musebase.Core/Meaning/IMeaningWriter.cs @@ -0,0 +1,77 @@ +using System.Text; + +namespace Musebase.Core.Meaning; + +/// +/// 수집한 영어 원문들을 읽고 "이 곡이 무엇에 대한 노래인지"를 대상 언어로 써 준다. +/// +/// 번역이 아니라 **요약**이라 로는 안 된다 — DeepL에 긴 영어 +/// bio를 넣으면 긴 한국어 문서가 나올 뿐 "의미"가 되지 않는다. 구현은 순수 HTTP + JSON이며 +/// 엔진은 로 갈아끼운다(번역 엔진과 같은 구조). +/// +/// 실패는 예외가 아니라 null이다 — 의미는 부가 기능이고, 없다고 가사가 안 뜨면 안 된다. +/// +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; + + /// + /// 마지막 문장이 이 프롬프트의 핵심이다 — 자료가 부족할 때 모델이 지어내지 않고 + /// "부족하다"고 쓰게 만든다. 곡 해설은 그럴듯한 창작이 특히 쉬운 영역이다. + /// + 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문장으로 써라. + + - 작곡 배경, 가사가 다루는 주제, 알려진 해석을 중심으로 쓴다. + - 자료에 없는 내용은 절대 지어내지 않는다. 추측하지 않는다. + - 자료가 부족해 의미를 말하기 어려우면, 그렇게만 한 문장으로 쓴다. + - 차트 성적·수상 이력 같은 곡의 의미와 무관한 사실은 넣지 않는다. + - 머리말 없이 본문만 쓴다. + """); + 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..b942a33 --- /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 = 2500) + { + _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/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/OpenRouterMeaningWriter.cs b/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs new file mode 100644 index 0000000..01649c2 --- /dev/null +++ b/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs @@ -0,0 +1,108 @@ +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 null; + 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 null; + + var body = await response.Content.ReadFromJsonAsync(Json, cts.Token).ConfigureAwait(false); + var text = body?.Choices?.FirstOrDefault()?.Message?.Content; + return string.IsNullOrWhiteSpace(text) ? null : text!.Trim(); + } + catch (Exception) + { + return null; // 조용한 강등 + } + } + + // ---- 요청/응답 모델(OpenAI 호환, 필요한 필드만) ---- + + private sealed record ChatRequest + { + public string Model { get; init; } = ""; + public ChatMessage[] Messages { get; init; } = []; + } + + 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..794a633 --- /dev/null +++ b/src/Musebase.Core/Meaning/SongMeaningService.cs @@ -0,0 +1,70 @@ +namespace Musebase.Core.Meaning; + +/// 한 곡에 대한 의미 생성 결과. +/// `ok` | `no-source` | `failed`. +/// 생성된 대상 언어 문단. `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"; + + 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 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 summary = await _writer.WriteAsync(title, artist, collected, targetLang, ct).ConfigureAwait(false); + return summary is null + ? new SongMeaning(SongMeaning.Failed, null, collected, _writer.EngineId, _writer.Model) + : new SongMeaning(SongMeaning.Ok, summary, 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..d93fb3c --- /dev/null +++ b/src/Musebase.Core/Meaning/WikipediaSource.cs @@ -0,0 +1,154 @@ +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 = 2500) + { + _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; + + var query = string.IsNullOrWhiteSpace(a) ? $"{t} song" : $"{t} {a} 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 wantedArtist = Normalize(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; + + var titleHasArtist = wantedArtist.Length > 0 + && normalizedPage.Contains(wantedArtist, StringComparison.Ordinal); + var snippetHasArtist = wantedArtist.Length > 0 + && Normalize(hit.Snippet ?? "").Contains(wantedArtist, StringComparison.Ordinal); + + // 필수 ②: 아티스트를 아는데 제목에도 스니펫에도 없으면 동명이곡일 수 있다 — 버린다. + if (wantedArtist.Length > 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; + } + + /// 비교용 정규화 — 소문자 + 영숫자/한글만 남긴다(괄호·구두점·공백 제거). + private static string Normalize(string s) + { + Span buffer = s.Length <= 256 ? stackalloc char[s.Length] : new char[s.Length]; + var n = 0; + foreach (var c in s) + if (char.IsLetterOrDigit(c)) buffer[n++] = char.ToLowerInvariant(c); + return new string(buffer[..n]); + } + + [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.Server/Admin/AdminEndpoints.cs b/src/Musebase.Server/Admin/AdminEndpoints.cs index 84fee06..d33ab5b 100644 --- a/src/Musebase.Server/Admin/AdminEndpoints.cs +++ b/src/Musebase.Server/Admin/AdminEndpoints.cs @@ -49,7 +49,9 @@ 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) + 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'"; @@ -102,7 +104,7 @@ void SetCookie(HttpResponse res) // ---- 대시보드 ---- - 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)) @@ -131,9 +133,11 @@ void SetCookie(HttpResponse res) WithoutTranslation: store.WithoutTranslation(100), DuplicateCandidates: store.DuplicateCandidates(), Health: Health(options.RetentionDays), - Diagnostics: Diagnostics(req, options)); + Diagnostics: Diagnostics(req, options), + Meanings: MeaningSummaryOf(), + Csrf: AdminAuth.Csrf(options.Token, Cookie(req) ?? "")); - return Html(AdminPages.Dashboard(model, now, options.TimeZone)); + return Html(AdminPages.Dashboard(model, now, options.TimeZone, notice)); }); // ---- 검색·열람 ---- @@ -156,7 +160,8 @@ 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)); }); app.MapGet("/admin/raw", (HttpRequest req, string? key) => @@ -197,6 +202,80 @@ void SetCookie(HttpResponse res) if (!string.IsNullOrWhiteSpace(key)) store.Delete(key); return Results.Redirect("/admin/search"); }); + + // ---- 곡의 의미 ---- + // 생성은 **사람이 누를 때만** 일어난다. 자동 생성을 두지 않는 이유는 쿼타·비용이 + // 예측 가능해야 하고, 실패가 조용히 쌓이면 안 되기 때문이다. + + app.MapPost("/admin/song/meaning", async (HttpRequest req) => + { + if (!LoggedIn(req)) return Html(AdminPages.Login()); + 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 Results.Redirect("/admin/search"); + + var notice = await GenerateMeaningAsync(entry.Key ?? key, entry.Title, entry.Artist); + return Results.Redirect( + $"/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()); + 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 Results.Redirect($"/admin?notice={Uri.EscapeDataString("의미 엔진이 구성되지 않았습니다.")}"); + + var targets = store.SongsWithoutMeaning(meaningOptions.BackfillLimit); + int ok = 0, none = 0, failed = 0; + foreach (var (key, title, artist) in targets) + { + var status = await GenerateStatusAsync(key, title, artist); + if (status == Musebase.Core.Meaning.SongMeaning.Ok) ok++; + else if (status == Musebase.Core.Meaning.SongMeaning.NoSource) none++; + else failed++; + } + + var summary = $"{targets.Count}곡 처리 — 생성 {ok} · 자료 없음 {none} · 실패 {failed}"; + return Results.Redirect($"/admin?notice={Uri.EscapeDataString(summary)}"); + }); + + // 단건 생성 후 사람에게 보여 줄 한 줄. + async Task GenerateMeaningAsync(string key, string title, string artist) + { + if (!meanings.IsEnabled) return "의미 엔진이 구성되지 않았습니다(키를 확인하세요)."; + var status = await GenerateStatusAsync(key, title, artist); + return status switch + { + Musebase.Core.Meaning.SongMeaning.Ok => "의미를 만들었습니다.", + Musebase.Core.Meaning.SongMeaning.NoSource => "외부 자료를 찾지 못했습니다.", + _ => "생성에 실패했습니다(키·쿼타·네트워크를 확인하세요).", + }; + } + + MeaningSummary MeaningSummaryOf() + { + var (ok, none, failed) = store.MeaningStats(); + // "아직 안 해 본 곡"은 백필 버튼이 실제로 처리할 대상 수다(상한까지만 센다). + var pending = store.SongsWithoutMeaning(meaningOptions.BackfillLimit).Count; + return new MeaningSummary(ok, none, failed, pending, meanings.IsEnabled); + } + + // 결과를 저장하고 status만 돌려준다. 실패·자료없음도 행으로 남겨 백필이 같은 곡을 + // 무한히 재시도하지 않게 한다. + async Task GenerateStatusAsync(string key, string title, string artist) + { + var result = await meanings.BuildAsync(title, artist, meaningOptions.Lang); + store.UpsertMeaning(MeaningMapper.ToEntry(key, title, artist, meaningOptions.Lang, result)); + return result.Status; + } } /// 기기 라벨 계산(요청 헤더 → 이름). 조회 기록과 업로드 표기에 함께 쓴다. diff --git a/src/Musebase.Server/Admin/AdminModels.cs b/src/Musebase.Server/Admin/AdminModels.cs index 2e572ca..c151a41 100644 --- a/src/Musebase.Server/Admin/AdminModels.cs +++ b/src/Musebase.Server/Admin/AdminModels.cs @@ -45,7 +45,12 @@ public sealed record DashboardModel( IReadOnlyList WithoutTranslation, IReadOnlyList DuplicateCandidates, ServerHealth Health, - IReadOnlyList<(string Name, string Value)> Diagnostics); + IReadOnlyList<(string Name, string Value)> Diagnostics, + MeaningSummary Meanings, + string Csrf); + +/// 대시보드의 "곡의 의미" 타일 — 만든 것 / 자료 없음 / 실패 + 아직 안 해 본 곡 수. +public sealed record MeaningSummary(int Ok, int NoSource, int Failed, int Pending, bool Enabled); /// 서버 상태(작은 인스턴스라 실제로 쓸모 있다). 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 d25adca..423d4f4 100644 --- a/src/Musebase.Server/Admin/AdminPages.cs +++ b/src/Musebase.Server/Admin/AdminPages.cs @@ -20,7 +20,8 @@ public static string Login(string? error = null) => Layout("로그인", $""" """); - 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 +37,12 @@ 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.NoSource} · 실패 {m.Meanings.Failed} · 남은 {m.Meanings.Pending}" + : "엔진 미구성")); var recent = Table( ["시각", "곡", "아티스트", "결과", "기기"], @@ -104,10 +110,22 @@ 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}

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

+ {backfill}

최근 조회

{recent}

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

{misses} @@ -152,7 +170,8 @@ public static string SearchPage(string? query, IReadOnlyList results, T 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) { var key = entry.Key ?? ""; var langLinks = langs.Count == 0 @@ -184,11 +203,7 @@ public static string SongPage( · 타임태그 {(showTags ? "숨기기" : "보기")} · 원문(.lrc)

-

곡의 배경·의미: - Musixmatch - · Genius

+ {MeaningCard(entry, meaning, csrf, meaningEnabled)} {body}

편집

@@ -212,6 +227,63 @@ 이 가사를 덮어쓰지 못합니다. 형식은 확장 LRC 그대로 유지 """, "search"); } + /// + /// 가사 위에 붙는 "이 곡의 의미" 카드. 의미가 없으면 외부 링크와 생성 버튼만 보인다. + /// + /// 출처 표기는 의무다 — Wikipedia 본문은 CC BY-SA고 Genius·Last.fm도 링크 표기를 + /// 요구하므로 요약과 항상 함께 렌더한다. + /// + private static string MeaningCard( + LyricsEntry entry, MeaningEntry? meaning, string csrf, bool enabled) + { + var key = entry.Key ?? ""; + var geniusUrl = MeaningLinks.Genius(entry.Title, entry.Artist, meaning?.GeniusUrl); + + var links = $""" + Musixmatch + · Genius + """; + + var button = !enabled + ? "의미 엔진이 구성되지 않았습니다." + : $""" +
+ + + +
+ """; + + var bodyHtml = meaning?.Status switch + { + MeaningEntry.StatusOk => $"

{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 Func SongRowHtml(TimeZoneInfo tz) => r => $""" diff --git a/src/Musebase.Server/ApiModels.cs b/src/Musebase.Server/ApiModels.cs index fceb385..294cc9a 100644 --- a/src/Musebase.Server/ApiModels.cs +++ b/src/Musebase.Server/ApiModels.cs @@ -43,6 +43,41 @@ 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; } + 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 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..0b41260 100644 --- a/src/Musebase.Server/LyricsStore.cs +++ b/src/Musebase.Server/LyricsStore.cs @@ -56,27 +56,53 @@ 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;"); + } } // ---- 키 계산 (클라이언트와 같은 코드를 쓴다) ---- @@ -317,6 +343,131 @@ ON CONFLICT(key) DO UPDATE SET } } + // ---- 곡의 의미 ---- + + /// + /// 저장된 의미를 찾는다. **가사와 같은 해석기()로 키를 정한다** — + /// 가사가 느슨한 키로 맞는 곡은 의미도 같이 맞아야 한다. + /// + public MeaningEntry? GetMeaning(string title, string artist) + { + lock (_lock) + { + var key = Locate(title, artist)?.Key ?? ExactKey(title, artist); + return ReadMeaning(key); + } + } + + /// 관리자 화면처럼 이미 key를 아는 곳에서 쓴다. + public MeaningEntry? GetMeaningByKey(string key) + { + lock (_lock) return ReadMeaning(key); + } + + 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 + 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), + }; + } + + /// 의미를 저장한다(같은 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) + VALUES ($key, $title, $artist, $summary, $lang, $sources, $genius, + $engine, $model, $status, $at) + 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; + """; + 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.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) 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) + FROM meanings; + """; + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) return (0, 0, 0); + return ( + reader.IsDBNull(0) ? 0 : reader.GetInt32(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? 0 : reader.GetInt32(2)); + } + } + // ---- 부가 ---- public ServerStats Stats() diff --git a/src/Musebase.Server/MeaningOptions.cs b/src/Musebase.Server/MeaningOptions.cs new file mode 100644 index 0000000..1a56723 --- /dev/null +++ b/src/Musebase.Server/MeaningOptions.cs @@ -0,0 +1,110 @@ +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, + bool UseWikipedia, + int BackfillLimit) +{ + /// + /// `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_MEANING_WIKIPEDIA`(0이면 끔), + /// `MUSEBASE_MEANING_BACKFILL_LIMIT`(기본 50). + /// + 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; + + 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"), + UseWikipedia: Env("MUSEBASE_MEANING_WIKIPEDIA") != "0", + BackfillLimit: limit); + } + + /// 구성된 소스만 골라 서비스를 만든다. 키가 하나도 없으면 소스가 비어 꺼진 상태가 된다. + public SongMeaningService BuildService() + { + var sources = new List(); + if (!string.IsNullOrWhiteSpace(GeniusToken)) sources.Add(new GeniusSource(GeniusToken!)); + if (!string.IsNullOrWhiteSpace(LastFmKey)) sources.Add(new LastFmSource(LastFmKey!)); + if (UseWikipedia) sources.Add(new WikipediaSource()); + + 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..6e32667 100644 --- a/src/Musebase.Server/Program.cs +++ b/src/Musebase.Server/Program.cs @@ -55,7 +55,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 () => @@ -153,5 +158,22 @@ 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); + + 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..5695048 100644 --- a/src/Musebase.Server/deploy/README.md +++ b/src/Musebase.Server/deploy/README.md @@ -28,6 +28,11 @@ 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 # 토큰 파일은 절대 저장소에 커밋하지 않는다 ``` @@ -163,7 +168,51 @@ Spotify Connect처럼 **PC에서 재생하고 폰에서 조작**하면 두 기 두 앱 모두 **저장 즉시 반영**되며(재시작 불필요), 서버에 못 붙으면 조용히 기존 동작 (로컬 캐시 → 제공자 검색)으로 강등된다. +## 11. 곡의 의미 (선택) + +곡이 무엇에 대한 노래인지 한 문단으로 만들어 관리자 화면과 `/v1/meaning`에 실어 준다. +**키를 넣지 않으면 통째로 꺼지고** 곡 상세에 Musixmatch·Genius 링크만 남는다(가사 기능엔 영향 없음). + +### 키 발급 + +| 키 | 어디서 | 비고 | +|---|---|---| +| `MUSEBASE_GEMINI_API_KEY` | | 기존 GCP 프로젝트에 결제를 연결하면 무료 크레딧이 그대로 적용된다. 무료 티어만으로도 보유 곡 전체를 하루에 채울 수 있다 | +| `MUSEBASE_GENIUS_TOKEN` | → New API Client → **Generate Access Token** | 무료. OAuth 사용자 플로우 불필요 | +| `MUSEBASE_LASTFM_KEY` | | 선택. Genius에 설명이 없는 곡을 메워 준다 | + +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_MEANING_BACKFILL_LIMIT=50 # 일괄 생성 1회 처리량 +``` + +### 모델을 바꿔 보고 싶다면 + +`MUSEBASE_MEANING_ENGINE=openrouter` + `MUSEBASE_OPENROUTER_API_KEY`로 바꾸고 +`MUSEBASE_OPENROUTER_MODEL`에 모델 문자열만 넣으면 된다(`anthropic/claude-opus-5`, +`google/gemini-2.5-flash` …). 같은 곡을 [다시 생성]으로 만들어 문장을 비교할 수 있다. +대량 백필은 무료 티어가 있는 Gemini 쪽이 낫다. + +### 쓰는 법 + +- 곡 상세 → **[의미 가져오기]** (다시 누르면 재생성) +- 대시보드 → **[의미 일괄 생성]** — 아직 안 해 본 곡을 상한까지 처리 +- **생성은 사람이 누를 때만 일어난다.** 자동 생성은 두지 않았다 — 쿼타·비용이 예측 가능해야 하고 + 실패가 조용히 쌓이면 안 되기 때문이다. + +> **출처 표기 의무**: Wikipedia 본문은 CC BY-SA, Genius·Last.fm도 링크 표기를 요구한다. +> 관리자 화면과 `/v1/meaning`의 `attribution`이 이를 담고 있으므로, 요약을 보여 주는 화면은 +> 출처를 함께 표시해야 한다. + ## 업데이트 3~4단계를 반복하면 된다(`systemctl restart musebase-server`). DB는 `/var/lib/musebase`에 -따로 있으므로 배포로 지워지지 않는다. +따로 있으므로 배포로 지워지지 않는다. 스키마는 `PRAGMA user_version`으로 자동 이행된다 +(현재 2 = `lyrics` + `lookups` + `meanings`). diff --git a/tests/Musebase.Core.Tests/AdminPageTests.cs b/tests/Musebase.Core.Tests/AdminPageTests.cs index 943ccfa..479837a 100644 --- a/tests/Musebase.Core.Tests/AdminPageTests.cs +++ b/tests/Musebase.Core.Tests/AdminPageTests.cs @@ -228,15 +228,58 @@ public void 번역_언어_목록에서_언어미상_제공자_번역은_제외 [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(" 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..b68029b 100644 --- a/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs +++ b/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs @@ -102,6 +102,68 @@ public void 서로_다른_곡은_합쳐지지_않는다() 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) = store.MeaningStats(); + Assert.Equal(0, ok); + Assert.Equal(1, none); + Assert.Equal(0, failed); + } + public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); diff --git a/tests/Musebase.Core.Tests/MeaningTests.cs b/tests/Musebase.Core.Tests/MeaningTests.cs new file mode 100644 index 0000000..bf35837 --- /dev/null +++ b/tests/Musebase.Core.Tests/MeaningTests.cs @@ -0,0 +1,337 @@ +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,"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,"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 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 NullWriter()); + + var result = await service.BuildAsync("Kids", "MGMT", "ko"); + + Assert.Equal(SongMeaning.Failed, result.Status); + Assert.Single(result.Sources); // 다시 시도할 때 재수집하지 않아도 되도록 남긴다 + } + + [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 text = await new GeminiMeaningWriter("key", null, handler.Client) + .WriteAsync("Kids", "MGMT", [new MeaningSource("Genius", null, "about growing up")], "ko"); + + Assert.Equal("이 곡은 성장의 불안을 다룬다.", text); + } + + [Fact] + public async Task OpenRouter_응답에서_본문을_뽑는다() + { + var handler = new StubHandler(_ => Json(""" + {"choices":[{"message":{"role":"assistant","content":"이 곡은 이별을 다룬다."}}]} + """)); + + var text = await new OpenRouterMeaningWriter("key", "anthropic/claude-opus-5", handler.Client) + .WriteAsync("X", "Y", [new MeaningSource("Genius", null, "about a breakup")], "ko"); + + Assert.Equal("이 곡은 이별을 다룬다.", text); + } + + [Fact] + public async Task 엔진_오류는_예외_대신_null() + { + var down = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)); + var sources = new[] { new MeaningSource("Genius", null, "text") }; + + Assert.Null(await new GeminiMeaningWriter("k", null, down.Client).WriteAsync("T", "A", sources, "ko")); + Assert.Null(await new OpenRouterMeaningWriter("k", null, down.Client).WriteAsync("T", "A", sources, "ko")); + } + + // ---- 테스트 더블 ---- + + 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("생성된 한국어 문단"); + } + } + + private sealed class NullWriter : 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(null); + } + + 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)); + } + } +} From 4bbca56cd16e361a679f00f1e689c25b1349515c Mon Sep 17 00:00:00 2001 From: Jay Date: Sat, 1 Aug 2026 23:42:15 +0900 Subject: [PATCH 03/15] =?UTF-8?q?fix(server):=20=EC=BF=BC=ED=83=80=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=EB=A5=BC=20=EC=98=81=EA=B5=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A1=9C=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 429는 시간이 지나면 풀리는데 `failed` 행으로 굳혀 버렸다. 백필은 행이 있는 곡을 건너뛰므로, 한도가 회복된 뒤에도 그 곡은 영영 의미가 만들어지지 않는다. 쿼타는 돌아오는데 기록만 남는 셈이다. 실측 전에 잡았다 — 무료 티어가 15 RPM이라 백필을 돌리면 바로 밟았을 자리다. 엔진이 "영구 실패"와 "일시적 실패"를 갈라 돌려준다(MeaningWriteResult). 429·5xx· 타임아웃·네트워크는 Retryable이고, 401·400처럼 다시 불러도 답이 같은 것은 아니다. 일시적이면 status='retry'로 올라오고 서버는 **아무것도 저장하지 않는다** — 이 값은 DB에 들어가지 않는다. 백필은 retry를 만나면 그 자리에서 멈춘다. 계속 돌아 봐야 남은 곡도 같은 벽에 부딪힐 뿐이고, 멈춰도 망가지는 게 없다(저장을 안 했으니 다음에 그 곡부터 이어진다). 안내문도 "남은 곡은 손대지 않았으니 잠시 후 다시" 로 사실대로 쓴다. 호출 간격은 MUSEBASE_MEANING_BACKFILL_DELAY_MS로 열어 두되 기본 0이다 — 유료 티어는 분당 한도가 넉넉해 일부러 느리게 돌 이유가 없고, 429가 나도 위처럼 안전하게 멈추기 때문이다. 무료 티어(15 RPM)에서 한 번에 끝까지 돌리려면 4500을 준다. 문서의 틀린 안내도 함께 고쳤다: "기존 GCP 프로젝트에 결제를 연결하면 무료 크레딧이 적용된다"고 써 뒀는데 **$300 크레딧은 Gemini API에 쓸 수 없다**(공식 문서의 명시적 제외 항목). 무료로 가는 길은 별개 제도인 무료 티어뿐이고 그건 결제가 연결되지 않은 프로젝트에만 적용된다 — 결제를 붙이면 즉시 Tier 1(유료)이 되고 무료 티어는 사라진다. 가사 번역 프로젝트는 Cloud Translation 때문에 결제가 필수라 그대로 쓰면 유료다. 그대로 뒀으면 "무료인 줄 알고 청구되는" 안내가 될 뻔했다. 테스트 7건 추가(212개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/0007-song-meaning.md | 18 ++++- .../Meaning/GeminiMeaningWriter.cs | 29 ++++++-- src/Musebase.Core/Meaning/IMeaningWriter.cs | 5 +- .../Meaning/MeaningWriteResult.cs | 32 +++++++++ .../Meaning/OpenRouterMeaningWriter.cs | 20 ++++-- .../Meaning/SongMeaningService.cs | 21 ++++-- src/Musebase.Server/Admin/AdminEndpoints.cs | 28 ++++++-- src/Musebase.Server/MeaningOptions.cs | 15 +++- src/Musebase.Server/deploy/README.md | 23 ++++++- tests/Musebase.Core.Tests/MeaningTests.cs | 69 +++++++++++++++---- 10 files changed, 214 insertions(+), 46 deletions(-) create mode 100644 src/Musebase.Core/Meaning/MeaningWriteResult.cs diff --git a/docs/adr/0007-song-meaning.md b/docs/adr/0007-song-meaning.md index b143ca1..27a9845 100644 --- a/docs/adr/0007-song-meaning.md +++ b/docs/adr/0007-song-meaning.md @@ -36,8 +36,15 @@ `IMeaningWriter` + `MeaningWriterRegistry`로 감쌌다(기존 `ITranslator`/`TranslatorRegistry`와 같은 모양). - **기본은 Google Gemini Developer API 직결.** Vertex AI가 아닌 이유는 인증이 API 키 한 줄이라 - 이미 쓰는 `GoogleTranslateTranslator`와 패턴이 같고(서비스 계정·ADC 불필요), 무료 티어가 - 있어 보유 곡 전체를 0원에 채울 수 있어서다. IAM·데이터 레지던시가 필요해지면 Vertex로 옮긴다. + 이미 쓰는 `GoogleTranslateTranslator`와 패턴이 같아서다(서비스 계정·ADC 불필요). + IAM·데이터 레지던시가 필요해지면 Vertex로 옮긴다. + 요금은 어느 쪽이든 부담이 없다 — 보유 곡 전체를 채워도 유료 기준 몇백 원이다. + 다만 **"$300 무료 체험 크레딧"은 Gemini API에 쓸 수 없다**(공식 문서의 명시적 제외 항목). + 진짜 무료로 가려면 별개 제도인 "무료 티어"를 써야 하고, 그건 **결제가 연결되지 않은 + 프로젝트에만** 적용된다 — 결제를 붙이는 순간 Tier 1(유료)이 되고 무료 티어는 사라진다. + 가사 번역용 프로젝트는 Cloud Translation 때문에 결제가 필요하므로 **그 프로젝트를 그대로 + 쓰면 유료다.** 무료를 원하면 결제 없는 별도 프로젝트가 필요하다(계정당 프로젝트 수 한도에 + 걸릴 수 있다). 유료 티어는 대신 보낸 내용이 학습에 쓰이지 않는다. - **OpenRouter를 함께 둔다.** OpenAI 호환 엔드포인트라 키 하나로 Claude·GPT·Gemini·Llama를 `model` 문자열만 바꿔 부를 수 있다. 같은 곡을 여러 모델로 만들어 문장 품질을 비교할 때 쓴다. - 둘 다 순수 HttpClient + System.Text.Json — SDK 의존성을 늘리지 않는다. @@ -52,6 +59,13 @@ 결과는 실패·자료없음도 행으로 남긴다 — 백필을 다시 눌러도 같은 곡을 무한히 재시도하지 않는다. +**단 일시적 실패는 남기지 않는다.** 429(쿼타)와 5xx·타임아웃은 시간이 지나면 풀리는데, 이걸 +`failed` 행으로 굳히면 한도가 회복된 뒤에도 그 곡은 영영 건너뛰어진다. 그래서 엔진은 +"영구 실패"와 "일시적 실패"를 갈라 돌려주고(`MeaningWriteResult.Retryable`), 후자는 **아무것도 +저장하지 않고** 백필이 그 자리에서 멈춘다 — 계속 돌아 봐야 남은 곡도 같은 벽에 부딪힐 뿐이고, +멈춰도 망가지는 것이 없다. 무료 티어처럼 분당 한도가 빡빡한 환경에서는 +`MUSEBASE_MEANING_BACKFILL_DELAY_MS`로 호출 간격을 줄 수 있다(유료 티어는 필요 없어 기본 0). + ### 5. 근거가 없으면 부르지 않고, 확신이 없으면 포기한다 곡 해설은 **그럴듯한 창작이 특히 쉬운 영역**이다. 두 가지 방어를 뒀다. diff --git a/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs b/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs index 2eee573..85781dc 100644 --- a/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs +++ b/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs @@ -13,8 +13,12 @@ namespace Musebase.Core.Meaning; /// 무료 티어가 있어 보유 곡 전체를 0원에 채울 수 있다. IAM·데이터 레지던시 같은 거버넌스가 /// 필요해지면 그때 Vertex로 옮기면 된다. /// -/// 키는 에서 만들고, 기존 GCP 프로젝트에 -/// 결제를 연결하면 무료 크레딧이 그대로 적용된다. +/// 키는 에서 만든다. **$300 무료 체험 +/// 크레딧은 Gemini API에 쓸 수 없다**(공식 문서에 명시된 제외 항목) — 별개인 "Gemini API +/// 무료 티어"가 있고, 그건 **결제를 연결하지 않은 프로젝트에만** 적용된다. 결제를 붙이는 +/// 순간 그 프로젝트는 Tier 1(유료)이 되고 무료 티어는 사라지므로, 무료로 쓰려면 결제가 +/// 없는 프로젝트에서 키를 만들어야 한다. 다만 요약 한 번은 매우 싸서(곡당 사실상 0원) +/// 유료 티어로 두는 선택도 합리적이다 — 그쪽은 보낸 내용이 학습에 쓰이지 않는다. /// public sealed class GeminiMeaningWriter : IMeaningWriter { @@ -44,11 +48,11 @@ public GeminiMeaningWriter(string apiKey, string? model = null, HttpClient? http public string EngineId => "gemini"; public string Model { get; } - public async Task WriteAsync( + public async Task WriteAsync( string title, string artist, IReadOnlyList sources, string targetLang, CancellationToken ct = default) { - if (_apiKey.Length == 0 || sources.Count == 0) return null; + if (_apiKey.Length == 0 || sources.Count == 0) return MeaningWriteResult.Failed; try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); @@ -67,17 +71,28 @@ public GeminiMeaningWriter(string apiKey, string? model = null, HttpClient? http request.Headers.Add("x-goog-api-key", _apiKey); using var response = await _http.SendAsync(request, cts.Token).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) return null; + 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) ? null : text!.Trim(); + return string.IsNullOrWhiteSpace(text) + ? MeaningWriteResult.Failed + : MeaningWriteResult.Written(text!.Trim()); + } + catch (OperationCanceledException) + { + // 타임아웃이든 호출자 취소든 "결과를 모른다"는 뜻이다 — 실패로 못 박지 않는다. + return MeaningWriteResult.Transient; + } + catch (HttpRequestException) + { + return MeaningWriteResult.Transient; // 네트워크는 다음에 될 수 있다 } catch (Exception) { - return null; // 의미는 부가 기능 — 실패해도 가사에 영향이 없어야 한다 + return MeaningWriteResult.Failed; // 의미는 부가 기능 — 가사에 영향이 없어야 한다 } } diff --git a/src/Musebase.Core/Meaning/IMeaningWriter.cs b/src/Musebase.Core/Meaning/IMeaningWriter.cs index 5ed857f..9a0dd01 100644 --- a/src/Musebase.Core/Meaning/IMeaningWriter.cs +++ b/src/Musebase.Core/Meaning/IMeaningWriter.cs @@ -9,7 +9,8 @@ namespace Musebase.Core.Meaning; /// bio를 넣으면 긴 한국어 문서가 나올 뿐 "의미"가 되지 않는다. 구현은 순수 HTTP + JSON이며 /// 엔진은 로 갈아끼운다(번역 엔진과 같은 구조). /// -/// 실패는 예외가 아니라 null이다 — 의미는 부가 기능이고, 없다고 가사가 안 뜨면 안 된다. +/// 실패는 예외가 아니라 다 — 의미는 부가 기능이고, 없다고 +/// 가사가 안 뜨면 안 된다. 다만 "일시적 실패"만은 구분해서 돌려준다(그쪽 설명 참고). /// public interface IMeaningWriter { @@ -19,7 +20,7 @@ public interface IMeaningWriter /// 실제로 호출한 모델 이름(재생성 판단·기록용). string Model { get; } - Task WriteAsync( + Task WriteAsync( string title, string artist, IReadOnlyList sources, string targetLang, CancellationToken ct = default); } diff --git a/src/Musebase.Core/Meaning/MeaningWriteResult.cs b/src/Musebase.Core/Meaning/MeaningWriteResult.cs new file mode 100644 index 0000000..294a4d1 --- /dev/null +++ b/src/Musebase.Core/Meaning/MeaningWriteResult.cs @@ -0,0 +1,32 @@ +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는 상대 서버 문제 — 둘 다 기다리면 풀린다. + /// 나머지 4xx(키 오류·잘못된 요청)는 다시 불러도 같은 답이 오므로 영구 실패다. + /// + public static MeaningWriteResult FromStatus(HttpStatusCode code) => + code == HttpStatusCode.TooManyRequests || (int)code >= 500 ? Transient : Failed; +} diff --git a/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs b/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs index 01649c2..3c0a2e4 100644 --- a/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs +++ b/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs @@ -43,11 +43,11 @@ public OpenRouterMeaningWriter(string apiKey, string? model = null, HttpClient? public string EngineId => "openrouter"; public string Model { get; } - public async Task WriteAsync( + public async Task WriteAsync( string title, string artist, IReadOnlyList sources, string targetLang, CancellationToken ct = default) { - if (_apiKey.Length == 0 || sources.Count == 0) return null; + if (_apiKey.Length == 0 || sources.Count == 0) return MeaningWriteResult.Failed; try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); @@ -76,15 +76,25 @@ public OpenRouterMeaningWriter(string apiKey, string? model = null, HttpClient? 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 null; + 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) ? null : text!.Trim(); + return string.IsNullOrWhiteSpace(text) + ? MeaningWriteResult.Failed + : MeaningWriteResult.Written(text!.Trim()); + } + catch (OperationCanceledException) + { + return MeaningWriteResult.Transient; // 타임아웃·취소 — 결과를 모른다 + } + catch (HttpRequestException) + { + return MeaningWriteResult.Transient; // 네트워크는 다음에 될 수 있다 } catch (Exception) { - return null; // 조용한 강등 + return MeaningWriteResult.Failed; // 조용한 강등 } } diff --git a/src/Musebase.Core/Meaning/SongMeaningService.cs b/src/Musebase.Core/Meaning/SongMeaningService.cs index 794a633..417a210 100644 --- a/src/Musebase.Core/Meaning/SongMeaningService.cs +++ b/src/Musebase.Core/Meaning/SongMeaningService.cs @@ -1,7 +1,7 @@ namespace Musebase.Core.Meaning; /// 한 곡에 대한 의미 생성 결과. -/// `ok` | `no-source` | `failed`. +/// `ok` | `no-source` | `failed` | `retry`. /// 생성된 대상 언어 문단. `ok`가 아니면 null. /// 근거로 쓴 원문들(출처 표기·재생성 판단용). public sealed record SongMeaning( @@ -14,9 +14,15 @@ public sealed record SongMeaning( public const string Ok = "ok"; /// 어느 소스에도 자료가 없었다 — LLM은 부르지 않았다. public const string NoSource = "no-source"; - /// 자료는 있었지만 생성이 실패했다(키·쿼타·네트워크). + /// 자료는 있었지만 생성이 영구적으로 실패했다(키가 틀렸다, 응답이 비었다). public const string Failed = "failed"; + /// + /// 일시적 실패(쿼타·서버·네트워크) — **저장하지 않는다.** 저장하면 쿼타가 풀린 뒤에도 + /// 백필이 이 곡을 영영 건너뛴다. 이 상태는 DB에 들어가지 않는 값이다. + /// + public const string Retry = "retry"; + public string? GeniusUrl => Sources.FirstOrDefault(s => s.Name == "Genius")?.Url; } @@ -53,10 +59,13 @@ public async Task BuildAsync( if (_writer is null) return new SongMeaning(SongMeaning.Failed, null, collected, null, null); - var summary = await _writer.WriteAsync(title, artist, collected, targetLang, ct).ConfigureAwait(false); - return summary is null - ? new SongMeaning(SongMeaning.Failed, null, collected, _writer.EngineId, _writer.Model) - : new SongMeaning(SongMeaning.Ok, summary, collected, _writer.EngineId, _writer.Model); + var written = await _writer.WriteAsync(title, artist, collected, targetLang, ct).ConfigureAwait(false); + if (written.Text is not null) + return new SongMeaning(SongMeaning.Ok, 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); } /// 모든 소스를 동시에 부르고 성공한 것만 모은다(레지스트리 등록 순서 유지). diff --git a/src/Musebase.Server/Admin/AdminEndpoints.cs b/src/Musebase.Server/Admin/AdminEndpoints.cs index d33ab5b..95e4a17 100644 --- a/src/Musebase.Server/Admin/AdminEndpoints.cs +++ b/src/Musebase.Server/Admin/AdminEndpoints.cs @@ -234,16 +234,30 @@ void SetCookie(HttpResponse res) return Results.Redirect($"/admin?notice={Uri.EscapeDataString("의미 엔진이 구성되지 않았습니다.")}"); var targets = store.SongsWithoutMeaning(meaningOptions.BackfillLimit); - int ok = 0, none = 0, failed = 0; + 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 == Musebase.Core.Meaning.SongMeaning.NoSource) none++; else failed++; } - var summary = $"{targets.Count}곡 처리 — 생성 {ok} · 자료 없음 {none} · 실패 {failed}"; + var summary = stopped + ? $"{done}곡 처리 후 중단 — 생성 {ok} · 자료 없음 {none} · 실패 {failed}. " + + "쿼타·네트워크 문제로 보입니다. 남은 곡은 손대지 않았으니 잠시 후 다시 눌러 주세요." + : $"{targets.Count}곡 처리 — 생성 {ok} · 자료 없음 {none} · 실패 {failed}"; return Results.Redirect($"/admin?notice={Uri.EscapeDataString(summary)}"); }); @@ -256,7 +270,9 @@ async Task GenerateMeaningAsync(string key, string title, string artist) { Musebase.Core.Meaning.SongMeaning.Ok => "의미를 만들었습니다.", Musebase.Core.Meaning.SongMeaning.NoSource => "외부 자료를 찾지 못했습니다.", - _ => "생성에 실패했습니다(키·쿼타·네트워크를 확인하세요).", + Musebase.Core.Meaning.SongMeaning.Retry => + "일시적인 오류입니다(쿼타·네트워크). 저장하지 않았으니 잠시 후 다시 시도하세요.", + _ => "생성에 실패했습니다(키를 확인하세요).", }; } @@ -269,11 +285,13 @@ MeaningSummary MeaningSummaryOf() } // 결과를 저장하고 status만 돌려준다. 실패·자료없음도 행으로 남겨 백필이 같은 곡을 - // 무한히 재시도하지 않게 한다. + // 무한히 재시도하지 않게 한다 — 단 **일시적 실패는 예외다.** 쿼타 초과를 행으로 + // 남기면 한도가 회복된 뒤에도 그 곡은 영영 건너뛰어진다. async Task GenerateStatusAsync(string key, string title, string artist) { var result = await meanings.BuildAsync(title, artist, meaningOptions.Lang); - store.UpsertMeaning(MeaningMapper.ToEntry(key, title, artist, meaningOptions.Lang, result)); + if (result.Status != Musebase.Core.Meaning.SongMeaning.Retry) + store.UpsertMeaning(MeaningMapper.ToEntry(key, title, artist, meaningOptions.Lang, result)); return result.Status; } } diff --git a/src/Musebase.Server/MeaningOptions.cs b/src/Musebase.Server/MeaningOptions.cs index 1a56723..6650c0a 100644 --- a/src/Musebase.Server/MeaningOptions.cs +++ b/src/Musebase.Server/MeaningOptions.cs @@ -17,14 +17,20 @@ public sealed record MeaningOptions( string? GeniusToken, string? LastFmKey, bool UseWikipedia, - int BackfillLimit) + int BackfillLimit, + int BackfillDelayMs) { /// /// `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_MEANING_WIKIPEDIA`(0이면 끔), - /// `MUSEBASE_MEANING_BACKFILL_LIMIT`(기본 50). + /// `MUSEBASE_MEANING_BACKFILL_LIMIT`(기본 50), + /// `MUSEBASE_MEANING_BACKFILL_DELAY_MS`(기본 0 — 아래 설명). + /// + /// 백필 간격이 기본 0인 이유: 유료 티어는 분당 한도가 넉넉해 일부러 느리게 돌 이유가 없고, + /// 429가 나더라도 백필이 그 자리에서 멈추고 **아무것도 저장하지 않으므로** 망가지지 않는다. + /// Gemini 무료 티어(15 RPM)처럼 빡빡한 한도에서 끝까지 한 번에 돌리고 싶으면 4500 정도를 준다. /// public static MeaningOptions FromEnvironment() { @@ -33,6 +39,8 @@ public static MeaningOptions FromEnvironment() 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; return new MeaningOptions( Engine: Env("MUSEBASE_MEANING_ENGINE") ?? MeaningWriterRegistry.None, @@ -44,7 +52,8 @@ public static MeaningOptions FromEnvironment() GeniusToken: Env("MUSEBASE_GENIUS_TOKEN"), LastFmKey: Env("MUSEBASE_LASTFM_KEY"), UseWikipedia: Env("MUSEBASE_MEANING_WIKIPEDIA") != "0", - BackfillLimit: limit); + BackfillLimit: limit, + BackfillDelayMs: delay); } /// 구성된 소스만 골라 서비스를 만든다. 키가 하나도 없으면 소스가 비어 꺼진 상태가 된다. diff --git a/src/Musebase.Server/deploy/README.md b/src/Musebase.Server/deploy/README.md index 5695048..553467f 100644 --- a/src/Musebase.Server/deploy/README.md +++ b/src/Musebase.Server/deploy/README.md @@ -177,7 +177,7 @@ Spotify Connect처럼 **PC에서 재생하고 폰에서 조작**하면 두 기 | 키 | 어디서 | 비고 | |---|---|---| -| `MUSEBASE_GEMINI_API_KEY` | | 기존 GCP 프로젝트에 결제를 연결하면 무료 크레딧이 그대로 적용된다. 무료 티어만으로도 보유 곡 전체를 하루에 채울 수 있다 | +| `MUSEBASE_GEMINI_API_KEY` | | 요금은 아래 "무료로 쓰려면" 참고 | | `MUSEBASE_GENIUS_TOKEN` | → New API Client → **Generate Access Token** | 무료. OAuth 사용자 플로우 불필요 | | `MUSEBASE_LASTFM_KEY` | | 선택. Genius에 설명이 없는 곡을 메워 준다 | @@ -191,14 +191,33 @@ MUSEBASE_GEMINI_MODEL=gemini-2.5-flash-lite # 생략 가능 MUSEBASE_GENIUS_TOKEN=... MUSEBASE_LASTFM_KEY=... MUSEBASE_MEANING_BACKFILL_LIMIT=50 # 일괄 생성 1회 처리량 +MUSEBASE_MEANING_BACKFILL_DELAY_MS=0 # 호출 간 간격 — 무료 티어면 4500 ``` +### 무료로 쓰려면 — 헷갈리는 지점 + +**"$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` …). 같은 곡을 [다시 생성]으로 만들어 문장을 비교할 수 있다. -대량 백필은 무료 티어가 있는 Gemini 쪽이 낫다. +OpenRouter는 Google Cloud 프로젝트가 아예 필요 없어, 프로젝트 한도에 막혔을 때의 우회로이기도 하다. ### 쓰는 법 diff --git a/tests/Musebase.Core.Tests/MeaningTests.cs b/tests/Musebase.Core.Tests/MeaningTests.cs index bf35837..4073a5e 100644 --- a/tests/Musebase.Core.Tests/MeaningTests.cs +++ b/tests/Musebase.Core.Tests/MeaningTests.cs @@ -185,7 +185,7 @@ public async Task 소스_하나만_살아_있어도_의미를_만든다() public async Task 엔진이_실패하면_자료는_남기고_failed로_기록한다() { var service = new SongMeaningService( - [new FixedSource("Genius", "설명")], new NullWriter()); + [new FixedSource("Genius", "설명")], new FailingWriter(MeaningWriteResult.Failed)); var result = await service.BuildAsync("Kids", "MGMT", "ko"); @@ -193,6 +193,41 @@ public async Task 엔진이_실패하면_자료는_남기고_failed로_기록한 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.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 void 프롬프트는_지어내지_말라고_못을_박는다() { @@ -249,10 +284,10 @@ public async Task Gemini_응답에서_본문을_뽑는다() {"candidates":[{"content":{"parts":[{"text":"이 곡은 성장의 불안을 다룬다."}]}}]} """)); - var text = await new GeminiMeaningWriter("key", null, handler.Client) + var result = await new GeminiMeaningWriter("key", null, handler.Client) .WriteAsync("Kids", "MGMT", [new MeaningSource("Genius", null, "about growing up")], "ko"); - Assert.Equal("이 곡은 성장의 불안을 다룬다.", text); + Assert.Equal("이 곡은 성장의 불안을 다룬다.", result.Text); } [Fact] @@ -262,20 +297,25 @@ public async Task OpenRouter_응답에서_본문을_뽑는다() {"choices":[{"message":{"role":"assistant","content":"이 곡은 이별을 다룬다."}}]} """)); - var text = await new OpenRouterMeaningWriter("key", "anthropic/claude-opus-5", handler.Client) + 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("이 곡은 이별을 다룬다.", text); + Assert.Equal("이 곡은 이별을 다룬다.", result.Text); } [Fact] - public async Task 엔진_오류는_예외_대신_null() + public async Task 엔진_오류는_예외_대신_빈_결과() { - var down = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)); + var denied = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized)); var sources = new[] { new MeaningSource("Genius", null, "text") }; - Assert.Null(await new GeminiMeaningWriter("k", null, down.Client).WriteAsync("T", "A", sources, "ko")); - Assert.Null(await new OpenRouterMeaningWriter("k", null, down.Client).WriteAsync("T", "A", sources, "ko")); + 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); } // ---- 테스트 더블 ---- @@ -300,22 +340,23 @@ private sealed class CountingWriter : IMeaningWriter public string EngineId => "gemini"; public string Model => "test-model"; - public Task WriteAsync( + public Task WriteAsync( string title, string artist, IReadOnlyList sources, string targetLang, CancellationToken ct = default) { Calls++; - return Task.FromResult("생성된 한국어 문단"); + return Task.FromResult(MeaningWriteResult.Written("생성된 한국어 문단")); } } - private sealed class NullWriter : IMeaningWriter + /// 정해진 실패를 돌려주는 엔진(영구 실패 / 일시적 실패를 갈라 보기 위한 것). + private sealed class FailingWriter(MeaningWriteResult result) : IMeaningWriter { public string EngineId => "gemini"; public string Model => "test-model"; - public Task WriteAsync( + public Task WriteAsync( string title, string artist, IReadOnlyList sources, - string targetLang, CancellationToken ct = default) => Task.FromResult(null); + string targetLang, CancellationToken ct = default) => Task.FromResult(result); } private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) From 1c1dfc3c52e9b78c54a0e92953cd96a56786be82 Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 2 Aug 2026 00:08:23 +0900 Subject: [PATCH 04/15] =?UTF-8?q?fix(core):=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=97=90=EC=84=9C=20=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=EA=B0=80=20=ED=86=B5=EC=A7=B8=EB=A1=9C=20=EB=B9=84?= =?UTF-8?q?=EB=8D=98=20=EB=91=90=20=EC=9B=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포 후 실측하니 Shallow 같은 유명 곡까지 전부 no-source였다. 위키피디아·Genius 모두 서버에서 200을 주는데도 그랬다. 두 가지가 겹쳐 있었다. **① 아티스트를 통째로 비교하고 있었다.** 정규화가 구두점을 지우므로 "Lady Gaga/Bradley Cooper"는 ladygagabradleycooper가 되는데, 문서 제목 "Shallow (Lady Gaga and Bradley Cooper song)"은 shallowladygaga**and**bradleycoopersong이다. 가운데 "and" 때문에 영영 일치하지 않는다. 재생 메타데이터가 아티스트에 앨범을 꼬리표로 붙여 오는 경우도 같은 방식으로 깨졌다("harry styles — harry's house"). ArtistNames로 꼬리표를 떼고 공동 아티스트를 나눠, **한 명만 확인돼도** 받아들인다 — 동명이곡을 거르는 목적은 그것으로 달성된다. 완화가 "아무나 통과"가 되지 않도록, 아무도 안 맞으면 여전히 버린다(테스트로 고정). 구분자는 앞뒤 공백이 있는 것만 보고, 쪼갠 조각이 너무 짧으면(AC/DC → ac, dc) 원본 전체로 되돌린다. 검색어에는 대표 이름만 넣는다. **② Genius 타임아웃이 2단 호출에 비해 빠듯했다.** 2.5초 예산 안에서 검색과 상세를 연달아 부르는데, 실측에서 Lady Gaga/Shallow가 0.96초 + 2.0초 = 2.95초였다. 즉 **설명이 길고 좋은 곡일수록 먼저 잘려 나갔다** — 가장 필요한 곡부터 조용히 사라지는 실패였다. 가사 검색과 달리 여기서는 사람이 버튼을 누르고 기다리므로 지연보다 누락이 훨씬 나쁘다. Genius 8초, 위키피디아·Last.fm 6초로 올렸다(소스는 병렬이라 최악이 합이 아니라 최댓값이다). 확인: 실제 위키피디아로 Shallow/Lady Gaga/Bradley Cooper가 정답 문서를 찾는 것을 봤다. oh baby/LCD Soundsystem과 little freak/Harry Styles가 여전히 null인 것은 버그가 아니라 **문서 자체가 없어서**다(앨범 문서만 있고, 필수 조건이 그걸 올바르게 걸러 냈다). 테스트 10건 추가(222개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- src/Musebase.Core/Meaning/ArtistNames.cs | 58 +++++++++++++++ src/Musebase.Core/Meaning/GeniusSource.cs | 8 ++- src/Musebase.Core/Meaning/LastFmSource.cs | 2 +- src/Musebase.Core/Meaning/WikipediaSource.cs | 41 ++++++++--- tests/Musebase.Core.Tests/MeaningTests.cs | 75 ++++++++++++++++++++ 5 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 src/Musebase.Core/Meaning/ArtistNames.cs 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/GeniusSource.cs b/src/Musebase.Core/Meaning/GeniusSource.cs index b347ae2..c2128d2 100644 --- a/src/Musebase.Core/Meaning/GeniusSource.cs +++ b/src/Musebase.Core/Meaning/GeniusSource.cs @@ -29,7 +29,13 @@ public sealed class GeniusSource : ISongMeaningSource private readonly string _token; private readonly TimeSpan _timeout; - public GeniusSource(string token, HttpClient? http = null, int timeoutMs = 2500) + /// + /// **두 번의 순차 호출 전체**에 대한 예산이라 넉넉해야 한다. 실측에서 유명 곡일수록 + /// 설명이 길어 느렸다 — 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; diff --git a/src/Musebase.Core/Meaning/LastFmSource.cs b/src/Musebase.Core/Meaning/LastFmSource.cs index b942a33..930c383 100644 --- a/src/Musebase.Core/Meaning/LastFmSource.cs +++ b/src/Musebase.Core/Meaning/LastFmSource.cs @@ -25,7 +25,7 @@ public sealed partial class LastFmSource : ISongMeaningSource private readonly string _apiKey; private readonly TimeSpan _timeout; - public LastFmSource(string apiKey, HttpClient? http = null, int timeoutMs = 2500) + public LastFmSource(string apiKey, HttpClient? http = null, int timeoutMs = 6000) { _apiKey = apiKey.Trim(); _http = http ?? MeaningHttp.Client; diff --git a/src/Musebase.Core/Meaning/WikipediaSource.cs b/src/Musebase.Core/Meaning/WikipediaSource.cs index d93fb3c..593cd89 100644 --- a/src/Musebase.Core/Meaning/WikipediaSource.cs +++ b/src/Musebase.Core/Meaning/WikipediaSource.cs @@ -23,7 +23,8 @@ public sealed partial class WikipediaSource : ISongMeaningSource private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true }; /// 위키 언어 코드. 기본 영어 — 곡 해설은 영어판이 압도적으로 두껍다. - public WikipediaSource(string language = "en", HttpClient? http = null, int timeoutMs = 2500) + /// 검색 + 본문 두 호출 전체의 예산( 참고). + public WikipediaSource(string language = "en", HttpClient? http = null, int timeoutMs = 6000) { _endpoint = $"https://{language}.wikipedia.org/w/api.php"; _http = http ?? MeaningHttp.Client; @@ -76,7 +77,10 @@ public WikipediaSource(string language = "en", HttpClient? http = null, int time var t = clean?.Title ?? title; var a = clean?.Artist ?? artist; - var query = string.IsNullOrWhiteSpace(a) ? $"{t} song" : $"{t} {a} song"; + // 검색어에는 **대표 이름 하나만** 넣는다 — 앨범 꼬리표나 공동 아티스트가 그대로 들어가면 + // 검색이 흐려진다("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); @@ -94,7 +98,7 @@ public WikipediaSource(string language = "en", HttpClient? http = null, int time { var wantedTitle = Normalize(title); if (wantedTitle.Length == 0) return null; - var wantedArtist = Normalize(artist); + var wantedArtists = ArtistCandidates(artist); SearchHit? best = null; var bestScore = int.MinValue; @@ -107,13 +111,15 @@ public WikipediaSource(string language = "en", HttpClient? http = null, int time var normalizedPage = Normalize(pageTitle); if (!normalizedPage.Contains(wantedTitle, StringComparison.Ordinal)) continue; - var titleHasArtist = wantedArtist.Length > 0 - && normalizedPage.Contains(wantedArtist, StringComparison.Ordinal); - var snippetHasArtist = wantedArtist.Length > 0 - && Normalize(hit.Snippet ?? "").Contains(wantedArtist, StringComparison.Ordinal); + // 이름 **하나라도** 걸리면 이 곡의 문서로 본다. 합작곡의 문서 제목은 + // "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 (wantedArtist.Length > 0 && !titleHasArtist && !snippetHasArtist) continue; + if (wantedArtists.Count > 0 && !titleHasArtist && !snippetHasArtist) continue; var score = (titleHasArtist ? 4 : 0) // "Kids (MGMT song)" @@ -127,6 +133,25 @@ public WikipediaSource(string language = "en", HttpClient? http = null, int time 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) { diff --git a/tests/Musebase.Core.Tests/MeaningTests.cs b/tests/Musebase.Core.Tests/MeaningTests.cs index 4073a5e..04cdf5a 100644 --- a/tests/Musebase.Core.Tests/MeaningTests.cs +++ b/tests/Musebase.Core.Tests/MeaningTests.cs @@ -152,6 +152,81 @@ public void 괄호와_구두점은_비교에서_무시한다() 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")); + } + + // ---- 아티스트 표기 정리 ---- + + [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] From 04e4571265302e2f169ab3cb3eae97a52330746e Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 2 Aug 2026 00:13:25 +0900 Subject: [PATCH 05/15] =?UTF-8?q?fix(core):=20Genius=EA=B0=80=20=EC=95=84?= =?UTF-8?q?=EB=AC=B4=20=EA=B3=A1=EC=9D=B4=EB=82=98=20=EB=AC=BC=EC=96=B4=20?= =?UTF-8?q?=EC=98=A4=EB=8D=98=20=EA=B2=83=EC=9D=84=20=EB=A7=89=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 위키피디아 문서 선택에는 "확신이 없으면 포기한다"를 걸어 뒀는데 Genius에는 아무 확인도 없었다. 검색 첫 히트 중 type=song이면 그냥 받았다. Genius 검색은 **무엇을 넣든 무언가를 돌려준다.** 실측으로 음악이 아닌 유튜브 제목 ("해외에서 화제라는 한국의 지하철 문화" / "여기는한국")으로 검색했더니 전혀 무관한 119 REMIX(GRAY)가 첫 히트로 나왔다. 그대로 받았으면 그 곡의 해설이 이 트랙의 "의미"로 붙었을 것이다 — 그럴듯하고 완전히 틀린 글이라 자료가 없는 것보다 나쁘다. 라이브러리에 유튜브 트랙이 섞여 있으므로 가상의 위험이 아니었다. 이제 제목 일치를 필수로 두고, 아티스트를 아는 경우 확인까지 요구한다(위키피디아와 같은 원칙). 제목은 어느 쪽이 담아도 인정한다 — "(Remix)"·"(Live)" 꼬리표가 흔하다. 첫 히트만 보지 않고 조건을 만족하는 첫 결과까지 훑는다. 정규화는 MeaningText로 모아 두 소스가 같은 기준을 쓰게 했다. 테스트 스텁이 실제 응답에 있는 title·artist_names를 빠뜨리고 있어 함께 채웠다 — 스텁이 실물보다 헐거우면 이런 검사를 통과시켜 버린다. 테스트 4건 추가(226개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- src/Musebase.Core/Meaning/GeniusSource.cs | 48 ++++++++++++++++++-- src/Musebase.Core/Meaning/MeaningText.cs | 16 +++++++ src/Musebase.Core/Meaning/WikipediaSource.cs | 10 +--- tests/Musebase.Core.Tests/MeaningTests.cs | 41 ++++++++++++++++- 4 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 src/Musebase.Core/Meaning/MeaningText.cs diff --git a/src/Musebase.Core/Meaning/GeniusSource.cs b/src/Musebase.Core/Meaning/GeniusSource.cs index c2128d2..490774e 100644 --- a/src/Musebase.Core/Meaning/GeniusSource.cs +++ b/src/Musebase.Core/Meaning/GeniusSource.cs @@ -82,14 +82,48 @@ public GeniusSource(string token, HttpClient? http = null, int timeoutMs = 8000) { var url = "/search?q=" + Uri.EscapeDataString(term); var found = await GetAsync(url, ct).ConfigureAwait(false); - var song = found?.Response?.Hits? - .FirstOrDefault(h => string.Equals(h.Type, "song", StringComparison.OrdinalIgnoreCase))? - .Result; - if (song is { Id: > 0 }) return song; + 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; } + /// + /// 이 검색 결과가 정말 그 곡인지 확인한다(순수 함수 — 테스트 대상). + /// + /// Genius 검색은 무엇을 넣든 무언가를 돌려준다. 실측에서 음악이 아닌 유튜브 제목 + /// ("해외에서 화제라는 한국의 지하철 문화")으로 검색했더니 전혀 무관한 119 REMIX가 + /// 첫 히트로 나왔고, 확인 없이 받았으면 그 곡의 해설이 이 트랙의 "의미"로 붙었을 것이다. + /// 엉뚱한 근거는 자료가 없는 것보다 나쁘다 — 위키피디아 문서 선택과 같은 원칙이라, + /// 제목 일치를 필수로 두고 아티스트를 아는 경우 확인까지 요구한다. + /// + internal static bool Matches(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; + + // 제목은 어느 쪽이 담아도 인정한다 — "(Remix)"·"(Live)" 같은 꼬리표가 흔하다. + 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)); + } + private static IEnumerable Terms(string title, string artist) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -119,7 +153,11 @@ private static IEnumerable Terms(string title, string artist) 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); + 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); diff --git a/src/Musebase.Core/Meaning/MeaningText.cs b/src/Musebase.Core/Meaning/MeaningText.cs new file mode 100644 index 0000000..7c4dbe1 --- /dev/null +++ b/src/Musebase.Core/Meaning/MeaningText.cs @@ -0,0 +1,16 @@ +namespace Musebase.Core.Meaning; + +/// 제목·아티스트를 견주기 위한 정규화. 소스 구현들이 같은 기준을 쓰게 한다. +public static class MeaningText +{ + /// 소문자 + 영숫자/한글만 남긴다(괄호·구두점·공백 제거). + public static string Normalize(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + Span buffer = s.Length <= 256 ? stackalloc char[s.Length] : new char[s.Length]; + var n = 0; + foreach (var c in s) + if (char.IsLetterOrDigit(c)) buffer[n++] = char.ToLowerInvariant(c); + return new string(buffer[..n]); + } +} diff --git a/src/Musebase.Core/Meaning/WikipediaSource.cs b/src/Musebase.Core/Meaning/WikipediaSource.cs index 593cd89..0238e22 100644 --- a/src/Musebase.Core/Meaning/WikipediaSource.cs +++ b/src/Musebase.Core/Meaning/WikipediaSource.cs @@ -152,15 +152,7 @@ private static IReadOnlyList ArtistCandidates(string artist) return names; } - /// 비교용 정규화 — 소문자 + 영숫자/한글만 남긴다(괄호·구두점·공백 제거). - private static string Normalize(string s) - { - Span buffer = s.Length <= 256 ? stackalloc char[s.Length] : new char[s.Length]; - var n = 0; - foreach (var c in s) - if (char.IsLetterOrDigit(c)) buffer[n++] = char.ToLowerInvariant(c); - return new string(buffer[..n]); - } + private static string Normalize(string s) => MeaningText.Normalize(s); [GeneratedRegex(@"\s+")] private static partial Regex WhitespaceRegex(); diff --git a/tests/Musebase.Core.Tests/MeaningTests.cs b/tests/Musebase.Core.Tests/MeaningTests.cs index 04cdf5a..9ebd8a6 100644 --- a/tests/Musebase.Core.Tests/MeaningTests.cs +++ b/tests/Musebase.Core.Tests/MeaningTests.cs @@ -24,7 +24,8 @@ public async Task Genius_응답에서_설명과_곡_주소를_뽑는다() if (path.StartsWith("/search")) return Json(""" {"response":{"hits":[ - {"type":"song","result":{"id":378195,"url":"https://genius.com/Mgmt-kids-lyrics"}}]}} + {"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", @@ -47,7 +48,10 @@ public async Task Genius_설명이_비면_소스가_없는_것으로_본다() // 대부분의 곡에는 About이 없다 — 빈 문자열을 근거로 넘기면 모델이 지어낸다. var handler = new StubHandler(req => req.RequestUri!.PathAndQuery.StartsWith("/search") - ? Json("""{"response":{"hits":[{"type":"song","result":{"id":1,"url":"u"}}]}}""") + ? 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")); @@ -192,6 +196,39 @@ public void 한_명만_맞으면_되지만_아무도_안_맞으면_여전히_버 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", "")); + } + // ---- 아티스트 표기 정리 ---- [Theory] From 23df726f9a8b4ec92bd3dc459f2f65f3de80057a Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 2 Aug 2026 16:24:55 +0900 Subject: [PATCH 06/15] =?UTF-8?q?fix(core):=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=EC=9D=84=20=EC=95=88=20=EB=B3=B4=EB=82=B4=20?= =?UTF-8?q?=EC=9E=94=EC=95=A1=EC=9D=B4=20=EC=A0=81=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9D=B4=20=EA=B1=B0=EC=A0=88=EB=90=98?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter로 바꾸자 402가 났다. 메시지가 친절해서 원인이 바로 보였다 — "You requested up to 65535 tokens, but can only afford 16000." max_tokens를 안 보내면 공급자가 **모델 최대치를 예약하려 든다.** 정작 우리가 쓰는 건 3~5문장, 몇백 토큰인데 65,535토큰치 잔액을 요구받고 거절당한 것이다. 상한을 명시하니 해결된다. 비용 폭주 방지이기도 해서 Gemini(generationConfig.maxOutputTokens)에도 같이 넣었다. MeaningPrompt.MaxOutputTokens(1200)로 두 엔진이 같은 값을 쓴다. 402도 일시적 실패로 옮겼다. 충전하면 풀리는 실패인데 영구 실패로 굳히면, 백필 도중 잔액이 떨어졌을 때 남은 곡이 전부 "의미 없음"으로 박제된다 — 429에서 고친 것과 같은 병이다. 테스트 2건 추가(228개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- .../Meaning/GeminiMeaningWriter.cs | 11 ++++++++ src/Musebase.Core/Meaning/IMeaningWriter.cs | 7 ++++++ .../Meaning/MeaningWriteResult.cs | 9 +++++-- .../Meaning/OpenRouterMeaningWriter.cs | 8 ++++++ tests/Musebase.Core.Tests/MeaningTests.cs | 25 +++++++++++++++++++ 5 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs b/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs index 85781dc..c76fe1e 100644 --- a/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs +++ b/src/Musebase.Core/Meaning/GeminiMeaningWriter.cs @@ -62,6 +62,10 @@ public async Task WriteAsync( 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") @@ -101,6 +105,13 @@ public async Task WriteAsync( 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 diff --git a/src/Musebase.Core/Meaning/IMeaningWriter.cs b/src/Musebase.Core/Meaning/IMeaningWriter.cs index 9a0dd01..c2b4fb1 100644 --- a/src/Musebase.Core/Meaning/IMeaningWriter.cs +++ b/src/Musebase.Core/Meaning/IMeaningWriter.cs @@ -34,6 +34,13 @@ public static class MeaningPrompt /// 원문이 아무리 길어도 이 길이까지만 넣는다(토큰 폭주 방지). public const int MaxSourceChars = 6000; + /// + /// 출력 상한. 3~5문장이면 충분하고, 넉넉히 잡아도 이 정도다. + /// **반드시 요청에 실어야 한다** — 상한을 안 보내면 공급자가 모델 최대치를 예약하려 들어 + /// 잔액이 적은 계정에서 402로 거절당한다(OpenRouter 실측). 비용 폭주 방지이기도 하다. + /// + public const int MaxOutputTokens = 1200; + /// /// 마지막 문장이 이 프롬프트의 핵심이다 — 자료가 부족할 때 모델이 지어내지 않고 /// "부족하다"고 쓰게 만든다. 곡 해설은 그럴듯한 창작이 특히 쉬운 영역이다. diff --git a/src/Musebase.Core/Meaning/MeaningWriteResult.cs b/src/Musebase.Core/Meaning/MeaningWriteResult.cs index 294a4d1..97e464d 100644 --- a/src/Musebase.Core/Meaning/MeaningWriteResult.cs +++ b/src/Musebase.Core/Meaning/MeaningWriteResult.cs @@ -24,9 +24,14 @@ public sealed record MeaningWriteResult(string? Text, bool Retryable) public static MeaningWriteResult Written(string text) => new(text, false); /// - /// 429는 쿼타, 5xx는 상대 서버 문제 — 둘 다 기다리면 풀린다. + /// 429는 쿼타, 5xx는 상대 서버 문제, 402는 잔액 부족 — 셋 다 시간이나 충전으로 풀린다. + /// 402를 영구 실패로 굳히면 백필 도중 잔액이 떨어졌을 때 남은 곡이 전부 "의미 없음"으로 + /// 박제된다(429에서 고친 것과 같은 병이다). /// 나머지 4xx(키 오류·잘못된 요청)는 다시 불러도 같은 답이 오므로 영구 실패다. /// public static MeaningWriteResult FromStatus(HttpStatusCode code) => - code == HttpStatusCode.TooManyRequests || (int)code >= 500 ? Transient : Failed; + code is HttpStatusCode.TooManyRequests or HttpStatusCode.PaymentRequired + || (int)code >= 500 + ? Transient + : Failed; } diff --git a/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs b/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs index 3c0a2e4..2038b12 100644 --- a/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs +++ b/src/Musebase.Core/Meaning/OpenRouterMeaningWriter.cs @@ -104,6 +104,14 @@ 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 diff --git a/tests/Musebase.Core.Tests/MeaningTests.cs b/tests/Musebase.Core.Tests/MeaningTests.cs index 9ebd8a6..5b49a55 100644 --- a/tests/Musebase.Core.Tests/MeaningTests.cs +++ b/tests/Musebase.Core.Tests/MeaningTests.cs @@ -321,6 +321,7 @@ public async Task 쿼타_초과는_failed가_아니라_retry다() [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) @@ -415,6 +416,30 @@ public async Task OpenRouter_응답에서_본문을_뽑는다() 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 엔진_오류는_예외_대신_빈_결과() { From 8d356276e8b24ab7ccc497bf7955a2b162cbdd29 Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 2 Aug 2026 16:46:28 +0900 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20Musixmatch=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?403,=20=EC=8A=A4=EB=8B=88=ED=8E=AB=20HTML,=20=EC=9D=B8=EC=9A=A9?= =?UTF-8?q?=EB=AC=B8=EC=9D=84=20=EC=82=AC=EC=8B=A4=EC=B2=98=EB=9F=BC=20?= =?UTF-8?q?=EC=98=AE=EA=B8=B0=EB=8D=98=20=EC=9A=94=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제로 눌러 보고 읽어 보니 세 가지가 나왔다. 셋은 서로 무관하다 — Musixmatch 링크가 죽어도 의미 생성에는 아무 영향이 없다(자료원이 아니라 링크일 뿐이다). **① Musixmatch 경로형 URL이 403이다.** 계획 단계에서는 Cloudflare 때문에 형식을 확인할 수 없어 /search/{검색어}로 넣어 뒀는데, 실측하니 그 형식만 403이고 ?query= 는 200이다. 바꿨다. **② 위키피디아 스니펫의 HTML을 이해하지 못했다.** 스니펫은 와 & 를 그대로 담아 온다. 우리 정규화는 글자만 남기므로 "Belle & Sebastian"이 belleampsebastian이 됐다("amp"가 글자로 섞인다). 찾는 값은 belleandsebastian이라 영영 맞지 않았고, 그래서 The Boy with the Arab Strap이 자료가 있는데도 no-source였다. 태그를 지우고 엔티티를 디코드한 뒤, &와 and를 같은 말로 본다(표기가 흔히 갈린다). **③ 인용문이 객관적 서술로 둔갑했다.** Even Flow의 Genius 설명은 맷 캐머런의 인용문인데, 요약이 "밴드에 들어온 이후 수천 번 연주했지만…"처럼 1인칭 진술을 그대로 옮겨 누구 이야기인지 알 수 없는 글이 됐다. 프롬프트에 인용·감상을 사실처럼 쓰지 말라고 못을 박았다. 참고로 Genius의 description이 "?"인 곡이 있다(About 없음의 자리표시자). 40자 미만이라 기존 검사가 이미 올바르게 걸러 낸다 — Arab Strap이 그 경우였다. 테스트 2건 추가(230개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- src/Musebase.Core/Meaning/IMeaningWriter.cs | 2 ++ src/Musebase.Core/Meaning/MeaningText.cs | 29 ++++++++++++++++--- src/Musebase.Server/Admin/MeaningLinks.cs | 7 ++++- .../Musebase.Core.Tests/MeaningLinksTests.cs | 3 +- tests/Musebase.Core.Tests/MeaningTests.cs | 22 ++++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/Musebase.Core/Meaning/IMeaningWriter.cs b/src/Musebase.Core/Meaning/IMeaningWriter.cs index c2b4fb1..4ccc952 100644 --- a/src/Musebase.Core/Meaning/IMeaningWriter.cs +++ b/src/Musebase.Core/Meaning/IMeaningWriter.cs @@ -69,6 +69,8 @@ public static string Build( - 자료에 없는 내용은 절대 지어내지 않는다. 추측하지 않는다. - 자료가 부족해 의미를 말하기 어려우면, 그렇게만 한 문장으로 쓴다. - 차트 성적·수상 이력 같은 곡의 의미와 무관한 사실은 넣지 않는다. + - 자료에 인용문이나 개인적 감상("내가 밴드에 들어온 뒤…", "정말 훌륭하다")이 섞여 + 있으면 그것을 객관적 서술처럼 옮기지 않는다. 곡이 무엇에 대한 노래인지만 쓴다. - 머리말 없이 본문만 쓴다. """); return sb.ToString(); diff --git a/src/Musebase.Core/Meaning/MeaningText.cs b/src/Musebase.Core/Meaning/MeaningText.cs index 7c4dbe1..74e190f 100644 --- a/src/Musebase.Core/Meaning/MeaningText.cs +++ b/src/Musebase.Core/Meaning/MeaningText.cs @@ -1,16 +1,37 @@ +using System.Net; +using System.Text.RegularExpressions; + namespace Musebase.Core.Meaning; /// 제목·아티스트를 견주기 위한 정규화. 소스 구현들이 같은 기준을 쓰게 한다. -public static class MeaningText +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 ""; - Span buffer = s.Length <= 256 ? stackalloc char[s.Length] : new char[s.Length]; + + 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 s) + 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.Server/Admin/MeaningLinks.cs b/src/Musebase.Server/Admin/MeaningLinks.cs index 2e259b5..7e9785a 100644 --- a/src/Musebase.Server/Admin/MeaningLinks.cs +++ b/src/Musebase.Server/Admin/MeaningLinks.cs @@ -21,8 +21,13 @@ public static string Query(string title, string artist) return a.Length == 0 ? t : $"{a} {t}"; } + /// + /// 반드시 ?query= 형식이어야 한다. 경로형(/search/{검색어})은 실측에서 + /// 403을 준다 — 계획 단계에서는 Cloudflare 때문에 형식을 미리 확인할 수 없어 + /// 경로형으로 넣어 뒀다가, 배포 후 실제로 눌러 보고 잡았다. + /// public static string MusixmatchSearch(string title, string artist) => - "https://www.musixmatch.com/search/" + Uri.EscapeDataString(Query(title, 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)); diff --git a/tests/Musebase.Core.Tests/MeaningLinksTests.cs b/tests/Musebase.Core.Tests/MeaningLinksTests.cs index efe1b3b..105296f 100644 --- a/tests/Musebase.Core.Tests/MeaningLinksTests.cs +++ b/tests/Musebase.Core.Tests/MeaningLinksTests.cs @@ -26,7 +26,8 @@ public void 아티스트가_없으면_제목만_쓴다() public void 공백과_특수문자는_URL로_이스케이프된다() { var url = MeaningLinks.MusixmatchSearch("Don't Delete The Kisses", "Wolf Alice"); - Assert.StartsWith("https://www.musixmatch.com/search/", url); + // 경로형(/search/{검색어})은 실측에서 403이다 — 쿼리 형식이어야 한다. + Assert.StartsWith("https://www.musixmatch.com/search?query=", url); Assert.DoesNotContain(" ", url); Assert.Contains("%20", url); Assert.Contains("%27", url); // 작은따옴표 diff --git a/tests/Musebase.Core.Tests/MeaningTests.cs b/tests/Musebase.Core.Tests/MeaningTests.cs index 5b49a55..aab4f45 100644 --- a/tests/Musebase.Core.Tests/MeaningTests.cs +++ b/tests/Musebase.Core.Tests/MeaningTests.cs @@ -229,6 +229,28 @@ 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] From d95c65562546abbbce528ec6493dd62b3c999e64 Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 2 Aug 2026 23:07:15 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat(server):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=99=94=EB=A9=B4=20=EC=A0=95=EB=A6=AC=20+=20Musix?= =?UTF-8?q?match=20=EA=B3=A1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **대시보드가 가사 서버처럼 보이게** 순서를 바꿨다. 조회 통계가 위에 있고 정작 "무슨 가사가 들어와 있는가"는 한참 아래였다. 이제 최근 올라온 가사 → 최근 조회 → 미스 상위 순이다. 모든 목록은 10행만 그리고 나머지는 `전체 보기 →`로 넘긴다 — 섹션마다 라우트를 파지 않고 `/admin/list?view=` 하나로 받는다. "최근 올라온 가사"의 전체 보기는 새 페이지를 만들지 않고 기존 `/admin/search`로 보낸다(질의 없는 검색이 이미 최신순 목록이라 같은 화면이다). **미스 행에서도 곡으로 간다.** 그때는 서버에 없었어도 지금은 있을 수 있다. 표시 시점에 `Locate`로 다시 찾아 키를 채우되 `result`는 그대로 둔다 — 그때 미스였던 것은 사실이므로 기록을 바꾸면 안 된다. **가사 검색에 의미 필터**(전체·있음·아직 없음)와 의미 열. 목록 질의가 `meanings`를 LEFT JOIN해 상태를 함께 읽으므로 필터를 안 걸어도 어떤 곡에 의미가 있는지 보인다. **Musixmatch 링크가 그 곡의 페이지로 간다.** 공식 API(`track.search`)가 주는 `track_share_url`만 쓴다. 주소를 규칙으로 만들면 안 되는 이유가 실측으로 확인됐다 — `/lyrics/Pearl-Jam/Even-Flow`는 오류 없이 200을 주면서 조용히 `/lyrics/Pearl-Jam/Alive` (**다른 곡**)로 넘어간다. 사용자를 엉뚱한 곡으로 보내는 실패라 추측은 금지다. 검색 결과 페이지를 서버가 긁는 길도 익명 요청이 로그인 페이지로 리다이렉트되어 막혀 있다. 키가 없으면 검색 링크로 물러난다. 검색 API는 무엇을 넣든 뭔가를 돌려주므로 Genius와 같은 관련성 검사를 건다 (그 판정을 MeaningMatch로 모아 두 소스가 같은 기준을 쓰게 했다). **의미 자료원을 고를 수 있다** — MUSEBASE_MEANING_SOURCES(기본 genius,lastfm,wikipedia). Musixmatch의 "Meaning"도 자료로 쓸 수 있게 열었지만 **기본은 꺼져 있다.** 그 텍스트는 사람이 쓴 해설이 아니라 가사를 기계로 분석한 결과다 — 페이지의 `lens` 블록에 무드·테마·콘텐츠 등급과 함께 들어 있다. 자료로 넣으면 LLM이 쓴 글을 다시 LLM에 넣어 요약하는 셈이라 "근거에 묶어 둔다"는 전제가 약해지고 무엇에 근거했는지 추적할 수 없다. 그래서 소스 이름을 "Musixmatch (AI 분석)"으로 두어 출처 표기에 성격이 드러나게 하고, 프롬프트도 다른 자료를 우선하게 했다. 켜는 판단과 약관 위험은 운영자 몫이고 기본값이 꺼져 있어 배포본이 저절로 그 상태가 되지는 않는다. 지금 켜져 있는 자료원은 대시보드에 그대로 표시한다 — 설정에만 있으면 나중에 아무도 모른다. 곡 페이지 주소는 `meanings.musixmatch_url`에 남긴다(user_version=3). 이번에 못 찾았다고 지난번에 확인한 주소를 지우지 않는다. 링크는 의미 소스와 별개라 Musixmatch를 자료로 쓰지 않아도 정확하다. Next.js 데이터에서 의미를 꺼낼 때 경로를 고정하지 않고 재귀로 찾는다 — 페이지 구조는 우리 사정과 무관하게 바뀌고, 박아 두면 바뀌는 순간 예외도 없이 조용히 빈다. 테스트 26건 추가(256개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- PROGRESS.md | 15 +- contracts/lyrics-api.md | 13 +- docs/adr/0007-song-meaning.md | 23 ++- src/Musebase.Core/Meaning/GeniusSource.cs | 30 +--- src/Musebase.Core/Meaning/IMeaningWriter.cs | 2 + src/Musebase.Core/Meaning/MeaningMatch.cs | 37 ++++ src/Musebase.Core/Meaning/MusixmatchApi.cs | 118 +++++++++++++ .../Meaning/MusixmatchMeaningSource.cs | 115 ++++++++++++ .../Meaning/SongMeaningService.cs | 3 + src/Musebase.Server/Admin/AdminEndpoints.cs | 91 +++++++--- src/Musebase.Server/Admin/AdminHtml.cs | 2 +- src/Musebase.Server/Admin/AdminModels.cs | 10 +- src/Musebase.Server/Admin/AdminPages.cs | 167 +++++++++++++++--- src/Musebase.Server/Admin/MeaningLinks.cs | 8 + src/Musebase.Server/ApiModels.cs | 4 + src/Musebase.Server/LyricsStore.cs | 90 ++++++++-- src/Musebase.Server/MeaningOptions.cs | 62 ++++++- src/Musebase.Server/Program.cs | 2 +- src/Musebase.Server/deploy/README.md | 25 +++ tests/Musebase.Core.Tests/AdminPageTests.cs | 96 +++++++++- .../LyricsStoreMergeTests.cs | 74 ++++++++ .../MeaningOptionsTests.cs | 72 ++++++++ tests/Musebase.Core.Tests/MusixmatchTests.cs | 113 ++++++++++++ 23 files changed, 1055 insertions(+), 117 deletions(-) create mode 100644 src/Musebase.Core/Meaning/MeaningMatch.cs create mode 100644 src/Musebase.Core/Meaning/MusixmatchApi.cs create mode 100644 src/Musebase.Core/Meaning/MusixmatchMeaningSource.cs create mode 100644 tests/Musebase.Core.Tests/MeaningOptionsTests.cs create mode 100644 tests/Musebase.Core.Tests/MusixmatchTests.cs diff --git a/PROGRESS.md b/PROGRESS.md index 2f6b999..4971b0e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -62,7 +62,20 @@ - **환각 방어 둘** — ① 소스가 하나도 없으면 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). 테스트 31건 추가(205개 통과). + - 키를 하나도 넣지 않으면 기능이 통째로 꺼지고 외부 링크만 남는다(가사 기능 영향 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`는 그대로 둔다 — 그때 미스였던 것은 사실이다). + - 테스트 82건 추가(256개 통과). - **가사 서버 백업 강화 + 컨테이너화** — 앱에는 영향 없음(서버 운영용). - 백업: `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 dc65102..25f4731 100644 --- a/contracts/lyrics-api.md +++ b/contracts/lyrics-api.md @@ -145,6 +145,7 @@ Authorization: Bearer <서버가 발급한 임의 문자열> | `summary` | string | **본문** — 대상 언어 한 문단 | | `lang` | string | 요약 언어(`ko` 등) | | `geniusUrl` | string? | 정확한 Genius 곡 페이지(있으면) | +| `musixmatchUrl` | string? | **공식 API로 확인한** Musixmatch 곡 페이지(있으면) — 아래 주의 | | `engine` / `model` | string? | 생성에 쓴 엔진·모델 | | `attribution` | `[{name, url}]` | **출처 목록 — 표시 의무가 있다(아래)** | | `updatedAt` | string | ISO-8601 UTC | @@ -156,8 +157,16 @@ Authorization: Bearer <서버가 발급한 임의 문자열> > 요구한다. `summary`를 보여 주는 화면은 `attribution`의 이름·링크를 함께 렌더해야 하며, > 목록에 Wikipedia가 있으면 CC BY-SA 표기도 함께 붙인다. -**Musixmatch는 이 API에 없다.** 사이트의 "Meaning" 섹션은 공개 API로 노출되지 않고(meaning -엔드포인트가 없다) 크롤링은 약관 위반이라, 사람이 직접 읽으러 가는 **링크로만** 제공한다. +### 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/*`) — 계약 밖 diff --git a/docs/adr/0007-song-meaning.md b/docs/adr/0007-song-meaning.md index 27a9845..3c9f3b6 100644 --- a/docs/adr/0007-song-meaning.md +++ b/docs/adr/0007-song-meaning.md @@ -11,12 +11,27 @@ ## 결정 -### 1. Musixmatch의 "Meaning"은 링크로만 제공한다 +### 1. Musixmatch의 "Meaning"은 링크가 기본, 자료로 쓰려면 켜야 한다 공개 API(`track.search` / `matcher.track.get` / `track.lyrics.get` / `track.snippet.get` / -`artist.search` …)에 **meaning 엔드포인트가 없다** — 그 섹션은 사용자 기여 웹 콘텐츠다. -크롤링은 약관 위반이고 Cloudflare로 막혀 있다. 그래서 자동 수집 대상에서 제외하고, -사람이 직접 읽으러 가는 링크만 건다. +`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 diff --git a/src/Musebase.Core/Meaning/GeniusSource.cs b/src/Musebase.Core/Meaning/GeniusSource.cs index 490774e..f6668ac 100644 --- a/src/Musebase.Core/Meaning/GeniusSource.cs +++ b/src/Musebase.Core/Meaning/GeniusSource.cs @@ -96,33 +96,11 @@ public GeniusSource(string token, HttpClient? http = null, int timeoutMs = 8000) } /// - /// 이 검색 결과가 정말 그 곡인지 확인한다(순수 함수 — 테스트 대상). - /// - /// Genius 검색은 무엇을 넣든 무언가를 돌려준다. 실측에서 음악이 아닌 유튜브 제목 - /// ("해외에서 화제라는 한국의 지하철 문화")으로 검색했더니 전혀 무관한 119 REMIX가 - /// 첫 히트로 나왔고, 확인 없이 받았으면 그 곡의 해설이 이 트랙의 "의미"로 붙었을 것이다. - /// 엉뚱한 근거는 자료가 없는 것보다 나쁘다 — 위키피디아 문서 선택과 같은 원칙이라, - /// 제목 일치를 필수로 두고 아티스트를 아는 경우 확인까지 요구한다. + /// 이 검색 결과가 정말 그 곡인지 확인한다. 판정 기준은 검색 기반 소스가 모두 공유한다 + /// ( — 그쪽에 이유를 적어 뒀다). /// - internal static bool Matches(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; - - // 제목은 어느 쪽이 담아도 인정한다 — "(Remix)"·"(Live)" 같은 꼬리표가 흔하다. - 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)); - } + 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) { diff --git a/src/Musebase.Core/Meaning/IMeaningWriter.cs b/src/Musebase.Core/Meaning/IMeaningWriter.cs index 4ccc952..a46cef1 100644 --- a/src/Musebase.Core/Meaning/IMeaningWriter.cs +++ b/src/Musebase.Core/Meaning/IMeaningWriter.cs @@ -71,6 +71,8 @@ public static string Build( - 차트 성적·수상 이력 같은 곡의 의미와 무관한 사실은 넣지 않는다. - 자료에 인용문이나 개인적 감상("내가 밴드에 들어온 뒤…", "정말 훌륭하다")이 섞여 있으면 그것을 객관적 서술처럼 옮기지 않는다. 곡이 무엇에 대한 노래인지만 쓴다. + - 출처 이름에 "AI 분석"이 붙은 자료는 기계가 가사를 해석한 것이라 사실 근거가 약하다. + 다른 자료와 어긋나면 다른 자료를 따르고, 그것만 있을 때는 단정하지 않는다. - 머리말 없이 본문만 쓴다. """); return sb.ToString(); 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/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/SongMeaningService.cs b/src/Musebase.Core/Meaning/SongMeaningService.cs index 417a210..e8f6220 100644 --- a/src/Musebase.Core/Meaning/SongMeaningService.cs +++ b/src/Musebase.Core/Meaning/SongMeaningService.cs @@ -49,6 +49,9 @@ public SongMeaningService(IReadOnlyList sources, IMeaningWri /// 소스도 엔진도 구성되지 않았으면 이 기능은 꺼진 것이다. 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) { diff --git a/src/Musebase.Server/Admin/AdminEndpoints.cs b/src/Musebase.Server/Admin/AdminEndpoints.cs index 95e4a17..8aa6950 100644 --- a/src/Musebase.Server/Admin/AdminEndpoints.cs +++ b/src/Musebase.Server/Admin/AdminEndpoints.cs @@ -49,6 +49,9 @@ public static class AdminEndpoints private const string CookieName = "musebase_admin"; private static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(30); + /// `/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) @@ -116,35 +119,41 @@ void SetCookie(HttpResponse res) if (!LoggedIn(req)) return Html(AdminPages.Login()); 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), - Meanings: MeaningSummaryOf(), - Csrf: AdminAuth.Csrf(options.Token, Cookie(req) ?? "")); + // 대시보드의 한 섹션을 전부 보여 준다. 섹션마다 라우트를 파지 않고 ?view= 하나로 받는다. + app.MapGet("/admin/list", (HttpRequest req, string? view) => + { + if (!LoggedIn(req)) return Html(AdminPages.Login()); + if (view is null || !AdminPages.ListViews.TryGetValue(view, out var heading)) + return Results.Redirect("/admin"); - return Html(AdminPages.Dashboard(model, now, options.TimeZone, notice)); + 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()); + 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) => { @@ -276,6 +285,32 @@ async Task GenerateMeaningAsync(string key, string title, string artist) }; } + // 대시보드와 `/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) = store.MeaningStats(); @@ -290,8 +325,14 @@ MeaningSummary MeaningSummaryOf() async Task GenerateStatusAsync(string key, string title, string artist) { var result = await meanings.BuildAsync(title, artist, meaningOptions.Lang); - if (result.Status != Musebase.Core.Meaning.SongMeaning.Retry) - store.UpsertMeaning(MeaningMapper.ToEntry(key, title, artist, meaningOptions.Lang, result)); + 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..22d47fd 100644 --- a/src/Musebase.Server/Admin/AdminHtml.cs +++ b/src/Musebase.Server/Admin/AdminHtml.cs @@ -76,7 +76,7 @@ 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} diff --git a/src/Musebase.Server/Admin/AdminModels.cs b/src/Musebase.Server/Admin/AdminModels.cs index c151a41..da25498 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) @@ -47,6 +51,8 @@ public sealed record DashboardModel( ServerHealth Health, IReadOnlyList<(string Name, string Value)> Diagnostics, MeaningSummary Meanings, + /// 지금 켜져 있는 의미 자료원 이름 — 무엇에 근거해 만들어지는지 화면에 드러낸다. + IReadOnlyList MeaningSources, string Csrf); /// 대시보드의 "곡의 의미" 타일 — 만든 것 / 자료 없음 / 실패 + 아직 안 해 본 곡 수. diff --git a/src/Musebase.Server/Admin/AdminPages.cs b/src/Musebase.Server/Admin/AdminPages.cs index 423d4f4..7445b20 100644 --- a/src/Musebase.Server/Admin/AdminPages.cs +++ b/src/Musebase.Server/Admin/AdminPages.cs @@ -44,6 +44,11 @@ public static string Dashboard( ? $"자료 없음 {m.Meanings.NoSource} · 실패 {m.Meanings.Failed} · 남은 {m.Meanings.Pending}" : "엔진 미구성")); + // 무엇에 근거해 만들어지는지는 화면에서 보여야 한다 — 설정에만 있으면 나중에 아무도 모른다. + var sourceLine = m.MeaningSources.Count == 0 + ? "" + : $"

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

"; + var recent = Table( ["시각", "곡", "아티스트", "결과", "기기"], m.Recent.Select(r => $""" @@ -56,11 +61,7 @@ public static string Dashboard( 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( @@ -84,18 +85,13 @@ public static string Dashboard( """; })); - 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( @@ -123,18 +119,19 @@ public static string Dashboard( 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}
진단 — 현재 요청 헤더 · 서버 상태 @@ -151,23 +148,101 @@ 같은 곡을 반복 재생해도 조회 수는 늘지 않습니다(로컬 캐 """, "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, @@ -239,9 +314,9 @@ private static string MeaningCard( 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 + Musixmatch · Genius """; @@ -286,10 +361,15 @@ private static string MeaningCard( // ---- 조각 ---- + /// 곡 목록 표의 열 이름 — 표를 만드는 곳이 여럿이라 한 군데서 정한다. + 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))} """; @@ -297,6 +377,37 @@ 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.StatusNoSource => "자료 없음", + MeaningEntry.StatusFailed => "실패", + _ => "-", + }; + private static string ResultText(string result) => result switch { LyricsEntry.MatchExact => "히트", diff --git a/src/Musebase.Server/Admin/MeaningLinks.cs b/src/Musebase.Server/Admin/MeaningLinks.cs index 7e9785a..38ea967 100644 --- a/src/Musebase.Server/Admin/MeaningLinks.cs +++ b/src/Musebase.Server/Admin/MeaningLinks.cs @@ -35,4 +35,12 @@ public static string GeniusSearch(string title, string 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 294cc9a..8176439 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). @@ -61,6 +63,8 @@ public sealed record MeaningEntry /// 근거로 쓴 원문들(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`. diff --git a/src/Musebase.Server/LyricsStore.cs b/src/Musebase.Server/LyricsStore.cs index 0b41260..24da35b 100644 --- a/src/Musebase.Server/LyricsStore.cs +++ b/src/Musebase.Server/LyricsStore.cs @@ -103,6 +103,13 @@ updated_at TEXT NOT NULL """); Execute("PRAGMA user_version = 2;"); } + + if (version < 3) + { + // 곡 페이지 주소는 공식 API로 확인한 것만 저장한다(규칙으로 만든 주소는 다른 곡으로 간다). + Execute("ALTER TABLE meanings ADD COLUMN musixmatch_url TEXT;"); + Execute("PRAGMA user_version = 3;"); + } } // ---- 키 계산 (클라이언트와 같은 코드를 쓴다) ---- @@ -368,7 +375,8 @@ ON CONFLICT(key) DO UPDATE SET { using var cmd = _conn.CreateCommand(); cmd.CommandText = """ - SELECT key, title, artist, summary, lang, sources, genius_url, engine, model, status, updated_at + 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); @@ -388,6 +396,7 @@ ON CONFLICT(key) DO UPDATE SET Model = reader.IsDBNull(8) ? null : reader.GetString(8), Status = reader.GetString(9), UpdatedAt = reader.GetString(10), + MusixmatchUrl = reader.IsDBNull(11) ? null : reader.GetString(11), }; } @@ -399,13 +408,15 @@ public void UpsertMeaning(MeaningEntry entry) using var cmd = _conn.CreateCommand(); cmd.CommandText = """ INSERT INTO meanings (key, title, artist, summary, lang, sources, genius_url, - engine, model, status, updated_at) + engine, model, status, updated_at, musixmatch_url) VALUES ($key, $title, $artist, $summary, $lang, $sources, $genius, - $engine, $model, $status, $at) + $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; + status = $status, updated_at = $at, + -- 이번에 못 찾았다고 지난번에 확인한 주소를 지우지 않는다. + musixmatch_url = COALESCE($mxm, musixmatch_url); """; cmd.Parameters.AddWithValue("$key", entry.Key); cmd.Parameters.AddWithValue("$title", entry.Title); @@ -418,6 +429,7 @@ ON CONFLICT(key) DO UPDATE SET 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(); } } @@ -618,7 +630,13 @@ public HitRate HitRateSince(string sinceUtc) return new HitRate(exact, cleaned, miss); } - /// 최근 조회 기록(최신순). + /// + /// 최근 조회 기록(최신순). + /// + /// 미스였던 행은 key가 비어 있지만 그 뒤에 곡이 올라왔을 수 있다. + /// 그래서 표시 시점에 다시 찾아 키를 채운다 — 화면에서 곡으로 넘어갈 수 있게 하기 위한 것이고, + /// result는 그대로 둔다(그때 미스였던 것은 사실이므로 기록을 바꾸면 안 된다). + /// public IReadOnlyList RecentLookups(int limit = 50) { var rows = new List(); @@ -634,10 +652,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) { @@ -658,6 +681,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; } @@ -730,23 +757,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); @@ -760,7 +811,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); } @@ -772,7 +823,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); } @@ -788,9 +839,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); @@ -809,7 +860,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 index 6650c0a..4451804 100644 --- a/src/Musebase.Server/MeaningOptions.cs +++ b/src/Musebase.Server/MeaningOptions.cs @@ -16,18 +16,31 @@ public sealed record MeaningOptions( string? OpenRouterModel, string? GeniusToken, string? LastFmKey, - bool UseWikipedia, + 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_MEANING_WIKIPEDIA`(0이면 끔), + /// `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 정도를 준다. @@ -42,6 +55,8 @@ public static MeaningOptions FromEnvironment() 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", @@ -51,18 +66,51 @@ public static MeaningOptions FromEnvironment() OpenRouterModel: Env("MUSEBASE_OPENROUTER_MODEL"), GeniusToken: Env("MUSEBASE_GENIUS_TOKEN"), LastFmKey: Env("MUSEBASE_LASTFM_KEY"), - UseWikipedia: Env("MUSEBASE_MEANING_WIKIPEDIA") != "0", + 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 SongMeaningService BuildService() { var sources = new List(); - if (!string.IsNullOrWhiteSpace(GeniusToken)) sources.Add(new GeniusSource(GeniusToken!)); - if (!string.IsNullOrWhiteSpace(LastFmKey)) sources.Add(new LastFmSource(LastFmKey!)); - if (UseWikipedia) sources.Add(new WikipediaSource()); + foreach (var id in 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 { diff --git a/src/Musebase.Server/Program.cs b/src/Musebase.Server/Program.cs index 6e32667..f34b5ab 100644 --- a/src/Musebase.Server/Program.cs +++ b/src/Musebase.Server/Program.cs @@ -114,7 +114,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); } diff --git a/src/Musebase.Server/deploy/README.md b/src/Musebase.Server/deploy/README.md index 553467f..4a20e0d 100644 --- a/src/Musebase.Server/deploy/README.md +++ b/src/Musebase.Server/deploy/README.md @@ -180,6 +180,7 @@ Spotify Connect처럼 **PC에서 재생하고 폰에서 조작**하면 두 기 | `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`으로 끔). @@ -190,10 +191,33 @@ 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에 @@ -234,4 +258,5 @@ OpenRouter는 Google Cloud 프로젝트가 아예 필요 없어, 프로젝트 3~4단계를 반복하면 된다(`systemctl restart musebase-server`). DB는 `/var/lib/musebase`에 따로 있으므로 배포로 지워지지 않는다. 스키마는 `PRAGMA user_version`으로 자동 이행된다 +(현재 3 — `meanings` 테이블과 `musixmatch_url` 컬럼까지) (현재 2 = `lyrics` + `lookups` + `meanings`). diff --git a/tests/Musebase.Core.Tests/AdminPageTests.cs b/tests/Musebase.Core.Tests/AdminPageTests.cs index 479837a..c970562 100644 --- a/tests/Musebase.Core.Tests/AdminPageTests.cs +++ b/tests/Musebase.Core.Tests/AdminPageTests.cs @@ -277,9 +277,103 @@ public void 처리할_곡이_없으면_버튼을_숨긴다() AdminPages.Dashboard(model, DateTimeOffset.UtcNow, Kst)); } + // ---- 대시보드 구성 ---- + + [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"); + 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 b68029b..c12925f 100644 --- a/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs +++ b/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs @@ -164,6 +164,80 @@ public void 이미_시도한_곡은_백필_대상에서_빠진다() Assert.Equal(0, failed); } + // ---- 관리자 화면이 기대는 조회 ---- + + [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)); + } + + [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/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/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)); + } +} From 34157ce6d339cdeb002ca528ef888f5f8d29a220 Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 2 Aug 2026 23:23:14 +0900 Subject: [PATCH 09/15] =?UTF-8?q?feat(server):=20=EA=B3=A1=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=97=90=20=EC=9E=90=EB=A3=8C=EC=9B=90=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EB=B0=95=EC=8A=A4=20+=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=A4=91=20=EC=8A=A4=ED=94=BC=EB=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 의미 생성은 외부 API를 여러 번 부르느라 수 초가 걸리는데 눌러도 아무 반응이 없었다. 그러면 사람이 다시 누르고, 같은 곡을 두 번 만든다. 제출하는 순간 버튼을 잠그고 스피너를 돌린다(일괄 생성 버튼도 같다). 이게 관리자 화면의 **유일한 JS**다. 지금까지 "JS 한 줄도 없음"을 근거로 CSP를 잠가 뒀는데, 그 보장을 포기하지 않으려고 'unsafe-inline' 대신 **그 스크립트의 sha256만** 허용한다. 해시는 스크립트 문자열에서 실행 시점에 계산하므로 손으로 맞출 필요가 없고, 스크립트가 늘거나 바뀌면 테스트가 먼저 걸린다(스크립트는 정확히 하나여야 한다). 버튼 비활성화는 setTimeout으로 미룬다 — 제출 전에 끄면 폼이 전송되지 않는 브라우저가 있다. [의미 가져오기] 옆에 자료원 체크박스를 뒀다. 설정을 건드리지 않고 한 곡으로 조합을 바꿔 가며 시험해 볼 수 있다. **키가 있는 소스만** 보여 준다 — 쓸 수 없는 걸 체크박스로 두면 눌러도 아무 일이 안 일어나 사람만 헷갈린다. Musixmatch 무료 개발자 플랜은 사라진 것으로 보인다: developer.musixmatch.com/plans는 상업용 Pro 요금제로 리다이렉트되고 /signup은 403, 공식 문서의 "Get API Key"도 같은 곳으로 간다. 그래서 곡 페이지 링크는 검색 폴백으로 두고 자료원으로도 쓸 수 없다(정확한 주소를 아는 길이 API뿐이라 추측은 다른 곡으로 간다). 코드는 그대로 두어 키가 생기면 켜진다. 테스트 3건 추가(259개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- PROGRESS.md | 4 ++- src/Musebase.Server/Admin/AdminEndpoints.cs | 27 ++++++++++----- src/Musebase.Server/Admin/AdminHtml.cs | 34 +++++++++++++++++- src/Musebase.Server/Admin/AdminPages.cs | 21 +++++++++--- src/Musebase.Server/MeaningOptions.cs | 37 ++++++++++++++++++-- tests/Musebase.Core.Tests/AdminPageTests.cs | 38 ++++++++++++++++++++- 6 files changed, 143 insertions(+), 18 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 4971b0e..f1fa1a1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -75,7 +75,9 @@ - **곡 페이지 링크는 공식 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`는 그대로 둔다 — 그때 미스였던 것은 사실이다). - - 테스트 82건 추가(256개 통과). + - **곡 상세에서 자료원을 그 자리에서 고른다** — [의미 가져오기] 옆 체크박스. 설정을 건드리지 않고 한 곡으로 조합을 시험해 볼 수 있다(키가 있는 소스만 보여 준다 — 못 쓰는 걸 체크박스로 두면 눌러도 아무 일이 안 일어나 헷갈린다). 제출하면 버튼이 잠기고 **스피너**가 돈다: 외부 API를 여러 번 부르느라 수 초 걸리는데 반응이 없으면 사람이 다시 눌러 같은 곡을 두 번 만든다. 이 스피너가 관리자 화면의 **유일한 JS**이고, CSP는 느슨하게 푸는 대신 **그 스크립트의 sha256만 허용**한다(스크립트가 늘거나 바뀌면 테스트가 먼저 걸린다). + - **Musixmatch 무료 개발자 플랜은 사라진 것으로 보인다** — `developer.musixmatch.com/plans`는 상업용 Pro 요금제로 리다이렉트되고 `/signup`은 403, 공식 문서의 "Get API Key"도 같은 곳으로 간다. 그래서 곡 페이지 링크는 검색 폴백으로 두고, Musixmatch를 자료원으로 쓰는 것도 사실상 불가능하다(정확한 주소를 아는 길이 API뿐이라서). 코드는 남겨 뒀고 키가 생기면 그때 켜진다. + - 테스트 85건 추가(259개 통과). - **가사 서버 백업 강화 + 컨테이너화** — 앱에는 영향 없음(서버 운영용). - 백업: `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/src/Musebase.Server/Admin/AdminEndpoints.cs b/src/Musebase.Server/Admin/AdminEndpoints.cs index 8aa6950..ae2bb49 100644 --- a/src/Musebase.Server/Admin/AdminEndpoints.cs +++ b/src/Musebase.Server/Admin/AdminEndpoints.cs @@ -56,8 +56,10 @@ 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 참고). + var Csp = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; " + + $"script-src {AdminHtml.ScriptCsp}"; IResult Html(string html) => Results.Text(html, "text/html; charset=utf-8"); @@ -170,7 +172,10 @@ 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, - store.GetMeaningByKey(entry.Key ?? ""), meanings.IsEnabled)); + 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) => @@ -227,7 +232,9 @@ void SetCookie(HttpResponse res) var entry = string.IsNullOrWhiteSpace(key) ? null : store.GetByKey(key); if (entry is null) return Results.Redirect("/admin/search"); - var notice = await GenerateMeaningAsync(entry.Key ?? key, entry.Title, entry.Artist); + // 화면에서 고른 자료원(체크박스). 하나도 안 고르면 설정값으로 만든다. + 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 Results.Redirect( $"/admin/song?key={Uri.EscapeDataString(key)}¬ice={Uri.EscapeDataString(notice)}"); }); @@ -271,10 +278,11 @@ void SetCookie(HttpResponse res) }); // 단건 생성 후 사람에게 보여 줄 한 줄. - async Task GenerateMeaningAsync(string key, string title, string artist) + async Task GenerateMeaningAsync( + string key, string title, string artist, IReadOnlyList? only = null) { if (!meanings.IsEnabled) return "의미 엔진이 구성되지 않았습니다(키를 확인하세요)."; - var status = await GenerateStatusAsync(key, title, artist); + var status = await GenerateStatusAsync(key, title, artist, only); return status switch { Musebase.Core.Meaning.SongMeaning.Ok => "의미를 만들었습니다.", @@ -322,9 +330,12 @@ MeaningSummary MeaningSummaryOf() // 결과를 저장하고 status만 돌려준다. 실패·자료없음도 행으로 남겨 백필이 같은 곡을 // 무한히 재시도하지 않게 한다 — 단 **일시적 실패는 예외다.** 쿼타 초과를 행으로 // 남기면 한도가 회복된 뒤에도 그 곡은 영영 건너뛰어진다. - async Task GenerateStatusAsync(string key, string title, string artist) + async Task GenerateStatusAsync( + string key, string title, string artist, IReadOnlyList? only = null) { - var result = await meanings.BuildAsync(title, artist, meaningOptions.Lang); + // 소스를 골라 왔으면 이번 한 번만 그 조합으로 만든다(설정은 그대로 둔다). + 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를 자료로 쓰지 않아도 링크는 정확해야 한다. diff --git a/src/Musebase.Server/Admin/AdminHtml.cs b/src/Musebase.Server/Admin/AdminHtml.cs index 22d47fd..b3335e2 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,25 @@ public static string Esc(string? s) /// 링크에 실을 쿼리 값(키 등) 인코딩. public static string Url(string? s) => Uri.EscapeDataString(s ?? ""); + /// + /// 이 페이지들의 유일한 스크립트. 의미 생성은 외부 API를 여러 번 부르므로 수 초가 걸리는데, + /// 눌러도 아무 반응이 없으면 사람이 다시 누른다(그러면 같은 곡을 두 번 만든다). + /// 그래서 제출 직후 버튼을 잠그고 스피너를 돌린다. + /// + /// CSP는 계속 잠가 둔다 — 'unsafe-inline'이 아니라 이 문자열의 해시만 허용하므로 + /// 다른 스크립트는 여전히 한 줄도 실행되지 않는다(). + /// 비활성화는 setTimeout으로 미룬다 — 제출 전에 버튼을 끄면 폼이 전송되지 않는 브라우저가 있다. + /// + 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]');if(!b)return;" + + "setTimeout(function(){b.disabled=true;b.classList.add('busy')},0);},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) { @@ -83,6 +106,14 @@ string Nav(string href, string label, string id) => 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 +128,7 @@ string Nav(string href, string label, string id) => {{Nav("/admin/logout", "로그아웃", "logout")}} {{body}} + """; diff --git a/src/Musebase.Server/Admin/AdminPages.cs b/src/Musebase.Server/Admin/AdminPages.cs index 7445b20..0d49182 100644 --- a/src/Musebase.Server/Admin/AdminPages.cs +++ b/src/Musebase.Server/Admin/AdminPages.cs @@ -109,7 +109,7 @@ public static string Dashboard( var backfill = !m.Meanings.Enabled || m.Meanings.Pending == 0 ? "" : $""" -
+
@@ -246,7 +246,8 @@ public static string ListTable( public static string SongPage( LyricsEntry entry, IReadOnlyList lines, IReadOnlyList langs, string? selectedLang, bool showTags, string csrf, TimeZoneInfo tz, string? notice = null, - MeaningEntry? meaning = null, bool meaningEnabled = false) + MeaningEntry? meaning = null, bool meaningEnabled = false, + IReadOnlyList<(string Id, string Label, bool Checked)>? meaningSources = null) { var key = entry.Key ?? ""; var langLinks = langs.Count == 0 @@ -278,7 +279,7 @@ public static string SongPage( · 타임태그 {(showTags ? "숨기기" : "보기")} · 원문(.lrc)

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

편집

@@ -309,7 +310,8 @@ 이 가사를 덮어쓰지 못합니다. 형식은 확장 LRC 그대로 유지 /// 요구하므로 요약과 항상 함께 렌더한다. ///
private static string MeaningCard( - LyricsEntry entry, MeaningEntry? meaning, string csrf, bool enabled) + 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); @@ -320,13 +322,22 @@ private static string MeaningCard( · Genius """; + // 어떤 자료로 만들지 그 자리에서 고른다 — 한 곡으로 소스를 바꿔 가며 시험해 볼 수 있다. + var picker = sources.Count == 0 ? "" : $""" + {string.Concat(sources.Select(s => $""" + + """))} + """; + + // data-busy: 제출하면 버튼이 잠기고 스피너가 돈다(외부 API를 여러 번 부르므로 수 초 걸린다). var button = !enabled ? "의미 엔진이 구성되지 않았습니다." : $""" -
+ + {picker}
"""; diff --git a/src/Musebase.Server/MeaningOptions.cs b/src/Musebase.Server/MeaningOptions.cs index 4451804..e37f4e0 100644 --- a/src/Musebase.Server/MeaningOptions.cs +++ b/src/Musebase.Server/MeaningOptions.cs @@ -90,14 +90,47 @@ public static IReadOnlyList ParseSources(string? configured, string? leg /// 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() + /// + /// 이번 한 번만 쓸 소스 목록(곡 상세에서 체크박스로 고른 경우). 비우면 설정값을 쓴다. + /// 설정에 없는 소스도 **키만 있으면** 여기서 켤 수 있다 — 한 곡으로 시험해 보라고 둔 문이다. + /// + public SongMeaningService BuildService(IReadOnlyList? only = null) { var sources = new List(); - foreach (var id in Sources) + foreach (var id in only is { Count: > 0 } ? only : Sources) { switch (id) { diff --git a/tests/Musebase.Core.Tests/AdminPageTests.cs b/tests/Musebase.Core.Tests/AdminPageTests.cs index c970562..593d2df 100644 --- a/tests/Musebase.Core.Tests/AdminPageTests.cs +++ b/tests/Musebase.Core.Tests/AdminPageTests.cs @@ -232,7 +232,43 @@ public void 조회_기록이_비면_안내행이_렌더된다() Assert.Contains("아직 조회가 없습니다", html); Assert.Contains("colspan", html); - Assert.DoesNotContain("{AdminHtml.BusyScript}", html); + } + + [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)); } // ---- 곡의 의미 ---- From 01f51b62fdc7e963cfe5c9777b1498f7e1d650b3 Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 2 Aug 2026 23:34:24 +0900 Subject: [PATCH 10/15] =?UTF-8?q?fix(server):=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=9B=84=20=EB=92=A4=EB=A1=9C=20=EA=B0=80=EA=B8=B0=EA=B0=80=20?= =?UTF-8?q?"=EC=83=9D=EC=84=B1=20=EC=A0=84=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B3=A1"=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 가지가 겹쳐 있었다. **① 캐시 지시가 아예 없었다.** 관리자 응답에 Cache-Control이 없어 브라우저가 뒤로 가기를 캐시(bfcache 포함)에서 그렸다. 그래서 방금 만든 의미가 사라진 화면이 나온다 — 사람은 작업이 실패한 줄 알고 다시 누른다. 관리자 화면은 전부 지금 상태를 봐야 하므로 no-store로 못 박았다. 의미뿐 아니라 가사 편집·삭제 뒤에도 같은 문제였다. **② 제출이 히스토리 칸을 하나 더 만들었다.** 평범한 폼 제출은 [검색 → 곡 → 곡(생성 후)]이 되어 뒤로 가기가 생성 전의 **같은 곡**으로 간다. 사람이 가고 싶은 곳은 그 곡에 들어오기 전 화면이다. HTTP에는 히스토리를 지우는 방법이 없어 서버로는 못 고친다 — 이미 있는 스피너 스크립트에서 fetch로 보내고(제출 자체가 칸을 만들지 않는다) 결과 주소로 location.replace 한다. [검색 → 곡(생성 후)]만 남아 한 번에 검색으로 돌아간다. fetch가 없으면 평소대로 제출하고, 리다이렉트가 아니면(CSRF 실패 등) 같은 자리를 다시 읽는다. CSP에 connect-src 'self'를 더했다. fetch는 connect-src가 지배하는데 기본값이 'none'이라 그대로 뒀으면 **조용히 막혀 버튼만 잠긴 채 아무 일도 일어나지 않았을 것이다.** 리다이렉트는 302 → 303으로 바꿨다. 302는 다음 요청의 메서드를 규정하지 않아 브라우저마다 다르고, 303은 반드시 GET이라 새로고침이 POST를 되풀이하지 않는다. 테스트 1건 추가(260개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- src/Musebase.Server/Admin/AdminEndpoints.cs | 48 ++++++++++++++++----- src/Musebase.Server/Admin/AdminHtml.cs | 26 ++++++++--- tests/Musebase.Core.Tests/AdminPageTests.cs | 14 ++++++ 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/Musebase.Server/Admin/AdminEndpoints.cs b/src/Musebase.Server/Admin/AdminEndpoints.cs index ae2bb49..e9188ce 100644 --- a/src/Musebase.Server/Admin/AdminEndpoints.cs +++ b/src/Musebase.Server/Admin/AdminEndpoints.cs @@ -49,6 +49,17 @@ public static class AdminEndpoints private const string CookieName = "musebase_admin"; private static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(30); + /// 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; @@ -58,12 +69,19 @@ public static void MapAdmin( { // 스크립트는 딱 하나(제출 스피너)뿐이라 '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) => @@ -86,7 +104,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(); }); @@ -104,7 +130,7 @@ void SetCookie(HttpResponse res) if (!TokenMatches(form["token"].ToString(), options.Token)) return Html(AdminPages.Login("토큰이 맞지 않습니다.")); SetCookie(res); - return Results.Redirect("/admin"); + return SeeOther("/admin"); }); // ---- 대시보드 ---- @@ -116,7 +142,7 @@ void SetCookie(HttpResponse res) { if (!TokenMatches(token!, options.Token)) return Html(AdminPages.Login("토큰이 맞지 않습니다.")); SetCookie(res); - return Results.Redirect("/admin"); + return SeeOther("/admin"); } if (!LoggedIn(req)) return Html(AdminPages.Login()); @@ -130,7 +156,7 @@ void SetCookie(HttpResponse res) { if (!LoggedIn(req)) return Html(AdminPages.Login()); if (view is null || !AdminPages.ListViews.TryGetValue(view, out var heading)) - return Results.Redirect("/admin"); + return SeeOther("/admin"); var now = DateTimeOffset.UtcNow; var model = BuildDashboard(req, now, FullRows); @@ -160,7 +186,7 @@ void SetCookie(HttpResponse res) 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 (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)); @@ -197,12 +223,12 @@ 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) => @@ -214,7 +240,7 @@ void SetCookie(HttpResponse res) var key = form["key"].ToString(); if (!string.IsNullOrWhiteSpace(key)) store.Delete(key); - return Results.Redirect("/admin/search"); + return SeeOther("/admin/search"); }); // ---- 곡의 의미 ---- @@ -230,12 +256,12 @@ void SetCookie(HttpResponse res) var key = form["key"].ToString(); var entry = string.IsNullOrWhiteSpace(key) ? null : store.GetByKey(key); - if (entry is null) return Results.Redirect("/admin/search"); + 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 Results.Redirect( + return SeeOther( $"/admin/song?key={Uri.EscapeDataString(key)}¬ice={Uri.EscapeDataString(notice)}"); }); @@ -247,7 +273,7 @@ void SetCookie(HttpResponse res) return Results.Json(new ApiError("csrf"), statusCode: StatusCodes.Status400BadRequest); if (!meanings.IsEnabled) - return Results.Redirect($"/admin?notice={Uri.EscapeDataString("의미 엔진이 구성되지 않았습니다.")}"); + return SeeOther($"/admin?notice={Uri.EscapeDataString("의미 엔진이 구성되지 않았습니다.")}"); var targets = store.SongsWithoutMeaning(meaningOptions.BackfillLimit); int ok = 0, none = 0, failed = 0, done = 0; @@ -274,7 +300,7 @@ void SetCookie(HttpResponse res) ? $"{done}곡 처리 후 중단 — 생성 {ok} · 자료 없음 {none} · 실패 {failed}. " + "쿼타·네트워크 문제로 보입니다. 남은 곡은 손대지 않았으니 잠시 후 다시 눌러 주세요." : $"{targets.Count}곡 처리 — 생성 {ok} · 자료 없음 {none} · 실패 {failed}"; - return Results.Redirect($"/admin?notice={Uri.EscapeDataString(summary)}"); + return SeeOther($"/admin?notice={Uri.EscapeDataString(summary)}"); }); // 단건 생성 후 사람에게 보여 줄 한 줄. diff --git a/src/Musebase.Server/Admin/AdminHtml.cs b/src/Musebase.Server/Admin/AdminHtml.cs index b3335e2..1a68b43 100644 --- a/src/Musebase.Server/Admin/AdminHtml.cs +++ b/src/Musebase.Server/Admin/AdminHtml.cs @@ -40,19 +40,33 @@ public static string Esc(string? s) public static string Url(string? s) => Uri.EscapeDataString(s ?? ""); /// - /// 이 페이지들의 유일한 스크립트. 의미 생성은 외부 API를 여러 번 부르므로 수 초가 걸리는데, - /// 눌러도 아무 반응이 없으면 사람이 다시 누른다(그러면 같은 곡을 두 번 만든다). - /// 그래서 제출 직후 버튼을 잠그고 스피너를 돌린다. + /// 이 페이지들의 유일한 스크립트. 두 가지를 한다. + /// + /// ① 스피너. 의미 생성은 외부 API를 여러 번 부르므로 수 초가 걸리는데, 눌러도 아무 반응이 + /// 없으면 사람이 다시 누른다(그러면 같은 곡을 두 번 만든다). + /// ② 히스토리를 늘리지 않는다. 평범한 폼 제출은 [검색 → 곡 → 곡(생성 후)] 세 칸을 만들어, + /// 뒤로 가기가 생성 전의 같은 곡으로 간다. 사람이 원하는 곳은 그 곡에 들어오기 전 화면이다. + /// 그래서 fetch로 보내고(제출 자체가 히스토리를 만들지 않는다) 결과 주소로 + /// location.replace한다 — 지금 칸을 덮어써서 [검색 → 곡(생성 후)]만 남는다. + /// 서버가 할 수 없는 일이라(HTTP에는 히스토리를 지우는 방법이 없다) 여기서 한다. /// /// CSP는 계속 잠가 둔다 — 'unsafe-inline'이 아니라 이 문자열의 해시만 허용하므로 /// 다른 스크립트는 여전히 한 줄도 실행되지 않는다(). - /// 비활성화는 setTimeout으로 미룬다 — 제출 전에 버튼을 끄면 폼이 전송되지 않는 브라우저가 있다. + /// + /// 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]');if(!b)return;" + - "setTimeout(function(){b.disabled=true;b.classList.add('busy')},0);},true);"; + "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; } = diff --git a/tests/Musebase.Core.Tests/AdminPageTests.cs b/tests/Musebase.Core.Tests/AdminPageTests.cs index 593d2df..171e503 100644 --- a/tests/Musebase.Core.Tests/AdminPageTests.cs +++ b/tests/Musebase.Core.Tests/AdminPageTests.cs @@ -246,6 +246,20 @@ public void 스크립트는_제출_스피너_하나뿐이다() Assert.Contains($"", 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는_그_스크립트의_해시만_허용한다() { From 0fce9d4d0edd361dcaed97fbbbcce5f7c568f38f Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 2 Aug 2026 23:46:54 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix(core):=20"=EC=9E=90=EB=A3=8C=EA=B0=80?= =?UTF-8?q?=20=EB=B6=80=EC=A1=B1=ED=95=98=EB=8B=A4"=EB=8A=94=20=EB=8B=B5?= =?UTF-8?q?=EC=9D=84=20=EC=9D=98=EB=AF=B8=20=EC=9E=88=EC=9D=8C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=84=B8=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프롬프트가 근거 없는 창작을 막으려고 "부족하면 부족하다고 쓰라"고 시키므로 그런 답은 정상 동작이다. 그런데 **글자가 있다는 이유만으로 ok로 저장**하고 있었다. 통계가 부풀고, 무엇보다 앱에는 "제시된 자료만으로는 파악하기 어렵다"가 곡 해설이라며 뜬다. `insufficient` 상태를 새로 뒀다. 문단은 남긴다 — 사람이 보고 자료원을 바꿔 다시 시도할지 판단할 수 있어야 한다. 다만 의미로 세지 않고, `/v1/meaning`은 404를 준다(앱은 그 영역을 감춘다). 판정은 두 겹이다. ① 프롬프트가 이 경우 첫 줄에 [자료부족] 표식을 쓰게 한다(가장 확실하다). 표식은 저장 전에 지운다. ② 표식을 안 붙이는 모델도 있어 문구도 본다. 자료·정보를 주어로 삼는 표현과 "말할 수 없다"는 서술이 함께 있을 때만 걸리게 해, "답을 알 수 없는 질문을 반복한다" 같은 진짜 의미는 통과한다 (양쪽 다 테스트로 고정). 이미 쌓인 행도 다시 갈라 준다(user_version=4). 그대로 두면 예전 통계가 계속 부풀어 있고, Arab Strap처럼 실제로 그렇게 저장된 곡이 있다. 판정은 생성 때와 같은 함수를 쓴다. 화면은 곡 목록에 "자료 부족"으로, 상세에는 문단 위에 그렇게 밝히고 보여 준다. 대시보드 타일에도 칸을 하나 늘렸다. 백필 집계는 "자료 없음"과 같은 칸에 센다(둘 다 못 만든 것이다). 테스트 12건 추가(272개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- src/Musebase.Core/Meaning/IMeaningWriter.cs | 3 +- src/Musebase.Core/Meaning/MeaningVerdict.cs | 51 +++++++++++++++ .../Meaning/SongMeaningService.cs | 16 ++++- src/Musebase.Server/Admin/AdminEndpoints.cs | 10 ++- src/Musebase.Server/Admin/AdminModels.cs | 5 +- src/Musebase.Server/Admin/AdminPages.cs | 8 ++- src/Musebase.Server/ApiModels.cs | 2 + src/Musebase.Server/LyricsStore.cs | 52 ++++++++++++--- src/Musebase.Server/Program.cs | 1 + .../LyricsStoreMergeTests.cs | 54 +++++++++++++++- tests/Musebase.Core.Tests/MeaningTests.cs | 64 +++++++++++++++++++ 11 files changed, 249 insertions(+), 17 deletions(-) create mode 100644 src/Musebase.Core/Meaning/MeaningVerdict.cs diff --git a/src/Musebase.Core/Meaning/IMeaningWriter.cs b/src/Musebase.Core/Meaning/IMeaningWriter.cs index a46cef1..53271d7 100644 --- a/src/Musebase.Core/Meaning/IMeaningWriter.cs +++ b/src/Musebase.Core/Meaning/IMeaningWriter.cs @@ -67,7 +67,8 @@ public static string Build( - 작곡 배경, 가사가 다루는 주제, 알려진 해석을 중심으로 쓴다. - 자료에 없는 내용은 절대 지어내지 않는다. 추측하지 않는다. - - 자료가 부족해 의미를 말하기 어려우면, 그렇게만 한 문장으로 쓴다. + - 자료가 부족해 이 곡이 무엇에 대한 노래인지 말할 수 없으면, 첫 줄에 정확히 + [자료부족] 이라고 쓰고 그 뒤에 이유를 한 문장으로만 쓴다. 억지로 채우지 않는다. - 차트 성적·수상 이력 같은 곡의 의미와 무관한 사실은 넣지 않는다. - 자료에 인용문이나 개인적 감상("내가 밴드에 들어온 뒤…", "정말 훌륭하다")이 섞여 있으면 그것을 객관적 서술처럼 옮기지 않는다. 곡이 무엇에 대한 노래인지만 쓴다. 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/SongMeaningService.cs b/src/Musebase.Core/Meaning/SongMeaningService.cs index e8f6220..ba93292 100644 --- a/src/Musebase.Core/Meaning/SongMeaningService.cs +++ b/src/Musebase.Core/Meaning/SongMeaningService.cs @@ -23,6 +23,13 @@ public sealed record SongMeaning( ///
public const string Retry = "retry"; + /// + /// 자료는 찾았지만 그것만으로는 곡의 의미를 말할 수 없었다. 문단은 남기되(사람이 판단할 수 + /// 있게) **"의미 있음"으로 세지 않는다** — 글자가 있다는 이유로 세면 통계가 부풀고, + /// 앱에는 "파악하기 어렵다"는 문장이 곡 해설이라며 뜬다. + /// + public const string Insufficient = "insufficient"; + public string? GeniusUrl => Sources.FirstOrDefault(s => s.Name == "Genius")?.Url; } @@ -64,7 +71,14 @@ public async Task BuildAsync( var written = await _writer.WriteAsync(title, artist, collected, targetLang, ct).ConfigureAwait(false); if (written.Text is not null) - return new SongMeaning(SongMeaning.Ok, written.Text, collected, _writer.EngineId, _writer.Model); + { + // "자료가 부족하다"는 답도 정상 동작이지만 의미는 아니다 — 갈라서 기록한다. + 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; diff --git a/src/Musebase.Server/Admin/AdminEndpoints.cs b/src/Musebase.Server/Admin/AdminEndpoints.cs index e9188ce..cd18675 100644 --- a/src/Musebase.Server/Admin/AdminEndpoints.cs +++ b/src/Musebase.Server/Admin/AdminEndpoints.cs @@ -291,8 +291,10 @@ void SetCookie(HttpResponse res) if (status == Musebase.Core.Meaning.SongMeaning.Retry) { stopped = true; break; } done++; + // 자료 부족은 "자료 없음"과 같은 칸에 센다 — 둘 다 "의미를 만들지 못함"이다. if (status == Musebase.Core.Meaning.SongMeaning.Ok) ok++; - else if (status == Musebase.Core.Meaning.SongMeaning.NoSource) none++; + else if (status is Musebase.Core.Meaning.SongMeaning.NoSource + or Musebase.Core.Meaning.SongMeaning.Insufficient) none++; else failed++; } @@ -313,6 +315,8 @@ async Task GenerateMeaningAsync( { Musebase.Core.Meaning.SongMeaning.Ok => "의미를 만들었습니다.", Musebase.Core.Meaning.SongMeaning.NoSource => "외부 자료를 찾지 못했습니다.", + Musebase.Core.Meaning.SongMeaning.Insufficient => + "자료가 부족해 의미를 판단하지 못했습니다 — 자료원을 바꿔 다시 시도해 보세요.", Musebase.Core.Meaning.SongMeaning.Retry => "일시적인 오류입니다(쿼타·네트워크). 저장하지 않았으니 잠시 후 다시 시도하세요.", _ => "생성에 실패했습니다(키를 확인하세요).", @@ -347,10 +351,10 @@ DashboardModel BuildDashboard(HttpRequest req, DateTimeOffset now, int rows) MeaningSummary MeaningSummaryOf() { - var (ok, none, failed) = store.MeaningStats(); + var (ok, none, failed, insufficient) = store.MeaningStats(); // "아직 안 해 본 곡"은 백필 버튼이 실제로 처리할 대상 수다(상한까지만 센다). var pending = store.SongsWithoutMeaning(meaningOptions.BackfillLimit).Count; - return new MeaningSummary(ok, none, failed, pending, meanings.IsEnabled); + return new MeaningSummary(ok, none, failed, pending, meanings.IsEnabled, insufficient); } // 결과를 저장하고 status만 돌려준다. 실패·자료없음도 행으로 남겨 백필이 같은 곡을 diff --git a/src/Musebase.Server/Admin/AdminModels.cs b/src/Musebase.Server/Admin/AdminModels.cs index da25498..94ed4ec 100644 --- a/src/Musebase.Server/Admin/AdminModels.cs +++ b/src/Musebase.Server/Admin/AdminModels.cs @@ -55,8 +55,9 @@ public sealed record DashboardModel( IReadOnlyList MeaningSources, string Csrf); -/// 대시보드의 "곡의 의미" 타일 — 만든 것 / 자료 없음 / 실패 + 아직 안 해 본 곡 수. -public sealed record MeaningSummary(int Ok, int NoSource, int Failed, int Pending, bool Enabled); +/// 대시보드의 "곡의 의미" 타일 — 만든 것 / 자료 없음 / 자료 부족 / 실패 + 아직 안 해 본 곡 수. +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 0d49182..9420f1b 100644 --- a/src/Musebase.Server/Admin/AdminPages.cs +++ b/src/Musebase.Server/Admin/AdminPages.cs @@ -41,7 +41,8 @@ public static string Dashboard( Tile("곡의 의미", $"{m.Meanings.Ok}곡", m.Meanings.Enabled - ? $"자료 없음 {m.Meanings.NoSource} · 실패 {m.Meanings.Failed} · 남은 {m.Meanings.Pending}" + ? $"자료 부족 {m.Meanings.Insufficient} · 자료 없음 {m.Meanings.NoSource}" + + $" · 실패 {m.Meanings.Failed} · 남은 {m.Meanings.Pending}" : "엔진 미구성")); // 무엇에 근거해 만들어지는지는 화면에서 보여야 한다 — 설정에만 있으면 나중에 아무도 모른다. @@ -344,6 +345,10 @@ private static string MeaningCard( var bodyHtml = meaning?.Status switch { MeaningEntry.StatusOk => $"

{Esc(meaning.Summary)}

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

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

" + + $"

{Esc(meaning.Summary)}

", MeaningEntry.StatusNoSource => "

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

", MeaningEntry.StatusFailed => @@ -414,6 +419,7 @@ private static string More(string href) => private static string MeaningCell(string? status) => status switch { MeaningEntry.StatusOk => "있음", + MeaningEntry.StatusInsufficient => "자료 부족", MeaningEntry.StatusNoSource => "자료 없음", MeaningEntry.StatusFailed => "실패", _ => "-", diff --git a/src/Musebase.Server/ApiModels.cs b/src/Musebase.Server/ApiModels.cs index 8176439..52088a1 100644 --- a/src/Musebase.Server/ApiModels.cs +++ b/src/Musebase.Server/ApiModels.cs @@ -77,6 +77,8 @@ public sealed record MeaningEntry public const string StatusOk = "ok"; public const string StatusNoSource = "no-source"; public const string StatusFailed = "failed"; + /// 자료는 있었지만 그것만으로는 의미를 말할 수 없었다 — 문단은 남되 의미로 세지 않는다. + public const string StatusInsufficient = "insufficient"; } /// 출처 한 건 — 이름과 원문 주소. diff --git a/src/Musebase.Server/LyricsStore.cs b/src/Musebase.Server/LyricsStore.cs index 24da35b..76cc543 100644 --- a/src/Musebase.Server/LyricsStore.cs +++ b/src/Musebase.Server/LyricsStore.cs @@ -110,6 +110,43 @@ updated_at TEXT NOT NULL Execute("ALTER TABLE meanings ADD COLUMN musixmatch_url TEXT;"); Execute("PRAGMA user_version = 3;"); } + + if (version < 4) + { + // "자료가 부족해 파악하기 어렵다"는 답도 글자가 있다는 이유로 ok로 저장돼 있었다. + // 이미 쌓인 것까지 다시 갈라 준다 — 안 그러면 통계가 계속 부풀어 있고, 앱에는 + // 그 문장이 곡 해설이라며 뜬다. 판정은 생성 때와 **같은 함수**를 쓴다. + ReclassifyInsufficient(); + Execute("PRAGMA user_version = 4;"); + } + } + + /// 테스트에서 마이그레이션을 다시 돌려 보기 위한 것. 운영 경로에서는 쓰지 않는다. + 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(); + } } // ---- 키 계산 (클라이언트와 같은 코드를 쓴다) ---- @@ -458,8 +495,8 @@ ORDER BY l.updated_at DESC } } - /// 대시보드 타일용 — 의미가 붙은 곡 수 / 전체 / 자료 없음. - public (int WithMeaning, int NoSource, int Failed) MeaningStats() + /// 대시보드 타일용. `자료 부족`은 글자는 있지만 의미가 아니므로 따로 센다. + public (int WithMeaning, int NoSource, int Failed, int Insufficient) MeaningStats() { lock (_lock) { @@ -468,15 +505,14 @@ ORDER BY l.updated_at DESC 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 = '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); - return ( - reader.IsDBNull(0) ? 0 : reader.GetInt32(0), - reader.IsDBNull(1) ? 0 : reader.GetInt32(1), - reader.IsDBNull(2) ? 0 : reader.GetInt32(2)); + 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)); } } diff --git a/src/Musebase.Server/Program.cs b/src/Musebase.Server/Program.cs index f34b5ab..dd4e898 100644 --- a/src/Musebase.Server/Program.cs +++ b/src/Musebase.Server/Program.cs @@ -164,6 +164,7 @@ bool Authorized(HttpRequest request) 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(); diff --git a/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs b/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs index c12925f..bbd63cf 100644 --- a/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs +++ b/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs @@ -158,10 +158,11 @@ public void 이미_시도한_곡은_백필_대상에서_빠진다() Assert.Single(remaining); Assert.Equal("Go!", remaining[0].Title); - var (ok, none, failed) = store.MeaningStats(); + var (ok, none, failed, insufficient) = store.MeaningStats(); Assert.Equal(0, ok); Assert.Equal(1, none); Assert.Equal(0, failed); + Assert.Equal(0, insufficient); } // ---- 관리자 화면이 기대는 조회 ---- @@ -208,6 +209,57 @@ public void 자료를_못_찾은_곡은_의미_있음에_들지_않는다() Assert.Single(store.Search(null, meaning: LyricsStore.MeaningFilterNone)); } + [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 미스로_기록된_조회도_나중에_올라온_가사를_찾아낸다() { diff --git a/tests/Musebase.Core.Tests/MeaningTests.cs b/tests/Musebase.Core.Tests/MeaningTests.cs index aab4f45..72fc33c 100644 --- a/tests/Musebase.Core.Tests/MeaningTests.cs +++ b/tests/Musebase.Core.Tests/MeaningTests.cs @@ -363,6 +363,59 @@ public async Task 엔진이_429를_주면_두_엔진_모두_일시적_실패로_ .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 프롬프트는_지어내지_말라고_못을_박는다() { @@ -508,6 +561,17 @@ public Task WriteAsync( } } + /// 정해진 문단을 돌려주는 엔진. + 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 { From a87adcc6c68003856638ca796e43db4b2b86f344 Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 2 Aug 2026 23:51:44 +0900 Subject: [PATCH 12/15] =?UTF-8?q?feat(server):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=97=90=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EB=94=94=C2=B7=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기기마다 긴 토큰을 주소창에 붙여 넣는 것이 이 화면의 가장 큰 불편이었다. MUSEBASE_ADMIN_USER(기본 admin) + MUSEBASE_ADMIN_PASSWORD로 로그인할 수 있게 했다. 저장은 PBKDF2-SHA256(210k회)이다. 비밀번호는 토큰과 달리 **다른 서비스와 돌려 쓰이기 쉬워**, 설정 파일이 한 번 새면 피해가 이 서버에서 끝나지 않는다. `--hash-password`로 해시를 만들어 넣게 하고, 평문도 받아 주되(개인 서버의 편의) 문서에서는 해시를 권한다. 설정이 비어 있으면 무엇을 넣어도 통과하지 못한다 — 비밀번호를 안 정했는데 빈 값으로 들어가지는 사고를 막는다. **토큰 로그인은 살려 둔다.** 비밀번호를 잊거나 해시를 잘못 넣으면 들어갈 길이 아예 없어진다. 로그인 화면에서 "토큰으로 들어가기"로 접어 두었다. 토큰은 어차피 앱이 API에 쓰는 값이라 새 비밀이 늘지도 않는다. 실패하면 0.7초 쉰다. 테일넷 안이라 온라인 추측 위험은 낮지만 값이 싸다. 테스트 7건 추가(279개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- PROGRESS.md | 5 +- src/Musebase.Server/Admin/AdminEndpoints.cs | 60 ++++++++++++++----- src/Musebase.Server/Admin/AdminPages.cs | 36 +++++++++-- src/Musebase.Server/Admin/AdminSupport.cs | 51 ++++++++++++++++ src/Musebase.Server/Program.cs | 14 +++++ src/Musebase.Server/deploy/README.md | 23 +++++++ tests/Musebase.Core.Tests/AdminPageTests.cs | 66 +++++++++++++++++++++ 7 files changed, 233 insertions(+), 22 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index f1fa1a1..471d69b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -77,7 +77,10 @@ - **관리자 화면 정리** — 대시보드를 가사 중심 순서로(최근 올라온 가사 → 최근 조회 → …), 각 섹션 10행 + `전체 보기 →`(`/admin/list?view=` 하나로 처리). 가사 검색에 **의미 필터**(전체·있음·아직 없음)와 의미 열. **미스로 기록된 조회·미스 상위도 지금 서버에 있으면 곡으로 바로 간다**(기록의 `result`는 그대로 둔다 — 그때 미스였던 것은 사실이다). - **곡 상세에서 자료원을 그 자리에서 고른다** — [의미 가져오기] 옆 체크박스. 설정을 건드리지 않고 한 곡으로 조합을 시험해 볼 수 있다(키가 있는 소스만 보여 준다 — 못 쓰는 걸 체크박스로 두면 눌러도 아무 일이 안 일어나 헷갈린다). 제출하면 버튼이 잠기고 **스피너**가 돈다: 외부 API를 여러 번 부르느라 수 초 걸리는데 반응이 없으면 사람이 다시 눌러 같은 곡을 두 번 만든다. 이 스피너가 관리자 화면의 **유일한 JS**이고, CSP는 느슨하게 푸는 대신 **그 스크립트의 sha256만 허용**한다(스크립트가 늘거나 바뀌면 테스트가 먼저 걸린다). - **Musixmatch 무료 개발자 플랜은 사라진 것으로 보인다** — `developer.musixmatch.com/plans`는 상업용 Pro 요금제로 리다이렉트되고 `/signup`은 403, 공식 문서의 "Get API Key"도 같은 곳으로 간다. 그래서 곡 페이지 링크는 검색 폴백으로 두고, Musixmatch를 자료원으로 쓰는 것도 사실상 불가능하다(정확한 주소를 아는 길이 API뿐이라서). 코드는 남겨 뒀고 키가 생기면 그때 켜진다. - - 테스트 85건 추가(259개 통과). + - **"자료가 부족하다"는 답을 의미로 세지 않는다** — 프롬프트가 근거 없는 창작을 막으려고 "부족하면 부족하다고 쓰라"고 시키므로 그런 답은 정상 동작인데, 글자가 있다는 이유만으로 `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초 지연. + - 테스트 105건 추가(279개 통과). - **가사 서버 백업 강화 + 컨테이너화** — 앱에는 영향 없음(서버 운영용). - 백업: `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/src/Musebase.Server/Admin/AdminEndpoints.cs b/src/Musebase.Server/Admin/AdminEndpoints.cs index cd18675..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")); } } @@ -121,14 +132,31 @@ 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 SeeOther("/admin"); }); @@ -140,11 +168,11 @@ void SetCookie(HttpResponse res) // ?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 SeeOther("/admin"); } - if (!LoggedIn(req)) return Html(AdminPages.Login()); + if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword)); var now = DateTimeOffset.UtcNow; return Html(AdminPages.Dashboard( @@ -154,7 +182,7 @@ void SetCookie(HttpResponse res) // 대시보드의 한 섹션을 전부 보여 준다. 섹션마다 라우트를 파지 않고 ?view= 하나로 받는다. app.MapGet("/admin/list", (HttpRequest req, string? view) => { - if (!LoggedIn(req)) return Html(AdminPages.Login()); + if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword)); if (view is null || !AdminPages.ListViews.TryGetValue(view, out var heading)) return SeeOther("/admin"); @@ -177,7 +205,7 @@ void SetCookie(HttpResponse res) app.MapGet("/admin/search", (HttpRequest req, string? q, string? meaning) => { - if (!LoggedIn(req)) return Html(AdminPages.Login()); + 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)); @@ -185,7 +213,7 @@ void SetCookie(HttpResponse res) app.MapGet("/admin/song", (HttpRequest req, string? key, string? lang, string? tags, string? notice) => { - if (!LoggedIn(req)) return Html(AdminPages.Login()); + if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword)); if (string.IsNullOrWhiteSpace(key)) return SeeOther("/admin/search"); var entry = store.GetByKey(key!); @@ -206,7 +234,7 @@ void SetCookie(HttpResponse res) 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"); }); @@ -215,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); @@ -233,7 +261,7 @@ void SetCookie(HttpResponse res) 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); @@ -249,7 +277,7 @@ void SetCookie(HttpResponse res) app.MapPost("/admin/song/meaning", 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); @@ -267,7 +295,7 @@ void SetCookie(HttpResponse res) app.MapPost("/admin/meanings/backfill", 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); diff --git a/src/Musebase.Server/Admin/AdminPages.cs b/src/Musebase.Server/Admin/AdminPages.cs index 9420f1b..3b20997 100644 --- a/src/Musebase.Server/Admin/AdminPages.cs +++ b/src/Musebase.Server/Admin/AdminPages.cs @@ -8,17 +8,43 @@ 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, string? notice = null) 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/Program.cs b/src/Musebase.Server/Program.cs index dd4e898..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 모드: 서버를 띄우지 않고 시드만 하고 끝낸다. diff --git a/src/Musebase.Server/deploy/README.md b/src/Musebase.Server/deploy/README.md index 4a20e0d..76e18e7 100644 --- a/src/Musebase.Server/deploy/README.md +++ b/src/Musebase.Server/deploy/README.md @@ -37,6 +37,29 @@ 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`로 바꾼다. diff --git a/tests/Musebase.Core.Tests/AdminPageTests.cs b/tests/Musebase.Core.Tests/AdminPageTests.cs index 171e503..a55f001 100644 --- a/tests/Musebase.Core.Tests/AdminPageTests.cs +++ b/tests/Musebase.Core.Tests/AdminPageTests.cs @@ -327,6 +327,72 @@ public void 처리할_곡이_없으면_버튼을_숨긴다() 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] From f98d8030f05ca5c5dbca698e4745eb9bb89c35c2 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 3 Aug 2026 16:12:30 +0900 Subject: [PATCH 13/15] =?UTF-8?q?feat(apps):=20Windows=C2=B7Android?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B3=A1=EC=9D=98=20=EC=9D=98=EB=AF=B8=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버가 미리 만들어 둔 문단을 **읽기만 한다.** 생성은 관리자 화면에서만 일어나므로(쿼타·비용을 사람이 통제) 앱에는 만드는 길을 두지 않았다. 계약은 이미 확정돼 있던 GET /v1/meaning 그대로다. - Core: IRemoteLyricsCache.GetMeaningAsync + SongMeaningView(본문·출처·언어). - Windows: 트레이 [이 곡의 의미…] → 작은 창. 출처는 눌러서 원문으로 갈 수 있는 링크로. - Android: 상단 아이콘 → 다이얼로그. **출처 표기는 본문과 함께 나간다.** Wikipedia 본문은 CC BY-SA이고 Genius·Last.fm도 링크 표기를 요구한다(contracts/lyrics-api.md) — 본문만 떼어 보여 주면 계약 위반이라, 뷰 모델이 CreditLine을 같이 들고 다니게 했다. 의미 조회 실패는 **서킷 브레이커에 세지 않는다.** 회로는 가사와 공유하는데, 부가 기능 하나 때문에 회로가 열려 가사 조회까지 60초 막히면 손해가 훨씬 크다(테스트로 고정). 서버가 없거나 그 곡에 의미가 없으면 안내 한 줄로 끝난다 — 대부분의 곡에는 아직 의미가 없고 그건 실패가 아니다. 번역 문자열은 ko·en에만 넣었다. 나머지 17개 언어는 기존 폴백(en)으로 자연스럽게 내려간다. 테스트 3건 추가(282개 통과), 경고 0. Windows·Android 모두 Release 빌드 확인. Co-Authored-By: Claude Opus 5 (1M context) --- PROGRESS.md | 3 +- src/Musebase.Android/MainActivity.cs | 47 ++++++ .../Search/HttpRemoteLyricsCache.cs | 52 ++++++ .../Search/IRemoteLyricsCache.cs | 31 ++++ src/Musebase.Windows/MeaningWindow.cs | 152 ++++++++++++++++++ src/Musebase.Windows/Program.cs | 20 +++ src/Musebase.Windows/i18n/en.json | 8 + src/Musebase.Windows/i18n/ko.json | 8 + .../RemoteLyricsCacheTests.cs | 44 +++++ .../TranslationSharingTests.cs | 5 + 10 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/Musebase.Windows/MeaningWindow.cs diff --git a/PROGRESS.md b/PROGRESS.md index 471d69b..c2f9925 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -80,7 +80,8 @@ - **"자료가 부족하다"는 답을 의미로 세지 않는다** — 프롬프트가 근거 없는 창작을 막으려고 "부족하면 부족하다고 쓰라"고 시키므로 그런 답은 정상 동작인데, 글자가 있다는 이유만으로 `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초 지연. - - 테스트 105건 추가(279개 통과). + - **앱에서 의미 보기(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/src/Musebase.Android/MainActivity.cs b/src/Musebase.Android/MainActivity.cs index 7a1826e..5055ae3 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,51 @@ 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 loading = new AlertDialog.Builder(this) + .SetTitle($"{track.Title} — {track.Artist}")! + .SetMessage("불러오는 중…")! + .SetCancelable(true)! + .Show(); + + Musebase.Core.Search.SongMeaningView? meaning = null; + try { meaning = await remote.GetMeaningAsync(track.Title, track.Artist); } + catch (Exception) { /* 조용한 강등 — 부가 기능이다 */ } + + loading?.Dismiss(); + if (IsFinishing || IsDestroyed) return; + + var body = meaning is null + // 대부분의 곡에는 아직 의미가 없다 — 실패가 아니라 정상이다. + ? "이 곡의 의미는 아직 없습니다." + : meaning.Summary + "\n\n" + meaning.CreditLine; + + new AlertDialog.Builder(this) + .SetTitle($"{track.Title} — {track.Artist}")! + .SetMessage(body)! + .SetPositiveButton("닫기", (_, _) => { })! + .Show(); + } + private void ConfirmMarkWrong() { if (MusebaseApp.Instance?.Source.CurrentTrack is null) 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.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/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/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 async Task Set_실패는_조용히_무시된다() 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; From e6f6cfd97a47ace24162dc4dca6dec52af141176 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 3 Aug 2026 16:43:15 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20=ED=91=9C=EA=B8=B0=EA=B0=80=20?= =?UTF-8?q?=EA=B0=88=EB=A0=A4=20=EC=9D=98=EB=AF=B8=EB=A5=BC=20=EB=AA=BB=20?= =?UTF-8?q?=EC=B0=BE=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20+=20=EC=95=88?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=EC=9D=B4=EB=93=9C=20=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=EB=91=90=20=EB=B2=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **① Shallow의 의미가 앱에서 안 보였다.** 서버에는 ok로 저장돼 있는데도 404였다. 실측하니 같은 폰(s26)이 같은 곡을 7/31에는 "Lady Gaga/Bradley Cooper", 8/3에는 "Lady Gaga, Bradley Cooper"로 보고해 **두 행으로 갈려** 있었고, 의미는 앞쪽에만 붙어 있었다. 느슨한 키가 흡수했어야 할 경우인데 구분자를 몰라 두 행 다 loose_key = key였다. 느슨한 키를 만들 때 **공동 아티스트를 대표 한 명으로 줄인다**(ArtistNames 재사용) — "A/B"·"A, B"·"A & B"·"A feat. B"가 같은 곡으로 모인다. 제목이 같고 대표 아티스트가 같으면 사실상 같은 곡이고, 정확 키가 먼저 시도되므로 어디까지나 폴백이다. 기존 행은 user_version=5 마이그레이션으로 loose_key만 다시 계산한다(행은 합치지 않는다 — 사용자 편집본이 있을 수 있다). 이미 갈려 버린 행은 그것만으로 부족해서, GetMeaning이 **같은 느슨한 키의 형제 행까지** 본다. "가사는 뜨는데 의미만 비는" 상태를 만들지 않겠다는 ADR-0007의 약속이 바로 이 경우다. 쓸 수 있는 의미(ok)를 자료 부족 행보다 먼저 고른다. **② 안드로이드에서 팝업이 두 번 떴다.** 불러오기용 다이얼로그를 닫고 결과용을 새로 띄우고 있었다. 창은 하나만 띄우고 내용만 바꾼다. 테스트 6건 추가(288개 통과), 경고 0. Co-Authored-By: Claude Opus 5 (1M context) --- src/Musebase.Android/MainActivity.cs | 19 ++- src/Musebase.Server/LyricsStore.cs | 112 +++++++++++++++++- .../LyricsStoreMergeTests.cs | 41 +++++++ 3 files changed, 154 insertions(+), 18 deletions(-) diff --git a/src/Musebase.Android/MainActivity.cs b/src/Musebase.Android/MainActivity.cs index 5055ae3..64eace2 100644 --- a/src/Musebase.Android/MainActivity.cs +++ b/src/Musebase.Android/MainActivity.cs @@ -563,29 +563,24 @@ private async void ShowMeaning() return; } - var loading = new AlertDialog.Builder(this) + // 창은 **하나**만 띄우고 내용만 바꾼다. 불러오기용과 결과용을 따로 띄우면 + // 팝업이 두 번 깜빡여 부자연스럽다(실측으로 지적받은 부분). + var dialog = new AlertDialog.Builder(this) .SetTitle($"{track.Title} — {track.Artist}")! .SetMessage("불러오는 중…")! - .SetCancelable(true)! + .SetPositiveButton("닫기", (_, _) => { })! .Show(); Musebase.Core.Search.SongMeaningView? meaning = null; try { meaning = await remote.GetMeaningAsync(track.Title, track.Artist); } catch (Exception) { /* 조용한 강등 — 부가 기능이다 */ } - loading?.Dismiss(); - if (IsFinishing || IsDestroyed) return; + if (IsFinishing || IsDestroyed || dialog is null || !dialog.IsShowing) return; - var body = meaning is null + dialog.SetMessage(meaning is null // 대부분의 곡에는 아직 의미가 없다 — 실패가 아니라 정상이다. ? "이 곡의 의미는 아직 없습니다." - : meaning.Summary + "\n\n" + meaning.CreditLine; - - new AlertDialog.Builder(this) - .SetTitle($"{track.Title} — {track.Artist}")! - .SetMessage(body)! - .SetPositiveButton("닫기", (_, _) => { })! - .Show(); + : meaning.Summary + "\n\n" + meaning.CreditLine); } private void ConfirmMarkWrong() diff --git a/src/Musebase.Server/LyricsStore.cs b/src/Musebase.Server/LyricsStore.cs index 76cc543..1da60c8 100644 --- a/src/Musebase.Server/LyricsStore.cs +++ b/src/Musebase.Server/LyricsStore.cs @@ -119,6 +119,62 @@ updated_at TEXT NOT NULL 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(); + } } /// 테스트에서 마이그레이션을 다시 돌려 보기 위한 것. 운영 경로에서는 쓰지 않는다. @@ -177,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(); @@ -209,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; } /// @@ -397,8 +470,15 @@ ON CONFLICT(key) DO UPDATE SET { lock (_lock) { - var key = Locate(title, artist)?.Key ?? ExactKey(title, artist); - return ReadMeaning(key); + 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))); } } @@ -408,6 +488,26 @@ ON CONFLICT(key) DO UPDATE SET 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(); diff --git a/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs b/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs index bbd63cf..a70b44d 100644 --- a/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs +++ b/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs @@ -209,6 +209,47 @@ public void 자료를_못_찾은_곡은_의미_있음에_들지_않는다() 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 자료부족은_의미_있음에서_빠진다() { From e04c88068b6224cd351486824c78ae0bfa4acad3 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 3 Aug 2026 17:00:58 +0900 Subject: [PATCH 15/15] =?UTF-8?q?chore:=20=EB=A6=B4=EB=A6=AC=EC=8A=A4=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=E2=80=94=20windows=200.18.0=20/=20android?= =?UTF-8?q?=200.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 곡의 의미 기능(서버 + 앱 표시)이 들어간 첫 릴리스다. 둘 다 기능 추가라 마이너를 올린다. Co-Authored-By: Claude Opus 5 (1M context) --- src/Musebase.Android/Musebase.Android.csproj | 4 ++-- src/Musebase.Windows/Musebase.Windows.csproj | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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.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