const http = require("node:http"); const fs = require("node:fs"); const fsp = require("node:fs/promises"); 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; const PORT = Number(process.env.PORT || 3000); const HOST = process.env.HOST || "127.0.0.1"; const LIBRARY_DIR = path.resolve(process.env.LIBRARY_DIR || path.join(ROOT, "Library")); 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 VIDEO_THUMB_DIR = path.join(DATA_DIR, "episode-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", ".m4v": "video/mp4", ".mkv": "video/x-matroska", ".webm": "video/webm", ".mov": "video/quicktime", ".avi": "video/x-msvideo" }; let db; let lastScan = []; const pendingVideoThumbs = new Set(); const failedVideoThumbs = new Set(); function send(res, status, body, type = "application/json") { const payload = type === "application/json" ? JSON.stringify(body) : body; res.writeHead(status, { "content-type": `${type}; charset=utf-8`, "cache-control": "no-store" }); res.end(payload); } function unauthorized(res) { res.writeHead(401, { "www-authenticate": 'Basic realm="h-player"', "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); res.end("Authentication required"); } function timingSafeEqualText(left, right) { const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); if (leftBuffer.length !== rightBuffer.length) return false; return crypto.timingSafeEqual(leftBuffer, rightBuffer); } function isAuthorized(req) { if (!AUTH_USERNAME && !AUTH_PASSWORD) return true; const header = req.headers.authorization || ""; if (!header.startsWith("Basic ")) return false; let decoded = ""; try { decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); } catch { return false; } const separator = decoded.indexOf(":"); if (separator === -1) return false; const username = decoded.slice(0, separator); const password = decoded.slice(separator + 1); return timingSafeEqualText(username, AUTH_USERNAME) && timingSafeEqualText(password, AUTH_PASSWORD); } function safeDecode(value) { try { return decodeURIComponent(value); } catch { return value; } } function isInside(parent, child) { const rel = path.relative(parent, child); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); } function slugFor(value) { return crypto.createHash("sha256").update(value).digest("hex").slice(0, 16); } async function ensureStorage() { await fsp.mkdir(THUMB_DIR, { recursive: true }); await fsp.mkdir(VIDEO_THUMB_DIR, { recursive: true }); await fsp.mkdir(CACHE_DIR, { recursive: true }); db = new DatabaseSync(DB_FILE); db.exec(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS title_metadata ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', thumbnail TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); `); await migrateLegacyJson(); } async function migrateLegacyJson() { let legacy; try { legacy = JSON.parse(await fsp.readFile(LEGACY_META_FILE, "utf8")); } catch (err) { if (err.code === "ENOENT") return; throw err; } const insert = db.prepare(` INSERT OR IGNORE INTO title_metadata (id, title, description, thumbnail, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) `); const now = new Date().toISOString(); for (const [id, item] of Object.entries(legacy || {})) { insert.run( id, String(item.title || "Untitled"), String(item.description || ""), String(item.thumbnail || ""), String(item.createdAt || item.created_at || now), String(item.updatedAt || item.updated_at || now) ); } } function getTitleMetadata(id, folder) { const existing = db.prepare("SELECT * FROM title_metadata WHERE id = ?").get(id); if (existing) return normalizeMetadata(existing); const now = new Date().toISOString(); db.prepare(` INSERT INTO title_metadata (id, title, description, thumbnail, created_at, updated_at) VALUES (?, ?, '', '', ?, ?) `).run(id, naturalName(folder), now, now); return { id, title: naturalName(folder), description: "", thumbnail: "", createdAt: now, updatedAt: now }; } function getExistingMetadata(id) { const existing = db.prepare("SELECT * FROM title_metadata WHERE id = ?").get(id); return existing ? normalizeMetadata(existing) : null; } function saveTitleMetadata(id, input) { const current = getExistingMetadata(id); if (!current) return null; const next = { title: String(input.title || "").trim() || current.title, description: String(input.description || "").trim(), thumbnail: String(input.thumbnail || "").trim(), 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 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; const updatedAt = new Date().toISOString(); db.prepare("UPDATE title_metadata SET thumbnail = ?, updated_at = ? WHERE id = ?") .run(thumbnail, updatedAt, id); return { ...current, thumbnail, updatedAt }; } function normalizeMetadata(row) { return { id: row.id, title: row.title, description: row.description, thumbnail: row.thumbnail, createdAt: row.created_at, updatedAt: row.updated_at }; } 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 { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch (err) { if (err.code === "ENOENT") return []; throw err; } const videos = []; for (const entry of entries) { if (entry.name.startsWith(".")) continue; const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { videos.push(...await walkVideos(fullPath, base)); continue; } if (entry.isFile() && VIDEO_EXTS.has(path.extname(entry.name).toLowerCase())) { const relativePath = path.relative(base, fullPath).split(path.sep).join("/"); const stat = await fsp.stat(fullPath); videos.push({ id: slugFor(relativePath), name: path.basename(entry.name, path.extname(entry.name)), fileName: entry.name, relativePath, size: stat.size, mtime: stat.mtimeMs }); } } return videos.sort((a, b) => a.relativePath.localeCompare(b.relativePath, undefined, { numeric: true })); } async function scanLibrary() { const titles = []; let roots; try { roots = await fsp.readdir(LIBRARY_DIR, { withFileTypes: true }); } catch (err) { if (err.code === "ENOENT") { lastScan = []; return lastScan; } throw err; } for (const entry of roots) { if (!entry.isDirectory() || entry.name.startsWith(".")) continue; const titlePath = path.join(LIBRARY_DIR, entry.name); const videos = await walkVideos(titlePath); if (!videos.length) continue; const id = slugFor(entry.name); const meta = getTitleMetadata(id, entry.name); const seasons = groupSeasons(videos); await attachVideoThumbnails(id, titlePath, videos); titles.push({ id, folder: entry.name, title: meta.title || naturalName(entry.name), description: meta.description || "", thumbnail: meta.thumbnail || "", count: videos.length, size: videos.reduce((sum, video) => sum + video.size, 0), updatedAt: Math.max(...videos.map((video) => video.mtime)), seasons, videos }); } titles.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true })); lastScan = titles; return lastScan; } async function attachVideoThumbnails(titleId, titlePath, videos) { for (const video of videos) { const outputName = `${titleId}-${video.id}.webp`; const output = path.join(VIDEO_THUMB_DIR, outputName); video.thumbnail = `/episode-thumbs/${outputName}`; try { await fsp.access(output, fs.constants.R_OK); } catch { scheduleVideoThumbnail(titleId, titlePath, video); } } } function scheduleVideoThumbnail(titleId, titlePath, video) { const key = `${titleId}:${video.id}`; if (pendingVideoThumbs.has(key) || failedVideoThumbs.has(key)) return; pendingVideoThumbs.add(key); generateVideoThumbnail(titleId, titlePath, video) .catch((err) => { failedVideoThumbs.add(key); console.error(`Episode thumbnail failed for ${video.relativePath}: ${err.message}`); }) .finally(() => pendingVideoThumbs.delete(key)); } async function generateVideoThumbnail(titleId, titlePath, video) { const source = path.resolve(titlePath, video.relativePath); if (!isInside(titlePath, source)) throw new Error("Invalid video path"); const output = path.join(VIDEO_THUMB_DIR, `${titleId}-${video.id}.webp`); const tmpOutput = `${output}.part`; try { await fsp.unlink(tmpOutput); } catch (err) { if (err.code !== "ENOENT") throw err; } await new Promise((resolve, reject) => { const ffmpeg = spawn("ffmpeg", [ "-y", "-ss", "00:00:12", "-t", "4", "-i", source, "-vf", "fps=6,scale=360:-1:flags=lanczos", "-loop", "0", "-an", "-f", "webp", tmpOutput ], { stdio: "ignore" }); ffmpeg.on("error", reject); ffmpeg.on("close", (code) => { if (code === 0) resolve(); else reject(new Error("ffmpeg could not generate an animated thumbnail")); }); }); await fsp.rename(tmpOutput, output); } function groupSeasons(videos) { const groups = new Map(); for (const video of videos) { const parts = video.relativePath.split("/"); const season = parts.length > 1 ? parts[0] : "Movies / Specials"; if (!groups.has(season)) groups.set(season, []); groups.get(season).push(video); } return [...groups.entries()].map(([name, items]) => ({ name, videos: items })); } function findVideo(videoId) { for (const title of lastScan) { const video = title.videos.find((item) => item.id === videoId); if (video) return { title, video }; } return null; } async function readBody(req) { const chunks = []; for await (const chunk of req) chunks.push(chunk); return Buffer.concat(chunks).toString("utf8"); } async function generateThumbnail(titleId) { const title = lastScan.find((item) => item.id === titleId); if (!title || !title.videos.length) { const error = new Error("Title not found"); error.status = 404; throw error; } const source = path.resolve(LIBRARY_DIR, title.folder, title.videos[0].relativePath); if (!isInside(LIBRARY_DIR, source)) throw new Error("Invalid video path"); const outputName = `${titleId}.jpg`; const output = path.join(THUMB_DIR, outputName); await new Promise((resolve, reject) => { const ffmpeg = spawn("ffmpeg", [ "-y", "-ss", "00:00:08", "-i", source, "-frames:v", "1", "-vf", "scale=640:-1", output ], { stdio: "ignore" }); ffmpeg.on("error", reject); ffmpeg.on("close", (code) => { if (code === 0) resolve(); else reject(new Error("ffmpeg could not generate a thumbnail")); }); }); const meta = setTitleThumbnail(titleId, `/thumbs/${outputName}`); await scanLibrary(); 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(//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 = /([\s\S]*?)<\/anime>/g; let animeMatch; while ((animeMatch = animePattern.exec(xml))) { const aid = animeMatch[1]; const titles = []; const titlePattern = /]*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 (/ 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" }); try { const data = await fsp.readFile(filePath); const ext = path.extname(filePath); const types = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript", ".svg": "image/svg+xml" }; send(res, 200, data, types[ext] || "application/octet-stream"); } catch (err) { if (err.code === "ENOENT") send(res, 404, { error: "Not found" }); else throw err; } } async function serveThumbnail(res, name) { const filePath = path.resolve(THUMB_DIR, safeDecode(name)); if (!isInside(THUMB_DIR, filePath)) return send(res, 403, { error: "Forbidden" }); try { const data = await fsp.readFile(filePath); res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "private, max-age=3600" }); res.end(data); } catch (err) { if (err.code === "ENOENT") send(res, 404, { error: "Not found" }); else throw err; } } async function serveEpisodeThumbnail(res, name) { const filePath = path.resolve(VIDEO_THUMB_DIR, safeDecode(name)); if (!isInside(VIDEO_THUMB_DIR, filePath)) return send(res, 403, { error: "Forbidden" }); try { const data = await fsp.readFile(filePath); res.writeHead(200, { "content-type": "image/webp", "cache-control": "private, max-age=86400" }); res.end(data); } catch (err) { if (err.code === "ENOENT") send(res, 404, { error: "Not found" }); else throw err; } } async function streamVideo(req, res, videoId) { const match = findVideo(videoId); if (!match) return send(res, 404, { error: "Video not found" }); const filePath = path.resolve(LIBRARY_DIR, match.title.folder, match.video.relativePath); if (!isInside(LIBRARY_DIR, filePath)) return send(res, 403, { error: "Forbidden" }); const stat = await fsp.stat(filePath); const range = req.headers.range; const type = MIME_BY_EXT[path.extname(filePath).toLowerCase()] || "application/octet-stream"; if (!range) { res.writeHead(200, { "content-length": stat.size, "content-type": type, "accept-ranges": "bytes" }); fs.createReadStream(filePath).pipe(res); return; } const [startRaw, endRaw] = range.replace(/bytes=/, "").split("-"); const start = Number(startRaw); const end = endRaw ? Math.min(Number(endRaw), stat.size - 1) : stat.size - 1; if (Number.isNaN(start) || Number.isNaN(end) || start > end) { res.writeHead(416, { "content-range": `bytes */${stat.size}` }); res.end(); return; } res.writeHead(206, { "content-range": `bytes ${start}-${end}/${stat.size}`, "accept-ranges": "bytes", "content-length": end - start + 1, "content-type": type }); fs.createReadStream(filePath, { start, end }).pipe(res); } async function route(req, res) { if (!isAuthorized(req)) return unauthorized(res); const url = new URL(req.url, `http://${req.headers.host}`); const pathname = url.pathname; if (pathname === "/" || pathname === "/index.html") return serveStatic(req, res, "/index.html"); if (pathname.startsWith("/assets/")) return serveStatic(req, res, pathname); if (pathname.startsWith("/thumbs/")) return serveThumbnail(res, pathname.slice("/thumbs/".length)); if (pathname.startsWith("/episode-thumbs/")) return serveEpisodeThumbnail(res, pathname.slice("/episode-thumbs/".length)); if (pathname === "/api/library" && req.method === "GET") { return send(res, 200, { libraryDir: LIBRARY_DIR, titles: await scanLibrary() }); } if (pathname === "/api/rescan" && req.method === "POST") { return send(res, 200, { titles: await scanLibrary() }); } const metaMatch = pathname.match(/^\/api\/titles\/([^/]+)$/); if (metaMatch && req.method === "PATCH") { const id = safeDecode(metaMatch[1]); if (!getExistingMetadata(id)) return send(res, 404, { error: "Title not found" }); const input = JSON.parse(await readBody(req) || "{}"); const meta = saveTitleMetadata(id, input); await scanLibrary(); return send(res, 200, meta); } const thumbMatch = pathname.match(/^\/api\/titles\/([^/]+)\/thumbnail$/); if (thumbMatch && req.method === "POST") { 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])); } return send(res, 404, { error: "Not found" }); } ensureStorage() .then(scanLibrary) .then(() => { const server = http.createServer((req, res) => { route(req, res).catch((err) => { console.error(err); send(res, err.status || 500, { error: err.message || "Server error" }); }); }); server.on("error", (err) => { console.error(`Could not start server: ${err.message}`); process.exit(1); }); server.listen(PORT, HOST, () => { console.log(`h-player listening on http://${HOST}:${PORT}`); console.log(`Library: ${LIBRARY_DIR}`); console.log(`Database: ${DB_FILE}`); if (AUTH_USERNAME || AUTH_PASSWORD) console.log("Basic authentication is enabled"); }); }) .catch((err) => { console.error(err); process.exit(1); });