From e1dfdc7bd7b53e3b3ce606430bd2e3c4e528e5b9 Mon Sep 17 00:00:00 2001 From: Dymas Date: Tue, 15 Sep 2026 15:18:12 +0200 Subject: [PATCH] Initial h-play app --- .dockerignore | 7 + .gitignore | 8 + CHANGELOG.md | 7 + Dockerfile | 18 ++ README.md | 77 +++++++ VERSION | 1 + docker-compose.yml | 16 ++ package.json | 13 ++ public/assets/app.js | 212 +++++++++++++++++ public/assets/styles.css | 470 +++++++++++++++++++++++++++++++++++++ public/index.html | 60 +++++ server.js | 486 +++++++++++++++++++++++++++++++++++++++ 12 files changed, 1375 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 VERSION create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/assets/app.js create mode 100644 public/assets/styles.css create mode 100644 public/index.html create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e628746 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +Library +data +node_modules +.git +.agents +.codex +npm-debug.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1eba13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +AGENTS.md +Library/ +data/ +node_modules/ +npm-debug.log +.agents/ +.codex/ +.repo.git/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b19cfe8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 1.1.0 - 2026-09-15 + +- Restyled the web UI to match Kaizoku's dark purple glass-panel design. +- Added a Kaizoku-branded sidebar shell, collection badge, rounded navigation controls, and redesigned detail panels. +- Changed the server to bind to `127.0.0.1` by default, with `HOST` available for explicit overrides. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0970893 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:24-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY package.json ./ +COPY server.js ./ +COPY public ./public + +ENV NODE_ENV=production +ENV PORT=3000 +ENV LIBRARY_DIR=/library +ENV DATA_DIR=/data + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6f83cf --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Kaizoku-Styled Private Anime Library + +A small local web app for browsing a folder-based private video collection. The interface uses a Kaizoku-inspired dark purple glass-panel design, but the app remains a standalone private library browser. It does not integrate with Plex, Jellyfin, Kodi, or any external metadata provider. + +## Folder Layout + +Place media in this shape: + +```text +Library/ + Movie Or Series/ + filename.mp4 + Series With Seasons/ + Season 01/ + episode-01.mp4 +``` + +Supported extensions: `.mp4`, `.m4v`, `.mkv`, `.webm`, `.mov`, `.avi`. + +## Run With Docker + +```bash +docker compose up --build +``` + +Open http://localhost:3000. + +The app binds to `127.0.0.1` by default, so it is only reachable from the same machine unless you explicitly set `HOST`. The compose file publishes the container on `127.0.0.1:3000`. Your library is mounted read-only, while metadata and generated thumbnails are saved in `./data`. + +To add a browser password prompt, uncomment `AUTH_USERNAME` and `AUTH_PASSWORD` in `docker-compose.yml`. + +## Run Without Docker + +```bash +npm start +``` + +Optional environment variables: + +```bash +HOST=127.0.0.1 +PORT=3000 +LIBRARY_DIR=/path/to/Library +DATA_DIR=/path/to/private-library-data +AUTH_USERNAME=viewer +AUTH_PASSWORD=change-me +``` + +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. + +Thumbnail generation requires `ffmpeg` on the host. Docker includes it. + +## Metadata Storage + +Metadata is stored in SQLite at: + +```text +data/library.sqlite +``` + +If an older `data/metadata.json` file exists, the app imports it into SQLite on startup without deleting the JSON file. + +## Metadata Fields + +Use the app to edit: + +- display title +- short description +- thumbnail path or URL + +The `Thumbnail` button extracts a frame from the first video in a title and stores it under `data/thumbnails`. + +## Privacy Notes + +- No external metadata lookup is performed. +- Optional Basic Auth is available through `AUTH_USERNAME` and `AUTH_PASSWORD`. +- Do not expose this container directly to the public internet. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..9084fa2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.1.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d06fde4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + private-library: + build: . + container_name: private-library + ports: + - "127.0.0.1:3000:3000" + environment: + LIBRARY_DIR: /library + DATA_DIR: /data + # Uncomment these to require browser Basic Auth. + # AUTH_USERNAME: viewer + # AUTH_PASSWORD: change-me + volumes: + - /mnt/hdd1/Video/Hentai:/library:ro + - /mnt/ssd2/docker_data/h-play:/data + restart: unless-stopped diff --git a/package.json b/package.json new file mode 100644 index 0000000..df728ce --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "private-anime-library", + "version": "1.1.0", + "private": true, + "description": "A small private web app for browsing a folder-based video collection.", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "engines": { + "node": ">=24" + } +} diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..c063eb4 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1,212 @@ +const state = { + titles: [], + activeId: "", + query: "" +}; + +const els = { + libraryBadge: document.querySelector("#libraryBadge"), + libraryPath: document.querySelector("#libraryPath"), + rescanBtn: document.querySelector("#rescanBtn"), + searchInput: document.querySelector("#searchInput"), + titleList: document.querySelector("#titleList"), + details: document.querySelector("#details"), + titleButtonTemplate: document.querySelector("#titleButtonTemplate") +}; + +function bytes(value) { + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = value; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +async function api(path, options = {}) { + const response = await fetch(path, { + headers: { "content-type": "application/json" }, + ...options + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Request failed"); + return data; +} + +async function loadLibrary() { + const data = await api("/api/library"); + state.titles = data.titles; + els.libraryPath.textContent = data.libraryDir; + els.libraryBadge.textContent = `${state.titles.length} title${state.titles.length === 1 ? "" : "s"}`; + if (!state.activeId && state.titles.length) state.activeId = state.titles[0].id; + if (!state.titles.some((title) => title.id === state.activeId)) { + state.activeId = state.titles[0]?.id || ""; + } + render(); +} + +function filteredTitles() { + const query = state.query.toLowerCase(); + if (!query) return state.titles; + return state.titles.filter((title) => { + return [ + title.title, + title.folder, + title.description, + ...title.videos.map((video) => video.relativePath) + ].join(" ").toLowerCase().includes(query); + }); +} + +function render() { + renderTitleList(); + renderDetails(); +} + +function renderTitleList() { + els.titleList.innerHTML = ""; + for (const title of filteredTitles()) { + const node = els.titleButtonTemplate.content.firstElementChild.cloneNode(true); + node.classList.toggle("is-active", title.id === state.activeId); + node.querySelector(".title-button__name").textContent = title.title; + node.querySelector(".title-button__meta").textContent = `${title.count} file${title.count === 1 ? "" : "s"} · ${bytes(title.size)}`; + node.addEventListener("click", () => { + state.activeId = title.id; + render(); + }); + els.titleList.append(node); + } +} + +function renderDetails() { + const title = state.titles.find((item) => item.id === state.activeId); + if (!title) { + els.details.innerHTML = ` +
+

