Merge pull request 'performance-stability' (#14) from performance-stability into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s

Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/14
This commit is contained in:
2026-04-07 00:22:00 +00: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,26 +104,31 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
for (const [itemKey, { fingerprint, movie }] of newItems) { db.transaction(() => {
upsert.run({ for (const [itemKey, { fingerprint, movie }] of newItems) {
library_id: library.id, upsert.run({
item_key: itemKey, library_id: library.id,
item_type: 'movie', item_key: itemKey,
title: movie.title, item_type: 'movie',
year: movie.year ?? null, title: movie.title,
plot: movie.plot ?? null, year: movie.year ?? null,
genres: JSON.stringify(movie.genres), plot: movie.plot ?? null,
metadata: JSON.stringify({ genres: JSON.stringify(movie.genres),
rating: movie.rating, metadata: JSON.stringify({
runtime: movie.runtime, rating: movie.rating,
posterUrl: movie.posterUrl, runtime: movie.runtime,
backdropUrl: movie.backdropUrl, posterUrl: movie.posterUrl,
}), backdropUrl: movie.backdropUrl,
file_path: movie.videoPath, }),
fingerprint, file_path: movie.videoPath,
scanned_at: now, fingerprint,
}) 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,83 +221,91 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
let episodeCount = 0 let episodeCount = 0
for (const { show, seriesKey, seasons } of allSeries) { // Phase 1: all DB writes in a single transaction
upsertSeries.run({ db.transaction(() => {
library_id: library.id, for (const { show, seriesKey, seasons } of allSeries) {
item_key: seriesKey, upsertSeries.run({
item_type: 'tv_series',
title: show.title,
year: show.year ?? null,
plot: show.plot ?? null,
genres: JSON.stringify(show.genres),
metadata: JSON.stringify({
status: show.status,
seasonCount: show.seasonCount,
posterUrl: show.posterUrl,
backdropUrl: show.backdropUrl,
}),
file_path: null,
fingerprint: null,
scanned_at: now,
})
if (show.posterUrl) {
await prewarmThumbnailFromUrl(show.posterUrl, library.id, libraryRoot, 'image')
}
for (const { season, seasonKey, episodes } of seasons) {
upsertChild.run({
library_id: library.id, library_id: library.id,
item_key: seasonKey, item_key: seriesKey,
item_type: 'tv_season', item_type: 'tv_series',
parent_key: seriesKey, title: show.title,
title: season.title, year: show.year ?? null,
year: null, plot: show.plot ?? null,
plot: null, genres: JSON.stringify(show.genres),
genres: JSON.stringify([]),
metadata: JSON.stringify({ metadata: JSON.stringify({
seasonNumber: season.seasonNumber, status: show.status,
episodeCount: season.episodeCount, seasonCount: show.seasonCount,
posterUrl: season.posterUrl, posterUrl: show.posterUrl,
backdropUrl: show.backdropUrl,
}), }),
file_path: null, file_path: null,
fingerprint: null, fingerprint: null,
scanned_at: now, scanned_at: now,
}) })
if (season.posterUrl) { for (const { season, seasonKey, episodes } of seasons) {
await prewarmThumbnailFromUrl(season.posterUrl, library.id, libraryRoot, 'image')
}
for (const { episode, episodeKey, fingerprint } of episodes) {
upsertChild.run({ upsertChild.run({
library_id: library.id, library_id: library.id,
item_key: episodeKey, item_key: seasonKey,
item_type: 'tv_episode', item_type: 'tv_season',
parent_key: seasonKey, parent_key: seriesKey,
title: episode.title, title: season.title,
year: null, year: null,
plot: episode.plot ?? null, plot: null,
genres: JSON.stringify([]), genres: JSON.stringify([]),
metadata: JSON.stringify({ metadata: JSON.stringify({
episodeNumber: episode.episodeNumber, seasonNumber: season.seasonNumber,
seasonNumber: episode.seasonNumber, episodeCount: season.episodeCount,
aired: episode.aired, posterUrl: season.posterUrl,
rating: episode.rating,
thumbnailUrl: episode.thumbnailUrl,
}), }),
file_path: episode.videoPath, file_path: null,
fingerprint, fingerprint: null,
scanned_at: now, scanned_at: now,
}) })
for (const { episode, episodeKey, fingerprint } of episodes) {
upsertChild.run({
library_id: library.id,
item_key: episodeKey,
item_type: 'tv_episode',
parent_key: seasonKey,
title: episode.title,
year: null,
plot: episode.plot ?? null,
genres: JSON.stringify([]),
metadata: JSON.stringify({
episodeNumber: episode.episodeNumber,
seasonNumber: episode.seasonNumber,
aired: episode.aired,
rating: episode.rating,
thumbnailUrl: episode.thumbnailUrl,
}),
file_path: episode.videoPath,
fingerprint,
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,30 +486,25 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
scanned_at = excluded.scanned_at scanned_at = excluded.scanned_at
`) `)
for (const [itemKey, { fingerprint, relPath, title }] of newItems) { // All upserts in a single transaction — critical for large libraries (48k+ files)
upsert.run({ db.transaction(() => {
library_id: library.id, for (const [itemKey, { fingerprint, relPath, title }] of newItems) {
item_key: itemKey, upsert.run({
item_type: 'mixed_file', library_id: library.id,
title, item_key: itemKey,
file_path: relPath, item_type: 'mixed_file',
fingerprint, title,
scanned_at: now, file_path: relPath,
}) fingerprint,
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 = ?')
for (const { item_key } of existing) { db.transaction(() => {
if (!newKeys.has(item_key)) { for (const { item_key } of existing) {
deleteItem.run(item_key) if (!newKeys.has(item_key)) {
deleteItem.run(item_key)
}
} }
} })()
} }
/** /**