Initial h-play app
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
Library
|
||||||
|
data
|
||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
npm-debug.log
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
AGENTS.md
|
||||||
|
Library/
|
||||||
|
data/
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
.agents/
|
||||||
|
.codex/
|
||||||
|
.repo.git/
|
||||||
@@ -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.
|
||||||
+18
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<div class="empty">
|
||||||
|
<h2>No titles found</h2>
|
||||||
|
<p>Expected: Library/<Movie or Series>/filename.mp4 or Library/<Series>/Season 01/filename.mp4</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.details.innerHTML = `
|
||||||
|
<div class="hero">
|
||||||
|
<div class="poster">${title.thumbnail ? `<img src="${escapeAttr(title.thumbnail)}" alt="">` : `<span>${initials(title.title)}</span>`}</div>
|
||||||
|
<div class="summary">
|
||||||
|
<div class="summary__header">
|
||||||
|
<div>
|
||||||
|
<h2>${escapeHtml(title.title)}</h2>
|
||||||
|
<p>${escapeHtml(title.folder)} · ${title.count} file${title.count === 1 ? "" : "s"} · ${bytes(title.size)}</p>
|
||||||
|
</div>
|
||||||
|
<button id="makeThumbBtn" type="button">Thumbnail</button>
|
||||||
|
</div>
|
||||||
|
<form id="metaForm" class="meta-form">
|
||||||
|
<label>
|
||||||
|
<span>Display title</span>
|
||||||
|
<input name="title" value="${escapeAttr(title.title)}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Thumbnail path or URL</span>
|
||||||
|
<input name="thumbnail" value="${escapeAttr(title.thumbnail)}" placeholder="/thumbs/example.jpg">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Short description</span>
|
||||||
|
<textarea name="description" rows="5" placeholder="Add a private note or synopsis">${escapeHtml(title.description)}</textarea>
|
||||||
|
</label>
|
||||||
|
<button class="primary" type="submit">Save metadata</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="seasons">
|
||||||
|
${title.seasons.map(renderSeason).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<section class="season">
|
||||||
|
<h3>${escapeHtml(season.name)}</h3>
|
||||||
|
<div class="episodes">
|
||||||
|
${season.videos.map((video) => `
|
||||||
|
<article class="episode">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(video.name)}</strong>
|
||||||
|
<span>${escapeHtml(video.relativePath)} · ${bytes(video.size)}</span>
|
||||||
|
</div>
|
||||||
|
<video controls preload="metadata" src="/video/${encodeURIComponent(video.id)}"></video>
|
||||||
|
</article>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<div class="empty"><h2>Could not load library</h2><p>${escapeHtml(err.message)}</p></div>`;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Kaizoku Library</title>
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="topline">
|
||||||
|
<div class="brand-wrap">
|
||||||
|
<h1>Kaizoku</h1>
|
||||||
|
<p class="shell-note">Private anime library</p>
|
||||||
|
</div>
|
||||||
|
<span id="libraryBadge" class="badge">Library</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="page-links" aria-label="Primary navigation">
|
||||||
|
<a class="page-link active" href="/">Collection</a>
|
||||||
|
<button id="rescanBtn" class="page-action" type="button">Rescan</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<label class="search-label">
|
||||||
|
<span>Search collection</span>
|
||||||
|
<input id="searchInput" type="search" placeholder="Title, folder, file" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="titleList" class="title-list" aria-label="Titles"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<header class="toolbar">
|
||||||
|
<div>
|
||||||
|
<h2>Collection</h2>
|
||||||
|
<p id="libraryPath" class="muted"></p>
|
||||||
|
</div>
|
||||||
|
<span class="badge">Local</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="details" class="details">
|
||||||
|
<div class="empty">
|
||||||
|
<h2>No titles found</h2>
|
||||||
|
<p>Mount or create a Library folder with one folder per movie or series.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template id="titleButtonTemplate">
|
||||||
|
<button class="title-button" type="button">
|
||||||
|
<span class="title-button__name"></span>
|
||||||
|
<span class="title-button__meta"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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