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
17 changes: 17 additions & 0 deletions contracts/lyrics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,25 @@ Authorization: Bearer <서버가 발급한 임의 문자열>
| GET | `/v1/stats` | 곡 수·최근 갱신(검증·디버깅용) |
| GET | `/v1/meaning?title=&artist=` | 곡의 의미 1건. 히트 `200 MeaningEntry`, 없으면 `404` |

광고로 표시된 제목은 조회·등록이 모두 막힌다(아래 "광고 차단").

요청 본문 상한은 256KB, `Content-Type: application/json`이 아니면 `415`.

### 광고 차단

관리자가 어떤 제목을 **광고로 표시**하면 그 제목은 이후 조회·등록에서 막힌다. Spotify가 광고
구간에 광고 플래그를 주지 않는 경로가 있어(실측) `광고 없이 음악을 감상하세요.` 같은 행이
가사로 올라오기 때문이다.

- `GET /v1/lyrics` → `404`, 본문 `{"error":"ad","ad":true}`.
클라이언트는 **제공자 검색을 하지 말아야 한다** — 광고는 곡이 아니라 검색이 늘 헛돌고,
어쩌다 뭔가 맞으면 광고 위에 엉뚱한 가사가 뜬다.
- `PUT /v1/lyrics` → `202`, 본문 `{"error":"ad"}`. 저장하지 않는다.
**차단의 실효는 이쪽에 있다** — 힌트를 모르는 구버전 클라이언트도 여기서 막힌다.

판정은 **제목만** 본다(공백·문장부호·대소문자 무시). 같은 광고가 아티스트를 여러 이름으로
달고 오기 때문이다.

### 클라이언트의 PUT 시점

번역 공유가 이 서버의 핵심 목적이므로, 클라이언트는 **가사를 새로 찾았을 때만이 아니라 번역이
Expand Down
10 changes: 9 additions & 1 deletion src/Musebase.Core/Search/HttpRemoteLyricsCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ private async Task<RemoteLyricsResult> ReadMissAsync(HttpResponseMessage respons
try
{
var body = await response.Content.ReadFromJsonAsync<MissBody>(Json, ct).ConfigureAwait(false);
if (body is { Ad: true })
{
_log?.Invoke("[server] 광고로 표시된 제목 — 가사를 찾지 않습니다");
return RemoteLyricsResult.Ad;
}
if (body is not { Pending: true }) return RemoteLyricsResult.Miss;
_log?.Invoke($"[server] 다른 기기도 이 곡을 찾는 중 — 번역을 양보합니다({body.RetryAfterMs}ms)");
return new RemoteLyricsResult(null, true, body.RetryAfterMs, []);
Expand Down Expand Up @@ -262,9 +267,12 @@ private sealed record RemoteAttribution
public string? Url { get; init; }
}

/// <summary>404 본문(계약 v1의 "번역 양보"). 구버전 서버는 본문이 없다.</summary>
/// <summary>404 본문(계약 v1의 "번역 양보"·"광고 차단"). 구버전 서버는 본문이 없다.</summary>
private sealed record MissBody
{
/// <summary>이 제목은 광고로 표시돼 있다 — 제공자 검색도 업로드도 하지 않는다.</summary>
public bool Ad { get; init; }

public string? Error { get; init; }
public bool Pending { get; init; }
public int RetryAfterMs { get; init; }
Expand Down
9 changes: 8 additions & 1 deletion src/Musebase.Core/Search/IRemoteLyricsCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ namespace Musebase.Core.Search;
/// <param name="Pending">최근에 다른 기기도 이 곡을 물었다 — 번역을 잠시 양보할 만하다.</param>
/// <param name="RetryAfterMs">서버가 제안하는 재조회 간격(0이면 제안 없음). 호출자가 clamp한다.</param>
/// <param name="Langs">받은 가사에 들어 있는 번역 언어들(소문자). 히트가 아니면 빈 배열.</param>
/// <param name="IsAd">
/// 서버가 이 제목을 <b>광고로 표시</b>해 뒀다. 제공자 검색을 하지 말아야 한다 — 광고는 곡이
/// 아니라서 검색이 늘 헛돌고, 어쩌다 뭔가 맞으면 엉뚱한 가사가 광고 위에 뜬다.
/// </summary>
public readonly record struct RemoteLyricsResult(
Lyrics? Lyrics, bool Pending, int RetryAfterMs, IReadOnlyList<string> Langs)
Lyrics? Lyrics, bool Pending, int RetryAfterMs, IReadOnlyList<string> Langs, bool IsAd = false)
{
/// <summary>미스·실패·미접속 — 아무 힌트도 없다.</summary>
public static readonly RemoteLyricsResult Miss = new(null, false, 0, []);

/// <summary>서버가 광고로 표시한 제목 — 검색하지 않는다.</summary>
public static readonly RemoteLyricsResult Ad = new(null, false, 0, [], IsAd: true);

/// <summary>대상 언어 번역이 들어 있는가(대소문자 무시).</summary>
public bool HasLanguage(string lang) =>
Langs.Contains(lang, StringComparer.OrdinalIgnoreCase);
Expand Down
10 changes: 10 additions & 0 deletions src/Musebase.Engine/LyricsCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ private async void OnTrackChanged(TrackInfo? track)
await TranslateAsync(remote, remoteCts.Token, persistAfter: true);
return;
}

// 서버가 이 제목을 광고로 표시해 뒀다면 여기서 끝낸다. 제공자 검색은 늘 헛돌고,
// 어쩌다 뭔가 맞으면 광고 위에 엉뚱한 가사가 뜬다. 자동 판정(AdSignals)이 놓친
// 경로 — 재생 메타데이터에 광고 플래그가 없는 경우 — 를 사람이 표시해 메운다.
if (result.IsAd)
{
Log?.Invoke($"[ad] 서버가 광고로 표시한 제목 — 검색하지 않습니다: {track.Title}");
RaiseStatus(new LyricsStatus(LyricsStatusKind.NotFound, track.ToString()));
return;
}
}

