From 343628a2e38cbd782b1285c042ad27ad9b81ad00 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Mon, 31 Aug 2026 01:52:38 +0000 Subject: [PATCH] Episode names: render MAL titles on the episode grid anidb's episodes API has no names (id/number/filler only), but the Jikan call we already make for intro timings carries them (title + title_japanese). _parse_intros now keeps one entry per episode with title and optional start/length (entries are never null anymore). Client: attachEpNames() reuses the introCache promise, stamps epsList[i].name, re-renders; buttons show the name (3-line clamp) + full tooltip. Jikan down => no names, no error (same graceful degradation as the skip-intro button). Verified headless: Jujutsu Kaisen S1 24/24 buttons named ('E1 | Ryomen Sukuna' ...); S2 (Jikan 504 at the time) rendered gracefully without names. 17/17 API tests. --- server/upstream.py | 9 ++++++--- static/app.js | 17 +++++++++++++++++ static/style.css | 2 ++ tests/test_api.py | 18 ++++++++++-------- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/server/upstream.py b/server/upstream.py index 7ce5a34..189e047 100644 --- a/server/upstream.py +++ b/server/upstream.py @@ -141,12 +141,15 @@ def _languages(ep_id): def _parse_intros(eps): - """Jikan episode list -> [{start, length}, ...], None where MAL has no timing""" + """Jikan episode list -> one entry per episode: {title?, start?, length?}. + title from MAL (episode name); start/length only when MAL has the timing.""" out = [] for e in eps: i = e.get("intro") or {} - out.append({"start": int(i["start"]), "length": int(i["length"])} - if i.get("start") is not None else None) + d = {"title": e.get("title") or None} + if i.get("start") is not None: + d.update(start=int(i["start"]), length=int(i["length"])) + out.append(d) return out diff --git a/static/app.js b/static/app.js index a40b443..3f25a3e 100644 --- a/static/app.js +++ b/static/app.js @@ -382,6 +382,7 @@ async function showAnime(slug_id, opts = {}) { // (ponytail: "first unwatched in list order" is wrong if you skip around; // good enough for sequential viewing) epsList = await api(`/api/anime/${seasonId}/episodes`); + attachEpNames(); watchDone = new Set(); epProg = new Map(); let started = false; @@ -657,6 +658,20 @@ function markActive(b) { b.classList.add("active"); } +// episode names (and intro timings) come from the same Jikan call the +// skip-intro button uses; server caches it 30d and the failure is silent +function attachEpNames() { + const mid = detail && detail.mal_id; + if (!mid) return; + let p = introCache.get(mid); + if (!p) { p = api(`/api/intros/${mid}`).then(t => t || []).catch(() => []); introCache.set(mid, p); } + p.then(list => { + let changed = false; + list.forEach((t, i) => { if (t && t.title && epsList[i]) { epsList[i].name = t.title; changed = true; } }); + if (changed) renderEps(); + }); +} + let epsList = [], hideFiller = localStorage.getItem("aniweb_ff") === "1"; let autoNext = localStorage.getItem("aniweb_auto") !== "0"; let watchDone = new Set(), watchNext = null, epProg = new Map(); @@ -670,6 +685,8 @@ function renderEps() { const b = el("button", "ep" + (e.filler ? " filler" : "")); const done = watchDone.has(String(e.id)); b.textContent = done ? "E" + e.number + " \u2713" : "E" + e.number; + if (e.name) b.append(el("span", "ep-name", e.name)); + b.title = `E${e.number}${e.name ? " — " + e.name : ""}`; if (done) b.classList.add("done"); const pct = epProg.get(String(e.id)); if (!done && pct != null && pct > 5) { diff --git a/static/style.css b/static/style.css index 20c415e..06515b8 100644 --- a/static/style.css +++ b/static/style.css @@ -164,6 +164,8 @@ button.tiny.on { border-color: var(--acc); color: var(--acc); } .eps { display: grid; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); gap: 0.4rem; } .ep { position: relative; font-size: 0.9rem; } +.ep-name { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; + overflow: hidden; font-size: .68rem; color: var(--muted); margin-top: .2rem; line-height: 1.2; } .ep.filler { opacity: 0.55; } .ep.done { opacity: 0.5; } .ep.nextup { border-color: #4b5568; background: #1f2130; } diff --git a/tests/test_api.py b/tests/test_api.py index 6b229c1..3b3afdc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -301,17 +301,19 @@ def _search_count(user): def test_parse_intros(): from server.upstream import _parse_intros out = _parse_intros([ - {"intro": {"start": 95, "length": 60}}, + {"intro": {"start": 95, "length": 60}, "title": "Ryomen Sukuna"}, {"intro": {"start": 0, "length": 0}}, # MAL "no intro" marker - {"intro": None}, + {"intro": None, "title": "Named, no timing"}, {}, ]) assert out == [ - {"start": 95, "length": 60}, - {"start": 0, "length": 0}, - None, - None, + {"title": "Ryomen Sukuna", "start": 95, "length": 60}, + {"title": None, "start": 0, "length": 0}, + {"title": "Named, no timing"}, + {"title": None}, ] + # setupIntro relies on `t.length` being absent (falsy) when no timing + assert "start" not in out[2] def test_intros_parses_jikan_shapes(): @@ -330,8 +332,8 @@ def test_intros_parses_jikan_shapes(): u._CACHE.clear() u._get = lambda url: R() # stub the network assert u._intros(92573) == [ - {"start": 111, "length": 77}, - {"start": 0, "length": 0}, + {"title": "Episode 1", "start": 111, "length": 77}, + {"title": "Episode 2", "start": 0, "length": 0}, ] # wrapped shape still works class W: