487 lines
15 KiB
JavaScript
487 lines
15 KiB
JavaScript
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);
|
|
});
|