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) {