Add episode thumbnails and playlist player
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.5.0 - 2026-09-15
|
||||||
|
|
||||||
|
- Replaced inline episode video players with clickable episode cards using cached animated WebP thumbnails generated by ffmpeg.
|
||||||
|
- Added automatic missing episode thumbnail generation during library scans, including newly added episodes.
|
||||||
|
- Added fullscreen playback for episode cards and a `Play all` playlist flow that advances through a movie or series.
|
||||||
|
- Removed redundant helper copy from the main website interface.
|
||||||
|
|
||||||
## 1.4.2 - 2026-09-15
|
## 1.4.2 - 2026-09-15
|
||||||
|
|
||||||
- Hid display title and thumbnail path from the read-only metadata view; those fields now appear only while editing metadata.
|
- Hid display title and thumbnail path from the read-only metadata view; those fields now appear only while editing metadata.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ h-player is a small local web app for browsing a folder-based private video coll
|
|||||||
|
|
||||||
The sidebar holds app navigation, search, and rescanning controls. Movies and series appear in the main collection grid, similar to Jellyfin-style library browsing. Selecting a collection card opens that movie or series folder with its metadata, seasons, episodes, and playable files.
|
The sidebar holds app navigation, search, and rescanning controls. Movies and series appear in the main collection grid, similar to Jellyfin-style library browsing. Selecting a collection card opens that movie or series folder with its metadata, seasons, episodes, and playable files.
|
||||||
|
|
||||||
|
Episodes are shown as thumbnail cards. h-player generates cached animated WebP thumbnails with `ffmpeg` when a thumbnail does not already exist, including thumbnails for newly added episodes discovered during scans. Clicking an episode opens it in the fullscreen player. `Play all` starts a playlist for the selected movie or series and advances through each episode in order.
|
||||||
|
|
||||||
## Folder Layout
|
## Folder Layout
|
||||||
|
|
||||||
Place media in this shape:
|
Place media in this shape:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "h-player",
|
"name": "h-player",
|
||||||
"version": "1.4.2",
|
"version": "1.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A small private web app for browsing a folder-based video collection.",
|
"description": "A small private web app for browsing a folder-based video collection.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+83
-10
@@ -7,12 +7,20 @@ const state = {
|
|||||||
|
|
||||||
const els = {
|
const els = {
|
||||||
libraryBadge: document.querySelector("#libraryBadge"),
|
libraryBadge: document.querySelector("#libraryBadge"),
|
||||||
libraryPath: document.querySelector("#libraryPath"),
|
|
||||||
backBtn: document.querySelector("#backBtn"),
|
backBtn: document.querySelector("#backBtn"),
|
||||||
rescanBtn: document.querySelector("#rescanBtn"),
|
rescanBtn: document.querySelector("#rescanBtn"),
|
||||||
searchInput: document.querySelector("#searchInput"),
|
searchInput: document.querySelector("#searchInput"),
|
||||||
details: document.querySelector("#details"),
|
details: document.querySelector("#details"),
|
||||||
titleCardTemplate: document.querySelector("#titleCardTemplate")
|
titleCardTemplate: document.querySelector("#titleCardTemplate"),
|
||||||
|
playerOverlay: document.querySelector("#playerOverlay"),
|
||||||
|
playerVideo: document.querySelector("#playerVideo"),
|
||||||
|
closePlayerBtn: document.querySelector("#closePlayerBtn"),
|
||||||
|
playerNowPlaying: document.querySelector("#playerNowPlaying")
|
||||||
|
};
|
||||||
|
|
||||||
|
const player = {
|
||||||
|
playlist: [],
|
||||||
|
index: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
function bytes(value) {
|
function bytes(value) {
|
||||||
@@ -39,7 +47,6 @@ async function api(path, options = {}) {
|
|||||||
async function loadLibrary() {
|
async function loadLibrary() {
|
||||||
const data = await api("/api/library");
|
const data = await api("/api/library");
|
||||||
state.titles = data.titles;
|
state.titles = data.titles;
|
||||||
els.libraryPath.textContent = data.libraryDir;
|
|
||||||
els.libraryBadge.textContent = `${state.titles.length} title${state.titles.length === 1 ? "" : "s"}`;
|
els.libraryBadge.textContent = `${state.titles.length} title${state.titles.length === 1 ? "" : "s"}`;
|
||||||
if (!state.titles.some((title) => title.id === state.activeId)) {
|
if (!state.titles.some((title) => title.id === state.activeId)) {
|
||||||
state.activeId = "";
|
state.activeId = "";
|
||||||
@@ -123,6 +130,7 @@ function renderDetails(title) {
|
|||||||
<button id="editMetaBtn" type="button">Edit</button>
|
<button id="editMetaBtn" type="button">Edit</button>
|
||||||
<button id="fetchMetadataBtn" type="button">Metadata</button>
|
<button id="fetchMetadataBtn" type="button">Metadata</button>
|
||||||
<button id="makeThumbBtn" type="button">Thumbnail</button>
|
<button id="makeThumbBtn" type="button">Thumbnail</button>
|
||||||
|
<button id="playAllBtn" class="primary" type="button">Play all</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="metaView" class="meta-view">
|
<div id="metaView" class="meta-view">
|
||||||
@@ -139,10 +147,16 @@ function renderDetails(title) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
wireEpisodeCards(title);
|
||||||
|
|
||||||
document.querySelector("#editMetaBtn").addEventListener("click", () => {
|
document.querySelector("#editMetaBtn").addEventListener("click", () => {
|
||||||
renderMetadataForm(title);
|
renderMetadataForm(title);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector("#playAllBtn").addEventListener("click", () => {
|
||||||
|
startPlaylist(flattenVideos(title), 0);
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelector("#makeThumbBtn").addEventListener("click", async (event) => {
|
document.querySelector("#makeThumbBtn").addEventListener("click", async (event) => {
|
||||||
event.currentTarget.disabled = true;
|
event.currentTarget.disabled = true;
|
||||||
event.currentTarget.textContent = "Working";
|
event.currentTarget.textContent = "Working";
|
||||||
@@ -220,19 +234,65 @@ function renderSeason(season) {
|
|||||||
<h3>${escapeHtml(season.name)}</h3>
|
<h3>${escapeHtml(season.name)}</h3>
|
||||||
<div class="episodes">
|
<div class="episodes">
|
||||||
${season.videos.map((video) => `
|
${season.videos.map((video) => `
|
||||||
<article class="episode">
|
<button class="episode" type="button" data-video-id="${escapeAttr(video.id)}">
|
||||||
<div>
|
<span class="episode__thumb">
|
||||||
<strong>${escapeHtml(video.name)}</strong>
|
<img src="${escapeAttr(video.thumbnail)}" alt="" loading="lazy" onerror="this.hidden=true">
|
||||||
<span>${escapeHtml(video.relativePath)} · ${bytes(video.size)}</span>
|
<span class="episode__play">Play</span>
|
||||||
</div>
|
</span>
|
||||||
<video controls preload="metadata" src="/video/${encodeURIComponent(video.id)}"></video>
|
<span class="episode__name">${escapeHtml(video.name)}</span>
|
||||||
</article>
|
<span class="episode__meta">${escapeHtml(video.relativePath)} · ${bytes(video.size)}</span>
|
||||||
|
</button>
|
||||||
`).join("")}
|
`).join("")}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flattenVideos(title) {
|
||||||
|
return title.seasons.flatMap((season) => season.videos);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireEpisodeCards(title) {
|
||||||
|
const videos = flattenVideos(title);
|
||||||
|
document.querySelectorAll("[data-video-id]").forEach((node) => {
|
||||||
|
node.addEventListener("click", () => {
|
||||||
|
const index = videos.findIndex((video) => video.id === node.dataset.videoId);
|
||||||
|
startPlaylist(videos, Math.max(index, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPlaylist(videos, index = 0) {
|
||||||
|
if (!videos.length) return;
|
||||||
|
player.playlist = videos;
|
||||||
|
player.index = index;
|
||||||
|
els.playerOverlay.classList.remove("hidden");
|
||||||
|
playCurrent();
|
||||||
|
const fullscreenTarget = els.playerOverlay;
|
||||||
|
if (fullscreenTarget.requestFullscreen) {
|
||||||
|
fullscreenTarget.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playCurrent() {
|
||||||
|
const video = player.playlist[player.index];
|
||||||
|
if (!video) {
|
||||||
|
closePlayer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
els.playerVideo.src = `/video/${encodeURIComponent(video.id)}`;
|
||||||
|
els.playerNowPlaying.textContent = `${video.name} · ${player.index + 1} / ${player.playlist.length}`;
|
||||||
|
els.playerVideo.play().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePlayer() {
|
||||||
|
els.playerVideo.pause();
|
||||||
|
els.playerVideo.removeAttribute("src");
|
||||||
|
els.playerVideo.load();
|
||||||
|
els.playerOverlay.classList.add("hidden");
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function initials(value) {
|
function initials(value) {
|
||||||
return value.split(/\s+/).slice(0, 2).map((part) => part[0] || "").join("").toUpperCase();
|
return value.split(/\s+/).slice(0, 2).map((part) => part[0] || "").join("").toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -262,6 +322,19 @@ els.backBtn.addEventListener("click", () => {
|
|||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
els.closePlayerBtn.addEventListener("click", closePlayer);
|
||||||
|
|
||||||
|
els.playerVideo.addEventListener("ended", () => {
|
||||||
|
player.index += 1;
|
||||||
|
playCurrent();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("fullscreenchange", () => {
|
||||||
|
if (!document.fullscreenElement && !els.playerOverlay.classList.contains("hidden")) {
|
||||||
|
closePlayer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
els.rescanBtn.addEventListener("click", async () => {
|
els.rescanBtn.addEventListener("click", async () => {
|
||||||
els.rescanBtn.disabled = true;
|
els.rescanBtn.disabled = true;
|
||||||
els.rescanBtn.textContent = "Scanning";
|
els.rescanBtn.textContent = "Scanning";
|
||||||
|
|||||||
+82
-37
@@ -246,28 +246,6 @@ label {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-note {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 18px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-note__label {
|
|
||||||
color: #ddd0ff;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-note p {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collection-grid {
|
.collection-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
@@ -524,41 +502,108 @@ label {
|
|||||||
|
|
||||||
.episodes {
|
.episodes {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode {
|
.episode {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(180px, 1fr) minmax(280px, 520px);
|
gap: 10px;
|
||||||
gap: 1rem;
|
align-items: start;
|
||||||
align-items: center;
|
min-height: auto;
|
||||||
padding: 0.85rem;
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode div {
|
.episode:hover {
|
||||||
display: grid;
|
border-color: var(--line-strong);
|
||||||
gap: 0.25rem;
|
background: linear-gradient(180deg, rgba(38, 28, 64, 0.94) 0%, rgba(19, 14, 32, 0.98) 100%);
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode strong,
|
.episode__thumb {
|
||||||
.episode span {
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border: 1px solid rgba(157, 123, 255, 0.16);
|
||||||
|
border-radius: 14px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(157, 123, 255, 0.22), transparent 44%),
|
||||||
|
linear-gradient(180deg, #221734 0%, #0c0915 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode__thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode__play {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(9, 6, 17, 0.74);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode__name {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode__meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode__name,
|
||||||
|
.episode__meta {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
video {
|
.player-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
place-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #050308;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 320px;
|
max-width: min(100%, 1600px);
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #050308;
|
background: #050308;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-close {
|
||||||
|
position: fixed;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
z-index: 21;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-now-playing {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.app,
|
.app,
|
||||||
.hero,
|
.hero {
|
||||||
.episode {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -26,17 +26,12 @@
|
|||||||
<span>Search collection</span>
|
<span>Search collection</span>
|
||||||
<input id="searchInput" type="search" placeholder="Title, folder, file" autocomplete="off">
|
<input id="searchInput" type="search" placeholder="Title, folder, file" autocomplete="off">
|
||||||
</label>
|
</label>
|
||||||
<div class="sidebar-note">
|
|
||||||
<span class="sidebar-note__label">Library view</span>
|
|
||||||
<p>Browse folders from the main collection grid.</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
<header class="toolbar">
|
<header class="toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h2>Collection</h2>
|
<h2>Collection</h2>
|
||||||
<p id="libraryPath" class="muted"></p>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="backBtn" class="ghost hidden" type="button">Collection</button>
|
<button id="backBtn" class="ghost hidden" type="button">Collection</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -44,12 +39,17 @@
|
|||||||
<section id="details" class="details">
|
<section id="details" class="details">
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<h2>No titles found</h2>
|
<h2>No titles found</h2>
|
||||||
<p>Mount or create a Library folder with one folder per movie or series.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="playerOverlay" class="player-overlay hidden">
|
||||||
|
<button id="closePlayerBtn" class="player-close" type="button">Close</button>
|
||||||
|
<video id="playerVideo" class="player-video" controls autoplay></video>
|
||||||
|
<div id="playerNowPlaying" class="player-now-playing"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template id="titleCardTemplate">
|
<template id="titleCardTemplate">
|
||||||
<button class="title-card" type="button">
|
<button class="title-card" type="button">
|
||||||
<span class="title-card__poster">
|
<span class="title-card__poster">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "";
|
|||||||
const ANIDB_CLIENT_NAME = process.env.ANIDB_CLIENT_NAME || "";
|
const ANIDB_CLIENT_NAME = process.env.ANIDB_CLIENT_NAME || "";
|
||||||
const ANIDB_CLIENT_VERSION = Number(process.env.ANIDB_CLIENT_VERSION || 1);
|
const ANIDB_CLIENT_VERSION = Number(process.env.ANIDB_CLIENT_VERSION || 1);
|
||||||
const THUMB_DIR = path.join(DATA_DIR, "thumbnails");
|
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 CACHE_DIR = path.join(DATA_DIR, "cache");
|
||||||
const DB_FILE = path.join(DATA_DIR, "library.sqlite");
|
const DB_FILE = path.join(DATA_DIR, "library.sqlite");
|
||||||
const LEGACY_META_FILE = path.join(DATA_DIR, "metadata.json");
|
const LEGACY_META_FILE = path.join(DATA_DIR, "metadata.json");
|
||||||
@@ -40,6 +41,8 @@ const MIME_BY_EXT = {
|
|||||||
|
|
||||||
let db;
|
let db;
|
||||||
let lastScan = [];
|
let lastScan = [];
|
||||||
|
const pendingVideoThumbs = new Set();
|
||||||
|
const failedVideoThumbs = new Set();
|
||||||
|
|
||||||
function send(res, status, body, type = "application/json") {
|
function send(res, status, body, type = "application/json") {
|
||||||
const payload = type === "application/json" ? JSON.stringify(body) : body;
|
const payload = type === "application/json" ? JSON.stringify(body) : body;
|
||||||
@@ -102,6 +105,7 @@ function slugFor(value) {
|
|||||||
|
|
||||||
async function ensureStorage() {
|
async function ensureStorage() {
|
||||||
await fsp.mkdir(THUMB_DIR, { recursive: true });
|
await fsp.mkdir(THUMB_DIR, { recursive: true });
|
||||||
|
await fsp.mkdir(VIDEO_THUMB_DIR, { recursive: true });
|
||||||
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
||||||
db = new DatabaseSync(DB_FILE);
|
db = new DatabaseSync(DB_FILE);
|
||||||
db.exec(`
|
db.exec(`
|
||||||
@@ -323,6 +327,7 @@ async function scanLibrary() {
|
|||||||
const meta = getTitleMetadata(id, entry.name);
|
const meta = getTitleMetadata(id, entry.name);
|
||||||
|
|
||||||
const seasons = groupSeasons(videos);
|
const seasons = groupSeasons(videos);
|
||||||
|
await attachVideoThumbnails(id, titlePath, videos);
|
||||||
titles.push({
|
titles.push({
|
||||||
id,
|
id,
|
||||||
folder: entry.name,
|
folder: entry.name,
|
||||||
@@ -342,6 +347,66 @@ async function scanLibrary() {
|
|||||||
return lastScan;
|
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) {
|
function groupSeasons(videos) {
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const video of videos) {
|
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) {
|
async function streamVideo(req, res, videoId) {
|
||||||
const match = findVideo(videoId);
|
const match = findVideo(videoId);
|
||||||
if (!match) return send(res, 404, { error: "Video not found" });
|
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 === "/" || pathname === "/index.html") return serveStatic(req, res, "/index.html");
|
||||||
if (pathname.startsWith("/assets/")) return serveStatic(req, res, pathname);
|
if (pathname.startsWith("/assets/")) return serveStatic(req, res, pathname);
|
||||||
if (pathname.startsWith("/thumbs/")) return serveThumbnail(res, pathname.slice("/thumbs/".length));
|
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") {
|
if (pathname === "/api/library" && req.method === "GET") {
|
||||||
return send(res, 200, { libraryDir: LIBRARY_DIR, titles: await scanLibrary() });
|
return send(res, 200, { libraryDir: LIBRARY_DIR, titles: await scanLibrary() });
|
||||||
|
|||||||
Reference in New Issue
Block a user