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.
This commit is contained in:
1 parent
d60524815f
commit
343628a2e3
4 files changed
+35
-11
No files matched your search
+6
-3
@@ -141,12 +141,15 @@ def _languages(ep_id):
|
|||||||
|
|
||||||
|
|
||||||
def _parse_intros(eps):
|
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 = []
|
out = []
|
||||||
for e in eps:
|
for e in eps:
|
||||||
i = e.get("intro") or {}
|
i = e.get("intro") or {}
|
||||||
out.append({"start": int(i["start"]), "length": int(i["length"])}
|
d = {"title": e.get("title") or None}
|
||||||
if i.get("start") is not None else None)
|
if i.get("start") is not None:
|
||||||
|
d.update(start=int(i["start"]), length=int(i["length"]))
|
||||||
|
out.append(d)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -382,6 +382,7 @@ async function showAnime(slug_id, opts = {}) {
|
|||||||
// (ponytail: "first unwatched in list order" is wrong if you skip around;
|
// (ponytail: "first unwatched in list order" is wrong if you skip around;
|
||||||
// good enough for sequential viewing)
|
// good enough for sequential viewing)
|
||||||
epsList = await api(`/api/anime/${seasonId}/episodes`);
|
epsList = await api(`/api/anime/${seasonId}/episodes`);
|
||||||
|
attachEpNames();
|
||||||
watchDone = new Set();
|
watchDone = new Set();
|
||||||
epProg = new Map();
|
epProg = new Map();
|
||||||
let started = false;
|
let started = false;
|
||||||
@@ -657,6 +658,20 @@ function markActive(b) {
|
|||||||
b.classList.add("active");
|
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 epsList = [], hideFiller = localStorage.getItem("aniweb_ff") === "1";
|
||||||
let autoNext = localStorage.getItem("aniweb_auto") !== "0";
|
let autoNext = localStorage.getItem("aniweb_auto") !== "0";
|
||||||
let watchDone = new Set(), watchNext = null, epProg = new Map();
|
let watchDone = new Set(), watchNext = null, epProg = new Map();
|
||||||
@@ -670,6 +685,8 @@ function renderEps() {
|
|||||||
const b = el("button", "ep" + (e.filler ? " filler" : ""));
|
const b = el("button", "ep" + (e.filler ? " filler" : ""));
|
||||||
const done = watchDone.has(String(e.id));
|
const done = watchDone.has(String(e.id));
|
||||||
b.textContent = done ? "E" + e.number + " \u2713" : "E" + e.number;
|
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");
|
if (done) b.classList.add("done");
|
||||||
const pct = epProg.get(String(e.id));
|
const pct = epProg.get(String(e.id));
|
||||||
if (!done && pct != null && pct > 5) {
|
if (!done && pct != null && pct > 5) {
|
||||||
|
|||||||
@@ -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; }
|
.eps { display: grid; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); gap: 0.4rem; }
|
||||||
.ep { position: relative; font-size: 0.9rem; }
|
.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.filler { opacity: 0.55; }
|
||||||
.ep.done { opacity: 0.5; }
|
.ep.done { opacity: 0.5; }
|
||||||
.ep.nextup { border-color: #4b5568; background: #1f2130; }
|
.ep.nextup { border-color: #4b5568; background: #1f2130; }
|
||||||
|
|||||||
+10
-8
@@ -301,17 +301,19 @@ def _search_count(user):
|
|||||||
def test_parse_intros():
|
def test_parse_intros():
|
||||||
from server.upstream import _parse_intros
|
from server.upstream import _parse_intros
|
||||||
out = _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": {"start": 0, "length": 0}}, # MAL "no intro" marker
|
||||||
{"intro": None},
|
{"intro": None, "title": "Named, no timing"},
|
||||||
{},
|
{},
|
||||||
])
|
])
|
||||||
assert out == [
|
assert out == [
|
||||||
{"start": 95, "length": 60},
|
{"title": "Ryomen Sukuna", "start": 95, "length": 60},
|
||||||
{"start": 0, "length": 0},
|
{"title": None, "start": 0, "length": 0},
|
||||||
None,
|
{"title": "Named, no timing"},
|
||||||
None,
|
{"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():
|
def test_intros_parses_jikan_shapes():
|
||||||
@@ -330,8 +332,8 @@ def test_intros_parses_jikan_shapes():
|
|||||||
u._CACHE.clear()
|
u._CACHE.clear()
|
||||||
u._get = lambda url: R() # stub the network
|
u._get = lambda url: R() # stub the network
|
||||||
assert u._intros(92573) == [
|
assert u._intros(92573) == [
|
||||||
{"start": 111, "length": 77},
|
{"title": "Episode 1", "start": 111, "length": 77},
|
||||||
{"start": 0, "length": 0},
|
{"title": "Episode 2", "start": 0, "length": 0},
|
||||||
]
|
]
|
||||||
# wrapped shape still works
|
# wrapped shape still works
|
||||||
class W:
|
class W:
|
||||||
|
|||||||
Reference in new issue
Block a user