RaiseStatus(new LyricsStatus(LyricsStatusKind.Searching, track.ToString()));
Expand Down
37 changes: 36 additions & 1 deletion src/Musebase.Server/Admin/AdminEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ void SetCookie(HttpResponse res)
"misses" => model.TopMisses.Count,
"untranslated" => model.WithoutTranslation.Count,
"duplicates" => model.DuplicateCandidates.Count,
"ads" => model.AdTitles?.Count ?? 0,
_ => model.CleanedMatches.Count,
};
var note = count < FullRows ? null : $"최대 {FullRows}건까지 보여 줍니다";
Expand Down Expand Up @@ -271,6 +272,39 @@ void SetCookie(HttpResponse res)
return SeeOther("/admin/search");
});

// ---- 광고 차단 ----
// 자동 판정(AdSignals)은 재생 메타데이터에 기대는데, Spotify가 광고 플래그를 안 주는
// 경로에서는 "광고 없이 음악을 감상하세요." 같은 행이 가사로 올라온다. 사람이 표시하면
// 그 제목은 이후 조회·등록에서 막힌다.

app.MapPost("/admin/song/ad", 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.AddAdTitle(entry.Title, entry.Artist);
var notice = $"\"{entry.Title}\"을(를) 광고로 표시했습니다 — 가사를 지웠고 앞으로 등록되지 않습니다.";
return SeeOther($"/admin?notice={Uri.EscapeDataString(notice)}");
});

app.MapPost("/admin/ads/remove", 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 titleKey = form["titleKey"].ToString();
if (!string.IsNullOrWhiteSpace(titleKey)) store.RemoveAdTitle(titleKey);
return SeeOther($"/admin?notice={Uri.EscapeDataString("광고 표시를 해제했습니다.")}");
});

