Add anime metadata providers

This commit is contained in:
Dymas
2026-09-15 15:57:43 +02:00
parent 9f39478018
commit 553bd6f427
8 changed files with 353 additions and 5 deletions
+310
View File
@@ -5,6 +5,7 @@ const path = require("node:path");
const crypto = require("node:crypto");
const { spawn } = require("node:child_process");
const { URL } = require("node:url");
const zlib = require("node:zlib");
const { DatabaseSync } = require("node:sqlite");
const ROOT = __dirname;
@@ -14,9 +15,19 @@ const LIBRARY_DIR = path.resolve(process.env.LIBRARY_DIR || path.join(ROOT, "Lib
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(ROOT, "data"));
const AUTH_USERNAME = process.env.AUTH_USERNAME || "";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "";
const ANIDB_CLIENT_NAME = process.env.ANIDB_CLIENT_NAME || "";
const ANIDB_CLIENT_VERSION = Number(process.env.ANIDB_CLIENT_VERSION || 1);
const THUMB_DIR = path.join(DATA_DIR, "thumbnails");
const CACHE_DIR = path.join(DATA_DIR, "cache");
const DB_FILE = path.join(DATA_DIR, "library.sqlite");
const LEGACY_META_FILE = path.join(DATA_DIR, "metadata.json");
const ANIDB_TITLES_CACHE = path.join(CACHE_DIR, "anidb-anime-titles.xml");
const ANILIST_URL = "https://graphql.anilist.co";
const ANIDB_TITLES_URL = "https://anidb.net/api/anime-titles.xml.gz";
const ANIDB_HTTP_API_URL = "http://api.anidb.net:9001/httpapi";
const ANIDB_IMAGE_BASE_URL = "https://cdn.anidb.net/images/main/";
const FETCH_TIMEOUT_MS = 12000;
const ANIDB_TITLES_MAX_AGE_MS = 36 * 60 * 60 * 1000;
const VIDEO_EXTS = new Set([".mp4", ".m4v", ".mkv", ".webm", ".mov", ".avi"]);
const MIME_BY_EXT = {
".mp4": "video/mp4",
@@ -91,6 +102,7 @@ function slugFor(value) {
async function ensureStorage() {
await fsp.mkdir(THUMB_DIR, { recursive: true });
await fsp.mkdir(CACHE_DIR, { recursive: true });
db = new DatabaseSync(DB_FILE);
db.exec(`
PRAGMA journal_mode = WAL;
@@ -184,6 +196,27 @@ function saveTitleMetadata(id, input) {
return { ...current, ...next };
}
function applyFetchedMetadata(id, fetched, { overwrite = false, generatedTitle = "" } = {}) {
const current = getExistingMetadata(id);
if (!current) return null;
const canReplaceGeneratedTitle = generatedTitle && normalizeSearch(current.title) === normalizeSearch(generatedTitle);
const next = {
title: overwrite || !current.title || canReplaceGeneratedTitle ? fetched.title || current.title : current.title,
description: overwrite || !current.description ? fetched.description || current.description : current.description,
thumbnail: overwrite || !current.thumbnail ? fetched.thumbnail || current.thumbnail : current.thumbnail,
updatedAt: new Date().toISOString()
};
db.prepare(`
UPDATE title_metadata
SET title = ?, description = ?, thumbnail = ?, updated_at = ?
WHERE id = ?
`).run(next.title, next.description, next.thumbnail, next.updatedAt, id);
return { ...current, ...next };
}
function setTitleThumbnail(id, thumbnail) {
const current = getExistingMetadata(id);
if (!current) return null;
@@ -209,11 +242,31 @@ function normalizeMetadata(row) {
function naturalName(value) {
return value
.replace(/[._-]+/g, " ")
.replace(/\[[^\]]+\]|\([12][0-9]{3}\)/g, " ")
.replace(/\b(S\d{1,2}|Season\s+\d{1,2}|Episode\s+\d{1,3}|E\d{1,3})\b/gi, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function searchName(value) {
return naturalName(value)
.replace(/\b(uncensored|uncen|subbed|dubbed|sub|dub|ova|oad|specials?|batch|complete|web[- ]?dl|blu[- ]?ray|bdrip|webrip|x264|x265|h264|h265|hevc|aac|flac|1080p|720p|480p)\b/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function normalizeSearch(value) {
return String(value || "")
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, " ")
.trim()
.replace(/\s+/g, " ");
}
async function walkVideos(dir, base = dir) {
let entries;
try {
@@ -349,6 +402,258 @@ async function generateThumbnail(titleId) {
return meta;
}
async function fetchText(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"user-agent": "h-player/1.3 metadata fetcher",
"accept": "application/json, application/xml, text/xml, */*",
...(options.headers || {})
},
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
}
async function fetchBuffer(url) {
const response = await fetch(url, {
headers: { "user-agent": "h-player/1.3 metadata fetcher" },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
function decodeHtml(value) {
return String(value || "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function scoreTitle(candidate, query) {
const left = normalizeSearch(candidate);
const right = normalizeSearch(query);
if (!left || !right) return 0;
if (left === right) return 100;
if (left.includes(right) || right.includes(left)) return 82;
const leftWords = new Set(left.split(" "));
const rightWords = right.split(" ");
const hits = rightWords.filter((word) => leftWords.has(word)).length;
return Math.round((hits / Math.max(rightWords.length, leftWords.size)) * 70);
}
function mediaTitle(media) {
return media?.title?.english || media?.title?.romaji || media?.title?.native || "";
}
function mediaDescription(media) {
const parts = [];
const description = decodeHtml(media?.description || "");
if (description) parts.push(description);
const meta = [
media?.format,
media?.seasonYear,
media?.episodes ? `${media.episodes} episodes` : "",
media?.averageScore ? `${media.averageScore}% AniList score` : ""
].filter(Boolean);
if (meta.length) parts.push(meta.join(" · "));
return parts.join("\n\n");
}
async function fetchAniListMetadata(title) {
const query = `
query ($search: String, $isAdult: Boolean) {
Page(page: 1, perPage: 6) {
media(type: ANIME, search: $search, isAdult: $isAdult, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
id
title { romaji english native }
description(asHtml: false)
episodes
format
seasonYear
averageScore
isAdult
coverImage { extraLarge large }
}
}
}
`;
for (const isAdult of [false, true]) {
const raw = await fetchText(ANILIST_URL, {
method: "POST",
headers: { "content-type": "application/json", "accept": "application/json" },
body: JSON.stringify({ query, variables: { search: title, isAdult } })
});
const json = JSON.parse(raw);
if (json.errors?.length) throw new Error(json.errors[0].message || "AniList request failed");
const media = (json.data?.Page?.media || [])
.map((item) => ({ item, score: Math.max(
scoreTitle(item.title?.romaji, title),
scoreTitle(item.title?.english, title),
scoreTitle(item.title?.native, title)
) }))
.sort((a, b) => b.score - a.score)[0];
if (media && media.score >= 45) {
return {
provider: "anilist",
providerId: String(media.item.id),
title: mediaTitle(media.item),
description: mediaDescription(media.item),
thumbnail: media.item.coverImage?.extraLarge || media.item.coverImage?.large || "",
matchScore: media.score
};
}
}
return null;
}
async function readAniDbTitlesXml() {
try {
const stat = await fsp.stat(ANIDB_TITLES_CACHE);
if (Date.now() - stat.mtimeMs < ANIDB_TITLES_MAX_AGE_MS) {
return fsp.readFile(ANIDB_TITLES_CACHE, "utf8");
}
} catch (err) {
if (err.code !== "ENOENT") throw err;
}
const compressed = await fetchBuffer(ANIDB_TITLES_URL);
const xml = zlib.gunzipSync(compressed).toString("utf8");
await fsp.writeFile(ANIDB_TITLES_CACHE, xml);
return xml;
}
function parseAniDbTitles(xml) {
const byAid = new Map();
const animePattern = /<anime\s+aid="(\d+)">([\s\S]*?)<\/anime>/g;
let animeMatch;
while ((animeMatch = animePattern.exec(xml))) {
const aid = animeMatch[1];
const titles = [];
const titlePattern = /<title\s+[^>]*type="([^"]+)"[^>]*>([\s\S]*?)<\/title>/g;
let titleMatch;
while ((titleMatch = titlePattern.exec(animeMatch[2]))) {
titles.push({ type: titleMatch[1], value: decodeHtml(titleMatch[2]) });
}
if (titles.length) byAid.set(aid, titles);
}
return byAid;
}
function findAniDbMatch(titlesByAid, query) {
let best = null;
for (const [aid, titles] of titlesByAid.entries()) {
for (const title of titles) {
const bonus = title.type === "main" ? 10 : title.type === "official" ? 5 : 0;
const score = scoreTitle(title.value, query) + bonus;
if (!best || score > best.score) best = { aid, title: title.value, score, titles };
}
}
return best && best.score >= 48 ? best : null;
}
function xmlValue(xml, tag) {
const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i"));
return match ? decodeHtml(match[1]) : "";
}
async function fetchAniDbAnimeDetails(aid) {
if (!ANIDB_CLIENT_NAME || !ANIDB_CLIENT_VERSION) return null;
const params = new URLSearchParams({
request: "anime",
client: ANIDB_CLIENT_NAME,
clientver: String(ANIDB_CLIENT_VERSION),
protover: "1",
aid
});
const xml = await fetchText(`${ANIDB_HTTP_API_URL}?${params.toString()}`, {
headers: { "accept": "application/xml, text/xml, */*" }
});
if (/<error/i.test(xml)) throw new Error(xmlValue(xml, "error") || "AniDB request failed");
const picture = xmlValue(xml, "picture");
return {
description: xmlValue(xml, "description"),
thumbnail: picture ? `${ANIDB_IMAGE_BASE_URL}${picture}` : ""
};
}
async function fetchAniDbMetadata(title) {
const titlesByAid = parseAniDbTitles(await readAniDbTitlesXml());
const match = findAniDbMatch(titlesByAid, title);
if (!match) return null;
let details = null;
try {
details = await fetchAniDbAnimeDetails(match.aid);
} catch (err) {
details = { description: `AniDB matched this title, but rich metadata could not be fetched: ${err.message}` };
}
const mainTitle = match.titles.find((item) => item.type === "main")?.value || match.title;
return {
provider: details ? "anidb" : "anidb-titles",
providerId: match.aid,
title: mainTitle,
description: details?.description || "",
thumbnail: details?.thumbnail || "",
matchScore: match.score
};
}
async function fetchTitleMetadata(titleId) {
const title = lastScan.find((item) => item.id === titleId);
if (!title) {
const error = new Error("Title not found");
error.status = 404;
throw error;
}
const query = searchName(title.folder || title.title);
const attempts = [];
try {
const anilist = await fetchAniListMetadata(query);
if (anilist && (anilist.description || anilist.thumbnail)) {
const metadata = applyFetchedMetadata(titleId, anilist, { generatedTitle: naturalName(title.folder) });
await scanLibrary();
return { metadata, fetched: anilist, attempts: ["anilist"] };
}
attempts.push("AniList had no usable match");
} catch (err) {
attempts.push(`AniList failed: ${err.message}`);
}
try {
const anidb = await fetchAniDbMetadata(query);
if (anidb) {
const metadata = applyFetchedMetadata(titleId, anidb, { generatedTitle: naturalName(title.folder) });
await scanLibrary();
return { metadata, fetched: anidb, attempts };
}
attempts.push("AniDB had no title match");
} catch (err) {
attempts.push(`AniDB failed: ${err.message}`);
}
const error = new Error(`No metadata found. ${attempts.join("; ")}`);
error.status = 404;
throw error;
}
async function serveStatic(req, res, pathname) {
const filePath = path.resolve(ROOT, "public", pathname.replace(/^\/+/, ""));
if (!isInside(path.join(ROOT, "public"), filePath)) return send(res, 403, { error: "Forbidden" });
@@ -452,6 +757,11 @@ async function route(req, res) {
return send(res, 200, await generateThumbnail(safeDecode(thumbMatch[1])));
}
const metadataMatch = pathname.match(/^\/api\/titles\/([^/]+)\/metadata$/);
if (metadataMatch && req.method === "POST") {
return send(res, 200, await fetchTitleMetadata(safeDecode(metadataMatch[1])));
}
const videoMatch = pathname.match(/^\/video\/([^/]+)$/);
if (videoMatch && req.method === "GET") {
return streamVideo(req, res, safeDecode(videoMatch[1]));