Add episode thumbnails and playlist player
This commit is contained in:
@@ -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() });
|
||||
|
||||
Reference in New Issue
Block a user