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
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 1.4.0 - 2026-09-15
- Added missing metadata fetching from AniList, with AniDB fallback when AniList has no usable match or is unavailable.
- Added a per-title `Metadata` action that fills only missing local metadata fields by default.
- Added cached AniDB title matching and optional rich AniDB HTTP metadata when `ANIDB_CLIENT_NAME` and `ANIDB_CLIENT_VERSION` are configured.
## 1.3.0 - 2026-09-15 ## 1.3.0 - 2026-09-15
- Moved movie and series browsing out of the sidebar into a Jellyfin-style main collection grid. - Moved movie and series browsing out of the sidebar into a Jellyfin-style main collection grid.
+8 -2
View File
@@ -1,6 +1,6 @@
# h-player # h-player
h-player is a small local web app for browsing a folder-based private video collection. The interface follows Kaizoku's dark purple glass-panel design, including the same card gradients, rounded controls, cover fallback treatment, and accent colors. The app remains a standalone private library browser and does not integrate with Plex, Jellyfin, Kodi, or any external metadata provider. h-player is a small local web app for browsing a folder-based private video collection. The interface follows Kaizoku's dark purple glass-panel design, including the same card gradients, rounded controls, cover fallback treatment, and accent colors. The app remains a standalone private library browser and does not integrate with Plex, Jellyfin, or Kodi.
The sidebar holds app navigation, search, and rescanning controls. Movies and series appear in the main collection grid, similar to Jellyfin-style library browsing. Selecting a collection card opens that movie or series folder with its metadata, seasons, episodes, and playable files. The sidebar holds app navigation, search, and rescanning controls. Movies and series appear in the main collection grid, similar to Jellyfin-style library browsing. Selecting a collection card opens that movie or series folder with its metadata, seasons, episodes, and playable files.
@@ -33,6 +33,8 @@ The included compose file keeps the existing `/mnt/ssd2/docker_data/h-play` host
To add a browser password prompt, uncomment `AUTH_USERNAME` and `AUTH_PASSWORD` in `docker-compose.yml`. To add a browser password prompt, uncomment `AUTH_USERNAME` and `AUTH_PASSWORD` in `docker-compose.yml`.
To enable rich AniDB fallback metadata, register an AniDB HTTP API client and set `ANIDB_CLIENT_NAME` plus `ANIDB_CLIENT_VERSION`. Without those values, h-player can still use AniDB's cached public title dump for fallback matching, but rich descriptions and images come from AniList unless AniDB API credentials are configured.
## Run Without Docker ## Run Without Docker
```bash ```bash
@@ -48,6 +50,8 @@ LIBRARY_DIR=/path/to/Library
DATA_DIR=/path/to/private-library-data DATA_DIR=/path/to/private-library-data
AUTH_USERNAME=viewer AUTH_USERNAME=viewer
AUTH_PASSWORD=change-me AUTH_PASSWORD=change-me
ANIDB_CLIENT_NAME=my_registered_client
ANIDB_CLIENT_VERSION=1
``` ```
Set `HOST=0.0.0.0` only when you intentionally want the app reachable from other machines on the network. Use `AUTH_USERNAME` and `AUTH_PASSWORD` before exposing it beyond localhost. Set `HOST=0.0.0.0` only when you intentionally want the app reachable from other machines on the network. Use `AUTH_USERNAME` and `AUTH_PASSWORD` before exposing it beyond localhost.
@@ -74,8 +78,10 @@ Use the app to edit:
The `Thumbnail` button extracts a frame from the first video in a title and stores it under `data/thumbnails`. The `Thumbnail` button extracts a frame from the first video in a title and stores it under `data/thumbnails`.
The `Metadata` button searches AniList first and falls back to AniDB if AniList has no usable result or is unavailable. Provider metadata only fills missing local fields by default, so manually edited descriptions and thumbnails are preserved.
## Privacy Notes ## Privacy Notes
- No external metadata lookup is performed. - External metadata lookup is performed only when the `Metadata` button is used.
- Optional Basic Auth is available through `AUTH_USERNAME` and `AUTH_PASSWORD`. - Optional Basic Auth is available through `AUTH_USERNAME` and `AUTH_PASSWORD`.
- Do not expose this container directly to the public internet. - Do not expose this container directly to the public internet.
+1 -1
View File
@@ -1 +1 @@
1.3.0 1.4.0
+3
View File
@@ -11,6 +11,9 @@ services:
# Uncomment these to require browser Basic Auth. # Uncomment these to require browser Basic Auth.
# AUTH_USERNAME: viewer # AUTH_USERNAME: viewer
# AUTH_PASSWORD: change-me # AUTH_PASSWORD: change-me
# Optional rich AniDB fallback metadata. Register your own AniDB client first.
# ANIDB_CLIENT_NAME: my_registered_client
# ANIDB_CLIENT_VERSION: 1
volumes: volumes:
- /mnt/hdd1/Video/Hentai:/library:ro - /mnt/hdd1/Video/Hentai:/library:ro
- /mnt/ssd2/docker_data/h-play:/data - /mnt/ssd2/docker_data/h-play:/data
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "h-player", "name": "h-player",
"version": "1.3.0", "version": "1.4.0",
"private": true, "private": true,
"description": "A small private web app for browsing a folder-based video collection.", "description": "A small private web app for browsing a folder-based video collection.",
"scripts": { "scripts": {
+16
View File
@@ -119,8 +119,11 @@ function renderDetails(title) {
<h2>${escapeHtml(title.title)}</h2> <h2>${escapeHtml(title.title)}</h2>
<p>${escapeHtml(title.folder)} · ${title.count} file${title.count === 1 ? "" : "s"} · ${bytes(title.size)}</p> <p>${escapeHtml(title.folder)} · ${title.count} file${title.count === 1 ? "" : "s"} · ${bytes(title.size)}</p>
</div> </div>
<div class="summary__actions">
<button id="fetchMetadataBtn" type="button">Metadata</button>
<button id="makeThumbBtn" type="button">Thumbnail</button> <button id="makeThumbBtn" type="button">Thumbnail</button>
</div> </div>
</div>
<form id="metaForm" class="meta-form"> <form id="metaForm" class="meta-form">
<label> <label>
<span>Display title</span> <span>Display title</span>
@@ -169,6 +172,19 @@ function renderDetails(title) {
event.currentTarget.textContent = "Thumbnail"; event.currentTarget.textContent = "Thumbnail";
} }
}); });
document.querySelector("#fetchMetadataBtn").addEventListener("click", async (event) => {
event.currentTarget.disabled = true;
event.currentTarget.textContent = "Fetching";
try {
await api(`/api/titles/${encodeURIComponent(title.id)}/metadata`, { method: "POST", body: "{}" });
await loadLibrary();
} catch (err) {
alert(err.message);
event.currentTarget.disabled = false;
event.currentTarget.textContent = "Metadata";
}
});
} }
function renderSeason(season) { function renderSeason(season) {
+7
View File
@@ -434,6 +434,13 @@ label {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.summary__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.summary h2 { .summary h2 {
font-size: clamp(1.8rem, 4vw, 3.4rem); font-size: clamp(1.8rem, 4vw, 3.4rem);
line-height: 1.02; line-height: 1.02;
+310
View File
@@ -5,6 +5,7 @@ const path = require("node:path");
const crypto = require("node:crypto"); const crypto = require("node:crypto");
const { spawn } = require("node:child_process"); const { spawn } = require("node:child_process");
const { URL } = require("node:url"); const { URL } = require("node:url");
const zlib = require("node:zlib");
const { DatabaseSync } = require("node:sqlite"); const { DatabaseSync } = require("node:sqlite");
const ROOT = __dirname; 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 DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(ROOT, "data"));
const AUTH_USERNAME = process.env.AUTH_USERNAME || ""; const AUTH_USERNAME = process.env.AUTH_USERNAME || "";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || ""; 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 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 DB_FILE = path.join(DATA_DIR, "library.sqlite");
const LEGACY_META_FILE = path.join(DATA_DIR, "metadata.json"); 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 VIDEO_EXTS = new Set([".mp4", ".m4v", ".mkv", ".webm", ".mov", ".avi"]);
const MIME_BY_EXT = { const MIME_BY_EXT = {
".mp4": "video/mp4", ".mp4": "video/mp4",
@@ -91,6 +102,7 @@ function slugFor(value) {
async function ensureStorage() { async function ensureStorage() {
await fsp.mkdir(THUMB_DIR, { recursive: true }); await fsp.mkdir(THUMB_DIR, { recursive: true });
await fsp.mkdir(CACHE_DIR, { recursive: true });
db = new DatabaseSync(DB_FILE); db = new DatabaseSync(DB_FILE);
db.exec(` db.exec(`
PRAGMA journal_mode = WAL; PRAGMA journal_mode = WAL;
@@ -184,6 +196,27 @@ function saveTitleMetadata(id, input) {
return { ...current, ...next }; 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) { function setTitleThumbnail(id, thumbnail) {
const current = getExistingMetadata(id); const current = getExistingMetadata(id);
if (!current) return null; if (!current) return null;
@@ -209,11 +242,31 @@ function normalizeMetadata(row) {
function naturalName(value) { function naturalName(value) {
return value return value
.replace(/[._-]+/g, " ") .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, " ") .replace(/\s+/g, " ")
.trim() .trim()
.replace(/\b\w/g, (char) => char.toUpperCase()); .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) { async function walkVideos(dir, base = dir) {
let entries; let entries;
try { try {
@@ -349,6 +402,258 @@ async function generateThumbnail(titleId) {
return meta; 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) { async function serveStatic(req, res, pathname) {
const filePath = path.resolve(ROOT, "public", pathname.replace(/^\/+/, "")); const filePath = path.resolve(ROOT, "public", pathname.replace(/^\/+/, ""));
if (!isInside(path.join(ROOT, "public"), filePath)) return send(res, 403, { error: "Forbidden" }); 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]))); 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\/([^/]+)$/); const videoMatch = pathname.match(/^\/video\/([^/]+)$/);
if (videoMatch && req.method === "GET") { if (videoMatch && req.method === "GET") {
return streamVideo(req, res, safeDecode(videoMatch[1])); return streamVideo(req, res, safeDecode(videoMatch[1]));