No titles found

+

Expected: Library/<Movie or Series>/filename.mp4 or Library/<Series>/Season 01/filename.mp4

+
+ `; + return; + } + + els.details.innerHTML = ` +
+
${title.thumbnail ? `` : `${initials(title.title)}`}
+
+
+
+

${escapeHtml(title.title)}

+

${escapeHtml(title.folder)} · ${title.count} file${title.count === 1 ? "" : "s"} · ${bytes(title.size)}

+
+ +
+
+ + + + +
+
+
+
+ ${title.seasons.map(renderSeason).join("")} +
+ `; + + document.querySelector("#metaForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + await api(`/api/titles/${encodeURIComponent(title.id)}`, { + method: "PATCH", + body: JSON.stringify({ + title: form.get("title"), + thumbnail: form.get("thumbnail"), + description: form.get("description") + }) + }); + await loadLibrary(); + }); + + document.querySelector("#makeThumbBtn").addEventListener("click", async (event) => { + event.currentTarget.disabled = true; + event.currentTarget.textContent = "Working"; + try { + await api(`/api/titles/${encodeURIComponent(title.id)}/thumbnail`, { method: "POST", body: "{}" }); + await loadLibrary(); + } catch (err) { + alert(err.message); + event.currentTarget.disabled = false; + event.currentTarget.textContent = "Thumbnail"; + } + }); +} + +function renderSeason(season) { + return ` +
+

${escapeHtml(season.name)}

