Add anime metadata providers
This commit is contained in:
@@ -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(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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]));
|
||||
|
||||
Reference in New Issue
Block a user