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)
This commit is contained in:
1 parent
c2461f8fba
commit
0613cf7e27
5 files changed
+77
-1
No files matched your search
@@ -417,6 +417,16 @@ def api_anime(slug_id: str):
|
|||||||
return _upstream(upstream.details, slug_id)
|
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")
|
@app.get("/api/anime/{anime_id}/episodes")
|
||||||
def api_episodes(anime_id: int):
|
def api_episodes(anime_id: int):
|
||||||
return _upstream(upstream.episodes, anime_id)
|
return _upstream(upstream.episodes, anime_id)
|
||||||
|
|||||||
+19
-1
@@ -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
|
anidb.app sits behind Cloudflare: plain curl gets 403. All requests use
|
||||||
curl_cffi Chrome TLS impersonation (verified to pass the challenge).
|
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"]
|
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"):
|
def _master_m3u8(ep_id, lang="eng"):
|
||||||
"""Steps 5+6: embed page -> 'file: <master m3u8>' URL (None if unavailable)"""
|
"""Steps 5+6: embed page -> 'file: <master m3u8>' URL (None if unavailable)"""
|
||||||
for l in languages(ep_id):
|
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
|
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)
|
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)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -840,6 +840,7 @@ async function play(ep_id, ep_no) {
|
|||||||
blocked();
|
blocked();
|
||||||
});
|
});
|
||||||
watchPos(v);
|
watchPos(v);
|
||||||
|
setupIntro(v, ep_no);
|
||||||
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
|
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
|
||||||
ep_id, lang, ep: ep_no, pos: saved?.pos || 0});
|
ep_id, lang, ep: ep_no, pos: saved?.pos || 0});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -848,6 +849,34 @@ async function play(ep_id, ep_no) {
|
|||||||
document.title = `${detail.name} E${ep_no} — ani-web`;
|
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<timings>
|
||||||
|
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)
|
// extra fields to persist with every history entry (progress + poster)
|
||||||
const meta = v => ({...(Number.isFinite(v.duration) && v.duration > 0 ? {dur: v.duration} : {}),
|
const meta = v => ({...(Number.isFinite(v.duration) && v.duration > 0 ? {dur: v.duration} : {}),
|
||||||
poster: detail ? (detail.poster || null) : null});
|
poster: detail ? (detail.poster || null) : null});
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ h2 { margin: 0.4rem 0; }
|
|||||||
|
|
||||||
.player { position: relative; }
|
.player { position: relative; }
|
||||||
.player video { width: 100%; max-height: 70vh; background: #000; border-radius: 10px; aspect-ratio: 16/9; }
|
.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; }
|
.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);
|
button { background: var(--card); color: var(--fg); border: 1px solid var(--bd);
|
||||||
border-radius: 8px; padding: 0.4rem 0.7rem; cursor: pointer; }
|
border-radius: 8px; padding: 0.4rem 0.7rem; cursor: pointer; }
|
||||||
|
|||||||
@@ -296,3 +296,19 @@ def _search_count(user):
|
|||||||
n = c.execute("SELECT COUNT(*) FROM searches WHERE user=?", (user,)).fetchone()[0]
|
n = c.execute("SELECT COUNT(*) FROM searches WHERE user=?", (user,)).fetchone()[0]
|
||||||
c.close()
|
c.close()
|
||||||
return n
|
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,
|
||||||
|
]
|
||||||
Reference in new issue
Block a user