From 0613cf7e27c97fb3de12b3ce6d3b3efee28a0e32 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Sun, 30 Aug 2026 21:01:24 +0000 Subject: [PATCH] Intro skip: MAL timings via keyless Jikan + player button - upstream: _intros(mal_id) -> per-episode {start,length} (seconds), 30d TTL cache; Jikan is down or show uncovered -> [] (no button) - app: GET /api/intros/{mal_id}, never errors into the player - player: 'skip intro' pill appears while currentTime is inside the intro window (+8s grace); click seeks past; reappears if rewound. Ep-1 intros are often missing in MAL data, so falls back to the show's first known timing (usually identical across eps) - test: _parse_intros unit test (Jikan itself is 504ing right now, live path verified by hand: endpoint returns [] cleanly) --- server/app.py | 10 ++++++++++ server/upstream.py | 20 +++++++++++++++++++- static/app.js | 29 +++++++++++++++++++++++++++++ static/style.css | 3 +++ tests/test_api.py | 16 ++++++++++++++++ 5 files changed, 77 insertions(+), 1 deletion(-) diff --git a/server/app.py b/server/app.py index 434bf2b..a0140cd 100644 --- a/server/app.py +++ b/server/app.py @@ -417,6 +417,16 @@ def api_anime(slug_id: str): return _upstream(upstream.details, slug_id) +@app.get("/api/intros/{mal_id}") +def api_intros(mal_id: int): + # keyless Jikan/MAL intro timings; Jikan down or no data = no skip + # button, never an error that could touch the player + try: + return upstream.intros(mal_id) + except Exception: + return [] + + @app.get("/api/anime/{anime_id}/episodes") def api_episodes(anime_id: int): return _upstream(upstream.episodes, anime_id) diff --git a/server/upstream.py b/server/upstream.py index 16b1b2c..e5154b9 100644 --- a/server/upstream.py +++ b/server/upstream.py @@ -1,4 +1,5 @@ -"""upstream.py — the only module that talks to anidb.app. +"""upstream.py — the only module that talks to anidb.app (and keyless Jikan +for intro timings). anidb.app sits behind Cloudflare: plain curl gets 403. All requests use curl_cffi Chrome TLS impersonation (verified to pass the challenge). @@ -120,6 +121,22 @@ def _languages(ep_id): return _get(f"{BASE}/api/frontend/episode/{ep_id}/languages").json()["languages"] +def _parse_intros(eps): + """Jikan episode list -> [{start, length}, ...], None where MAL has no 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) + return out + + +def _intros(mal_id): + """Jikan (keyless MAL mirror): per-episode intro start/length, seconds""" + r = _get("https://api.jikan.moe/v4/anime/{}".format(mal_id) + "/episodes") + return _parse_intros(r.json().get("data", {}).get("episodes", [])) + + def _master_m3u8(ep_id, lang="eng"): """Steps 5+6: embed page -> 'file: ' URL (None if unavailable)""" for l in languages(ep_id): @@ -136,6 +153,7 @@ details = lambda s: _cached(f"details:{s}", 3600, _details, s) episodes = lambda i: _cached(f"episodes:{i}", 300, _episodes, i) # 5m: airing shows gain eps languages = lambda e: _cached(f"languages:{e}", 3600, _languages, e) master_m3u8 = lambda e, l="eng": _cached(f"master:{e}:{l}", 3600, _master_m3u8, e, l) +intros = lambda m: _cached(f"intros:{m}", 30 * 86400, _intros, m) # static data if __name__ == "__main__": diff --git a/static/app.js b/static/app.js index 5c13bda..ebe21c4 100644 --- a/static/app.js +++ b/static/app.js @@ -840,6 +840,7 @@ async function play(ep_id, ep_no) { blocked(); }); watchPos(v); + setupIntro(v, ep_no); postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId, ep_id, lang, ep: ep_no, pos: saved?.pos || 0}); } catch (e) { @@ -848,6 +849,34 @@ async function play(ep_id, ep_no) { document.title = `${detail.name} E${ep_no} — ani-web`; } +// ---------- intro skip (MAL timings via keyless Jikan, cached server-side) ---------- +const introCache = new Map(); // mal_id -> Promise +function setupIntro(v, epNo) { + 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 => { + if (!currentEp || currentEp.v !== v) return; // superseded by a newer play() + // ponytail: ep 1 intros are often missing in MAL data; fall back to the + // show's first known timing (intros are usually identical across eps) + let t = list[epNo - 1]; + if (!t) for (const x of list) if (x) { t = x; break; } + if (!t || !(t.length > 0)) return; + const s = t.start, e = t.start + t.length; + let skipped = false; + const b = el("button", "skip-intro", "skip intro \u23ed"); + b.hidden = true; + b.onclick = () => { v.currentTime = e + 0.5; skipped = true; b.hidden = true; }; + document.getElementById("player").append(b); + v.addEventListener("timeupdate", () => { + if (skipped && v.currentTime < s) skipped = false; // user rewound back to the window + if (skipped) return; + b.hidden = !(v.currentTime >= s && v.currentTime <= e + 8); + }); + }); +} + // extra fields to persist with every history entry (progress + poster) const meta = v => ({...(Number.isFinite(v.duration) && v.duration > 0 ? {dur: v.duration} : {}), poster: detail ? (detail.poster || null) : null}); diff --git a/static/style.css b/static/style.css index edf06f6..20c415e 100644 --- a/static/style.css +++ b/static/style.css @@ -110,6 +110,9 @@ h2 { margin: 0.4rem 0; } .player { position: relative; } .player video { width: 100%; max-height: 70vh; background: #000; border-radius: 10px; aspect-ratio: 16/9; } +.skip-intro { position: absolute; right: .8rem; bottom: 3rem; z-index: 2; + padding: .4rem 1rem; border-radius: 99px; border: 1px solid var(--acc); + background: rgba(14, 15, 19, .85); color: var(--fg); font-size: .9rem; cursor: pointer; } .controls { display: flex; align-items: center; gap: 0.4rem; margin: 0.6rem 0; flex-wrap: wrap; } button { background: var(--card); color: var(--fg); border: 1px solid var(--bd); border-radius: 8px; padding: 0.4rem 0.7rem; cursor: pointer; } diff --git a/tests/test_api.py b/tests/test_api.py index 10d79e2..955e0da 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -296,3 +296,19 @@ def _search_count(user): n = c.execute("SELECT COUNT(*) FROM searches WHERE user=?", (user,)).fetchone()[0] c.close() return n + + +def test_parse_intros(): + from server.upstream import _parse_intros + out = _parse_intros([ + {"intro": {"start": 95, "length": 60}}, + {"intro": {"start": 0, "length": 0}}, # MAL "no intro" marker + {"intro": None}, + {}, + ]) + assert out == [ + {"start": 95, "length": 60}, + {"start": 0, "length": 0}, + None, + None, + ]