Add anime metadata providers
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# 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
|
||||
|
||||
- Moved movie and series browsing out of the sidebar into a Jellyfin-style main collection grid.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -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 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
|
||||
|
||||
```bash
|
||||
@@ -48,6 +50,8 @@ LIBRARY_DIR=/path/to/Library
|
||||
DATA_DIR=/path/to/private-library-data
|
||||
AUTH_USERNAME=viewer
|
||||
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.
|
||||
@@ -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 `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
|
||||
|
||||
- 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`.
|
||||
- Do not expose this container directly to the public internet.
|
||||
|
||||
@@ -11,6 +11,9 @@ services:
|
||||
# Uncomment these to require browser Basic Auth.
|
||||
# AUTH_USERNAME: viewer
|
||||
# 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:
|
||||
- /mnt/hdd1/Video/Hentai:/library:ro
|
||||
- /mnt/ssd2/docker_data/h-play:/data
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "h-player",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"description": "A small private web app for browsing a folder-based video collection.",
|
||||
"scripts": {
|
||||
|
||||
@@ -119,8 +119,11 @@ function renderDetails(title) {
|
||||
<h2>${escapeHtml(title.title)}</h2>
|
||||
<p>${escapeHtml(title.folder)} · ${title.count} file${title.count === 1 ? "" : "s"} · ${bytes(title.size)}</p>
|
||||
</div>
|
||||
<div class="summary__actions">
|
||||
<button id="fetchMetadataBtn" type="button">Metadata</button>
|
||||
<button id="makeThumbBtn" type="button">Thumbnail</button>
|
||||
</div>
|
||||
</div>
|
||||
<form id="metaForm" class="meta-form">
|
||||
<label>
|
||||
<span>Display title</span>
|
||||
@@ -169,6 +172,19 @@ function renderDetails(title) {
|
||||
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) {
|
||||
|
||||
@@ -434,6 +434,13 @@ label {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.summary__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.summary h2 {
|
||||
font-size: clamp(1.8rem, 4vw, 3.4rem);
|
||||
line-height: 1.02;
|
||||
|
||||
@@ -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