diff --git a/PROGRESS.md b/PROGRESS.md index c2f9925..5d099a6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -55,6 +55,13 @@ - **삼성 One UI는 서드파티 앱 로그를 막는다** — `adb shell setprop log.tag.Musebase VERBOSE` 없이는 `Musebase` 태그가 logcat에 한 줄도 안 나와 앱이 죽은 것처럼 보인다. `dumpsys media_session`은 metadata를 제목/아티스트/앨범 3개로만 덤프해서 광고 플래그가 안 보이므로, 앱이 찍는 `ad-signals` 로그가 사실상 유일한 프로브다. ## 미배포 (서버 쪽 작업 — 앱 릴리스와 무관하게 이미 운영 중) +- **곡 상세 보강(서버 관리자 화면 전용)** — 앱과 `/v1` 계약은 건드리지 않는다. + - **외부 링크 다섯 개** — Last.fm · Tunefind · YouTube · Musixmatch · Genius를 의미 카드가 아니라 **머리말 아래**로 옮겼다(Tunefind·YouTube는 의미의 출처가 아니라 곡을 더 보러 가는 통로이고, 의미가 비었을 때 카드가 안내하는 "위 링크"가 실제로 위에 있어야 말이 맞는다). + - **Tunefind API는 쓸 수 없다 — 링크만** — 셀프서비스 가입 창구가 없고 `info@tunefind.com`으로 라이선스 계약을 맺어야 하며 **무료 티어가 없다**. robots.txt는 AI 크롤러를 전면 차단한다. 주소는 반드시 `/search?q=`다 — 흔히 보이는 `/search/site?q=`는 실측 404. Last.fm 곡 주소는 반대로 **규칙 생성이 안전하다**(이름이 안 맞으면 조용히 다른 곡으로 가지 않고 "없는 곡"이 뜬다 — Musixmatch가 `Even-Flow`에서 `Alive`로 넘어가던 것과 대조). + - **Last.fm 좋아요 표시·토글** — `track.getInfo`에 `username`을 주면 `userloved`가 실려 온다(읽기는 지금 키로 충분). 켜고 끄기는 POST + `api_sig` + 세션 키가 필요해 **브라우저 승인 플로우**를 넣었다(`?cb=`로 콜백을 그때그때 넘겨 API 계정에 등록할 필요가 없다). **함정: 관리자 쿠키가 `SameSite=Strict`라 last.fm에서 돌아오는 이동에 실리지 않는다** — 그대로 두면 콜백이 로그인 화면으로 떨어지고 1회용 토큰이 날아간다. 콜백의 신원 증명은 `SameSite=Lax`인 state 논스 쿠키로 따로 한다. 조회 실패는 **"좋아요 안 함"으로 그리지 않는다**(꺼진 하트를 보고 누르면 이미 켜 둔 것을 끄게 된다). + - **커버 이미지** — iTunes Search(키 불필요, `100x100bb.jpg` → `600x600bb.jpg`)에서 찾고 없으면 Deezer. 첫 결과를 믿지 않고 `MeaningMatch.IsSameSong`을 통과시킨다. **못 찾은 것도 기억**해 열 때마다 다시 부르지 않는다(`song_links.cover_at`, [커버 다시 찾기]로 해제). **Last.fm 이미지는 쓰지 않는다** — API 약관이 artwork를 계약 대상에서 명시적으로 제외한다. CSP `default-src 'none'` 때문에 `img-src`를 그 두 호스트로 열었다(안 열면 이미지가 **조용히** 안 뜬다). + - **로그아웃을 우상단으로** — 네비 가운데(대시보드·가사 검색·로그아웃)에 있어 잘못 눌렀다. `margin-left:auto`로 오른쪽 끝에 흐린 색으로 뺐다. + - 저장은 `song_links` + `app_settings`(`user_version=7`). 컬럼·테이블 추가뿐이라 **구 버전 바이너리로 롤백해도 안전**. 테스트 38건 추가(333개 통과). - **곡의 의미(서버)** — 곡이 무엇에 대한 노래인지 한 문단으로. 관리자 곡 상세의 가사 **위**에 카드로 뜨고, 앱용 `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. diff --git a/docs/adr/0007-song-meaning.md b/docs/adr/0007-song-meaning.md index e280192..3e419ee 100644 --- a/docs/adr/0007-song-meaning.md +++ b/docs/adr/0007-song-meaning.md @@ -73,6 +73,27 @@ 대안으로 TheAudioDB(무료 API)도 확인했는데, `strDescriptionEN` 필드는 있으나 `Even Flow`에서 비어 있었다 — 곡 단위 해설 커버리지가 얇아 Genius를 대체하지 못한다. +#### Tunefind도 넣지 않는다 — 링크만 (2026-08-17) + +"이 곡이 어느 드라마·영화에 쓰였는가"는 곡 배경으로 값진 정보라 [Tunefind](https://www.tunefind.com) +API를 검토했다. **개인이 쓸 수 있는 경로가 없다.** + +- 셀프서비스 가입 창구가 없고 `info@tunefind.com`으로 **라이선스 계약**을 맺어야 한다. + 티어는 Starter~Enterprise로 나뉘지만 **무료 티어가 없다.** +- robots.txt가 `ClaudeBot`·`anthropic-ai`·`GPTBot` 등을 전면 차단한다 — 크롤링도 답이 아니다. + +그래서 곡 상세에 **검색 링크만** 단다(`MeaningLinks.Tunefind`). 주소는 반드시 `/search?q=`다 — +검색 결과에 흔히 나오는 `/search/site?q=`는 실측에서 404였다. + +계약을 맺게 되면 붙이는 자리는 `ISongMeaningSource` + `MUSEBASE_MEANING_SOURCES` 그대로다. + +#### Last.fm 이미지는 자료원이 아니다 + +`track.getInfo`는 앨범 이미지를 함께 주지만, Last.fm API 약관은 audio·audiovisual·**artwork**를 +계약 대상에서 명시적으로 제외한다("all such content is expressly excluded from this Agreement"). +커버 이미지는 iTunes Search(폴백 Deezer)에서 따로 가져온다 — 가져올 수 있다는 것과 써도 된다는 +것은 다르다. + ### 3. 번역이 아니라 요약이다 — LLM을 쓴다 세 소스 모두 영어 산문이다. DeepL은 번역만 하므로 그대로 넣으면 "의미"가 아니라 긴 영어 diff --git a/src/Musebase.Server/Admin/AdminEndpoints.cs b/src/Musebase.Server/Admin/AdminEndpoints.cs index 469f0f2..42860fd 100644 --- a/src/Musebase.Server/Admin/AdminEndpoints.cs +++ b/src/Musebase.Server/Admin/AdminEndpoints.cs @@ -58,6 +58,10 @@ public static AdminOptions FromEnvironment(string apiToken) public static class AdminEndpoints { private const string CookieName = "musebase_admin"; + + /// Last.fm 승인 플로우의 1회용 논스. 관리자 쿠키와 달리 SameSite=Lax여야 한다. + private const string StateCookie = "musebase_lastfm_state"; + private static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(30); /// 303 See Other — 이 프레임워크에 기본 헬퍼가 없어 직접 만든다. @@ -82,8 +86,11 @@ public static void MapAdmin( // 다른 스크립트는 여전히 한 줄도 실행되지 않는다(AdminHtml.BusyScript 참고). // connect-src가 필요한 이유: 그 스크립트가 폼을 fetch로 보낸다. 기본값 'none'이면 // 조용히 막혀 버튼만 잠긴 채 아무 일도 일어나지 않는다. 대상은 같은 출처뿐이다. + // img-src를 열지 않으면 default-src 'none' 때문에 커버가 **조용히** 안 뜬다(콘솔에만 남는다). + // 호스트는 CoverArt가 실제로 부르는 두 곳으로 한정한다 — 새 자료원을 더하면 여기도 같이 는다. var Csp = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; " + "connect-src 'self'; " + + "img-src 'self' https://*.mzstatic.com https://*.dzcdn.net; " + $"script-src {AdminHtml.ScriptCsp}"; IResult Html(string html) => @@ -93,6 +100,9 @@ IResult Html(string html) => // 303은 반드시 GET으로 가라는 뜻이라 새로고침이 POST를 되풀이하지 않는다. static IResult SeeOther(string location) => new SeeOtherResult(location); + var lastfm = meaningOptions.LastFmAccount(); + var covers = new CoverArt(); + string? Cookie(HttpRequest req) => req.Cookies.TryGetValue(CookieName, out var v) ? v : null; bool LoggedIn(HttpRequest req) => @@ -212,7 +222,7 @@ void SetCookie(HttpResponse res) 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) => + app.MapGet("/admin/song", async (HttpRequest req, string? key, string? lang, string? tags, string? notice) => { if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword)); if (string.IsNullOrWhiteSpace(key)) return SeeOther("/admin/search"); @@ -224,13 +234,17 @@ void SetCookie(HttpResponse res) var selected = string.IsNullOrWhiteSpace(lang) ? langs.FirstOrDefault() : lang; var showTags = tags != "0"; + var links = await ResolveLinksAsync(entry); + var love = await LoveStateOf(entry); + 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, meaningOptions.SelectableSources() .Select(s => (s.Id, MeaningOptions.SourceLabel(s.Id), s.Default)) - .ToList())); + .ToList(), + links, love)); }); app.MapGet("/admin/raw", (HttpRequest req, string? key) => @@ -305,6 +319,140 @@ void SetCookie(HttpResponse res) return SeeOther($"/admin?notice={Uri.EscapeDataString("광고 표시를 해제했습니다.")}"); }); + // ---- 커버 이미지 ---- + + app.MapPost("/admin/song/cover", async (HttpRequest req) => + { + if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword)); + var form = await req.ReadFormAsync(); + if (!AdminAuth.VerifyCsrf(form["csrf"].ToString(), options.Token, Cookie(req) ?? "")) + return Results.Json(new ApiError("csrf"), statusCode: StatusCodes.Status400BadRequest); + + var key = form["key"].ToString(); + var entry = string.IsNullOrWhiteSpace(key) ? null : store.GetByKey(key); + if (entry is null) return SeeOther("/admin/search"); + + store.ForgetCover(entry.Key ?? key); + var found = await FindCoverAsync(entry); + var notice = found is null ? "커버를 찾지 못했습니다." : $"커버를 찾았습니다({found.Source})."; + return SeeOther($"/admin/song?key={Uri.EscapeDataString(key)}¬ice={Uri.EscapeDataString(notice)}"); + }); + + // ---- Last.fm 계정 연결 ---- + // 승인은 last.fm에서 일어나고 브라우저가 여기로 돌아온다. **관리자 쿠키는 SameSite=Strict라 + // 그 크로스사이트 이동에는 실리지 않는다** — 그대로 두면 콜백이 로그인 화면으로 떨어지고 + // 1회용 토큰이 날아간다. 그래서 콜백의 신원 증명은 아래 state 논스 쿠키(SameSite=Lax)로 한다. + // 논스는 로그인한 관리자가 /connect를 눌렀을 때만 구워지므로 그 사람이 시작한 플로우임을 증명한다. + + app.MapGet("/admin/lastfm/connect", (HttpRequest req, HttpResponse res) => + { + if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword)); + if (!lastfm.CanConnect) + return SeeOther($"/admin?notice={Uri.EscapeDataString("MUSEBASE_LASTFM_KEY와 MUSEBASE_LASTFM_SECRET이 필요합니다.")}"); + + var nonce = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)); + res.Cookies.Append(StateCookie, nonce, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, // Strict면 last.fm에서 돌아올 때 실리지 않는다 + Path = "/admin/lastfm", + Expires = DateTimeOffset.UtcNow.AddMinutes(10), + }); + + // 콜백은 지금 요청의 출처로 만든다 — API 계정에 콜백을 미리 등록하지 않아도 된다. + return SeeOther(lastfm.AuthorizeUrl($"{req.Scheme}://{req.Host}/admin/lastfm/callback")); + }); + + app.MapGet("/admin/lastfm/callback", async (HttpRequest req, HttpResponse res, string? token) => + { + var nonce = req.Cookies.TryGetValue(StateCookie, out var v) ? v : null; + res.Cookies.Delete(StateCookie, new CookieOptions { Path = "/admin/lastfm" }); + + if (string.IsNullOrEmpty(nonce)) + return SeeOther($"/admin?notice={Uri.EscapeDataString("연결 요청이 만료됐습니다 — 다시 눌러 주세요.")}"); + if (string.IsNullOrWhiteSpace(token)) + return SeeOther($"/admin?notice={Uri.EscapeDataString("Last.fm이 승인을 거절했습니다.")}"); + + var session = await lastfm.ExchangeTokenAsync(token!); + if (session is null) + return SeeOther($"/admin?notice={Uri.EscapeDataString("세션 키를 받지 못했습니다(토큰은 1회용입니다 — 다시 시도하세요).")}"); + + store.SetSetting(LastFmAccount.SessionSetting, session.Value.Session); + store.SetSetting(LastFmAccount.UserSetting, session.Value.User); + return SeeOther($"/admin?notice={Uri.EscapeDataString($"Last.fm에 연결했습니다: {session.Value.User}")}"); + }); + + app.MapPost("/admin/lastfm/disconnect", async (HttpRequest req) => + { + if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword)); + var form = await req.ReadFormAsync(); + if (!AdminAuth.VerifyCsrf(form["csrf"].ToString(), options.Token, Cookie(req) ?? "")) + return Results.Json(new ApiError("csrf"), statusCode: StatusCodes.Status400BadRequest); + + store.DeleteSetting(LastFmAccount.SessionSetting); + store.DeleteSetting(LastFmAccount.UserSetting); + return SeeOther($"/admin?notice={Uri.EscapeDataString("Last.fm 연결을 해제했습니다.")}"); + }); + + app.MapPost("/admin/song/love", async (HttpRequest req) => + { + if (!LoggedIn(req)) return Html(AdminPages.Login(null, options.HasPassword)); + var form = await req.ReadFormAsync(); + if (!AdminAuth.VerifyCsrf(form["csrf"].ToString(), options.Token, Cookie(req) ?? "")) + return Results.Json(new ApiError("csrf"), statusCode: StatusCodes.Status400BadRequest); + + var key = form["key"].ToString(); + var entry = string.IsNullOrWhiteSpace(key) ? null : store.GetByKey(key); + if (entry is null) return SeeOther("/admin/search"); + + var session = store.GetSetting(LastFmAccount.SessionSetting); + var notice = string.IsNullOrEmpty(session) + ? "Last.fm 계정이 연결돼 있지 않습니다." + : await SetLovedAsync(entry, form["on"].ToString() != "0", session!); + + return SeeOther($"/admin/song?key={Uri.EscapeDataString(key)}¬ice={Uri.EscapeDataString(notice)}"); + }); + + async Task SetLovedAsync(LyricsEntry entry, bool loved, string session) + { + var ok = await lastfm.SetLovedAsync(entry.Title, entry.Artist, loved, session); + if (!ok) return "Last.fm에 반영하지 못했습니다(연결이 끊겼을 수 있습니다)."; + return loved ? "Last.fm 좋아요를 켰습니다." : "Last.fm 좋아요를 껐습니다."; + } + + /// 커버를 찾아 저장한다. **못 찾아도 저장한다** — 그래야 화면을 열 때마다 다시 부르지 않는다. + async Task FindCoverAsync(LyricsEntry entry) + { + var found = await covers.FindAsync(entry.Title, entry.Artist); + store.SetCover(entry.Key ?? "", found?.Url, found?.Source); + return found; + } + + async Task ResolveLinksAsync(LyricsEntry entry) + { + var links = store.GetSongLinks(entry.Key ?? ""); + if (links.CoverTried) return links; + + var found = await FindCoverAsync(entry); + return links with { CoverUrl = found?.Url, CoverSource = found?.Source, CoverAt = "now" }; + } + + /// 좋아요 여부. **모르면 Known=false다** — 모르는 것을 "안 함"으로 그리면 이미 켜 둔 곡을 끄게 된다. + async Task LoveStateOf(LyricsEntry entry) + { + var session = store.GetSetting(LastFmAccount.SessionSetting); + var user = store.GetSetting(LastFmAccount.UserSetting); + if (string.IsNullOrEmpty(session) || string.IsNullOrEmpty(user)) return LoveState.NotConnected; + + var state = await lastfm.GetStateAsync(entry.Title, entry.Artist, user!); + if (state is null) return new LoveState(true, false, false); + + // 정식 곡 주소는 알아낸 김에 기억해 둔다 — 다음부터는 규칙으로 만든 주소를 쓰지 않는다. + if (state.Url is not null) store.SetLastFmUrl(entry.Key ?? "", state.Url); + return new LoveState(true, true, state.Loved); + } + // ---- 곡의 의미 ---- // 생성은 **사람이 누를 때만** 일어난다. 자동 생성을 두지 않는 이유는 쿼타·비용이 // 예측 가능해야 하고, 실패가 조용히 쌓이면 안 되기 때문이다. @@ -409,7 +557,11 @@ DashboardModel BuildDashboard(HttpRequest req, DateTimeOffset now, int rows) Meanings: MeaningSummaryOf(), MeaningSources: meanings.SourceNames, Csrf: AdminAuth.Csrf(options.Token, Cookie(req) ?? ""), - AdTitles: store.AdTitles(rows)); + AdTitles: store.AdTitles(rows), + // 연결할 수 없는 구성이면 null — 카드를 아예 그리지 않는다. + LastFm: lastfm.CanConnect + ? new LastFmLink(store.GetSetting(LastFmAccount.UserSetting)) + : null); } MeaningSummary MeaningSummaryOf() diff --git a/src/Musebase.Server/Admin/AdminHtml.cs b/src/Musebase.Server/Admin/AdminHtml.cs index 1a68b43..1391843 100644 --- a/src/Musebase.Server/Admin/AdminHtml.cs +++ b/src/Musebase.Server/Admin/AdminHtml.cs @@ -75,8 +75,11 @@ public static string Esc(string? s) /// 공통 레이아웃 — 다크 표 스타일 + 상단 네비게이션. public static string Layout(string title, string body, string? activeNav = null) { - string Nav(string href, string label, string id) => - $"{Esc(label)}"; + string Nav(string href, string label, string id, string? extra = null) + { + var classes = string.Join(" ", new[] { activeNav == id ? "on" : null, extra }.Where(c => c is not null)); + return $"{Esc(label)}"; + } // CSS에 중괄호가 많아 $$(이중 보간) 원시 문자열을 쓴다 — 보간은 {{…}}, CSS 중괄호는 그대로. return $$""" @@ -99,6 +102,8 @@ string Nav(string href, string label, string id) => nav{display:flex;gap:1rem;margin:.75rem 0 1.25rem;font-size:.9rem; border-bottom:1px solid var(--line);padding-bottom:.6rem} nav a.on{color:var(--text);font-weight:600} + /* 로그아웃은 오른쪽 끝으로 밀고 흐리게 둔다 — 가운데 있으면 잘못 누른다. */ + nav a.out{margin-left:auto;color:var(--dim)} nav a.out:hover{color:var(--bad)} table{border-collapse:collapse;width:100%;font-size:.85rem} th,td{border:1px solid var(--line);padding:.35rem .5rem;text-align:left;vertical-align:top} th{background:var(--panel)} tr:nth-child(even) td{background:#181818} @@ -126,6 +131,12 @@ string Nav(string href, string label, string id) => 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} } + .song{display:flex;gap:1rem;align-items:flex-start;margin-top:1.5rem} + .song>div{min-width:0} .song h2{margin-top:0} + .cover{width:6rem;height:6rem;border-radius:.4rem;border:1px solid var(--line); + object-fit:cover;flex:0 0 auto;background:var(--panel)} + button.love{background:#3a2330} button.love:hover{background:#4c2c3e} + button.love.on{color:#ff8fb1} .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)} @@ -139,7 +150,7 @@ string Nav(string href, string label, string id) => {{body}} diff --git a/src/Musebase.Server/Admin/AdminModels.cs b/src/Musebase.Server/Admin/AdminModels.cs index 63e92d7..f594b80 100644 --- a/src/Musebase.Server/Admin/AdminModels.cs +++ b/src/Musebase.Server/Admin/AdminModels.cs @@ -37,6 +37,30 @@ public sealed record HitRate(int Exact, int Cleaned, int Miss) /// 상세 화면의 가사 한 줄(원문과 번역을 나란히 보여주기 위한 형태). public sealed record DisplayLine(string TimeTag, string Content, string? Translation); +/// +/// 곡 하나에 대해 밖에서 알아낸 것. 가사·의미와 따로 두는 이유는 이쪽이 비어 있어도 +/// 가사는 멀쩡해야 하기 때문이다. +/// +/// +/// 커버를 찾아본 시각. null이면 아직 안 찾아본 것이고, 값이 있는데 +/// 이 비어 있으면 찾아봤지만 없었다는 뜻이다(다시 부르지 않는다). +/// +public sealed record SongLinks( + string Key, string? CoverUrl = null, string? CoverSource = null, + string? CoverAt = null, string? LastFmUrl = null) +{ + public bool CoverTried => !string.IsNullOrEmpty(CoverAt); +} + +/// +/// 곡 상세가 보여 줄 Last.fm 상태. 계정을 연결하지 않았거나 조회가 실패하면 전부 꺼진 값이다 — +/// 모르는 것을 "좋아요 안 함"으로 그리면 안 된다(꺼진 하트를 보고 다시 누르게 된다). +/// +public sealed record LoveState(bool Connected, bool Known, bool Loved) +{ + public static readonly LoveState NotConnected = new(false, false, false); +} + /// 대시보드가 그리는 데 필요한 전부. 페이지 렌더러는 DB를 모른다(테스트 가능하도록). public sealed record DashboardModel( ServerStats Stats, @@ -58,7 +82,12 @@ public sealed record DashboardModel( IReadOnlyList MeaningSources, string Csrf, /// 광고로 표시해 차단한 제목들(되돌릴 수 있어야 하므로 화면에 보여 준다). - IReadOnlyList? AdTitles = null); + IReadOnlyList? AdTitles = null, + /// Last.fm 계정 연결 상태 — 쓸 수 없는 구성이면 null이라 카드를 아예 안 그린다. + LastFmLink? LastFm = null); + +/// 대시보드의 Last.fm 카드 — 연결한 아이디(없으면 미연결). +public sealed record LastFmLink(string? User); /// 대시보드의 "곡의 의미" 타일 — 만든 것 / 자료 없음 / 자료 부족 / 실패 + 아직 안 해 본 곡 수. public sealed record MeaningSummary( diff --git a/src/Musebase.Server/Admin/AdminPages.cs b/src/Musebase.Server/Admin/AdminPages.cs index b38f431..01eb249 100644 --- a/src/Musebase.Server/Admin/AdminPages.cs +++ b/src/Musebase.Server/Admin/AdminPages.cs @@ -145,6 +145,8 @@ public static string Dashboard( 한 번에 처리할 곡 수는 MUSEBASE_MEANING_BACKFILL_LIMIT로 정합니다. """; + var lastfm = LastFmCard(m.LastFm, m.Csrf); + return Layout("대시보드", $""" {(notice is null ? "" : $"

