From 6e68f762b78dc809d883ebc30c78f51f536a1bd0 Mon Sep 17 00:00:00 2001 From: Dymas Date: Thu, 10 Sep 2026 08:34:29 +0200 Subject: [PATCH] Fix Anikoto encrypted source resolution --- CHANGELOG.md | 6 ++ README.md | 4 +- VERSION | 2 +- provider_downloader.py | 7 +- providers/extensions/Anime/anikoto.js | 48 +++++++++++-- test_app.py | 100 ++++++++++++++++++++++++++ 6 files changed, 158 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f894b17..590fddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.52.31 - 2026-09-10 + +- Fixed Anikoto Megaplay source resolution by decoding encrypted `enc` source responses, restoring playable dub links that were visible on the website but returned as empty sources in Kaizoku. +- Tightened Anikoto episode language detection so empty language groups no longer count as available sources. +- Shortened provider fallback errors in queue logs so failures such as AnimePahe `HTTP 403` show the useful error line without the full Node bridge stack. + ## 0.52.30 - 2026-09-09 - Allowed season/series `0` in search and watchlist auto-download settings so Jellyfin specials finalize under `Season 00` with `S00E..` filenames. diff --git a/README.md b/README.md index b167a2a..d51bd88 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Kaizoku is a local web app for searching, tracking, and downloading anime from A ## Features -- Search anime through the configured provider and tag results with available `SUB` and `DUB` episode languages. +- Search anime through the configured provider and tag results with available `SUB` and `DUB` episode languages, ignoring empty provider language groups that do not expose playable servers. - Choose the active provider from Config defaults or directly from the Search page. - Prefer provider-supplied search artwork, with a local title-based thumbnail fallback when provider artwork is missing or broken. - Fill Watchlist thumbnails from AnimeSchedule or AniDB title metadata when provider artwork is missing. @@ -113,7 +113,7 @@ Kaizoku uses additive SQLite migrations for queue and watchlist schema changes. The Anikoto, AniNeko, and AnimePahe parser modules in `providers/extensions/Anime/` are adapted from [TheYogMehta/extensions](https://github.com/TheYogMehta/extensions) and retain their GPL/license headers. The bundled anime provider versions are currently: -- Anikoto `5.0.3` +- Anikoto `5.0.4` - AniNeko `3.0.3` - AnimePahe `4.0.1` diff --git a/VERSION b/VERSION index f0b8ff5..389bff2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.52.30 +0.52.31 diff --git a/provider_downloader.py b/provider_downloader.py index b100dc5..92bd79d 100755 --- a/provider_downloader.py +++ b/provider_downloader.py @@ -863,6 +863,11 @@ def download_episode(stream, target, episode_number=None, episode_index=None, ep return code +def concise_error(exc): + text = str(exc or "").strip() + return text.splitlines()[0] if text else exc.__class__.__name__ + + def provider_episode_candidates(primary_provider, primary_show_id, title, wanted_episode): number = wanted_episode.get("number") for provider in provider_order(primary_provider): @@ -878,7 +883,7 @@ def provider_episode_candidates(primary_provider, primary_show_id, title, wanted continue yield provider, provider_show_id, episode except Exception as exc: - print(f"Fallback skipped {provider}: {exc}") + print(f"Fallback skipped {provider}: {concise_error(exc)}") def provider_stream_candidates(provider, episode_id, mode, quality): diff --git a/providers/extensions/Anime/anikoto.js b/providers/extensions/Anime/anikoto.js index f867bdd..38c4bc0 100644 --- a/providers/extensions/Anime/anikoto.js +++ b/providers/extensions/Anime/anikoto.js @@ -22,9 +22,36 @@ * service of the target website. */ +const crypto = require("crypto"); const cheerio = require("cheerio"); const baseUrl = "https://anikototv.to"; +const megaPlayEncKey = "i?LMTAx0Q6,:}50U"; +const megaPlayEncIv = "W0;27ToaUpl_P%'c"; + +function decodeMegaPlayEnc(value) { + if (!value || typeof value !== "string") return null; + try { + const key = Buffer.alloc(32); + Buffer.from(megaPlayEncKey).copy(key); + const input = Buffer.from( + value.replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ); + const decipher = crypto.createDecipheriv( + "aes-256-cbc", + key, + Buffer.from(megaPlayEncIv), + ); + const decoded = Buffer.concat([ + decipher.update(input), + decipher.final(), + ]).toString("utf8"); + return JSON.parse(decoded); + } catch (e) { + return null; + } +} function parsePagination($, defaultPage) { let totalPages = 1; @@ -229,11 +256,17 @@ async function fetchServerTypes(dataIds) { }); const $ = cheerio.load(serverRes.data?.result || ""); const types = new Set(); + const serverLinkSelector = + "[data-link-id], li[data-id], a[data-id], button[data-id], .server-item[data-id], .item[data-id]"; $(".servers .type[data-type], [data-type]").each((i, el) => { const type = String($(el).attr("data-type") || "") .trim() .toLowerCase(); - if (type) types.add(type); + const hasOwnServerLink = + Boolean($(el).attr("data-link-id")) || + ($(el).is("li,a,button,.server-item,.item") && Boolean($(el).attr("data-id"))); + const hasNestedServerLink = $(el).find(serverLinkSelector).length > 0; + if (type && (hasOwnServerLink || hasNestedServerLink)) types.add(type); }); return types; } catch (e) { @@ -406,8 +439,13 @@ async function processServer(server) { }, ); - if (sourcesRes.data && sourcesRes.data.sources) { - const rawSrc = sourcesRes.data.sources; + const decodedSourcesData = + sourcesRes.data?.sources || sourcesRes.data?.file + ? sourcesRes.data + : decodeMegaPlayEnc(sourcesRes.data?.enc); + + if (decodedSourcesData && (decodedSourcesData.sources || decodedSourcesData.file)) { + const rawSrc = decodedSourcesData.sources || decodedSourcesData.file; const m3u8Url = typeof rawSrc === "string" ? rawSrc @@ -425,7 +463,7 @@ async function processServer(server) { global.setFallbackReferer(playerReferer); } catch (e) {} - const subtitles = (sourcesRes.data.tracks || []) + const subtitles = (sourcesRes.data.tracks || decodedSourcesData.tracks || []) .filter( (t) => t.file && (!t.kind || t.kind.toLowerCase() !== "thumbnails"), ) @@ -584,7 +622,7 @@ async function fetchEpisodeSources(episodeIdStr, category = null) { module.exports = { name: "anikoto", - version: "5.0.3", + version: "5.0.4", SearchAnime, AnimeInfo, fetchEpisodeSources, diff --git a/test_app.py b/test_app.py index b66385a..6a46d8c 100644 --- a/test_app.py +++ b/test_app.py @@ -6,6 +6,7 @@ import importlib.util import io import json import os +import subprocess import sys import tempfile import threading @@ -4904,6 +4905,105 @@ https://shard-102.snapcdn.top/anime/show/episode/seg-f1-00083.css self.assertEqual([source["server"] for source in sources], ["HD-1", "HD-2"]) + def test_concise_error_keeps_first_provider_error_line(self): + error = RuntimeError("Error: Request failed with status code 403\n at async main") + + self.assertEqual(provider_downloader.concise_error(error), "Error: Request failed with status code 403") + + def test_anikoto_episode_language_probe_ignores_empty_dub_group(self): + script = r""" +global.axios = { + get: async (url) => { + if (url.includes("/ajax/episode/list/123")) { + return { + data: { + result: '
' + } + }; + } + if (url.includes("/ajax/server/list?servers=servers4")) { + return { + data: { + result: '
  • HD-1
    ' + } + }; + } + throw new Error(`unexpected url ${url}`); + } +}; +const provider = require("./providers/extensions/Anime/anikoto.js"); +provider.fetchEpisode("123").then((data) => { + console.log(JSON.stringify(data.episodes[0].langs)); +}).catch((err) => { + console.error(err && err.stack ? err.stack : err); + process.exit(1); +}); +""" + proc = subprocess.run( + ["node", "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(json.loads(proc.stdout), ["sub"]) + + def test_anikoto_process_server_decodes_encrypted_megaplay_sources(self): + script = r""" +const encryptedPayload = "wdeBruh3qqn_i5wUNnyaPQQl1wp7r0SrL6KQPNFd24_nH3bjQ-zZlyDs9ryZRqOqluMFxGXpzqi4xA9i5N3T4SDfaQAFzkb6BCj2QorrmxfAd1_nBhcma5SSJZ9rSUWBrBzsNlbhi3a9aCjlekwZGRoHmqEcH3gn2pl0RNQ2JsM"; +global.setDynamicReferer = () => {}; +global.setFallbackReferer = () => {}; +global.axios = { + get: async (url) => { + if (url.includes("/ajax/server?get=dub-link")) { + return { data: { result: { url: "https://megaplay.buzz/stream/s-2/664726/dub" } } }; + } + if (url.includes("/stream/s-2/664726/dub")) { + return { + data: ` +
    + + ` + }; + } + if (url.includes("/stream/getSources?id=179510&type=dub&cidu=6aa1ade0620e3")) { + return { data: { enc: encryptedPayload, tracks: [{ file: "https://cdn.example/sub.vtt", label: "English" }] } }; + } + throw new Error(`unexpected url ${url}`); + } +}; +const provider = require("./providers/extensions/Anime/anikoto.js"); +provider.processServer({ name: "HD-2", type: "dub", linkId: "dub-link" }).then((source) => { + console.log(JSON.stringify(source)); +}).catch((err) => { + console.error(err && err.stack ? err.stack : err); + process.exit(1); +}); +""" + proc = subprocess.run( + ["node", "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(proc.returncode, 0, proc.stderr) + source = json.loads(proc.stdout) + self.assertEqual( + source["url"], + "https://cdn.imgnex.top/anime/a7e93c78e547abf7dce1e0f3f0b77977/0eb80d76b8867b42b6e6e62ebe515704/master.m3u8", + ) + self.assertEqual(source["type"], "dub") + self.assertEqual(source["subtitles"][0]["url"], "https://cdn.example/sub.vtt") + def test_provider_downloader_tries_next_same_provider_source_after_failure(self): calls = []