// ---- 곡의 의미 ----
// 생성은 **사람이 누를 때만** 일어난다. 자동 생성을 두지 않는 이유는 쿼타·비용이
// 예측 가능해야 하고, 실패가 조용히 쌓이면 안 되기 때문이다.
Expand Down Expand Up @@ -374,7 +408,8 @@ DashboardModel BuildDashboard(HttpRequest req, DateTimeOffset now, int rows)
Diagnostics: Diagnostics(req, options),
Meanings: MeaningSummaryOf(),
MeaningSources: meanings.SourceNames,
Csrf: AdminAuth.Csrf(options.Token, Cookie(req) ?? ""));
Csrf: AdminAuth.Csrf(options.Token, Cookie(req) ?? ""),
AdTitles: store.AdTitles(rows));
}

MeaningSummary MeaningSummaryOf()
Expand Down
7 changes: 6 additions & 1 deletion src/Musebase.Server/Admin/AdminModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ public sealed record LookupRow(string At, string Title, string Artist, string Re
public sealed record MissRow(
string Title, string Artist, int Count, string LastAt, int Devices, string? Key = null);

/// <summary>광고로 표시해 차단한 제목 1건.</summary>
public sealed record AdTitleRow(string TitleKey, string Title, string Artist, string AddedAt);

/// <summary>기기별 활동.</summary>
public sealed record DeviceRow(string Device, int Lookups, int Hits, string LastAt);

Expand Down Expand Up @@ -53,7 +56,9 @@ public sealed record DashboardModel(
MeaningSummary Meanings,
/// <summary>지금 켜져 있는 의미 자료원 이름 — 무엇에 근거해 만들어지는지 화면에 드러낸다.</summary>
IReadOnlyList<string> MeaningSources,
string Csrf);
string Csrf,
/// <summary>광고로 표시해 차단한 제목들(되돌릴 수 있어야 하므로 화면에 보여 준다).</summary>
IReadOnlyList<AdTitleRow>? AdTitles = null);