{Esc(notice)}

")}
{tiles}
@@ -152,6 +154,7 @@ public static string Dashboard(

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

{backfill} + {lastfm}

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

{uploads}

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

{recent} @@ -280,7 +283,8 @@ 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, - IReadOnlyList<(string Id, string Label, bool Checked)>? meaningSources = null) + IReadOnlyList<(string Id, string Label, bool Checked)>? meaningSources = null, + SongLinks? links = null, LoveState? love = null) { var key = entry.Key ?? ""; var langLinks = langs.Count == 0 @@ -298,8 +302,16 @@ public static string SongPage( ? $"{Esc(l.TimeTag)}{Esc(l.Content)}{Esc(l.Translation)}" : $"{Esc(l.Content)}{Esc(l.Translation)}")); + // 커버가 없으면 자리를 아예 그리지 않는다 — 깨진 이미지 아이콘이 더 나쁘다. + var cover = string.IsNullOrWhiteSpace(links?.CoverUrl) + ? "" + : $"""{Esc(entry.Title)} 커버"""; + return Layout($"{entry.Title} — {entry.Artist}", $""" {(notice is null ? "" : $"

{Esc(notice)}

")} +
+ {cover} +

{Esc(entry.Title)} — {Esc(entry.Artist)}

출처 {Esc(entry.Service ?? "-")} · origin {Esc(entry.Origin)} · rev {entry.Revision} @@ -312,6 +324,10 @@ public static string SongPage( · 타임태그 {(showTags ? "숨기기" : "보기")} · 원문(.lrc)

+

{ExternalLinks(entry, meaning, links)}

+ {LoveForm(key, csrf, love ?? LoveState.NotConnected)} +
+
{MeaningCard(entry, meaning, csrf, meaningEnabled, meaningSources ?? [])} {body} @@ -341,11 +357,97 @@ public static string SongPage( 제목을 차단합니다 — 가사를 지우고, 앞으로 어느 기기가 올려도 등록하지 않으며 검색도 하지 않습니다. 되돌리기는 대시보드에서. + +
+ + + + 한 번 못 찾으면 다시 찾지 않습니다{(links?.CoverSource is { } src ? $" (지금: {Esc(src)})" : "")} — + 곡명·아티스트를 고친 뒤에는 여기서 다시 시켜 주세요. +
""", "search"); } /// - /// 가사 위에 붙는 "이 곡의 의미" 카드. 의미가 없으면 외부 링크와 생성 버튼만 보인다. + /// 대시보드의 Last.fm 계정 연결. MUSEBASE_LASTFM_SECRET이 없으면 가 + /// null로 와서 카드 자체를 그리지 않는다 — 눌러도 안 되는 것을 보여 주지 않는다. + /// + /// 연결은 폼이 아니라 링크다. CSP form-action 'self'가 외부 도메인으로의 + /// 폼 제출을 막기 때문이다(눌러도 조용히 아무 일도 안 일어난다). + /// + private static string LastFmCard(LastFmLink? link, string csrf) + { + if (link is null) return ""; + + if (string.IsNullOrEmpty(link.User)) + return """ +

Last.fm 계정 연결 → + — 연결하면 곡 상세에서 좋아요를 켜고 끌 수 있습니다.

+ """; + + return $""" +
+ + + 연결됨: {Esc(link.User)} · + last.fm 설정 > Applications에서도 권한을 회수할 수 있습니다. +
+ """; + } + + /// + /// 곡에서 밖으로 나가는 링크. 의미 카드가 아니라 머리말 아래에 둔다 — + /// Tunefind·YouTube는 의미의 출처가 아니라 곡을 더 보러 가는 통로이고, 의미가 비었을 때 + /// 카드가 안내하는 "위 링크"가 실제로 위에 있어야 말이 맞는다. + /// + /// Musixmatch·Genius·Last.fm은 확인한 주소가 있으면 그것을 쓴다(없으면 검색으로 강등). + /// + private static string ExternalLinks(LyricsEntry entry, MeaningEntry? meaning, SongLinks? links) + { + var targets = new (string Label, string Url)[] + { + ("Last.fm", MeaningLinks.LastFm(entry.Title, entry.Artist, links?.LastFmUrl)), + ("Tunefind", MeaningLinks.Tunefind(entry.Title, entry.Artist)), + ("YouTube", MeaningLinks.YouTube(entry.Title, entry.Artist)), + ("Musixmatch", MeaningLinks.Musixmatch(entry.Title, entry.Artist, meaning?.MusixmatchUrl)), + ("Genius", MeaningLinks.Genius(entry.Title, entry.Artist, meaning?.GeniusUrl)), + }; + + return string.Join(" · ", targets.Select(t => + $"{Esc(t.Label)}")); + } + + /// + /// Last.fm 좋아요 토글. 계정을 연결하지 않았으면 아무것도 그리지 않는다 — + /// 눌러도 안 되는 버튼을 보여 주면 사람을 헷갈리게 한다. + /// + /// 모르는 상태(조회 실패)를 "좋아요 안 함"으로 그리지 않는다. 그러면 이미 좋아요한 곡을 + /// 다시 눌러 꺼 버리게 된다 — 그때는 확인만 다시 시킨다. + /// + /// data-busy라 기존 스크립트가 스피너를 돌리고 히스토리도 늘리지 않는다. + /// + private static string LoveForm(string key, string csrf, LoveState love) + { + if (!love.Connected) return ""; + + var (label, extra, on) = love.Known + ? (love.Loved ? "♥ 좋아요 해제" : "♡ 좋아요", love.Loved ? "0" : "1", love.Loved) + : ("♡ 좋아요 확인", "1", false); + + var note = love.Known ? "" : "Last.fm 상태를 확인하지 못했습니다."; + return $""" +
+ + + + + {note} +
+ """; + } + + /// + /// 가사 위에 붙는 "이 곡의 의미" 카드. 의미가 없으면 생성 버튼만 보인다. /// /// 출처 표기는 의무다 — Wikipedia 본문은 CC BY-SA고 Genius·Last.fm도 링크 표기를 /// 요구하므로 요약과 항상 함께 렌더한다. @@ -355,13 +457,6 @@ private static string MeaningCard( IReadOnlyList<(string Id, string Label, bool Checked)> sources) { var key = entry.Key ?? ""; - var geniusUrl = MeaningLinks.Genius(entry.Title, entry.Artist, meaning?.GeniusUrl); - - var musixmatchUrl = MeaningLinks.Musixmatch(entry.Title, entry.Artist, meaning?.MusixmatchUrl); - var links = $""" - Musixmatch - · Genius - """; // 어떤 자료로 만들지 그 자리에서 고른다 — 한 곡으로 소스를 바꿔 가며 시험해 볼 수 있다. var picker = sources.Count == 0 ? "" : $""" @@ -390,7 +485,7 @@ private static string MeaningCard( "

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

" + $"

{Esc(meaning.Summary)}

", MeaningEntry.StatusNoSource => - "

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

", + "

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

", MeaningEntry.StatusFailed => "

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

", _ => "

아직 만들지 않았습니다.

", @@ -410,7 +505,6 @@ private static string MeaningCard(

이 곡의 의미

{bodyHtml} {credit} -

{links}

{button} """; } diff --git a/src/Musebase.Server/Admin/MeaningLinks.cs b/src/Musebase.Server/Admin/MeaningLinks.cs index 38ea967..92711a4 100644 --- a/src/Musebase.Server/Admin/MeaningLinks.cs +++ b/src/Musebase.Server/Admin/MeaningLinks.cs @@ -43,4 +43,37 @@ public static string Genius(string title, string artist, string? knownUrl) => ///
public static string Musixmatch(string title, string artist, string? knownUrl) => string.IsNullOrWhiteSpace(knownUrl) ? MusixmatchSearch(title, artist) : knownUrl!; + + /// + /// 이 곡이 어느 드라마·영화에 쓰였는지 보러 간다. + /// + /// API는 쓸 수 없다 — Tunefind는 셀프서비스 가입 창구가 없고 라이선스 계약이 필요하며 + /// 무료 티어가 없다. robots.txt도 AI 크롤러를 전면 차단한다. 그래서 링크만 단다. + /// + /// 반드시 /search?q=다. 검색 결과에 흔히 나오는 /search/site?q=는 실측에서 404다. + /// + public static string Tunefind(string title, string artist) => + "https://www.tunefind.com/search?q=" + Uri.EscapeDataString(Query(title, artist)); + + public static string YouTube(string title, string artist) => + "https://www.youtube.com/results?search_query=" + Uri.EscapeDataString(Query(title, artist)); + + /// + /// Last.fm 곡 페이지. 아는 주소(track.getInfo가 알려 준 정식 주소)가 있으면 그것을 쓰고, + /// 없으면 이름으로 만든다. + /// + /// Musixmatch와 달리 규칙 생성이 안전하다 — 이름이 안 맞으면 조용히 다른 곡으로 넘어가지 않고 + /// "그런 곡 없음" 페이지가 뜬다(엉뚱한 곡으로 보내는 것이 훨씬 나쁘다). + /// + public static string LastFm(string title, string artist, string? knownUrl) + { + if (!string.IsNullOrWhiteSpace(knownUrl)) return knownUrl!; + + var a = (artist ?? "").Trim(); + var t = (title ?? "").Trim(); + if (a.Length == 0) + return "https://www.last.fm/search?q=" + Uri.EscapeDataString(t); + + return $"https://www.last.fm/music/{Uri.EscapeDataString(a)}/_/{Uri.EscapeDataString(t)}"; + } } diff --git a/src/Musebase.Server/CoverArt.cs b/src/Musebase.Server/CoverArt.cs new file mode 100644 index 0000000..c634364 --- /dev/null +++ b/src/Musebase.Server/CoverArt.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using Musebase.Core.Meaning; + +namespace Musebase.Server; + +/// 찾아낸 커버 한 장. +/// 어디서 왔는지 — CSP 허용 목록과 짝이 맞아야 한다(AdminEndpoints). +public sealed record CoverImage(string Url, string Source); + +/// +/// 곡의 커버 이미지를 찾는다. 키가 필요 없는 두 곳을 순서대로 본다. +/// +/// Last.fm은 쓰지 않는다. API 응답에 앨범 이미지가 들어 있지만, Last.fm API 약관은 +/// "audio, audiovisual materials, and artwork ... expressly excluded from this Agreement"라고 +/// 명시한다 — 가져올 수 있다는 것과 써도 된다는 것은 다르다. +/// +/// MusicBrainz + Cover Art Archive는 요청이 2단(recording→release-group)이고 초당 1회 제한에 +/// 커버 누락이 잦아(실측 404) 쓰지 않는다. +/// +public sealed class CoverArt +{ + public const string ITunes = "itunes"; + public const string Deezer = "deezer"; + + private readonly HttpClient _http; + private readonly TimeSpan _timeout; + + public CoverArt(HttpClient? http = null, int timeoutMs = 2500) + { + _http = http ?? MeaningHttp.Client; + _timeout = TimeSpan.FromMilliseconds(Math.Clamp(timeoutMs, 500, 30_000)); + } + + /// 못 찾으면 null. 호출자는 그 사실도 저장해 다음에 다시 부르지 않는다. + public async Task FindAsync(string title, string artist, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(title)) return null; + return await FromITunesAsync(title, artist ?? "", ct).ConfigureAwait(false) + ?? await FromDeezerAsync(title, artist ?? "", ct).ConfigureAwait(false); + } + + /// + /// iTunes Search — 키가 필요 없고 곡 단위 커버리지가 가장 좋다. + /// 첫 결과를 그냥 믿지 않는다: 검색 API는 무엇을 넣든 뭔가를 돌려주므로 + /// Genius·Musixmatch와 같은 기준()을 통과시킨다. + /// + private async Task FromITunesAsync(string title, string artist, CancellationToken ct) + { + var term = Uri.EscapeDataString(MeaningLinks.Query(title, artist)); + var json = await GetJsonAsync($"https://itunes.apple.com/search?term={term}&entity=song&limit=5", ct) + .ConfigureAwait(false); + if (json is null || !json.Value.TryGetProperty("results", out var results) + || results.ValueKind != JsonValueKind.Array) return null; + + foreach (var hit in results.EnumerateArray()) + { + if (!MeaningMatch.IsSameSong(Text(hit, "trackName"), Text(hit, "artistName"), title, artist)) continue; + var art = Text(hit, "artworkUrl100"); + if (art is null) continue; + return new CoverImage(Promote(art), ITunes); + } + return null; + } + + /// Deezer — 역시 키가 없어도 되고, iTunes에 없는 곡을 가끔 메워 준다. + private async Task FromDeezerAsync(string title, string artist, CancellationToken ct) + { + var query = artist.Length == 0 + ? $"track:\"{title}\"" + : $"artist:\"{artist}\" track:\"{title}\""; + var json = await GetJsonAsync( + $"https://api.deezer.com/search?q={Uri.EscapeDataString(query)}&limit=5", ct).ConfigureAwait(false); + if (json is null || !json.Value.TryGetProperty("data", out var data) + || data.ValueKind != JsonValueKind.Array) return null; + + foreach (var hit in data.EnumerateArray()) + { + var hitArtist = hit.TryGetProperty("artist", out var a) ? Text(a, "name") : null; + if (!MeaningMatch.IsSameSong(Text(hit, "title"), hitArtist, title, artist)) continue; + if (!hit.TryGetProperty("album", out var album)) continue; + var cover = Text(album, "cover_big") ?? Text(album, "cover_medium"); + if (cover is null) continue; + return new CoverImage(cover, Deezer); + } + return null; + } + + /// + /// iTunes 아트워크 주소는 끝의 크기가 그대로 파일명이다 — 100을 600으로 바꾸면 큰 그림이 온다. + /// 형태가 예상과 다르면 건드리지 않는다(억지로 만들면 404가 된다). + /// + public static string Promote(string artworkUrl) => + artworkUrl.EndsWith("/100x100bb.jpg", StringComparison.Ordinal) + ? artworkUrl[..^"100x100bb.jpg".Length] + "600x600bb.jpg" + : artworkUrl; + + private static string? Text(JsonElement element, string name) => + element.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() : null; + + private async Task GetJsonAsync(string url, CancellationToken ct) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(_timeout); + + var body = await _http.GetStringAsync(url, cts.Token).ConfigureAwait(false); + using var document = JsonDocument.Parse(body); + return document.RootElement.Clone(); + } + catch (Exception) + { + return null; // 조용한 강등 — 커버가 없다고 곡 상세가 안 뜨면 안 된다 + } + } +} diff --git a/src/Musebase.Server/LastFm/LastFmAccount.cs b/src/Musebase.Server/LastFm/LastFmAccount.cs new file mode 100644 index 0000000..1b96c34 --- /dev/null +++ b/src/Musebase.Server/LastFm/LastFmAccount.cs @@ -0,0 +1,216 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Musebase.Core.Meaning; + +namespace Musebase.Server; + +/// 이 곡에 대해 Last.fm이 알려 준 것 — 좋아요 여부와 정식 곡 주소. +public sealed record LastFmTrackState(bool Loved, string? Url); + +/// +/// Last.fm **계정** API — 좋아요를 읽고 켜고 끈다. +/// +/// (의미 자료 수집)와 같은 서비스지만 성격이 다르다. 저쪽은 API 키만으로 +/// 되는 공개 데이터고 이쪽은 그 사람의 계정에 쓰는 인증 호출이다. 관리자 화면 전용이라 +/// Musebase.Core가 아니라 서버에 둔다. +/// +/// 두 가지를 계속 조심해야 한다. +/// ① 오류가 HTTP 200에 담겨 온다 — 본문의 error 코드를 반드시 본다. +/// ② 쓰기 호출은 POST + 서명이다. 서명 규칙은 참고. +/// 실패는 예외가 아니라 null/false로 강등한다 — 곡 상세가 이것 때문에 안 뜨면 안 된다. +/// +public sealed class LastFmAccount +{ + private const string Endpoint = "https://ws.audioscrobbler.com/2.0/"; + + /// 승인 페이지. 여기로 사람을 보내면 돌아올 때 ?token=이 붙는다. + public const string AuthPage = "https://www.last.fm/api/auth/"; + + /// 세션 키를 담아 두는 설정 이름(). + public const string SessionSetting = "lastfm.session"; + public const string UserSetting = "lastfm.user"; + + private readonly string _apiKey; + private readonly string _secret; + private readonly HttpClient _http; + private readonly TimeSpan _timeout; + + public LastFmAccount(string? apiKey, string? secret, HttpClient? http = null, int timeoutMs = 2500) + { + _apiKey = (apiKey ?? "").Trim(); + _secret = (secret ?? "").Trim(); + _http = http ?? MeaningHttp.Client; + _timeout = TimeSpan.FromMilliseconds(Math.Clamp(timeoutMs, 500, 30_000)); + } + + /// 읽기(좋아요 여부)에 쓸 수 있는가 — API 키만 있으면 된다. + public bool CanRead => _apiKey.Length > 0; + + /// 계정 연결(승인 플로우)을 할 수 있는가 — shared secret까지 있어야 한다. + public bool CanConnect => _apiKey.Length > 0 && _secret.Length > 0; + + /// 사람을 보낼 승인 페이지 주소. 돌아올 곳을 cb로 그때그때 넘긴다. + public string AuthorizeUrl(string callback) => + $"{AuthPage}?api_key={Uri.EscapeDataString(_apiKey)}&cb={Uri.EscapeDataString(callback)}"; + + /// + /// 서명 = 파라미터를 이름순으로 <이름><값>으로 이어붙이고 shared secret을 + /// 뒤에 붙여 MD5. formatapi_sig 자신은 넣지 않는다. + /// + /// MD5는 Last.fm 규격이라 쓰는 것이고 보안 용도가 아니다(우리가 고를 수 있는 값이 없다). + /// + public static string Signature(IReadOnlyDictionary parameters, string secret) + { + var sb = new StringBuilder(); + foreach (var (name, value) in parameters.OrderBy(p => p.Key, StringComparer.Ordinal)) + sb.Append(name).Append(value); + sb.Append(secret); + + return Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(sb.ToString()))).ToLowerInvariant(); + } + + /// + /// 승인 토큰을 세션 키로 바꾼다. 토큰은 1회용이라 실패하면 처음부터 다시 승인해야 한다. + /// 성공하면 (세션 키, 아이디). + /// + public async Task<(string Session, string User)?> ExchangeTokenAsync(string token, CancellationToken ct = default) + { + if (!CanConnect || string.IsNullOrWhiteSpace(token)) return null; + + var parameters = new Dictionary(StringComparer.Ordinal) + { + ["api_key"] = _apiKey, + ["method"] = "auth.getSession", + ["token"] = token.Trim(), + }; + + var json = await GetAsync(parameters, ct).ConfigureAwait(false); + if (json is null) return null; + + try + { + var session = json.Value.GetProperty("session"); + var key = session.GetProperty("key").GetString(); + var name = session.TryGetProperty("name", out var n) ? n.GetString() : null; + return string.IsNullOrWhiteSpace(key) ? null : (key!, name ?? ""); + } + catch (Exception e) when (e is KeyNotFoundException or InvalidOperationException) + { + return null; + } + } + + /// + /// 좋아요 여부와 정식 곡 주소. 를 함께 넘겨야 userloved가 실린다. + /// 모르면 null — 호출자는 "좋아요 안 함"으로 그리지 않는다. + /// + public async Task GetStateAsync( + string title, string artist, string user, CancellationToken ct = default) + { + if (!CanRead || string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(user)) return null; + + var parameters = new Dictionary(StringComparer.Ordinal) + { + ["api_key"] = _apiKey, + ["method"] = "track.getInfo", + ["artist"] = artist ?? "", + ["track"] = title, + ["username"] = user, + ["autocorrect"] = "1", + }; + + // 읽기 전용이라 서명이 필요 없다(secret이 없어도 동작해야 한다). + var json = await GetAsync(parameters, ct, sign: false).ConfigureAwait(false); + if (json is null || !json.Value.TryGetProperty("track", out var track)) return null; + + // userloved는 문자열 "0"/"1"로 온다 — 숫자로 오는 경우도 방어한다. + var loved = track.TryGetProperty("userloved", out var lv) && lv.ValueKind switch + { + JsonValueKind.String => lv.GetString() == "1", + JsonValueKind.Number => lv.GetInt32() == 1, + JsonValueKind.True => true, + _ => false, + }; + var url = track.TryGetProperty("url", out var u) ? u.GetString() : null; + return new LastFmTrackState(loved, string.IsNullOrWhiteSpace(url) ? null : url); + } + + /// 좋아요를 켜거나 끈다. 세션 키가 없으면 아무 일도 하지 않는다. + public async Task SetLovedAsync( + string title, string artist, bool loved, string sessionKey, CancellationToken ct = default) + { + if (!CanConnect || string.IsNullOrWhiteSpace(sessionKey) || string.IsNullOrWhiteSpace(title)) + return false; + + var parameters = new Dictionary(StringComparer.Ordinal) + { + ["api_key"] = _apiKey, + ["method"] = loved ? "track.love" : "track.unlove", + ["artist"] = artist ?? "", + ["track"] = title, + ["sk"] = sessionKey, + }; + parameters["api_sig"] = Signature(parameters, _secret); + + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(_timeout); + + // 쓰기는 반드시 POST이고 method까지 본문에 담는다(쿼리스트링에 두면 실패한다). + var fields = parameters.ToDictionary(p => p.Key, p => p.Value); + fields["format"] = "json"; + using var response = await _http + .PostAsync(Endpoint, new FormUrlEncodedContent(fields), cts.Token).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); + return response.IsSuccessStatusCode && ErrorCode(body) is null; + } + catch (Exception) + { + return false; // 조용한 강등 + } + } + + /// GET 한 번. 오류(본문 error 포함)면 null. + private async Task GetAsync( + Dictionary parameters, CancellationToken ct, bool sign = true) + { + try + { + if (sign) parameters["api_sig"] = Signature(parameters, _secret); + + var query = string.Join("&", parameters + .Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(_timeout); + + var body = await _http.GetStringAsync($"{Endpoint}?{query}&format=json", cts.Token).ConfigureAwait(false); + if (ErrorCode(body) is not null) return null; + + using var document = JsonDocument.Parse(body); + return document.RootElement.Clone(); + } + catch (Exception) + { + return null; // 조용한 강등 + } + } + + /// 본문에 실려 온 오류 코드(정상이면 null). HTTP 200에도 실려 온다. + private static int? ErrorCode(string body) + { + try + { + using var document = JsonDocument.Parse(body); + return document.RootElement.TryGetProperty("error", out var e) && e.TryGetInt32(out var code) + ? code : null; + } + catch (JsonException) + { + return -1; // JSON이 아니면 정상일 리 없다 + } + } +} diff --git a/src/Musebase.Server/LyricsStore.cs b/src/Musebase.Server/LyricsStore.cs index 68246d7..60ac801 100644 --- a/src/Musebase.Server/LyricsStore.cs +++ b/src/Musebase.Server/LyricsStore.cs @@ -142,6 +142,141 @@ added_at TEXT NOT NULL """); Execute("PRAGMA user_version = 6;"); } + + if (version < 7) + { + // 밖에서 알아낸 사실(커버 주소·Last.fm 정식 주소)과 서버 설정값. + // 가사·의미와 따로 두는 이유는 같다 — 이쪽이 없거나 낡아도 가사는 멀쩡해야 한다. + Execute(""" + CREATE TABLE IF NOT EXISTS song_links ( + key TEXT PRIMARY KEY, -- lyrics.key와 같은 규칙 + cover_url TEXT, -- cover_at이 있는데 여기가 NULL = 찾아봤지만 없었다 + cover_source TEXT, -- 'itunes' | 'deezer' + cover_at TEXT, -- 커버를 찾아본 시각. NULL이면 아직 안 찾아봤다 + lastfm_url TEXT, -- track.getInfo가 알려 준 정식 주소 + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS app_settings ( + name TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + """); + Execute("PRAGMA user_version = 7;"); + } + } + + // ---- 곡 바깥 링크(커버·Last.fm) ---- + + /// 이 곡에 대해 밖에서 알아낸 것. 아무것도 없으면 빈 . + public SongLinks GetSongLinks(string key) + { + lock (_lock) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = + "SELECT cover_url, cover_source, cover_at, lastfm_url FROM song_links WHERE key = $k;"; + cmd.Parameters.AddWithValue("$k", key); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) return new SongLinks(key); + return new SongLinks( + key, + reader.IsDBNull(0) ? null : reader.GetString(0), + reader.IsDBNull(1) ? null : reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3)); + } + } + + /// + /// 커버 찾기 결과를 확정한다. 못 찾았을 때도 부른다(=null) — + /// cover_at이 채워져 다음부터 화면을 열 때마다 외부 API를 다시 부르지 않는다. + /// + public void SetCover(string key, string? url, string? source) + { + lock (_lock) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO song_links (key, cover_url, cover_source, cover_at, updated_at) + VALUES ($k, $u, $s, $at, $at) + ON CONFLICT(key) DO UPDATE SET + cover_url = $u, cover_source = $s, cover_at = $at, updated_at = $at; + """; + cmd.Parameters.AddWithValue("$k", key); + cmd.Parameters.AddWithValue("$u", (object?)url ?? DBNull.Value); + cmd.Parameters.AddWithValue("$s", (object?)source ?? DBNull.Value); + cmd.Parameters.AddWithValue("$at", UtcNow()); + cmd.ExecuteNonQuery(); + } + } + + /// Last.fm이 알려 준 정식 곡 주소를 기억한다(커버 쪽 값은 건드리지 않는다). + public void SetLastFmUrl(string key, string url) + { + lock (_lock) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO song_links (key, lastfm_url, updated_at) VALUES ($k, $u, $at) + ON CONFLICT(key) DO UPDATE SET lastfm_url = $u, updated_at = $at; + """; + cmd.Parameters.AddWithValue("$k", key); + cmd.Parameters.AddWithValue("$u", url); + cmd.Parameters.AddWithValue("$at", UtcNow()); + cmd.ExecuteNonQuery(); + } + } + + /// 커버를 처음부터 다시 찾게 한다(관리자의 [커버 다시 찾기]). + public void ForgetCover(string key) + { + lock (_lock) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = + "UPDATE song_links SET cover_url = NULL, cover_source = NULL, cover_at = NULL WHERE key = $k;"; + cmd.Parameters.AddWithValue("$k", key); + cmd.ExecuteNonQuery(); + } + } + + // ---- 서버 설정값(환경변수로 넣을 수 없는 것 — 지금은 Last.fm 세션 키뿐) ---- + + public string? GetSetting(string name) + { + lock (_lock) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT value FROM app_settings WHERE name = $n;"; + cmd.Parameters.AddWithValue("$n", name); + return cmd.ExecuteScalar() as string; + } + } + + public void SetSetting(string name, string value) + { + lock (_lock) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO app_settings (name, value) VALUES ($n, $v) + ON CONFLICT(name) DO UPDATE SET value = $v; + """; + cmd.Parameters.AddWithValue("$n", name); + cmd.Parameters.AddWithValue("$v", value); + cmd.ExecuteNonQuery(); + } + } + + public void DeleteSetting(string name) + { + lock (_lock) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "DELETE FROM app_settings WHERE name = $n;"; + cmd.Parameters.AddWithValue("$n", name); + cmd.ExecuteNonQuery(); + } } // ---- 광고 제목 차단 ---- diff --git a/src/Musebase.Server/MeaningOptions.cs b/src/Musebase.Server/MeaningOptions.cs index e37f4e0..0771c2b 100644 --- a/src/Musebase.Server/MeaningOptions.cs +++ b/src/Musebase.Server/MeaningOptions.cs @@ -16,6 +16,7 @@ public sealed record MeaningOptions( string? OpenRouterModel, string? GeniusToken, string? LastFmKey, + string? LastFmSecret, string? MusixmatchKey, IReadOnlyList Sources, int BackfillLimit, @@ -32,7 +33,8 @@ public sealed record MeaningOptions( /// `MUSEBASE_MEANING_ENGINE`(gemini|openrouter|none, 기본 none), /// `MUSEBASE_MEANING_LANG`(기본 ko), `MUSEBASE_GEMINI_API_KEY` / `MUSEBASE_GEMINI_MODEL`, /// `MUSEBASE_OPENROUTER_API_KEY` / `MUSEBASE_OPENROUTER_MODEL`, - /// `MUSEBASE_GENIUS_TOKEN`, `MUSEBASE_LASTFM_KEY`, `MUSEBASE_MUSIXMATCH_KEY`, + /// `MUSEBASE_GENIUS_TOKEN`, `MUSEBASE_LASTFM_KEY`, `MUSEBASE_LASTFM_SECRET`, + /// `MUSEBASE_MUSIXMATCH_KEY`, /// `MUSEBASE_MEANING_SOURCES`(쉼표 구분, 기본 `genius,lastfm,wikipedia`), /// `MUSEBASE_MEANING_WIKIPEDIA`(0이면 끔 — 예전 변수, 아래 설명), /// `MUSEBASE_MEANING_BACKFILL_LIMIT`(기본 50), @@ -66,6 +68,7 @@ public static MeaningOptions FromEnvironment() OpenRouterModel: Env("MUSEBASE_OPENROUTER_MODEL"), GeniusToken: Env("MUSEBASE_GENIUS_TOKEN"), LastFmKey: Env("MUSEBASE_LASTFM_KEY"), + LastFmSecret: Env("MUSEBASE_LASTFM_SECRET"), MusixmatchKey: Env("MUSEBASE_MUSIXMATCH_KEY"), Sources: sources, BackfillLimit: limit, @@ -90,6 +93,12 @@ public static IReadOnlyList ParseSources(string? configured, string? leg /// Musixmatch 곡 페이지 주소를 찾아 주는 클라이언트(키가 없으면 꺼진 상태로 동작). public MusixmatchApi MusixmatchApi() => new(MusixmatchKey ?? ""); + /// + /// Last.fm 계정 API(좋아요). 의미 수집과 같은 API 키를 쓰지만 쓰기에는 shared secret이 더 필요하다 — + /// 키만 있으면 읽기(좋아요 여부)까지만 된다. + /// + public LastFmAccount LastFmAccount() => new(LastFmKey, LastFmSecret); + /// 화면에 보여 줄 소스 이름. public static string SourceLabel(string id) => id switch { diff --git a/src/Musebase.Server/deploy/README.md b/src/Musebase.Server/deploy/README.md index 76e18e7..f10c326 100644 --- a/src/Musebase.Server/deploy/README.md +++ b/src/Musebase.Server/deploy/README.md @@ -33,6 +33,8 @@ MUSEBASE_DB=/var/lib/musebase/lyrics.db # MUSEBASE_GEMINI_API_KEY=... # MUSEBASE_GENIUS_TOKEN=... # MUSEBASE_LASTFM_KEY=... +# --- Last.fm 좋아요(선택) — 12절 참고 --- +# MUSEBASE_LASTFM_SECRET=... # 있어야 좋아요를 켜고 끌 수 있다(읽기는 KEY만으로 된다) EOF sudo chmod 600 /etc/musebase/server.env # 토큰 파일은 절대 저장소에 커밋하지 않는다 ``` @@ -202,7 +204,7 @@ Spotify Connect처럼 **PC에서 재생하고 폰에서 조작**하면 두 기 |---|---|---| | `MUSEBASE_GEMINI_API_KEY` | | 요금은 아래 "무료로 쓰려면" 참고 | | `MUSEBASE_GENIUS_TOKEN` | → New API Client → **Generate Access Token** | 무료. OAuth 사용자 플로우 불필요 | -| `MUSEBASE_LASTFM_KEY` | | 선택. Genius에 설명이 없는 곡을 메워 준다 | +| `MUSEBASE_LASTFM_KEY` | | 선택. Genius에 설명이 없는 곡을 메워 준다. 같은 페이지의 Shared secret은 12절(좋아요)에서 쓴다 | | `MUSEBASE_MUSIXMATCH_KEY` | | 선택. **곡 페이지 링크를 정확히** 만드는 데 쓴다(아래) | Wikipedia는 키가 필요 없고 기본으로 켜져 있다(`MUSEBASE_MEANING_WIKIPEDIA=0`으로 끔). @@ -277,9 +279,51 @@ OpenRouter는 Google Cloud 프로젝트가 아예 필요 없어, 프로젝트 > 관리자 화면과 `/v1/meaning`의 `attribution`이 이를 담고 있으므로, 요약을 보여 주는 화면은 > 출처를 함께 표시해야 한다. +## 12. Last.fm 좋아요 · 커버 이미지 (선택) + +관리자 화면 전용이다. 앱에는 나가지 않으므로 이 절을 건너뛰어도 앱 동작은 그대로다. + +### 곡 상세의 외부 링크 + +곡 상세 머리말 아래에 **Last.fm · Tunefind · YouTube · Musixmatch · Genius** 링크가 있다. +Tunefind는 이 곡이 어느 드라마·영화에 쓰였는지 보러 가는 곳인데 **API는 쓰지 않는다** — +셀프서비스 가입 창구가 없고 라이선스 계약이 필요하며 무료 티어가 없다(문의처 `info@tunefind.com`). + +### 커버 이미지 + +키가 필요 없다. 곡 상세를 처음 열 때 iTunes Search API로 찾고(없으면 Deezer), +찾은 주소를 DB에 기억한다. **못 찾은 것도 기억하므로** 열 때마다 다시 부르지 않는다 — +곡명·아티스트를 고친 뒤에는 곡 상세 아래 **[커버 다시 찾기]** 를 누른다. + +Last.fm은 앨범 이미지를 주지만 쓰지 않는다. API 약관이 artwork를 계약 대상에서 **명시적으로 +제외**하기 때문이다(가져올 수 있다는 것과 써도 된다는 것은 다르다). + +### Last.fm 좋아요 + +곡 상세에서 좋아요를 켜고 끌 수 있다. 읽기(좋아요 여부)는 `MUSEBASE_LASTFM_KEY`만으로 되고, +**켜고 끄려면 shared secret과 계정 연결이 더 필요하다.** + +``` +MUSEBASE_LASTFM_KEY=... # API 계정 페이지의 API key +MUSEBASE_LASTFM_SECRET=... # 같은 페이지의 Shared secret — 이게 있어야 연결 버튼이 뜬다 +``` + + 에서 두 값을 함께 볼 수 있다. 넣고 재시작한 뒤: + +1. 대시보드 → **[Last.fm 계정 연결]** +2. last.fm 승인 페이지에서 허용 +3. 돌아오면 `연결됨: 아이디`가 뜬다 + +콜백 주소는 등록하지 않아도 된다 — 그때 접속한 주소를 그대로 넘긴다. 승인은 **브라우저에서** +일어나므로 테일넷 안 주소여도 문제없다. + +> **세션 키는 DB(`app_settings`)에 저장된다.** 백업 파일에 Last.fm 쓰기 자격증명이 함께 들어간다는 +> 뜻이다. 지우려면 대시보드의 **[Last.fm 연결 해제]**, 또는 last.fm 설정 > Applications에서 +> 권한 자체를 회수한다. + ## 업데이트 3~4단계를 반복하면 된다(`systemctl restart musebase-server`). DB는 `/var/lib/musebase`에 따로 있으므로 배포로 지워지지 않는다. 스키마는 `PRAGMA user_version`으로 자동 이행된다 -(현재 3 — `meanings` 테이블과 `musixmatch_url` 컬럼까지) -(현재 2 = `lyrics` + `lookups` + `meanings`). +(현재 7 = `lyrics` + `lookups` + `meanings` + `ad_titles` + `song_links` + `app_settings`). +컬럼·테이블 추가뿐이라 **구 버전 바이너리로 롤백해도 안전하다.** diff --git a/tests/Musebase.Core.Tests/AdminPageTests.cs b/tests/Musebase.Core.Tests/AdminPageTests.cs index a55f001..44bfc11 100644 --- a/tests/Musebase.Core.Tests/AdminPageTests.cs +++ b/tests/Musebase.Core.Tests/AdminPageTests.cs @@ -483,6 +483,114 @@ public class AdminPageTests Assert.Contains("의미 있음", html); } + // ---- 로그아웃 위치 ---- + + [Fact] + public void 로그아웃은_네비의_맨_끝에_따로_있다() + { + // 가운데 있으면 잘못 누른다 — `out` 클래스가 오른쪽 끝으로 미는 CSS와 짝이다. + var html = AdminPages.Dashboard(EmptyDashboard(), DateTimeOffset.UtcNow, Kst); + + var search = html.IndexOf("/admin/search\"", StringComparison.Ordinal); + var logout = html.IndexOf("/admin/logout", StringComparison.Ordinal); + + Assert.True(search > 0 && logout > search, "로그아웃이 가사 검색보다 뒤여야 한다"); + Assert.Contains("", html); + Assert.Contains("nav a.out{margin-left:auto", html); + } + + // ---- 곡 상세: 외부 링크 · 커버 · 좋아요 ---- + + [Fact] + public void 곡_상세에_외부_링크_다섯_개가_있다() + { + var html = SongPage(); + + foreach (var name in new[] { "Last.fm", "Tunefind", "YouTube", "Musixmatch", "Genius" }) + Assert.Contains($">{name}", html); + + // 새 창으로 나가되 원본 탭을 넘겨주지 않는다. + Assert.Contains("rel=\"noopener noreferrer\"", html); + } + + [Fact] + public void 커버가_없으면_이미지를_그리지_않는다() + { + // 깨진 이미지 아이콘이 빈자리보다 나쁘다. + Assert.DoesNotContain(" + /// 조회에 실패한 것과 "좋아요 안 함"은 다르다. 꺼진 하트로 그리면 이미 켜 둔 곡을 끄게 된다. + /// + [Fact] + public void 상태를_모르면_좋아요_안_함으로_그리지_않는다() + { + var html = SongPage(love: new LoveState(Connected: true, Known: false, Loved: false)); + + Assert.Contains("확인하지 못했습니다", html); + Assert.DoesNotContain("♥ 좋아요 해제", html); + } + + [Fact] + public void 정식_주소를_알면_LastFm_링크가_그것으로_간다() + { + var html = SongPage(links: new SongLinks("k", LastFmUrl: "https://www.last.fm/music/MGMT/_/Kids+")); + + Assert.Contains("https://www.last.fm/music/MGMT/_/Kids+", html); + } + + // ---- 대시보드: Last.fm 연결 ---- + + [Fact] + public void 연결할_수_없는_구성이면_LastFm_카드를_숨긴다() + { + // 눌러도 안 되는 것을 보여 주면 사람을 헷갈리게 한다(secret이 없는 상태). + Assert.DoesNotContain("/admin/lastfm/connect", + AdminPages.Dashboard(EmptyDashboard(), DateTimeOffset.UtcNow, Kst)); + } + + [Fact] + public void 연결_전에는_연결_링크를_연결_후에는_아이디를_보여_준다() + { + var before = AdminPages.Dashboard( + EmptyDashboard() with { LastFm = new LastFmLink(null) }, DateTimeOffset.UtcNow, Kst); + Assert.Contains("", before); + + var after = AdminPages.Dashboard( + EmptyDashboard() with { LastFm = new LastFmLink("jay") }, DateTimeOffset.UtcNow, Kst); + Assert.Contains("연결됨: jay", after); + Assert.Contains("/admin/lastfm/disconnect", after); + } + + private static string SongPage(SongLinks? links = null, LoveState? love = null) => + AdminPages.SongPage( + new LyricsEntry { Key = "kids|mgmt", Title = "Kids", Artist = "MGMT", Lrc = "[00:01.00]hello" }, + [], [], null, showTags: false, "csrf-token", Kst, + links: links, love: love); + 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); diff --git a/tests/Musebase.Core.Tests/CoverArtTests.cs b/tests/Musebase.Core.Tests/CoverArtTests.cs new file mode 100644 index 0000000..d7b61bf --- /dev/null +++ b/tests/Musebase.Core.Tests/CoverArtTests.cs @@ -0,0 +1,95 @@ +using System.Net; +using System.Text; +using Musebase.Server; +using Xunit; + +namespace Musebase.Core.Tests; + +/// +/// 커버 이미지 찾기. 검색 API는 무엇을 넣든 뭔가를 돌려주므로 관련성 검사가 핵심이다 — +/// 엉뚱한 표지가 붙으면 곡을 잘못 알아본다. +/// +public class CoverArtTests +{ + private const string ITunesHit = """ + {"resultCount":1,"results":[{"trackName":"Kids","artistName":"MGMT", + "artworkUrl100":"https://is1-ssl.mzstatic.com/image/thumb/a/b.jpg/100x100bb.jpg"}]} + """; + + [Fact] + public async Task iTunes_아트워크를_600으로_승격한다() + { + var cover = await Create(_ => Json(ITunesHit)).FindAsync("Kids", "MGMT"); + + Assert.NotNull(cover); + Assert.Equal(CoverArt.ITunes, cover!.Source); + Assert.EndsWith("/600x600bb.jpg", cover.Url); + } + + [Fact] + public void 예상과_다른_주소는_건드리지_않는다() + { + // 억지로 크기를 바꾸면 404가 된다 — 모르는 형태는 그대로 쓴다. + var url = "https://is1-ssl.mzstatic.com/image/thumb/a/b.jpg/60x60bb.png"; + Assert.Equal(url, CoverArt.Promote(url)); + } + + /// Genius·Musixmatch에서 이미 겪은 함정 — 첫 결과를 그냥 믿으면 안 된다. + [Fact] + public async Task 무관한_곡의_표지는_쓰지_않는다() + { + var unrelated = """ + {"results":[{"trackName":"119 REMIX","artistName":"Someone Else", + "artworkUrl100":"https://is1-ssl.mzstatic.com/x/100x100bb.jpg"}]} + """; + + var cover = await Create(req => req.RequestUri!.Host.Contains("itunes") + ? Json(unrelated) + : Json("""{"data":[]}""")).FindAsync("Kids", "MGMT"); + + Assert.Null(cover); + } + + [Fact] + public async Task iTunes가_비면_Deezer로_간다() + { + var deezer = """ + {"data":[{"title":"Kids","artist":{"name":"MGMT"}, + "album":{"cover_big":"https://cdn-images.dzcdn.net/images/cover/x/500x500.jpg"}}]} + """; + + var cover = await Create(req => req.RequestUri!.Host.Contains("itunes") + ? Json("""{"resultCount":0,"results":[]}""") + : Json(deezer)).FindAsync("Kids", "MGMT"); + + Assert.Equal(CoverArt.Deezer, cover!.Source); + Assert.StartsWith("https://cdn-images.dzcdn.net/", cover.Url); + } + + [Fact] + public async Task 둘_다_없으면_null이고_예외는_없다() + { + var cover = await Create(_ => throw new HttpRequestException("down")).FindAsync("Kids", "MGMT"); + Assert.Null(cover); + } + + [Fact] + public async Task 응답이_깨져도_null() + { + Assert.Null(await Create(_ => Json("{ this is not json")).FindAsync("Kids", "MGMT")); + } + + private static CoverArt Create(Func responder) => + new(new HttpClient(new StubHandler(responder)), timeoutMs: 500); + + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => + Task.FromResult(responder(request)); + } +} diff --git a/tests/Musebase.Core.Tests/LastFmAccountTests.cs b/tests/Musebase.Core.Tests/LastFmAccountTests.cs new file mode 100644 index 0000000..6beca98 --- /dev/null +++ b/tests/Musebase.Core.Tests/LastFmAccountTests.cs @@ -0,0 +1,179 @@ +using System.Net; +using System.Text; +using Musebase.Server; +using Xunit; + +namespace Musebase.Core.Tests; + +/// +/// Last.fm **계정** API(좋아요 읽기·쓰기). 두 가지가 계속 문제를 일으키는 지점이다 — +/// ① 오류가 HTTP 200에 담겨 온다 ② 쓰기는 서명이 틀리면 조용히 거절된다. +/// +public class LastFmAccountTests +{ + /// + /// 문서의 예시를 그대로 고정한다: 이름순으로 <이름><값>을 이어붙이고 secret을 붙여 MD5. + /// 순서를 넣은 순서로 착각하면 서명이 통과하다가 파라미터를 하나 더한 날 갑자기 깨진다. + /// + [Fact] + public void 서명은_이름순으로_이어붙여_해시한다() + { + var parameters = new Dictionary + { + ["token"] = "xxx", // 일부러 이름순과 다른 순서로 넣는다 + ["method"] = "auth.getSession", + ["api_key"] = "abc", + }; + + var expected = Convert.ToHexString(System.Security.Cryptography.MD5.HashData( + Encoding.UTF8.GetBytes("api_keyabcmethodauth.getSessiontokenxxxsecret"))).ToLowerInvariant(); + + Assert.Equal(expected, LastFmAccount.Signature(parameters, "secret")); + } + + [Fact] + public async Task 좋아요_여부와_정식_주소를_읽는다() + { + var account = Create(_ => Json(""" + {"track":{"name":"Kids","url":"https://www.last.fm/music/MGMT/_/Kids","userloved":"1"}} + """)); + + var state = await account.GetStateAsync("Kids", "MGMT", "jay"); + + Assert.NotNull(state); + Assert.True(state!.Loved); + Assert.Equal("https://www.last.fm/music/MGMT/_/Kids", state.Url); + } + + [Fact] + public async Task 좋아요하지_않은_곡은_false다() + { + var account = Create(_ => Json("""{"track":{"name":"Kids","userloved":"0"}}""")); + var state = await account.GetStateAsync("Kids", "MGMT", "jay"); + Assert.False(state!.Loved); + } + + /// + /// Last.fm은 오류도 200에 담아 보낸다. 이것을 못 걸러 내면 "좋아요 안 함"으로 그려지고, + /// 사람이 눌러서 이미 켜 둔 좋아요를 끄게 된다. + /// + [Fact] + public async Task 오류가_200에_담겨_와도_실패로_본다() + { + var account = Create(_ => Json("""{"error":6,"message":"Track not found"}""")); + Assert.Null(await account.GetStateAsync("Kids", "MGMT", "jay")); + } + + [Fact] + public async Task 서버가_죽어도_예외_대신_null() + { + var account = Create(_ => throw new HttpRequestException("down")); + Assert.Null(await account.GetStateAsync("Kids", "MGMT", "jay")); + } + + [Fact] + public async Task 아이디를_모르면_아예_묻지_않는다() + { + var calls = 0; + var account = Create(_ => { calls++; return Json("{}"); }); + + Assert.Null(await account.GetStateAsync("Kids", "MGMT", "")); + Assert.Equal(0, calls); + } + + // ---- 쓰기 ---- + + [Fact] + public async Task 좋아요는_POST로_서명과_세션_키를_함께_보낸다() + { + string? body = null; + HttpMethod? method = null; + var account = Create(req => + { + method = req.Method; + body = req.Content?.ReadAsStringAsync().Result; + return Json("{}"); + }); + + Assert.True(await account.SetLovedAsync("Kids", "MGMT", loved: true, "session-key")); + Assert.Equal(HttpMethod.Post, method); + Assert.Contains("method=track.love", body); + Assert.Contains("sk=session-key", body); + Assert.Contains("api_sig=", body); + } + + [Fact] + public async Task 좋아요_해제는_track_unlove다() + { + string? body = null; + var account = Create(req => { body = req.Content?.ReadAsStringAsync().Result; return Json("{}"); }); + + await account.SetLovedAsync("Kids", "MGMT", loved: false, "session-key"); + Assert.Contains("method=track.unlove", body); + } + + [Fact] + public async Task 세션_키가_없으면_아무_요청도_보내지_않는다() + { + var calls = 0; + var account = Create(_ => { calls++; return Json("{}"); }); + + Assert.False(await account.SetLovedAsync("Kids", "MGMT", true, "")); + Assert.Equal(0, calls); + } + + [Fact] + public async Task secret이_없으면_연결도_쓰기도_못_한다() + { + var readOnly = new LastFmAccount("key", null); + + Assert.True(readOnly.CanRead); + Assert.False(readOnly.CanConnect); + Assert.False(await readOnly.SetLovedAsync("Kids", "MGMT", true, "sk")); + } + + // ---- 승인 플로우 ---- + + [Fact] + public void 승인_주소에_돌아올_곳을_실어_보낸다() + { + // cb를 넘길 수 있어 API 계정에 콜백을 미리 등록하지 않아도 된다. + var url = new LastFmAccount("api-key", "secret").AuthorizeUrl("https://box.ts.net/admin/lastfm/callback"); + + Assert.StartsWith("https://www.last.fm/api/auth/?api_key=api-key&cb=", url); + Assert.Contains("https%3A%2F%2Fbox.ts.net%2Fadmin%2Flastfm%2Fcallback", url); + } + + [Fact] + public async Task 토큰을_세션_키와_아이디로_바꾼다() + { + var account = Create(_ => Json("""{"session":{"name":"jay","key":"sk-123","subscriber":0}}""")); + + var session = await account.ExchangeTokenAsync("token"); + + Assert.Equal("sk-123", session!.Value.Session); + Assert.Equal("jay", session.Value.User); + } + + [Fact] + public async Task 이미_쓴_토큰은_실패로_돌아온다() + { + // 토큰은 1회용이라 두 번째부터 error 14가 온다 — HTTP는 여전히 200이다. + var account = Create(_ => Json("""{"error":14,"message":"This token has not been authorized"}""")); + Assert.Null(await account.ExchangeTokenAsync("token")); + } + + private static LastFmAccount Create(Func responder) => + new("api-key", "secret", new HttpClient(new StubHandler(responder)), timeoutMs: 500); + + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => + Task.FromResult(responder(request)); + } +} diff --git a/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs b/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs index bcd8bf0..76ca221 100644 --- a/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs +++ b/tests/Musebase.Core.Tests/LyricsStoreMergeTests.cs @@ -395,6 +395,84 @@ private static LyricsEntry Entry(string title, string artist, string lrc, string Assert.Null(misses.Single(m => m.Title == "Go!").Key); // 정말 없는 곡은 그대로 null } + // ---- 커버·설정값 ---- + + /// + /// "아직 안 찾아봤다"와 "찾아봤지만 없었다"는 다르다. 못 찾은 것을 기억하지 않으면 + /// 곡 상세를 열 때마다 외부 API를 다시 부른다. + /// + [Fact] + public void 커버를_못_찾은_것도_기억한다() + { + using var store = NewStore(); + store.Upsert(Entry("Kids", "MGMT", Plain), "안드로이드", out _); + + Assert.False(store.GetSongLinks("kids|mgmt").CoverTried); + + store.SetCover("kids|mgmt", null, null); + + var links = store.GetSongLinks("kids|mgmt"); + Assert.True(links.CoverTried); + Assert.Null(links.CoverUrl); + } + + [Fact] + public void 커버_다시_찾기는_없었다는_기억을_지운다() + { + using var store = NewStore(); + store.SetCover("kids|mgmt", null, null); + store.ForgetCover("kids|mgmt"); + + Assert.False(store.GetSongLinks("kids|mgmt").CoverTried); + } + + /// + /// 커버 찾기와 Last.fm 조회는 서로 다른 때에 일어난다 — 한쪽이 다른 쪽 값을 지우면 안 된다. + /// + [Fact] + public void LastFm_주소와_커버는_서로를_지우지_않는다() + { + using var store = NewStore(); + store.SetLastFmUrl("kids|mgmt", "https://www.last.fm/music/MGMT/_/Kids"); + store.SetCover("kids|mgmt", "https://is1-ssl.mzstatic.com/x/600x600bb.jpg", CoverArt.ITunes); + + var links = store.GetSongLinks("kids|mgmt"); + Assert.Equal("https://www.last.fm/music/MGMT/_/Kids", links.LastFmUrl); + Assert.Equal(CoverArt.ITunes, links.CoverSource); + + // 순서를 뒤집어도 같아야 한다. + store.SetLastFmUrl("kids|mgmt", "https://www.last.fm/music/MGMT/_/Kids2"); + Assert.NotNull(store.GetSongLinks("kids|mgmt").CoverUrl); + } + + [Fact] + public void 설정값은_넣은_대로_돌아오고_지워진다() + { + using var store = NewStore(); + Assert.Null(store.GetSetting(LastFmAccount.SessionSetting)); + + store.SetSetting(LastFmAccount.SessionSetting, "sk-123"); + store.SetSetting(LastFmAccount.SessionSetting, "sk-456"); // 덮어쓰기 + Assert.Equal("sk-456", store.GetSetting(LastFmAccount.SessionSetting)); + + store.DeleteSetting(LastFmAccount.SessionSetting); + Assert.Null(store.GetSetting(LastFmAccount.SessionSetting)); + } + + /// + /// 이미 배포된 DB(user_version=6)를 열면 v7 블록이 다시 돌아야 하고, 그때 던지지 않아야 한다 — + /// 마이그레이션이 실패하면 서버가 아예 못 뜬다. + /// + [Fact] + public void 예전_스키마를_열어도_마이그레이션이_다시_돈다() + { + using (var old = NewStore()) old.SetUserVersionForTest(6); + + using var store = NewStore(); + store.SetCover("kids|mgmt", null, null); + Assert.True(store.GetSongLinks("kids|mgmt").CoverTried); + } + public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); diff --git a/tests/Musebase.Core.Tests/MeaningLinksTests.cs b/tests/Musebase.Core.Tests/MeaningLinksTests.cs index 105296f..5cbdfe1 100644 --- a/tests/Musebase.Core.Tests/MeaningLinksTests.cs +++ b/tests/Musebase.Core.Tests/MeaningLinksTests.cs @@ -62,4 +62,54 @@ public class MeaningLinksTests Assert.StartsWith("https://genius.com/search?q=", MeaningLinks.Genius("Kids", "MGMT", null)); Assert.StartsWith("https://genius.com/search?q=", MeaningLinks.Genius("Kids", "MGMT", " ")); } + + /// + /// 검색으로 흔히 나오는 /search/site?q=는 실측에서 404다 — 그쪽으로 보내면 + /// 사람이 "Tunefind가 죽었나" 하고 만다. + /// + [Fact] + public void Tunefind는_경로형이_아니라_query다() + { + var url = MeaningLinks.Tunefind("Kids", "MGMT"); + + Assert.StartsWith("https://www.tunefind.com/search?q=", url); + Assert.DoesNotContain("/search/site", url); + Assert.Contains("MGMT%20Kids", url); + } + + [Fact] + public void YouTube는_아티스트와_제목으로_검색한다() + { + var url = MeaningLinks.YouTube("Kids", "MGMT"); + + Assert.StartsWith("https://www.youtube.com/results?search_query=", url); + Assert.Contains("MGMT%20Kids", url); + } + + [Fact] + public void LastFm은_확인한_주소를_우선한다() + { + var known = "https://www.last.fm/music/MGMT/_/Kids"; + Assert.Equal(known, MeaningLinks.LastFm("Kids", "MGMT", known)); + } + + /// + /// Musixmatch와 달리 규칙 생성이 허용된다 — 이름이 안 맞으면 다른 곡으로 넘어가지 않고 + /// "그런 곡 없음"이 뜬다. 대신 조각마다 이스케이프해서 슬래시가 경로를 늘리지 않게 한다. + /// + [Fact] + public void LastFm_주소를_모르면_이름으로_만든다() + { + Assert.Equal("https://www.last.fm/music/MGMT/_/Kids", MeaningLinks.LastFm("Kids", "MGMT", null)); + + var slashed = MeaningLinks.LastFm("Shallow", "Lady Gaga/Bradley Cooper", null); + Assert.Equal("https://www.last.fm/music/Lady%20Gaga%2FBradley%20Cooper/_/Shallow", slashed); + } + + [Fact] + public void 아티스트를_모르면_LastFm_검색으로_보낸다() + { + // 이름 없이 /music//_/Kids를 만들면 404가 아니라 엉뚱한 페이지가 된다. + Assert.StartsWith("https://www.last.fm/search?q=", MeaningLinks.LastFm("Kids", "", null)); + } } diff --git a/tests/Musebase.Core.Tests/MeaningOptionsTests.cs b/tests/Musebase.Core.Tests/MeaningOptionsTests.cs index 453e558..7158372 100644 --- a/tests/Musebase.Core.Tests/MeaningOptionsTests.cs +++ b/tests/Musebase.Core.Tests/MeaningOptionsTests.cs @@ -67,6 +67,6 @@ public class MeaningOptionsTests private static readonly MeaningOptions Empty = new( Engine: "none", Lang: "ko", GeminiApiKey: null, GeminiModel: null, OpenRouterApiKey: null, OpenRouterModel: null, - GeniusToken: null, LastFmKey: null, MusixmatchKey: null, + GeniusToken: null, LastFmKey: null, LastFmSecret: null, MusixmatchKey: null, Sources: [], BackfillLimit: 50, BackfillDelayMs: 0); }