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(
각 기기의 로컬 캐시에 없는 곡만 서버로 옵니다 —
같은 곡을 반복 재생해도 조회 수는 늘지 않습니다(로컬 캐시 → 서버 → 제공자 검색 순).
+ {LoveForm(key, csrf, love ?? LoveState.NotConnected)}
+
+
{MeaningCard(entry, meaning, csrf, meaningEnabled, meaningSources ?? [])}
{body}
@@ -341,11 +357,97 @@ public static string SongPage(
이 제목을 차단합니다 — 가사를 지우고, 앞으로 어느 기기가
올려도 등록하지 않으며 검색도 하지 않습니다. 되돌리기는 대시보드에서.
+
+
""", "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 """
+