From 9481791ac57f8455027ee5bb7a104f3d4b6b1df3 Mon Sep 17 00:00:00 2001 From: Towsty Date: Wed, 30 Sep 2026 00:28:22 -0500 Subject: [PATCH] Keep fetching library pages after the first 50 so the whole folder is reachable. Co-authored-by: Cursor --- components/studio2/Bench.vue | 7 ++- components/studio2/LibraryModal.vue | 24 +++++--- pages/library.vue | 88 ++++++++++++++++++----------- server/utils/library.ts | 15 +++-- 4 files changed, 89 insertions(+), 45 deletions(-) diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue index c773a8d..32406c6 100644 --- a/components/studio2/Bench.vue +++ b/components/studio2/Bench.vue @@ -737,7 +737,12 @@ const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form. const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'}))) const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId)) const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId)) -const familyStillCount=computed(()=>family.value.filter((a:any)=>a.kind==='still').length) +const familyStillCount=computed(()=>{ + const folder=(library.value.folders||[]).find((f:any)=>f.id===form.folderId) + const fromFolder=Number(folder?.imageCount||0) + if(fromFolder>0) return fromFolder + return family.value.filter((a:any)=>a.kind==='still').length +}) const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId||form.photos?.[0]?.stillId))) const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value) const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine)) diff --git a/components/studio2/LibraryModal.vue b/components/studio2/LibraryModal.vue index 8322e6f..7b30d9c 100644 --- a/components/studio2/LibraryModal.vue +++ b/components/studio2/LibraryModal.vue @@ -286,7 +286,7 @@ function applyLibraryPage(data: any, replace: boolean) { fetchedAt.value = Date.now() } -async function fetchLibraryPage(cursor: string | null, replace: boolean) { +async function fetchLibraryPage(cursor: string | null) { const folder = activeFolderId.value && activeFolderId.value !== 'all' ? activeFolderId.value : '' const type = filter.value === 'all' ? 'all' : filter.value === 'images' ? 'images' : filter.value === 'videos' ? 'videos' : 'music' const qs = new URLSearchParams({ limit: '50' }) @@ -296,28 +296,33 @@ async function fetchLibraryPage(cursor: string | null, replace: boolean) { return await $fetch(`/api/library?${qs.toString()}`) } +let fetchGen = 0 + async function fetchLibrary(force = false) { - if (loading.value) return const stale = !fetchedAt.value || Date.now() - fetchedAt.value > 15_000 const empty = !stills.value.length && !clips.value.length && !tracks.value.length if (!force && !empty && !stale) return + const gen = ++fetchGen loading.value = true loadError.value = '' liveThumbs.value = new Set() try { - const first = await fetchLibraryPage(null, true) + const first = await fetchLibraryPage(null) + if (gen !== fetchGen) return applyLibraryPage(first, true) loading.value = false nextTick(() => observeThumbs()) - // Pull remaining pages in the background — first paint already has ≤50 items + // Keep fetching until the folder is exhausted — 50 is a page size, not a cap let cursor = first?.nextCursor || null - while (cursor) { - const more = await fetchLibraryPage(cursor, false) + while (cursor && gen === fetchGen) { + const more = await fetchLibraryPage(cursor) + if (gen !== fetchGen) return applyLibraryPage(more, false) cursor = more?.nextCursor || null nextTick(() => observeThumbs()) } } catch (e: any) { + if (gen !== fetchGen) return loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library' loading.value = false } @@ -354,7 +359,7 @@ async function fetchMore() { if (!nextCursor.value || loading.value) return loading.value = true try { - const more = await fetchLibraryPage(nextCursor.value, false) + const more = await fetchLibraryPage(nextCursor.value) applyLibraryPage(more, false) nextTick(() => observeThumbs()) } catch { /* keep what we have */ } @@ -404,6 +409,11 @@ watch(() => props.open, (on) => { watch(entries, () => nextTick(() => observeThumbs())) +watch([filter, activeFolderId], () => { + if (!props.open) return + void fetchLibrary(true) +}) + onBeforeUnmount(() => { if (import.meta.client) { document.body.style.overflow = '' diff --git a/pages/library.vue b/pages/library.vue index 648f507..9dd701e 100644 --- a/pages/library.vue +++ b/pages/library.vue @@ -634,24 +634,6 @@ const allCount = computed(() => { sort: 'date-desc' }).length }) -const folderChipCounts = computed(() => { - const map: Record = {} - for (const clip of clips.value) { - if (clip.hasVideo === false) continue - const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0, music: 0 }) - row.videos += 1 - } - for (const still of stills.value) { - if (still.role !== 'output') continue - const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0, music: 0 }) - row.images += 1 - } - for (const track of tracks.value) { - const row = map[track.folderId] || (map[track.folderId] = { videos: 0, images: 0, music: 0 }) - row.music += 1 - } - return map -}) const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value)) const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null) const focusedStill = computed(() => { @@ -771,7 +753,8 @@ function toast(message: string) { }, 4200) } -function applyLibrary(data: unknown) { +function applyLibrary(data: unknown, opts?: { replace?: boolean }) { + const replace = opts?.replace !== false const payload = (data || {}) as { id?: string hideInput?: boolean @@ -780,6 +763,8 @@ function applyLibrary(data: unknown) { clips?: LibraryClip[] stills?: LibraryStill[] tracks?: LibraryTrack[] + nextCursor?: string | null + hasMore?: boolean } if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) { if (typeof payload.hideInput === 'boolean') { @@ -792,29 +777,62 @@ function applyLibrary(data: unknown) { } return } - folders.value = payload.folders || [] - clips.value = payload.clips || [] - stills.value = payload.stills || [] - tracks.value = payload.tracks || [] + if (payload.folders?.length) folders.value = payload.folders + if (replace) { + clips.value = payload.clips || [] + stills.value = payload.stills || [] + tracks.value = payload.tracks || [] + } else { + const merge = (prev: T[], next: T[]) => { + const map = new Map() + for (const item of prev) map.set(item.id, item) + for (const item of next) map.set(item.id, item) + return [...map.values()] + } + clips.value = merge(clips.value, payload.clips || []) + stills.value = merge(stills.value, payload.stills || []) + tracks.value = merge(tracks.value, payload.tracks || []) + } + libraryNextCursor.value = payload.nextCursor || null if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) { folderId.value = folders.value[0]?.id || 'all' } } +const libraryNextCursor = ref(null) +let libraryFetchGen = 0 + +async function fetchLibraryPage(cursor: string | null) { + const qs = new URLSearchParams({ limit: '50' }) + if (folderId.value && folderId.value !== 'all') qs.set('folderId', folderId.value) + if (filter.value === 'images') qs.set('type', 'images') + else if (filter.value === 'videos') qs.set('type', 'videos') + else if (filter.value === 'music') qs.set('type', 'music') + if (cursor) qs.set('cursor', cursor) + return await $fetch(`/api/library?${qs.toString()}`) +} + async function loadLibrary() { - // First page of metadata only — never follow with a full light=1 catalog scan - const first = await $fetch('/api/library?limit=50') - applyLibrary(first) + // First page paints immediately; remaining pages append in the background + const gen = ++libraryFetchGen + const first = await fetchLibraryPage(null) + if (gen !== libraryFetchGen) return + applyLibrary(first, { replace: true }) + let cursor = first?.nextCursor || null + while (cursor && gen === libraryFetchGen) { + const page = await fetchLibraryPage(cursor) + if (gen !== libraryFetchGen) return + applyLibrary(page, { replace: false }) + cursor = page?.nextCursor || null + } } function folderChipCount(folder: LibraryFolder) { - if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0) - const row = folderChipCounts.value[folder.id] - if (!row) return 0 - if (filter.value === 'videos') return row.videos - if (filter.value === 'images') return row.images - if (filter.value === 'music') return row.music - return row.videos + row.images + row.music + // Prefer server totals so chips match the full folder before pages finish appending + if (filter.value === 'videos') return folder.clipCount || 0 + if (filter.value === 'images') return folder.imageCount || 0 + if (filter.value === 'music') return folder.trackCount || 0 + return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0) } function folderChipClass(id: string) { @@ -1280,6 +1298,10 @@ watch([folderId, filter], () => { watch(search, () => { if (scroller.value) scroller.value.scrollTop = 0 }) +watch([folderId, filter], () => { + if (scroller.value) scroller.value.scrollTop = 0 + void loadLibrary() +}) watch([cols, rowH], ([nextCols, nextRowH], [prevCols, prevRowH]) => { const el = scroller.value if (!el || !prevRowH) return diff --git a/server/utils/library.ts b/server/utils/library.ts index c06a69b..14e538b 100644 --- a/server/utils/library.ts +++ b/server/utils/library.ts @@ -752,12 +752,18 @@ export function publicLibraryPage( let start = 0 if (cursor) { - const idx = rows.findIndex(row => `${row.type}:${row.id}` === cursor) - start = idx >= 0 ? idx + 1 : 0 + if (cursor.startsWith('offset:')) { + start = Math.max(0, Math.min(rows.length, Number(cursor.slice(7)) || 0)) + } else { + const idx = rows.findIndex(row => `${row.type}:${row.id}` === cursor) + // Unknown cursor → end (never restart at 0 or the client loops the first page forever) + start = idx >= 0 ? idx + 1 : rows.length + } } const page = rows.slice(start, start + limit) - const last = page[page.length - 1] - const nextCursor = page.length === limit && last ? `${last.type}:${last.id}` : null + const nextStart = start + page.length + const hasMore = nextStart < rows.length + const nextCursor = hasMore ? `offset:${nextStart}` : null const stillIds = new Set(page.filter(r => r.type === 'still').map(r => r.id)) const clipIdPage = new Set(page.filter(r => r.type === 'clip').map(r => r.id)) @@ -767,6 +773,7 @@ export function publicLibraryPage( folders, items: page, nextCursor, + hasMore, limit, stills: catalog.stills .filter(s => stillIds.has(s.id))