Animate episode thumbnails on hover

This commit is contained in:
Dymas
2026-09-15 16:25:18 +02:00
parent 2597aeaf1d
commit b19eb7b958
7 changed files with 98 additions and 20 deletions
+41 -10
View File
@@ -349,11 +349,15 @@ async function scanLibrary() {
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}`;
const stillName = `${titleId}-${video.id}.jpg`;
const animatedName = `${titleId}-${video.id}.webp`;
const stillOutput = path.join(VIDEO_THUMB_DIR, stillName);
const animatedOutput = path.join(VIDEO_THUMB_DIR, animatedName);
video.thumbnail = `/episode-thumbs/${stillName}`;
video.animatedThumbnail = `/episode-thumbs/${animatedName}`;
try {
await fsp.access(output, fs.constants.R_OK);
await fsp.access(stillOutput, fs.constants.R_OK);
await fsp.access(animatedOutput, fs.constants.R_OK);
} catch {
scheduleVideoThumbnail(titleId, titlePath, video);
}
@@ -376,13 +380,38 @@ 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`;
const stillOutput = path.join(VIDEO_THUMB_DIR, `${titleId}-${video.id}.jpg`);
const animatedOutput = path.join(VIDEO_THUMB_DIR, `${titleId}-${video.id}.webp`);
const tmpStill = `${stillOutput}.part`;
const tmpAnimated = `${animatedOutput}.part`;
try {
await fsp.unlink(tmpOutput);
await fsp.unlink(tmpStill);
} catch (err) {
if (err.code !== "ENOENT") throw err;
}
try {
await fsp.unlink(tmpAnimated);
} catch (err) {
if (err.code !== "ENOENT") throw err;
}
await new Promise((resolve, reject) => {
const ffmpeg = spawn("ffmpeg", [
"-y",
"-ss", "00:00:12",
"-i", source,
"-frames:v", "1",
"-vf", "scale=360:-1:flags=lanczos",
"-f", "image2",
tmpStill
], { stdio: "ignore" });
ffmpeg.on("error", reject);
ffmpeg.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error("ffmpeg could not generate a still thumbnail"));
});
});
await new Promise((resolve, reject) => {
const ffmpeg = spawn("ffmpeg", [
@@ -394,7 +423,7 @@ async function generateVideoThumbnail(titleId, titlePath, video) {
"-loop", "0",
"-an",
"-f", "webp",
tmpOutput
tmpAnimated
], { stdio: "ignore" });
ffmpeg.on("error", reject);
@@ -404,7 +433,8 @@ async function generateVideoThumbnail(titleId, titlePath, video) {
});
});
await fsp.rename(tmpOutput, output);
await fsp.rename(tmpStill, stillOutput);
await fsp.rename(tmpAnimated, animatedOutput);
}
function groupSeasons(videos) {
@@ -756,7 +786,8 @@ async function serveEpisodeThumbnail(res, 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" });
const type = path.extname(filePath).toLowerCase() === ".webp" ? "image/webp" : "image/jpeg";
res.writeHead(200, { "content-type": type, "cache-control": "private, max-age=86400" });
res.end(data);
} catch (err) {
if (err.code === "ENOENT") send(res, 404, { error: "Not found" });