diff --git a/pages/index.vue b/pages/index.vue index d6c09fb..904d063 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1717,8 +1717,7 @@ @click="openClip(entry.group.head)" > - - +
diff --git a/pages/library.vue b/pages/library.vue index 6f3d656..b3d411b 100644 --- a/pages/library.vue +++ b/pages/library.vue @@ -745,7 +745,7 @@ async function toggleFocusedHidden() { } function thumbSrc(entry: LibraryEntry) { - if (entry.kind === 'clip' && entry.group?.head.hasThumbnail) return `/api/library/clips/${entry.group.head.id}/thumb` + if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb` if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1` return '' } diff --git a/server/api/library/clips/[id]/thumb.get.ts b/server/api/library/clips/[id]/thumb.get.ts index 68f848d..cd0719b 100644 --- a/server/api/library/clips/[id]/thumb.get.ts +++ b/server/api/library/clips/[id]/thumb.get.ts @@ -1,13 +1,14 @@ import { existsSync, createReadStream } from 'node:fs' -import { clipThumbPath, fileEtag, sniffImageType } from '~/server/utils/library' +import { clipThumbPath, ensureClipThumb, fileEtag, sniffImageType } from '~/server/utils/library' -export default defineEventHandler((event) => { +export default defineEventHandler(async (event) => { const { owner } = assertLibraryOwner(event) const id = String(getRouterParam(event, 'id') || '') const clip = getClip(owner, id) assertFolderAccess(event, clip.folderId) - const path = clipThumbPath(owner, clip.id) - if (!existsSync(path)) { + let path = clipThumbPath(owner, clip.id) + if (!existsSync(path)) path = await ensureClipThumb(owner, clip.id) + if (!path || !existsSync(path)) { throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' }) } const etag = fileEtag(path) diff --git a/server/api/library/index.get.ts b/server/api/library/index.get.ts index ff69df5..fb67572 100644 --- a/server/api/library/index.get.ts +++ b/server/api/library/index.get.ts @@ -1,12 +1,10 @@ export default defineEventHandler((event) => { const light = String(getQuery(event).light || '') === '1' const library = publicLibrary(event, { light }) - if (!light) { - try { - const { owner } = assertLibraryOwner(event) - const folderId = library.folders.find(folder => folder.unlocked)?.id - void maintainLibrary(owner, folderId).catch(() => null) - } catch { /* locked or anonymous — still return what we can */ } - } + try { + const { owner } = assertLibraryOwner(event) + const folderId = library.folders.find(folder => folder.unlocked)?.id + void maintainLibrary(owner, folderId).catch(() => null) + } catch { /* locked or anonymous — still return what we can */ } return library }) diff --git a/server/utils/library.ts b/server/utils/library.ts index 7b95b80..82aaaef 100644 --- a/server/utils/library.ts +++ b/server/utils/library.ts @@ -520,7 +520,7 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) { clips: catalog.clips.filter(clip => unlockedIds.has(clip.folderId)).map(clip => ({ ...clip, name: clipTitle(clip), - hasThumbnail: light ? !clip.hideThumbnail : !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id)), + hasThumbnail: !clip.hideThumbnail, hasVideo: light ? true : existsSync(clipVideoPath(owner, clip.id)) })), stills: catalog.stills @@ -837,27 +837,36 @@ export function clipThumbPath(owner: string, id: string) { const dir = clipDir(owner, id) for (const name of ['thumb.jpg', 'thumb.png', 'thumb']) { const path = join(dir, name) - if (existsSync(path)) return path + if (existsSync(path) && statSync(path).size >= 64) return path } return join(dir, 'thumb.jpg') } -function extractVideoThumb(videoPath: string, destPath: string) { - return new Promise((resolve) => { - const proc = spawn('ffmpeg', ['-y', '-ss', '0.2', '-i', videoPath, '-frames:v', '1', '-update', '1', '-q:v', '4', destPath], { stdio: 'ignore' }) - const timer = setTimeout(() => { - try { proc.kill('SIGKILL') } catch { /* ignore */ } - resolve(false) - }, 20000) - proc.on('close', (code) => { - clearTimeout(timer) - resolve(code === 0 && existsSync(destPath)) - }) - proc.on('error', () => { - clearTimeout(timer) - resolve(false) - }) +async function extractVideoThumb(videoPath: string, destPath: string) { + try { + await extractFirstFrame(videoPath, destPath) + return existsSync(destPath) && statSync(destPath).size >= 64 + } catch { + return false + } +} + +export async function ensureClipThumb(owner: string, id: string) { + const existing = clipThumbPath(owner, id) + if (existsSync(existing) && statSync(existing).size >= 64) return existing + const video = clipVideoPath(owner, id) + if (!existsSync(video)) return '' + const dest = join(clipDir(owner, id), 'thumb.jpg') + const pending = thumbBuilds.get(dest) + if (pending) return pending + const build = withThumbSlot(async () => { + const ok = await extractVideoThumb(video, dest) + return ok ? dest : '' + }).finally(() => { + thumbBuilds.delete(dest) }) + thumbBuilds.set(dest, build) + return build } function probeVideoSize(videoPath: string) { @@ -913,7 +922,7 @@ export async function ensureClipThumbnails(owner: string, opts?: { skipHistory?: const thumb = clipThumbPath(owner, clip.id) const video = clipVideoPath(owner, clip.id) if (!existsSync(video)) continue - if (!existsSync(thumb)) { + if (!existsSync(thumb) || statSync(thumb).size < 64) { await extractVideoThumb(video, join(clipDir(owner, clip.id), 'thumb.jpg')) } if (!clip.width || !clip.height) {