Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions docs/adr/0007-song-meaning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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은 번역만 하므로 그대로 넣으면 "의미"가 아니라 긴 영어
Expand Down
158 changes: 155 additions & 3 deletions src/Musebase.Server/Admin/AdminEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public static AdminOptions FromEnvironment(string apiToken)
public static class AdminEndpoints
{
private const string CookieName = "musebase_admin";

/// <summary>Last.fm 승인 플로우의 1회용 논스. 관리자 쿠키와 달리 <b>SameSite=Lax</b>여야 한다.</summary>
private const string StateCookie = "musebase_lastfm_state";

private static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(30);

/// <summary>303 See Other — 이 프레임워크에 기본 헬퍼가 없어 직접 만든다.</summary>
Expand All @@ -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) =>
Expand All @@ -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) =>
Expand Down Expand Up @@ -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");
Expand All @@ -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) =>
Expand Down Expand Up @@ -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)}&notice={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)}&notice={Uri.EscapeDataString(notice)}");
});

async Task<string> 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<CoverImage?> FindCoverAsync(LyricsEntry entry)
{
var found = await covers.FindAsync(entry.Title, entry.Artist);
store.SetCover(entry.Key ?? "", found?.Url, found?.Source);
return found;
}

async Task<SongLinks> 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<LoveState> 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);
}

// ---- 곡의 의미 ----
// 생성은 **사람이 누를 때만** 일어난다. 자동 생성을 두지 않는 이유는 쿼타·비용이
// 예측 가능해야 하고, 실패가 조용히 쌓이면 안 되기 때문이다.
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading