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 { 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 THUMB_DIR = path.join(DATA_DIR, "thumbnails"); const DB_FILE = path.join(DATA_DIR, "library.sqlite"); const LEGACY_META_FILE = path.join(DATA_DIR, "metadata.json"); 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 = []; 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 }); 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 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(/\s+/g, " ") .trim() .replace(/\b\w/g, (char) => char.toUpperCase()); } 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); 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; } 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 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 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 === "/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 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); });