diff --git a/server.py b/server.py
index 0c99ccc..0165823 100644
--- a/server.py
+++ b/server.py
@@ -1266,6 +1266,18 @@ class MetadataDB:
out.setdefault(fn, {})[field] = {"value": value, "locked": bool(locked)}
return out
+ def _romaji_display(self, filename: str, artist: str, title: str):
+ """English-base display fallback. A blank-artist CDLC pack named
+ 'Artist_Title_v1_p' has no readable name (artist blank; title = the raw
+ filename), and a match would fill it with the artist's NATIVE script
+ (kanji/kana). Surface the author's own romaji parsed from the filename
+ instead, so an English base reads 'Junko Yagami - BAY CITY'. Only kicks in
+ when the pack has no artist of its own — a real pack artist is untouched."""
+ if (artist or "").strip():
+ return artist, title
+ d = _artist_title_from_filename(filename)
+ return (d["artist"], d["title"]) if d else (artist, title)
+
def pack_fields(self, filename: str) -> dict:
"""The stored (pack) values for the overridable catalog fields — the
Fix-metadata popup shows these behind each override as the 'revert to
@@ -1275,7 +1287,11 @@ class MetadataDB:
row = self.conn.execute(
"SELECT title, artist, album, year, genre FROM songs WHERE filename = ?",
(filename,)).fetchone()
- return {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
+ vals = {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
+ # Baseline the author's romaji (from the filename) for a blank-artist pack,
+ # so the Details tab's Pack reference matches what the grid shows.
+ vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
+ return vals
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
@@ -4215,6 +4231,17 @@ class MetadataDB:
s["unmatched"] = s["filename"] in um
if amap:
s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist"))
+ # English-base romaji fallback: a blank-artist CDLC pack shows nothing
+ # useful (artist blank; title = the raw filename). Surface the author's
+ # romaji from the "Artist_Title_v1_p" filename so the card reads
+ # "Junko Yagami — BAY CITY", never blank or native script. Display-only;
+ # a user override (below) still wins. Keyset-safe: stash the raw title
+ # for the cursor before replacing it.
+ if not (s.get("artist") or "").strip():
+ r_artist, r_title = self._romaji_display(s["filename"], s.get("artist"), s.get("title"))
+ if r_title != s.get("title") and "_sort_title" not in s:
+ s["_sort_title"] = s["title"]
+ s["artist"], s["title"] = r_artist, r_title
# Override wins over the pack AND the alias re-label — it's the user's
# explicit per-song choice. Only a non-empty override VALUE replaces a
# cell; a lock-only row (value None) leaves the displayed value alone.
@@ -4224,7 +4251,7 @@ class MetadataDB:
cell = ov.get(field)
val = cell.get("value") if cell else None
if val:
- if field == "title":
+ if field == "title" and "_sort_title" not in s:
s["_sort_title"] = s["title"] # raw title, for the keyset cursor
s[field] = val
# Grouped rows carry the ⚑ N (chart_count) + the work_key from the
diff --git a/static/v3/match-review.js b/static/v3/match-review.js
index cc216d7..2337778 100644
--- a/static/v3/match-review.js
+++ b/static/v3/match-review.js
@@ -511,10 +511,17 @@
// then land on Details pre-filled for review.
async function useTheseValues(song, cand) {
if (!cand) return;
+ // Smart adopt for an English base: KEEP the readable name + title the card
+ // already shows (the author's romaji, e.g. "Junko Yagami / BAY CITY") — the
+ // match is often native script (kanji/kana). Take only what the pack lacks
+ // — album / year / genre — from the match; the pin below still brings the
+ // correct art + identity. The user can still edit any field.
song._pendingDetails = {
- title: String(cand.title || ''), artist: String(cand.artist || ''),
- album: String(cand.album || ''), year: String(cand.year || ''),
- genre: (Array.isArray(cand.genres) && cand.genres[0]) ? String(cand.genres[0]) : String(cand.genre || ''),
+ artist: String(song.artist || cand.artist || ''),
+ title: String(song.title || cand.title || ''),
+ album: String(cand.album || song.album || ''),
+ year: String(cand.year || song.year || ''),
+ genre: String((Array.isArray(cand.genres) && cand.genres[0]) || cand.genre || ''),
};
try {
await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand });
diff --git a/static/v3/songs.js b/static/v3/songs.js
index 32674eb..1c5490f 100644
--- a/static/v3/songs.js
+++ b/static/v3/songs.js
@@ -515,15 +515,22 @@
done: ['bg-fb-good/90 text-black', '✓ Updated', ''],
nochange: ['bg-black/60 text-fb-textDim', '— No match', ''],
// Resting indicator: subtle, so a mostly-unmatched library isn't a
- // wall of loud badges; points at the manual fix.
- nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'No metadata match found — right-click to fix it by hand'],
+ // wall of loud badges. Clickable — a one-click handoff into the
+ // Fix-metadata popup for this song (see the [data-meta-fix] wiring).
+ nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'Click to fix the metadata by hand'],
};
const conf = M[st] || M.queued;
+ const fixable = st === 'nomatch'; // resting badge → opens Fix-metadata
// top-10 clears the tuning chip (top-2) in both normal and select mode;
- // z-20 sits it above the art. Non-interactive so it never eats a click.
- return '' + conf[1] + '';
+ // z-20 sits it above the art. Batch states are non-interactive; the
+ // resting "no match" badge is the handoff into the popup.
+ const cls = 'v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
+ ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight ' +
+ (fixable ? 'pointer-events-auto cursor-pointer hover:bg-fb-primary hover:text-white transition-colors' : 'pointer-events-none');
+ return '' + conf[1] + '';
}
// After a song is scored, the badge for that card is stale until the next
@@ -1467,6 +1474,13 @@
e.stopPropagation();
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
});
+ // "No match" badge → straight into the Fix-metadata popup for this
+ // song (the batch → fix handoff). stopPropagation so it doesn't also
+ // trigger the card's play. Follows the displayed chart, like the menu.
+ el.querySelector('[data-meta-fix]')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (window.__fbFixMatch) window.__fbFixMatch(playTarget);
+ });
// Artist line → the artist page (PR-B). In select mode the grid's
// capture-phase toggle intercepts first, so selection still wins.
el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => {
diff --git a/tests/test_field_overrides.py b/tests/test_field_overrides.py
index d4a3f78..8761aab 100644
--- a/tests/test_field_overrides.py
+++ b/tests/test_field_overrides.py
@@ -233,6 +233,24 @@ def test_lock_only_genre_does_not_change_facet(server):
assert server.meta_db._effective_genre_expr() == "genre"
+def test_romaji_fallback_for_blank_artist_pack(server):
+ fn = "CDLC/0 - City Pop/Junko-Yagami_BAY-CITY_v1_p.feedpak"
+ _put(server, fn, title="Junko-Yagami_BAY-CITY_v1_p", artist="") # scanner fell back to the filename
+ s = {x["filename"]: x for x in server.meta_db.query_page()[0]}[fn]
+ # the grid shows the author's romaji, not blank / the raw filename / kanji
+ assert s["artist"] == "Junko Yagami"
+ assert s["title"] == "BAY CITY"
+ # the Details baseline (pack_fields) matches, so the popup agrees with the grid
+ pack = server.meta_db.pack_fields(fn)
+ assert pack["artist"] == "Junko Yagami" and pack["title"] == "BAY CITY"
+
+
+def test_romaji_fallback_left_alone_when_pack_has_artist(server):
+ _put(server, "a.archive", title="Real Title", artist="Real Artist")
+ s = {x["filename"]: x for x in server.meta_db.query_page()[0]}["a.archive"]
+ assert s["artist"] == "Real Artist" and s["title"] == "Real Title"
+
+
def test_title_keyset_paging_is_complete_with_overrides(client, server):
# Raw titles A/B/C → title-sort order is A, B, C on the RAW column.
_put(server, "b.archive", title="B")