Fix Anikoto encrypted source resolution
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- Allowed season/series `0` in search and watchlist auto-download settings so Jellyfin specials finalize under `Season 00` with `S00E..` filenames.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Kaizoku is a local web app for searching, tracking, and downloading anime from A
|
|||||||
|
|
||||||
## Features
|
## 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.
|
- 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.
|
- 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.
|
- 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:
|
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`
|
- AniNeko `3.0.3`
|
||||||
- AnimePahe `4.0.1`
|
- AnimePahe `4.0.1`
|
||||||
|
|
||||||
|
|||||||
@@ -863,6 +863,11 @@ def download_episode(stream, target, episode_number=None, episode_index=None, ep
|
|||||||
return code
|
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):
|
def provider_episode_candidates(primary_provider, primary_show_id, title, wanted_episode):
|
||||||
number = wanted_episode.get("number")
|
number = wanted_episode.get("number")
|
||||||
for provider in provider_order(primary_provider):
|
for provider in provider_order(primary_provider):
|
||||||
@@ -878,7 +883,7 @@ def provider_episode_candidates(primary_provider, primary_show_id, title, wanted
|
|||||||
continue
|
continue
|
||||||
yield provider, provider_show_id, episode
|
yield provider, provider_show_id, episode
|
||||||
except Exception as exc:
|
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):
|
def provider_stream_candidates(provider, episode_id, mode, quality):
|
||||||
|
|||||||
@@ -22,9 +22,36 @@
|
|||||||
* service of the target website.
|
* service of the target website.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const crypto = require("crypto");
|
||||||
const cheerio = require("cheerio");
|
const cheerio = require("cheerio");
|
||||||
|
|
||||||
const baseUrl = "https://anikototv.to";
|
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) {
|
function parsePagination($, defaultPage) {
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
@@ -229,11 +256,17 @@ async function fetchServerTypes(dataIds) {
|
|||||||
});
|
});
|
||||||
const $ = cheerio.load(serverRes.data?.result || "");
|
const $ = cheerio.load(serverRes.data?.result || "");
|
||||||
const types = new Set();
|
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) => {
|
$(".servers .type[data-type], [data-type]").each((i, el) => {
|
||||||
const type = String($(el).attr("data-type") || "")
|
const type = String($(el).attr("data-type") || "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.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;
|
return types;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -406,8 +439,13 @@ async function processServer(server) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sourcesRes.data && sourcesRes.data.sources) {
|
const decodedSourcesData =
|
||||||
const rawSrc = sourcesRes.data.sources;
|
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 =
|
const m3u8Url =
|
||||||
typeof rawSrc === "string"
|
typeof rawSrc === "string"
|
||||||
? rawSrc
|
? rawSrc
|
||||||
@@ -425,7 +463,7 @@ async function processServer(server) {
|
|||||||
global.setFallbackReferer(playerReferer);
|
global.setFallbackReferer(playerReferer);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const subtitles = (sourcesRes.data.tracks || [])
|
const subtitles = (sourcesRes.data.tracks || decodedSourcesData.tracks || [])
|
||||||
.filter(
|
.filter(
|
||||||
(t) => t.file && (!t.kind || t.kind.toLowerCase() !== "thumbnails"),
|
(t) => t.file && (!t.kind || t.kind.toLowerCase() !== "thumbnails"),
|
||||||
)
|
)
|
||||||
@@ -584,7 +622,7 @@ async function fetchEpisodeSources(episodeIdStr, category = null) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: "anikoto",
|
name: "anikoto",
|
||||||
version: "5.0.3",
|
version: "5.0.4",
|
||||||
SearchAnime,
|
SearchAnime,
|
||||||
AnimeInfo,
|
AnimeInfo,
|
||||||
fetchEpisodeSources,
|
fetchEpisodeSources,
|
||||||
|
|||||||
+100
@@ -6,6 +6,7 @@ import importlib.util
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
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"])
|
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):
|
def test_provider_downloader_tries_next_same_provider_source_after_failure(self):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user