Initial h-play app
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
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="Private Library"',
|
||||
"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(`Private library 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);
|
||||
});
|
||||
Reference in New Issue
Block a user