Fix Anikoto encrypted source resolution

This commit is contained in:
Dymas
2026-09-10 09:08:28 +02:00
parent 079488fa5a
commit 6e68f762b7
6 changed files with 158 additions and 9 deletions
+6
View File
@@ -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.
+2 -2
View File
@@ -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`
+1 -1
View File
@@ -1 +1 @@
0.52.30
0.52.31
+6 -1
View File
@@ -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):
+43 -5
View File
@@ -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,
+100
View File
@@ -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: '<div class="filter type"><button data-value="dub">Dub</button></div><a data-id="ep4" data-ids="servers4" data-num="4" title="Episode 4"></a>'
}
};
}
if (url.includes("/ajax/server/list?servers=servers4")) {
return {
data: {
result: '<div class="servers"><div class="type" data-type="sub"><ul><li data-id="s1" data-link-id="l1">HD-1</li></ul></div><div class="type" data-type="dub"><ul></ul></div></div>'
}
};
}
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: `
<div id="megaplay-player" data-id="179510"></div>
<script>
const settings = {
type: 'dub',
cidu : '6aa1ade0620e3',
};
</script>
`
};
}
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 = []