+
+ ${season.videos.map((video) => ` +
+
+ ${escapeHtml(video.name)} + ${escapeHtml(video.relativePath)} · ${bytes(video.size)} +
+ +
+ `).join("")} +
+
+ `; +} + +function initials(value) { + return value.split(/\s+/).slice(0, 2).map((part) => part[0] || "").join("").toUpperCase(); +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + })[char]); +} + +function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, "`"); +} + +els.searchInput.addEventListener("input", (event) => { + state.query = event.target.value; + renderTitleList(); +}); + +els.rescanBtn.addEventListener("click", async () => { + els.rescanBtn.disabled = true; + els.rescanBtn.textContent = "Scanning"; + try { + await api("/api/rescan", { method: "POST", body: "{}" }); + await loadLibrary(); + } finally { + els.rescanBtn.disabled = false; + els.rescanBtn.textContent = "Rescan"; + } +}); + +loadLibrary().catch((err) => { + els.details.innerHTML = `

Could not load library

${escapeHtml(err.message)}

`; +}); diff --git a/public/assets/styles.css b/public/assets/styles.css new file mode 100644 index 0000000..aa39638 --- /dev/null +++ b/public/assets/styles.css @@ -0,0 +1,470 @@ +:root { + color-scheme: dark; + --bg: #090611; + --bg-2: #140d21; + --panel: rgba(25, 18, 41, 0.82); + --panel-strong: rgba(18, 13, 31, 0.92); + --panel-2: rgba(157, 123, 255, 0.12); + --line: rgba(255, 255, 255, 0.08); + --line-strong: rgba(157, 123, 255, 0.26); + --text: #f6f2ff; + --muted: #b8b0cf; + --accent: #9d7bff; + --accent-strong: #7b5cff; + --accent-soft: rgba(157, 123, 255, 0.14); + --good: #95e4ba; + --bad: #ff90a4; + --focus: #b296ff; + --shadow: 0 24px 60px rgba(5, 2, 15, 0.42); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(157, 123, 255, 0.18), transparent 28%), + radial-gradient(circle at top right, rgba(117, 201, 255, 0.12), transparent 24%), + linear-gradient(180deg, #120c1d 0%, #0b0813 56%, #07050d 100%); + color: var(--text); + font: 15px/1.5 "Segoe UI", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%); + opacity: 0.5; +} + +button, +input, +textarea { + font: inherit; +} + +button { + min-height: 40px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel-2); + color: var(--text); + padding: 0 14px; + cursor: pointer; + transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease; +} + +button:hover { + border-color: var(--line-strong); + background: rgba(157, 123, 255, 0.18); + transform: translateY(-1px); +} + +button:disabled { + cursor: wait; + opacity: 0.72; + transform: none; +} + +input, +textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(10, 7, 18, 0.82); + color: var(--text); + padding: 0.75rem 0.85rem; + outline: none; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +input { + min-height: 42px; +} + +textarea { + resize: vertical; +} + +input:focus, +textarea:focus { + border-color: var(--focus); + box-shadow: 0 0 0 3px rgba(178, 150, 255, 0.12); +} + +h1, +h2, +h3, +p { + margin: 0; +} + +h1 { + font-size: 29px; + line-height: 1.05; + letter-spacing: 0; +} + +h2 { + font-size: 17px; + letter-spacing: 0; +} + +h3 { + font-size: 15px; +} + +.app { + position: relative; + display: grid; + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar, +.layout { + padding: 22px; +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + overflow: auto; + border-right: 1px solid var(--line); + background: linear-gradient(180deg, rgba(25, 18, 41, 0.94), rgba(14, 10, 24, 0.9)); + display: grid; + align-content: start; + gap: 20px; + backdrop-filter: blur(18px) saturate(170%); +} + +.layout { + display: grid; + gap: 20px; + min-width: 0; + align-content: start; +} + +.topline, +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.brand-wrap { + display: grid; + gap: 6px; +} + +.shell-note, +.muted, +.title-button__meta, +.episode span, +.summary p, +label span { + color: var(--muted); + font-size: 13px; +} + +.badge { + border: 1px solid var(--line); + border-radius: 999px; + padding: 6px 10px; + color: var(--muted); + background: rgba(255, 255, 255, 0.04); + font-size: 12px; + line-height: 1; + white-space: nowrap; +} + +.page-links { + display: grid; + gap: 8px; + padding: 6px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.03); +} + +.page-link, +.page-action { + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + min-height: 40px; + border: 0; + border-radius: 12px; + padding: 0 14px; + color: var(--muted); + background: transparent; + font-weight: 700; + text-decoration: none; + transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease; +} + +.page-link.active, +.page-action:hover { + background: var(--accent-soft); + color: var(--text); + box-shadow: inset 0 0 0 1px rgba(157, 123, 255, 0.22); +} + +.page-link:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.05); +} + +.search-label, +label { + display: grid; + gap: 7px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.title-list { + display: grid; + gap: 8px; + min-width: 0; +} + +.title-button { + display: grid; + gap: 0.2rem; + width: 100%; + min-height: 62px; + border-radius: 16px; + text-align: left; + background: rgba(255, 255, 255, 0.03); +} + +.title-button.is-active { + border-color: rgba(157, 123, 255, 0.34); + background: var(--accent-soft); + box-shadow: inset 0 0 0 1px rgba(157, 123, 255, 0.16); +} + +.title-button__name { + font-weight: 800; + overflow-wrap: anywhere; +} + +.toolbar, +.details, +.empty, +.episode { + border: 1px solid var(--line); + border-radius: 22px; + background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94)); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.toolbar { + min-height: 76px; + padding: 18px; +} + +.details { + padding: 18px; + overflow: hidden; +} + +.empty { + min-height: 55vh; + display: grid; + place-content: center; + text-align: center; + gap: 0.6rem; + color: var(--muted); + box-shadow: none; +} + +.empty h2 { + color: var(--text); +} + +.hero { + display: grid; + grid-template-columns: minmax(190px, 260px) minmax(0, 1fr); + gap: 18px; + align-items: start; +} + +.poster { + aspect-ratio: 2 / 3; + border: 1px solid var(--line); + border-radius: 20px; + background: + radial-gradient(circle at 35% 20%, rgba(157, 123, 255, 0.28), transparent 34%), + linear-gradient(180deg, rgba(32, 24, 54, 0.9), rgba(17, 13, 29, 0.96)); + overflow: hidden; + display: grid; + place-items: center; + box-shadow: 0 16px 36px rgba(5, 2, 15, 0.3); +} + +.poster img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.poster span { + color: var(--accent); + font-size: clamp(2.5rem, 7vw, 5rem); + font-weight: 900; +} + +.summary { + min-width: 0; +} + +.summary__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.summary h2 { + font-size: clamp(1.8rem, 4vw, 3.4rem); + line-height: 1.02; + overflow-wrap: anywhere; +} + +.summary p { + margin-top: 0.45rem; +} + +.meta-form { + display: grid; + gap: 0.85rem; + max-width: 780px; +} + +.meta-form button { + justify-self: start; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border-color: transparent; + color: #fcfaff; + font-weight: 800; + box-shadow: 0 16px 32px rgba(123, 92, 255, 0.26); +} + +.seasons { + display: grid; + gap: 1rem; + margin-top: 1.5rem; +} + +.season { + border-top: 1px solid var(--line); + padding-top: 1rem; +} + +.season h3 { + color: #ddd0ff; + font-size: 13px; + font-weight: 800; + margin-bottom: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.episodes { + display: grid; + gap: 0.9rem; +} + +.episode { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(280px, 520px); + gap: 1rem; + align-items: center; + padding: 0.85rem; + box-shadow: none; +} + +.episode div { + display: grid; + gap: 0.25rem; + min-width: 0; +} + +.episode strong, +.episode span { + overflow-wrap: anywhere; +} + +video { + width: 100%; + max-height: 320px; + border: 1px solid var(--line); + border-radius: 16px; + background: #050308; +} + +@media (max-width: 980px) { + .app, + .hero, + .episode { + grid-template-columns: 1fr; + } + + .sidebar { + position: relative; + height: auto; + max-height: none; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .layout { + padding-top: 18px; + } + + .summary__header, + .toolbar { + align-items: flex-start; + flex-direction: column; + } +} + +@media (max-width: 560px) { + .sidebar, + .layout { + padding: 16px; + } + + .topline { + align-items: flex-start; + } + + .details, + .toolbar { + border-radius: 18px; + padding: 14px; + } + + .summary h2 { + font-size: 2rem; + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..778caf2 --- /dev/null +++ b/public/index.html @@ -0,0 +1,60 @@ + + + + + + Kaizoku Library + + + +
+ + +
+
+
+

Collection

+

+
+ Local +
+ +
+
+

No titles found

+

Mount or create a Library folder with one folder per movie or series.

+
+
+
+
+ + + + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..6c38024 --- /dev/null +++ b/server.js @@ -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); + });