Compare commits

...

2 Commits

Author SHA1 Message Date
Garret Patti
f08950f456 Fix Doom Scroll mode bugs in TV libraries and video autoplay
TV library fix: the unfiltered Doom Scroll path was calling
/api/browse which explicitly rejects non-mixed libraries with a 400
error, leaving the item list empty. Replace it with the same TV API
hierarchy fetch already used by the filtered path (series → seasons →
episodes), matching how the rest of the TV library is loaded.

Autoplay fix (all library types): two interacting bugs caused videos
to silently stall on navigation. First, the play/pause effect had
current?.url in its deps, so navigating while paused would call
pause() on the freshly-mounted video element before the isPaused reset
could take effect. Second, browser autoplay policy blocks unmuted
play() calls and the rejection was silently swallowed with no recovery.
Fix by merging the isPaused reset and the play() call into one
navigation effect, and adding a muted fallback on rejection so playback
always starts — the user can unmute manually afterward.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:21:27 -04:00
Garret Patti
4d75e74cab Fix post-scan CPU spike and improve scan performance at scale
- Remove fire-and-forget thumbnail pre-warming from scanMixed(): firing
  48k+ simultaneous unresolved getThumbnailPath() promises was saturating
  sharp and ffmpeg after scan completion, keeping CPU pegged. Mixed-library
  thumbnails are now generated on-demand by /api/thumbnail as before.
- Add incremental fingerprinting: load existing (item_key → fingerprint)
  map from DB before each walk; reuse stored fingerprint for unchanged paths
  instead of re-reading 64 KB per file. Stable re-scans now do ~0 bytes of
  fingerprint I/O.
- Wrap all bulk DB upsert and delete loops in db.transaction() in
  scanMovies(), scanTv(), scanMixed(), and reconcileAndPrune(). Reduces
  N auto-committed WAL writes to a single batch commit per scan phase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:58:05 -04:00
3 changed files with 177 additions and 119 deletions

View File

@@ -73,9 +73,20 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
setTimeout(() => { cooldownRef.current = false }, 300) setTimeout(() => { cooldownRef.current = false }, 300)
}, [goNext, goPrev]) }, [goNext, goPrev])
// Reset pause when switching items // On navigation to a new item: reset pause state and start playing.
// Merging the reset + play() into one effect prevents the old isPaused=true
// value from calling pause() on the freshly-mounted video element before the
// reset fires. If autoplay is blocked by browser policy (common when unmuted),
// fall back to muted and retry — the user can unmute manually afterward.
useEffect(() => { useEffect(() => {
setIsPaused(false) setIsPaused(false)
if (!videoRef.current) return
videoRef.current.play().catch(() => {
if (!videoRef.current) return
videoRef.current.muted = true
setLocalMuted(true)
videoRef.current.play().catch(() => {})
})
}, [current?.url]) }, [current?.url])
// Sync muted imperatively — React's muted prop is not reliable // Sync muted imperatively — React's muted prop is not reliable
@@ -83,7 +94,8 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
if (videoRef.current) videoRef.current.muted = localMuted if (videoRef.current) videoRef.current.muted = localMuted
}, [localMuted, current?.url]) }, [localMuted, current?.url])
// Sync play/pause imperatively // Sync play/pause imperatively for user-initiated pause/unpause only.
// current?.url is intentionally excluded: navigation is handled above.
useEffect(() => { useEffect(() => {
if (!videoRef.current) return if (!videoRef.current) return
if (isPaused) { if (isPaused) {
@@ -91,7 +103,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
} else { } else {
videoRef.current.play().catch(() => {}) videoRef.current.play().catch(() => {})
} }
}, [isPaused, current?.url]) }, [isPaused])
// Auto-play timer — resets on each new item, pause, enable/disable, or interval change // Auto-play timer — resets on each new item, pause, enable/disable, or interval change
useEffect(() => { useEffect(() => {

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { TvSeries, TvSeason, TvEpisode } from '@/types' import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import type { DirectoryListing } from '@/types'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
@@ -190,13 +190,30 @@ export default function TvView({ libraryId }: Props) {
mediaType: 'video' as const, mediaType: 'video' as const,
})) }))
} else { } else {
// No filters — use full recursive browse // No filters — fetch all episodes via the TV API hierarchy
const data: DirectoryListing = await fetch( const allSeries: TvSeries[] = await fetch(
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=&recursive=true` `/api/tv?libraryId=${encodeURIComponent(libraryId)}`
).then((r) => r.json()) ).then((r) => r.json())
items = data.entries const episodeLists = await Promise.all(
.filter((e) => e.type === 'file' && e.mediaType === 'video' && e.url) allSeries.map(async (s) => {
.map((e) => ({ url: e.url!, name: e.name, mediaType: 'video' as const })) const seasons: TvSeason[] = await fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}`
).then((r) => r.json())
const seasonEps = await Promise.all(
seasons.map((season) =>
fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(s.id)}&seasonId=${encodeURIComponent(season.id)}`
).then((r) => r.json() as Promise<TvEpisode[]>)
)
)
return seasonEps.flat()
})
)
items = episodeLists.flat().map((ep) => ({
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`,
name: ep.title,
mediaType: 'video' as const,
}))
} }
setDoomScrollItems(items) setDoomScrollItems(items)
setDoomScrollActive(true) setDoomScrollActive(true)