/// <summary>대시보드의 "곡의 의미" 타일 — 만든 것 / 자료 없음 / 자료 부족 / 실패 + 아직 안 해 본 곡 수.</summary>
public sealed record MeaningSummary(
Expand Down
31 changes: 31 additions & 0 deletions src/Musebase.Server/Admin/AdminPages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ public static string Dashboard(
var uploads = Table(SongHeaders, m.RecentUploads.Select(SongRowHtml(tz)),
"아직 올라온 가사가 없습니다.");

var ads = AdTable(m.AdTitles ?? [], m.Csrf, tz);

var noTranslation = Table(SongHeaders, m.WithoutTranslation.Select(SongRowHtml(tz)),
"모든 곡에 번역이 있습니다.");

Expand Down Expand Up @@ -157,6 +159,7 @@ 같은 곡을 반복 재생해도 조회 수는 늘지 않습니다(로컬 캐
<h2>번역 없는 곡 — 일괄 사전번역 대상{More("/admin/list?view=untranslated")}</h2>{noTranslation}
<h2>기기별 (7일)</h2>{devices}
<h2>일별 (7일)</h2>{daily}
<h2>광고로 차단한 제목{More("/admin/list?view=ads")}</h2>{ads}
<h2>표기 차이로 갈린 곡 후보 (같은 느슨한 키){More("/admin/list?view=duplicates")}</h2>{duplicates}
<h2>느슨한 키로 맞은 조회 (7일){More("/admin/list?view=cleaned")}</h2>{cleaned}

Expand Down Expand Up @@ -232,6 +235,7 @@ public static string ListPage(string heading, string tableHtml, int count, strin
["misses"] = "미스 상위 (7일)",
["untranslated"] = "번역 없는 곡",
["duplicates"] = "표기 차이로 갈린 곡 후보",
["ads"] = "광고로 차단한 제목",
["cleaned"] = "느슨한 키로 맞은 조회 (7일)",
};

Expand Down Expand Up @@ -260,6 +264,8 @@ public static string ListTable(
"duplicates" => Table(SongHeaders, m.DuplicateCandidates.Select(SongRowHtml(tz)),
"표기 차이로 갈린 곡이 없습니다."),

"ads" => AdTable(m.AdTitles ?? [], m.Csrf, tz),

_ => Table(
["시각", "요청한 곡", "요청한 아티스트", "맞은 곡", "기기"],
m.CleanedMatches.Select(r => $"""
Expand Down Expand Up @@ -327,6 +333,14 @@ 이 가사를 덮어쓰지 못합니다. 형식은 확장 LRC 그대로 유지
<button class="danger" type="submit">이 곡 삭제</button>
<span class="meta">삭제하면 다음에 어느 기기든 재생할 때 다시 검색해 새로 채웁니다.</span>
</form>

<form method="post" action="/admin/song/ad" style="margin-top:.6rem">
<input type="hidden" name="key" value="{Esc(key)}">
<input type="hidden" name="csrf" value="{Esc(csrf)}">
<button class="danger" type="submit">광고로 표시</button>
<span class="meta">이 <b>제목</b>을 차단합니다 — 가사를 지우고, 앞으로 어느 기기가
올려도 등록하지 않으며 검색도 하지 않습니다. 되돌리기는 대시보드에서.</span>
</form>
""", "search");
}

Expand Down Expand Up @@ -435,6 +449,23 @@ private static Func<MissRow, string> MissRowHtml(TimeZoneInfo tz) => r =>
""";
};

/// <summary>
/// 차단한 광고 제목 표. 되돌리는 버튼을 같이 둔다 — 잘못 눌러 진짜 곡을 막았을 때
/// 서버에 들어가지 않고도 풀 수 있어야 한다.
/// </summary>
private static string AdTable(IReadOnlyList<AdTitleRow> rows, string csrf, TimeZoneInfo tz) => Table(
["차단한 제목", "표시 당시 아티스트", "표시 시각", ""],
rows.Select(r => $"""
<tr><td>{Esc(r.Title)}</td><td>{Esc(r.Artist)}</td>
<td class="nowrap">{Esc(AdminTime.ToLocal(r.AddedAt, tz))}</td>
<td><form method="post" action="/admin/ads/remove" class="inline" style="margin:0">
<input type="hidden" name="titleKey" value="{Esc(r.TitleKey)}">
<input type="hidden" name="csrf" value="{Esc(csrf)}">
<button type="submit">해제</button>
</form></td></tr>
"""),
"차단한 광고가 없습니다 — 광고가 가사로 올라오면 곡 상세에서 [광고로 표시]를 누르세요.");

/// <summary>대시보드의 각 목록이 보여 주는 행 수 — 나머지는 "전체 보기"로 넘긴다.</summary>
public const int DashboardRows = 10;

Expand Down
8 changes: 7 additions & 1 deletion src/Musebase.Server/ApiModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,13 @@ public sealed record ApiError([property: JsonPropertyName("error")] string Error
/// 받는 쪽은 제공자 검색은 그대로 하되 번역을 잠시 미루고 서버를 다시 조회하면 중복 번역을 피한다
/// (`contracts/lyrics-api.md`의 "번역 양보"). 모르는 필드는 무시하면 되므로 하위 호환이다.
/// </summary>
/// <param name="Ad">
/// 이 제목은 <b>광고로 표시</b>돼 있다. 클라이언트는 제공자 검색도 업로드도 하지 말아야 한다 —
/// 광고는 곡이 아니라서 검색이 늘 헛돌고 서버에는 쓰레기 행이 쌓인다.
/// 구버전 클라이언트는 이 필드를 모르고 평범한 미스로 처리한다(그래도 서버가 업로드를 막는다).
/// </param>
public sealed record NotFoundBody(
[property: JsonPropertyName("error")] string Error,
bool Pending,
int RetryAfterMs);
int RetryAfterMs,
bool Ad = false);
99 changes: 99 additions & 0 deletions src/Musebase.Server/LyricsStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,105 @@ updated_at TEXT NOT NULL
RecomputeLooseKeys();
Execute("PRAGMA user_version = 5;");
}

if (version < 6)
{
// 광고 제목 차단 목록. 자동 판정(AdSignals)이 놓친 것을 사람이 표시해 막는다 —
// 실제로 "광고 없이 음악을 감상하세요." 같은 행이 가사로 올라와 있었다.
Execute("""
CREATE TABLE IF NOT EXISTS ad_titles (
title_key TEXT PRIMARY KEY, -- 정규화한 제목(공백·구두점 무시)
title TEXT NOT NULL, -- 표시용 원본 표기
artist TEXT NOT NULL, -- 표시용(판단 근거)
added_at TEXT NOT NULL
);
""");
Execute("PRAGMA user_version = 6;");
}
}

// ---- 광고 제목 차단 ----

/// <summary>
/// 비교용 제목 키. 표기가 조금씩 달라도(공백·문장부호·대소문자) 같은 광고로 본다.
/// <b>제목만</b> 본다 — 같은 광고가 아티스트를 여러 이름으로 달고 오기 때문이다.
/// </summary>
public static string AdKey(string title) => Musebase.Core.Meaning.MeaningText.Normalize(title);

/// <summary>이 제목이 광고로 표시돼 있는가.</summary>
public bool IsAdTitle(string title)
{
var key = AdKey(title);
if (key.Length == 0) return false;

lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT 1 FROM ad_titles WHERE title_key = $k LIMIT 1;";
cmd.Parameters.AddWithValue("$k", key);
return cmd.ExecuteScalar() is not null;
}
}

/// <summary>광고로 표시한다. 이미 올라온 가사 행도 함께 지운다(쓰레기 행이다).</summary>
public void AddAdTitle(string title, string artist)
{
var key = AdKey(title);
if (key.Length == 0) return;

lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = """
INSERT INTO ad_titles (title_key, title, artist, added_at)
VALUES ($k, $t, $a, $at)
ON CONFLICT(title_key) DO UPDATE SET title = $t, artist = $a, added_at = $at;
""";
cmd.Parameters.AddWithValue("$k", key);
cmd.Parameters.AddWithValue("$t", title);
cmd.Parameters.AddWithValue("$a", artist);
cmd.Parameters.AddWithValue("$at", UtcNow());
cmd.ExecuteNonQuery();

// 같은 제목으로 저장된 가사가 여러 아티스트로 갈려 있을 수 있다 — 전부 지운다.
using var purge = _conn.CreateCommand();
purge.CommandText = """
DELETE FROM lyrics WHERE key IN (
SELECT key FROM lyrics WHERE lower(title) = lower($t)
);
""";
purge.Parameters.AddWithValue("$t", title);
purge.ExecuteNonQuery();
}
}

/// <summary>광고 표시를 되돌린다(잘못 눌렀을 때).</summary>
public void RemoveAdTitle(string titleKey)
{
lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "DELETE FROM ad_titles WHERE title_key = $k;";
cmd.Parameters.AddWithValue("$k", titleKey);
cmd.ExecuteNonQuery();
}
}

/// <summary>차단된 광고 제목 목록(최근 표시순).</summary>
public IReadOnlyList<AdTitleRow> AdTitles(int limit = 200)
{
var rows = new List<AdTitleRow>();
lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT title_key, title, artist, added_at FROM ad_titles ORDER BY added_at DESC LIMIT $limit;";
cmd.Parameters.AddWithValue("$limit", limit);
using var reader = cmd.ExecuteReader();
while (reader.Read())
rows.Add(new AdTitleRow(
reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3)));
}
return rows;
}

/// <summary>모든 행의 <c>loose_key</c>를 지금 규칙으로 다시 계산한다(행은 합치지 않는다).</summary>
Expand Down
Loading
Loading