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
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:
@@ -73,9 +73,20 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
|
||||
setTimeout(() => { cooldownRef.current = false }, 300)
|
||||
}, [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(() => {
|
||||
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])
|
||||
|
||||
// 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
|
||||
}, [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(() => {
|
||||
if (!videoRef.current) return
|
||||
if (isPaused) {
|
||||
@@ -91,7 +103,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
|
||||
} else {
|
||||
videoRef.current.play().catch(() => {})
|
||||
}
|
||||
}, [isPaused, current?.url])
|
||||
}, [isPaused])
|
||||
|
||||
// Auto-play timer — resets on each new item, pause, enable/disable, or interval change
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
||||
import type { DirectoryListing } from '@/types'
|
||||
|
||||
import FilterPanel from '@/components/FilterPanel'
|
||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||
import TagSelector from '@/components/tags/TagSelector'
|
||||
@@ -190,13 +190,30 @@ export default function TvView({ libraryId }: Props) {
|
||||
mediaType: 'video' as const,
|
||||
}))
|
||||
} else {
|
||||
// No filters — use full recursive browse
|
||||
const data: DirectoryListing = await fetch(
|
||||
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=&recursive=true`
|
||||
// No filters — fetch all episodes via the TV API hierarchy
|
||||
const allSeries: TvSeries[] = await fetch(
|
||||
`/api/tv?libraryId=${encodeURIComponent(libraryId)}`
|
||||
).then((r) => r.json())
|
||||
items = data.entries
|
||||
.filter((e) => e.type === 'file' && e.mediaType === 'video' && e.url)
|
||||
.map((e) => ({ url: e.url!, name: e.name, mediaType: 'video' as const }))
|
||||
const episodeLists = await Promise.all(
|
||||
allSeries.map(async (s) => {
|
||||
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)
|
||||
setDoomScrollActive(true)
|
||||
|
||||
@@ -10,9 +10,6 @@ import { scanGamesLibrary } from './games'
|
||||
import { getThumbnailPath } from './thumbnails'
|
||||
import { computeFingerprint } from './fingerprint'
|
||||
import { reKeyMediaItem } from './tags'
|
||||
import { VIDEO_EXTENSIONS } from './media-utils'
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'])
|
||||
|
||||
let scanRunning = false
|
||||
|
||||
@@ -70,14 +67,20 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
|
||||
const db = getDb()
|
||||
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 }
|
||||
type MovieEntry = { fingerprint: string | null; movie: Movie }
|
||||
const newItems = new Map<string, MovieEntry>()
|
||||
for (const movie of movies) {
|
||||
const itemKey = `${library.id}:movie:${movie.id}`
|
||||
const fingerprint = movie.videoPath
|
||||
? computeFingerprint(path.join(libraryRoot, movie.videoPath))
|
||||
: null
|
||||
const fingerprint =
|
||||
existingFpMap.get(itemKey) ??
|
||||
(movie.videoPath ? computeFingerprint(path.join(libraryRoot, movie.videoPath)) : null)
|
||||
newItems.set(itemKey, { fingerprint, movie })
|
||||
}
|
||||
|
||||
@@ -101,6 +104,7 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
db.transaction(() => {
|
||||
for (const [itemKey, { fingerprint, movie }] of newItems) {
|
||||
upsert.run({
|
||||
library_id: library.id,
|
||||
@@ -120,7 +124,11 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
|
||||
fingerprint,
|
||||
scanned_at: now,
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
// Prewarm poster thumbnails after the transaction (bounded by number of movies)
|
||||
for (const [, { movie }] of newItems) {
|
||||
if (movie.posterUrl) {
|
||||
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 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 newKeys = new Set<string>()
|
||||
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)) {
|
||||
const episodeKey = `${library.id}:tv_episode:${show.id}:${season.id}:${episode.id}`
|
||||
newKeys.add(episodeKey)
|
||||
const fingerprint = episode.videoPath
|
||||
? computeFingerprint(path.join(libraryRoot, episode.videoPath))
|
||||
: null
|
||||
const fingerprint =
|
||||
existingEpFpMap.get(episodeKey) ??
|
||||
(episode.videoPath ? computeFingerprint(path.join(libraryRoot, episode.videoPath)) : null)
|
||||
episodeRows.push({ episode, episodeKey, fingerprint })
|
||||
newEpisodes.set(episodeKey, { fingerprint })
|
||||
}
|
||||
@@ -207,6 +221,8 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
||||
|
||||
let episodeCount = 0
|
||||
|
||||
// Phase 1: all DB writes in a single transaction
|
||||
db.transaction(() => {
|
||||
for (const { show, seriesKey, seasons } of allSeries) {
|
||||
upsertSeries.run({
|
||||
library_id: library.id,
|
||||
@@ -227,10 +243,6 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
||||
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,
|
||||
@@ -251,10 +263,6 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
||||
scanned_at: now,
|
||||
})
|
||||
|
||||
if (season.posterUrl) {
|
||||
await prewarmThumbnailFromUrl(season.posterUrl, library.id, libraryRoot, 'image')
|
||||
}
|
||||
|
||||
for (const { episode, episodeKey, fingerprint } of episodes) {
|
||||
upsertChild.run({
|
||||
library_id: library.id,
|
||||
@@ -276,14 +284,28 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
||||
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)
|
||||
try {
|
||||
await getThumbnailPath(videoAbsPath, library.id, 'video')
|
||||
} catch (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 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
|
||||
type MixedEntry = { fingerprint: string | null; relPath: string; title: string }
|
||||
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)
|
||||
} else {
|
||||
const itemKey = `${library.id}:mixed_file:${encodeURIComponent(relPath)}`
|
||||
const absPath = path.join(absDir, name)
|
||||
const fingerprint = computeFingerprint(absPath)
|
||||
// Reuse stored fingerprint if the path is unchanged; only read for new/unknown files
|
||||
const fingerprint =
|
||||
existingMixedFpMap.get(itemKey) ??
|
||||
computeFingerprint(path.join(absDir, name))
|
||||
newItems.set(itemKey, {
|
||||
fingerprint,
|
||||
relPath,
|
||||
@@ -456,6 +486,8 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
|
||||
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) {
|
||||
upsert.run({
|
||||
library_id: library.id,
|
||||
@@ -466,20 +498,13 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
|
||||
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 {
|
||||
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) {
|
||||
renameItem.run(newKey, oldKey)
|
||||
// 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 = ?')
|
||||
.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 = ?')
|
||||
db.transaction(() => {
|
||||
for (const { item_key } of existing) {
|
||||
if (!newKeys.has(item_key)) {
|
||||
deleteItem.run(item_key)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user