View File

@@ -10,9 +10,6 @@ import { scanGamesLibrary } from './games'
import { getThumbnailPath } from './thumbnails' import { getThumbnailPath } from './thumbnails'
import { computeFingerprint } from './fingerprint' import { computeFingerprint } from './fingerprint'
import { reKeyMediaItem } from './tags' import { reKeyMediaItem } from './tags'
import { VIDEO_EXTENSIONS } from './media-utils'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'])
let scanRunning = false let scanRunning = false
@@ -70,14 +67,20 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
const db = getDb() const db = getDb()
const now = Date.now() const now = Date.now()
// Load existing fingerprints for incremental hashing (skip re-reading unchanged files)
const existingFps = db
.prepare('SELECT item_key, fingerprint FROM media_items WHERE library_id = ? AND fingerprint IS NOT NULL')
.all(library.id) as Array<{ item_key: string; fingerprint: string }>
const existingFpMap = new Map(existingFps.map((r) => [r.item_key, r.fingerprint]))
// Build new items map: item_key → { fingerprint, movie } // Build new items map: item_key → { fingerprint, movie }
type MovieEntry = { fingerprint: string | null; movie: Movie } type MovieEntry = { fingerprint: string | null; movie: Movie }
const newItems = new Map<string, MovieEntry>() const newItems = new Map<string, MovieEntry>()
for (const movie of movies) { for (const movie of movies) {
const itemKey = `${library.id}:movie:${movie.id}` const itemKey = `${library.id}:movie:${movie.id}`
const fingerprint = movie.videoPath const fingerprint =
? computeFingerprint(path.join(libraryRoot, movie.videoPath)) existingFpMap.get(itemKey) ??
: null (movie.videoPath ? computeFingerprint(path.join(libraryRoot, movie.videoPath)) : null)
newItems.set(itemKey, { fingerprint, movie }) newItems.set(itemKey, { fingerprint, movie })
} }
@@ -101,6 +104,7 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
db.transaction(() => {
for (const [itemKey, { fingerprint, movie }] of newItems) { for (const [itemKey, { fingerprint, movie }] of newItems) {
upsert.run({ upsert.run({
library_id: library.id, library_id: library.id,
@@ -120,7 +124,11 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
fingerprint, fingerprint,
scanned_at: now, scanned_at: now,
}) })
}
})()
// Prewarm poster thumbnails after the transaction (bounded by number of movies)
for (const [, { movie }] of newItems) {
if (movie.posterUrl) { if (movie.posterUrl) {
await prewarmThumbnailFromUrl(movie.posterUrl, library.id, libraryRoot, 'image') await prewarmThumbnailFromUrl(movie.posterUrl, library.id, libraryRoot, 'image')
} }
@@ -142,6 +150,12 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
type EpisodeRow = { episode: TvEpisode; episodeKey: string; fingerprint: string | null } type EpisodeRow = { episode: TvEpisode; episodeKey: string; fingerprint: string | null }
type SeriesRow = { show: TvSeries; seriesKey: string; seasons: SeasonRow[] } type SeriesRow = { show: TvSeries; seriesKey: string; seasons: SeasonRow[] }
// Load existing episode fingerprints for incremental hashing
const existingEpFps = db
.prepare('SELECT item_key, fingerprint FROM media_items WHERE library_id = ? AND item_type = ? AND fingerprint IS NOT NULL')
.all(library.id, 'tv_episode') as Array<{ item_key: string; fingerprint: string }>
const existingEpFpMap = new Map(existingEpFps.map((r) => [r.item_key, r.fingerprint]))
const allSeries: SeriesRow[] = [] const allSeries: SeriesRow[] = []
const newKeys = new Set<string>() const newKeys = new Set<string>()
const newEpisodes = new Map<string, { fingerprint: string | null }>() const newEpisodes = new Map<string, { fingerprint: string | null }>()
@@ -159,9 +173,9 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
for (const episode of scanTvEpisodes(libraryRoot, library.id, show.id, season.id)) { for (const episode of scanTvEpisodes(libraryRoot, library.id, show.id, season.id)) {
const episodeKey = `${library.id}:tv_episode:${show.id}:${season.id}:${episode.id}` const episodeKey = `${library.id}:tv_episode:${show.id}:${season.id}:${episode.id}`
newKeys.add(episodeKey) newKeys.add(episodeKey)
const fingerprint = episode.videoPath const fingerprint =
? computeFingerprint(path.join(libraryRoot, episode.videoPath)) existingEpFpMap.get(episodeKey) ??
: null (episode.videoPath ? computeFingerprint(path.join(libraryRoot, episode.videoPath)) : null)
episodeRows.push({ episode, episodeKey, fingerprint }) episodeRows.push({ episode, episodeKey, fingerprint })
newEpisodes.set(episodeKey, { fingerprint }) newEpisodes.set(episodeKey, { fingerprint })
} }
@@ -207,6 +221,8 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
let episodeCount = 0 let episodeCount = 0
// Phase 1: all DB writes in a single transaction
db.transaction(() => {
for (const { show, seriesKey, seasons } of allSeries) { for (const { show, seriesKey, seasons } of allSeries) {
upsertSeries.run({ upsertSeries.run({
library_id: library.id, library_id: library.id,
@@ -227,10 +243,6 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
scanned_at: now, scanned_at: now,
}) })
if (show.posterUrl) {
await prewarmThumbnailFromUrl(show.posterUrl, library.id, libraryRoot, 'image')
}
for (const { season, seasonKey, episodes } of seasons) { for (const { season, seasonKey, episodes } of seasons) {
upsertChild.run({ upsertChild.run({
library_id: library.id, library_id: library.id,
@@ -251,10 +263,6 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
scanned_at: now, scanned_at: now,
}) })
if (season.posterUrl) {
await prewarmThumbnailFromUrl(season.posterUrl, library.id, libraryRoot, 'image')
}
for (const { episode, episodeKey, fingerprint } of episodes) { for (const { episode, episodeKey, fingerprint } of episodes) {
upsertChild.run({ upsertChild.run({
library_id: library.id, library_id: library.id,
@@ -276,14 +284,28 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
fingerprint, fingerprint,
scanned_at: now, scanned_at: now,
}) })
episodeCount++
}
}
}
})()
// Phase 2: async thumbnail generation (bounded — one at a time, awaited)
for (const { show, seasons } of allSeries) {
if (show.posterUrl) {
await prewarmThumbnailFromUrl(show.posterUrl, library.id, libraryRoot, 'image')
}
for (const { season, episodes } of seasons) {
if (season.posterUrl) {
await prewarmThumbnailFromUrl(season.posterUrl, library.id, libraryRoot, 'image')
}
for (const { episode } of episodes) {
const videoAbsPath = path.join(libraryRoot, episode.videoPath) const videoAbsPath = path.join(libraryRoot, episode.videoPath)
try { try {
await getThumbnailPath(videoAbsPath, library.id, 'video') await getThumbnailPath(videoAbsPath, library.id, 'video')
} catch (err) { } catch (err) {
console.warn(`[scanner] Could not generate thumbnail for ${episode.videoPath}:`, err instanceof Error ? err.message : err) console.warn(`[scanner] Could not generate thumbnail for ${episode.videoPath}:`, err instanceof Error ? err.message : err)
} }
episodeCount++
} }
} }
} }
@@ -410,6 +432,12 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
const db = getDb() const db = getDb()
const now = Date.now() const now = Date.now()
// Load existing fingerprints for incremental hashing (skip re-reading unchanged files)
const existingMixedFps = db
.prepare('SELECT item_key, fingerprint FROM media_items WHERE library_id = ? AND item_type = ? AND fingerprint IS NOT NULL')
.all(library.id, 'mixed_file') as Array<{ item_key: string; fingerprint: string }>
const existingMixedFpMap = new Map(existingMixedFps.map((r) => [r.item_key, r.fingerprint]))
// Collect all new items with fingerprints // Collect all new items with fingerprints
type MixedEntry = { fingerprint: string | null; relPath: string; title: string } type MixedEntry = { fingerprint: string | null; relPath: string; title: string }
const newItems = new Map<string, MixedEntry>() const newItems = new Map<string, MixedEntry>()
@@ -429,8 +457,10 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
walk(path.join(absDir, name), relPath) walk(path.join(absDir, name), relPath)
} else { } else {
const itemKey = `${library.id}:mixed_file:${encodeURIComponent(relPath)}` const itemKey = `${library.id}:mixed_file:${encodeURIComponent(relPath)}`
const absPath = path.join(absDir, name) // Reuse stored fingerprint if the path is unchanged; only read for new/unknown files
const fingerprint = computeFingerprint(absPath) const fingerprint =
existingMixedFpMap.get(itemKey) ??
computeFingerprint(path.join(absDir, name))
newItems.set(itemKey, { newItems.set(itemKey, {
fingerprint, fingerprint,
relPath, relPath,
@@ -456,6 +486,8 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
// All upserts in a single transaction — critical for large libraries (48k+ files)
db.transaction(() => {
for (const [itemKey, { fingerprint, relPath, title }] of newItems) { for (const [itemKey, { fingerprint, relPath, title }] of newItems) {
upsert.run({ upsert.run({
library_id: library.id, library_id: library.id,
@@ -466,20 +498,13 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
fingerprint, fingerprint,
scanned_at: now, scanned_at: now,
}) })
const ext = path.extname(relPath).toLowerCase()
let mediaType: 'image' | 'video' | null = null
if (IMAGE_EXTENSIONS.has(ext)) mediaType = 'image'
else if (VIDEO_EXTENSIONS.has(ext)) mediaType = 'video'
if (mediaType) {
const absPath = path.join(libraryRoot, relPath)
getThumbnailPath(absPath, library.id, mediaType).catch((err) => {
console.warn(`[scanner] Could not generate thumbnail for ${relPath}:`, err instanceof Error ? err.message : err)
})
}
} }
})()
console.log(`[scanner] mixed: indexed ${newItems.size} files, pre-generating thumbnails`) // Thumbnails for mixed libraries are generated on-demand by /api/thumbnail.
// Pre-warming 48k+ files simultaneously was the cause of the post-scan CPU spike.
console.log(`[scanner] mixed: indexed ${newItems.size} files`)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -555,6 +580,7 @@ function reconcileAndPrune(
): void { ): void {
const renameItem = db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?') const renameItem = db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?')
// Apply moves first (outside transaction so console.log is visible as they happen)
for (const { oldKey, newKey } of moves) { for (const { oldKey, newKey } of moves) {
renameItem.run(newKey, oldKey) renameItem.run(newKey, oldKey)
// Convert item_keys to the media_key format actually used in media_tags // Convert item_keys to the media_key format actually used in media_tags
@@ -570,12 +596,15 @@ function reconcileAndPrune(
.prepare('SELECT item_key FROM media_items WHERE library_id = ?') .prepare('SELECT item_key FROM media_items WHERE library_id = ?')
.all(libraryId) as Array<{ item_key: string }> .all(libraryId) as Array<{ item_key: string }>
// Batch all deletes in a single transaction
const deleteItem = db.prepare('DELETE FROM media_items WHERE item_key = ?') const deleteItem = db.prepare('DELETE FROM media_items WHERE item_key = ?')
db.transaction(() => {
for (const { item_key } of existing) { for (const { item_key } of existing) {
if (!newKeys.has(item_key)) { if (!newKeys.has(item_key)) {
deleteItem.run(item_key) deleteItem.run(item_key)
} }
} }
})()
} }
/** /**