Add episode thumbnails and playlist player

This commit is contained in:
Dymas
2026-09-15 16:17:57 +02:00
parent 55954376e2
commit 2597aeaf1d
8 changed files with 261 additions and 55 deletions
+79
View File
@@ -18,6 +18,7 @@ const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "";
const ANIDB_CLIENT_NAME = process.env.ANIDB_CLIENT_NAME || "";
const ANIDB_CLIENT_VERSION = Number(process.env.ANIDB_CLIENT_VERSION || 1);
const THUMB_DIR = path.join(DATA_DIR, "thumbnails");
const VIDEO_THUMB_DIR = path.join(DATA_DIR, "episode-thumbnails");
const CACHE_DIR = path.join(DATA_DIR, "cache");
const DB_FILE = path.join(DATA_DIR, "library.sqlite");
const LEGACY_META_FILE = path.join(DATA_DIR, "metadata.json");
@@ -40,6 +41,8 @@ const MIME_BY_EXT = {
let db;
let lastScan = [];
const pendingVideoThumbs = new Set();
const failedVideoThumbs = new Set();
function send(res, status, body, type = "application/json") {
const payload = type === "application/json" ? JSON.stringify(body) : body;
@@ -102,6 +105,7 @@ function slugFor(value) {
async function ensureStorage() {
await fsp.mkdir(THUMB_DIR, { recursive: true });
await fsp.mkdir(VIDEO_THUMB_DIR, { recursive: true });
await fsp.mkdir(CACHE_DIR, { recursive: true });
db = new DatabaseSync(DB_FILE);
db.exec(`
@@ -323,6 +327,7 @@ async function scanLibrary() {
const meta = getTitleMetadata(id, entry.name);
const seasons = groupSeasons(videos);
await attachVideoThumbnails(id, titlePath, videos);
titles.push({
id,
folder: entry.name,
@@ -342,6 +347,66 @@ async function scanLibrary() {
return lastScan;
}
async function attachVideoThumbnails(titleId, titlePath, videos) {
for (const video of videos) {
const outputName = `${titleId}-${video.id}.webp`;
const output = path.join(VIDEO_THUMB_DIR, outputName);
video.thumbnail = `/episode-thumbs/${outputName}`;
try {
await fsp.access(output, fs.constants.R_OK);
} catch {
scheduleVideoThumbnail(titleId, titlePath, video);
}
}
}
function scheduleVideoThumbnail(titleId, titlePath, video) {
const key = `${titleId}:${video.id}`;
if (pendingVideoThumbs.has(key) || failedVideoThumbs.has(key)) return;
pendingVideoThumbs.add(key);
generateVideoThumbnail(titleId, titlePath, video)
.catch((err) => {
failedVideoThumbs.add(key);
console.error(`Episode thumbnail failed for ${video.relativePath}: ${err.message}`);
})
.finally(() => pendingVideoThumbs.delete(key));
}
async function generateVideoThumbnail(titleId, titlePath, video) {
const source = path.resolve(titlePath, video.relativePath);
if (!isInside(titlePath, source)) throw new Error("Invalid video path");
const output = path.join(VIDEO_THUMB_DIR, `${titleId}-${video.id}.webp`);
const tmpOutput = `${output}.part`;
try {
await fsp.unlink(tmpOutput);
} catch (err) {
if (err.code !== "ENOENT") throw err;
}
await new Promise((resolve, reject) => {
const ffmpeg = spawn("ffmpeg", [
"-y",
"-ss", "00:00:12",
"-t", "4",
"-i", source,
"-vf", "fps=6,scale=360:-1:flags=lanczos",
"-loop", "0",
"-an",
"-f", "webp",
tmpOutput
], { stdio: "ignore" });
ffmpeg.on("error", reject);
ffmpeg.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error("ffmpeg could not generate an animated thumbnail"));
});
});
await fsp.rename(tmpOutput, output);
}
function groupSeasons(videos) {
const groups = new Map();
for (const video of videos) {
@@ -686,6 +751,19 @@ async function serveThumbnail(res, name) {
}
}
async function serveEpisodeThumbnail(res, name) {
const filePath = path.resolve(VIDEO_THUMB_DIR, safeDecode(name));
if (!isInside(VIDEO_THUMB_DIR, filePath)) return send(res, 403, { error: "Forbidden" });
try {
const data = await fsp.readFile(filePath);
res.writeHead(200, { "content-type": "image/webp", "cache-control": "private, max-age=86400" });
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" });
@@ -733,6 +811,7 @@ async function route(req, res) {
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.startsWith("/episode-thumbs/")) return serveEpisodeThumbnail(res, pathname.slice("/episode-thumbs/".length));
if (pathname === "/api/library" && req.method === "GET") {
return send(res, 200, { libraryDir: LIBRARY_DIR, titles: await scanLibrary() });