Merge pull request 'file-fingerprinting' (#13) from file-fingerprinting into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/13
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
import { getLibrary } from '@/lib/libraries'
|
||||||
import { scanGamesLibrary } from '@/lib/games'
|
import { gamesFromDb } from '@/lib/games'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -22,7 +22,5 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 })
|
return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = resolveLibraryRoot(library)
|
return NextResponse.json(gamesFromDb(libraryId))
|
||||||
const games = scanGamesLibrary(root, libraryId)
|
|
||||||
return NextResponse.json(games)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { scanMoviesLibrary } from '@/lib/movies'
|
import { moviesFromDb } from '@/lib/movies'
|
||||||
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
||||||
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
@@ -25,9 +25,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 })
|
return NextResponse.json({ error: 'Library is not a movies library' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = resolveLibraryRoot(library)
|
return NextResponse.json(moviesFromDb(libraryId))
|
||||||
const movies = scanMoviesLibrary(root, libraryId)
|
|
||||||
return NextResponse.json(movies)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
|||||||
159
src/app/api/nfo-refresh/route.ts
Normal file
159
src/app/api/nfo-refresh/route.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import { findNfoFile } from '@/lib/movies'
|
||||||
|
import { parseMovieNfo, parseTvShowNfo, parseEpisodeNfo } from '@/lib/nfo'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
const itemType = searchParams.get('itemType') as 'movie' | 'tv_series' | 'tv_episode' | null
|
||||||
|
const itemKey = searchParams.get('itemKey')
|
||||||
|
|
||||||
|
if (!libraryId || !itemType || !itemKey) {
|
||||||
|
return NextResponse.json({ error: 'Missing libraryId, itemType, or itemKey' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['movie', 'tv_series', 'tv_episode'].includes(itemType)) {
|
||||||
|
return NextResponse.json({ error: 'itemType must be movie, tv_series, or tv_episode' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT * FROM media_items WHERE item_key = ?')
|
||||||
|
.get(itemKey) as {
|
||||||
|
item_key: string
|
||||||
|
item_type: string
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
genres: string | null
|
||||||
|
metadata: string | null
|
||||||
|
file_path: string | null
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: 'Item not found in database' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryRoot = resolveLibraryRoot(library)
|
||||||
|
const existingMeta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
|
||||||
|
if (itemType === 'movie') {
|
||||||
|
// item_key: {libraryId}:movie:{encodedDirName}
|
||||||
|
const encodedDirName = itemKey.split(':movie:')[1]
|
||||||
|
if (!encodedDirName) {
|
||||||
|
return NextResponse.json({ error: 'Invalid item key' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const dirName = decodeURIComponent(encodedDirName)
|
||||||
|
const movieDir = path.join(libraryRoot, dirName)
|
||||||
|
const nfoFileName = findNfoFile(movieDir, dirName)
|
||||||
|
if (!nfoFileName) {
|
||||||
|
return NextResponse.json({ updated: false, reason: 'no nfo found' })
|
||||||
|
}
|
||||||
|
const nfo = parseMovieNfo(path.join(movieDir, nfoFileName))
|
||||||
|
if (!nfo) {
|
||||||
|
return NextResponse.json({ updated: false, reason: 'nfo parse failed' })
|
||||||
|
}
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE media_items SET
|
||||||
|
title = @title,
|
||||||
|
year = @year,
|
||||||
|
plot = @plot,
|
||||||
|
genres = @genres,
|
||||||
|
metadata = @metadata
|
||||||
|
WHERE item_key = @item_key
|
||||||
|
`).run({
|
||||||
|
item_key: itemKey,
|
||||||
|
title: nfo.title ?? row.title,
|
||||||
|
year: nfo.year ?? null,
|
||||||
|
plot: nfo.plot ?? null,
|
||||||
|
genres: JSON.stringify(nfo.genres ?? []),
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
...existingMeta,
|
||||||
|
rating: nfo.rating ?? null,
|
||||||
|
runtime: nfo.runtime ?? null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemType === 'tv_series') {
|
||||||
|
// item_key: {libraryId}:tv_series:{encodedDirName}
|
||||||
|
const encodedDirName = itemKey.split(':tv_series:')[1]
|
||||||
|
if (!encodedDirName) {
|
||||||
|
return NextResponse.json({ error: 'Invalid item key' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const dirName = decodeURIComponent(encodedDirName)
|
||||||
|
const seriesDir = path.join(libraryRoot, dirName)
|
||||||
|
const nfoPath = path.join(seriesDir, 'tvshow.nfo')
|
||||||
|
const nfo = parseTvShowNfo(nfoPath)
|
||||||
|
if (!nfo) {
|
||||||
|
return NextResponse.json({ updated: false, reason: 'no nfo found' })
|
||||||
|
}
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE media_items SET
|
||||||
|
title = @title,
|
||||||
|
year = @year,
|
||||||
|
plot = @plot,
|
||||||
|
genres = @genres,
|
||||||
|
metadata = @metadata
|
||||||
|
WHERE item_key = @item_key
|
||||||
|
`).run({
|
||||||
|
item_key: itemKey,
|
||||||
|
title: nfo.title ?? row.title,
|
||||||
|
year: nfo.year ?? null,
|
||||||
|
plot: nfo.plot ?? null,
|
||||||
|
genres: JSON.stringify(nfo.genres ?? []),
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
...existingMeta,
|
||||||
|
status: nfo.status ?? null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return NextResponse.json({ updated: true, title: nfo.title, year: nfo.year })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemType === 'tv_episode') {
|
||||||
|
if (!row.file_path) {
|
||||||
|
return NextResponse.json({ updated: false, reason: 'no file_path in database' })
|
||||||
|
}
|
||||||
|
const episodeDir = path.join(libraryRoot, path.dirname(row.file_path))
|
||||||
|
const baseName = path.basename(row.file_path, path.extname(row.file_path))
|
||||||
|
const nfoPath = path.join(episodeDir, `${baseName}.nfo`)
|
||||||
|
const nfo = parseEpisodeNfo(nfoPath)
|
||||||
|
if (!nfo) {
|
||||||
|
return NextResponse.json({ updated: false, reason: 'no nfo found' })
|
||||||
|
}
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE media_items SET
|
||||||
|
title = @title,
|
||||||
|
plot = @plot,
|
||||||
|
metadata = @metadata
|
||||||
|
WHERE item_key = @item_key
|
||||||
|
`).run({
|
||||||
|
item_key: itemKey,
|
||||||
|
title: nfo.title ?? row.title,
|
||||||
|
plot: nfo.plot ?? null,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
...existingMeta,
|
||||||
|
episodeNumber: nfo.episode ?? existingMeta.episodeNumber ?? null,
|
||||||
|
seasonNumber: nfo.season ?? existingMeta.seasonNumber ?? null,
|
||||||
|
aired: nfo.aired ?? null,
|
||||||
|
rating: nfo.rating ?? null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return NextResponse.json({ updated: true, title: nfo.title })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Unhandled itemType' }, { status: 400 })
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import fs from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from '@/lib/tv'
|
import { tvSeriesFromDb, tvSeasonsFromDb, tvEpisodesFromDb } from '@/lib/tv'
|
||||||
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
||||||
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
@@ -27,20 +27,15 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 })
|
return NextResponse.json({ error: 'Library is not a TV library' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = resolveLibraryRoot(library)
|
|
||||||
|
|
||||||
if (seriesId && seasonId) {
|
if (seriesId && seasonId) {
|
||||||
const episodes = scanTvEpisodes(root, libraryId, seriesId, seasonId)
|
return NextResponse.json(tvEpisodesFromDb(libraryId, seriesId, seasonId))
|
||||||
return NextResponse.json(episodes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seriesId) {
|
if (seriesId) {
|
||||||
const seasons = scanTvSeasons(root, libraryId, seriesId)
|
return NextResponse.json(tvSeasonsFromDb(libraryId, seriesId))
|
||||||
return NextResponse.json(seasons)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const series = scanTvLibrary(root, libraryId)
|
return NextResponse.json(tvSeriesFromDb(libraryId))
|
||||||
return NextResponse.json(series)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
|||||||
@@ -13,15 +13,17 @@ interface Props {
|
|||||||
onNext?: () => void
|
onNext?: () => void
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
onDeleted: (movieId: string) => void
|
onDeleted: (movieId: string) => void
|
||||||
|
onMetadataRefreshed?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted }: Props) {
|
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [playing, setPlaying] = useState(false)
|
const [playing, setPlaying] = useState(false)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
@@ -66,6 +68,18 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
.catch(() => setDeleting(false))
|
.catch(() => setDeleting(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRefreshMetadata = () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
setMenuOpen(false)
|
||||||
|
const itemKey = `${libraryId}:movie:${movie.id}`
|
||||||
|
fetch(
|
||||||
|
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=movie&itemKey=${encodeURIComponent(itemKey)}`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
)
|
||||||
|
.then(() => onMetadataRefreshed?.())
|
||||||
|
.finally(() => setRefreshing(false))
|
||||||
|
}
|
||||||
|
|
||||||
if (playing) {
|
if (playing) {
|
||||||
return (
|
return (
|
||||||
<VideoPlayerModal
|
<VideoPlayerModal
|
||||||
@@ -175,6 +189,16 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleRefreshMetadata}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ export default function MoviesView({ libraryId }: Props) {
|
|||||||
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
||||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||||
onDeleted={handleDeleted}
|
onDeleted={handleDeleted}
|
||||||
|
onMetadataRefreshed={fetchMovies}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [refreshingMeta, setRefreshingMeta] = useState(false)
|
||||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||||
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
||||||
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
||||||
@@ -149,6 +150,19 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
.catch(() => setDeleting(false))
|
.catch(() => setDeleting(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRefreshSeriesMetadata = () => {
|
||||||
|
if (!selectedSeries) return
|
||||||
|
setRefreshingMeta(true)
|
||||||
|
setMenuOpen(false)
|
||||||
|
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
|
||||||
|
fetch(
|
||||||
|
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
)
|
||||||
|
.then(() => fetchSeries())
|
||||||
|
.finally(() => setRefreshingMeta(false))
|
||||||
|
}
|
||||||
|
|
||||||
const handleDoomScroll = async () => {
|
const handleDoomScroll = async () => {
|
||||||
setDoomScrollLoading(true)
|
setDoomScrollLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -415,6 +429,16 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleRefreshSeriesMetadata}
|
||||||
|
disabled={refreshingMeta}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
{refreshingMeta ? 'Refreshing…' : 'Refresh metadata'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
|||||||
@@ -81,21 +81,26 @@ function initDb(db: Database.Database): void {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
item_key TEXT NOT NULL UNIQUE,
|
item_key TEXT NOT NULL UNIQUE,
|
||||||
item_type TEXT NOT NULL CHECK(item_type IN ('movie','tv_series','tv_season','tv_episode','game','game_series')),
|
item_type TEXT NOT NULL CHECK(item_type IN ('movie','tv_series','tv_season','tv_episode','game','game_series','mixed_file')),
|
||||||
parent_key TEXT,
|
parent_key TEXT,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
year INTEGER,
|
year INTEGER,
|
||||||
plot TEXT,
|
plot TEXT,
|
||||||
genres TEXT,
|
genres TEXT,
|
||||||
metadata TEXT,
|
metadata TEXT,
|
||||||
|
file_path TEXT,
|
||||||
|
fingerprint TEXT,
|
||||||
scanned_at INTEGER NOT NULL
|
scanned_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS media_items_library_id ON media_items(library_id);
|
CREATE INDEX IF NOT EXISTS media_items_library_id ON media_items(library_id);
|
||||||
CREATE INDEX IF NOT EXISTS media_items_parent_key ON media_items(parent_key);
|
CREATE INDEX IF NOT EXISTS media_items_parent_key ON media_items(parent_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS media_items_fingerprint ON media_items(fingerprint);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
migrateLibrariesType(db)
|
migrateLibrariesType(db)
|
||||||
|
migrateMediaItemsSchema(db)
|
||||||
|
migrateMediaItemsFingerprint(db)
|
||||||
seedAppSettings(db)
|
seedAppSettings(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +118,65 @@ function seedAppSettings(db: Database.Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateMediaItemsSchema(db: Database.Database): void {
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
|
||||||
|
if (!row) return
|
||||||
|
|
||||||
|
const needsFilePath = !row.sql.includes('file_path')
|
||||||
|
const needsMixedFile = !row.sql.includes("'mixed_file'")
|
||||||
|
|
||||||
|
if (!needsFilePath && !needsMixedFile) return
|
||||||
|
|
||||||
|
// Determine whether the current table already has file_path (partial migration)
|
||||||
|
const hasFilePath = !needsFilePath ? 'file_path,' : 'NULL as file_path,'
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
CREATE TABLE media_items_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
item_key TEXT NOT NULL UNIQUE,
|
||||||
|
item_type TEXT NOT NULL CHECK(item_type IN (
|
||||||
|
'movie','tv_series','tv_season','tv_episode',
|
||||||
|
'game','game_series','mixed_file')),
|
||||||
|
parent_key TEXT,
|
||||||
|
title TEXT,
|
||||||
|
year INTEGER,
|
||||||
|
plot TEXT,
|
||||||
|
genres TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
file_path TEXT,
|
||||||
|
scanned_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO media_items_new
|
||||||
|
SELECT id, library_id, item_key, item_type, parent_key,
|
||||||
|
title, year, plot, genres, metadata,
|
||||||
|
${hasFilePath}
|
||||||
|
scanned_at
|
||||||
|
FROM media_items;
|
||||||
|
DROP TABLE media_items;
|
||||||
|
ALTER TABLE media_items_new RENAME TO media_items;
|
||||||
|
CREATE INDEX media_items_library_id ON media_items(library_id);
|
||||||
|
CREATE INDEX media_items_parent_key ON media_items(parent_key);
|
||||||
|
COMMIT;
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateMediaItemsFingerprint(db: Database.Database): void {
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
if (row && !row.sql.includes('fingerprint')) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE media_items ADD COLUMN fingerprint TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS media_items_fingerprint ON media_items(fingerprint);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function migrateLibrariesType(db: Database.Database): void {
|
function migrateLibrariesType(db: Database.Database): void {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
||||||
|
|||||||
36
src/lib/fingerprint.ts
Normal file
36
src/lib/fingerprint.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 64 * 1024 // 64 KB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a stable partial-content fingerprint for a file.
|
||||||
|
* Uses SHA-256 of the file size + first 64 KB of content.
|
||||||
|
* Fast enough for large video files (~instant) and collision-resistant
|
||||||
|
* for real-world media libraries.
|
||||||
|
*
|
||||||
|
* Returns null if the file cannot be read (missing, permission error, etc.).
|
||||||
|
*/
|
||||||
|
export function computeFingerprint(absolutePath: string): string | null {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(absolutePath)
|
||||||
|
const size = stat.size
|
||||||
|
const chunkLen = Math.min(CHUNK_SIZE, size)
|
||||||
|
const buf = Buffer.alloc(chunkLen)
|
||||||
|
if (chunkLen > 0) {
|
||||||
|
const fd = fs.openSync(absolutePath, 'r')
|
||||||
|
try {
|
||||||
|
fs.readSync(fd, buf, 0, chunkLen, 0)
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(`${size}:`)
|
||||||
|
.update(buf)
|
||||||
|
.digest('hex')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type { Game, GameSeries } from '@/types'
|
import type { Game, GameSeries } from '@/types'
|
||||||
|
import { getDb } from './db'
|
||||||
import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,3 +120,64 @@ export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game
|
|||||||
|
|
||||||
return results.sort((a, b) => a.title.localeCompare(b.title))
|
return results.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
type DbRow = {
|
||||||
|
item_key: string
|
||||||
|
item_type: string
|
||||||
|
parent_key: string | null
|
||||||
|
title: string | null
|
||||||
|
metadata: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRows = db
|
||||||
|
.prepare(`SELECT item_key, item_type, parent_key, title, metadata
|
||||||
|
FROM media_items
|
||||||
|
WHERE library_id = ? AND item_type IN ('game', 'game_series')
|
||||||
|
ORDER BY title`)
|
||||||
|
.all(libraryId) as DbRow[]
|
||||||
|
|
||||||
|
const seriesMap = new Map<string, GameSeries>()
|
||||||
|
const standaloneGames: Game[] = []
|
||||||
|
|
||||||
|
// First pass: build series
|
||||||
|
for (const row of allRows) {
|
||||||
|
if (row.item_type !== 'game_series') continue
|
||||||
|
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
const idPart = row.item_key.split(':game_series:')[1] ?? row.item_key
|
||||||
|
seriesMap.set(row.item_key, {
|
||||||
|
id: idPart,
|
||||||
|
title: row.title ?? decodeURIComponent(idPart),
|
||||||
|
coverUrl: meta.coverUrl ?? null,
|
||||||
|
wideCoverUrl: meta.wideCoverUrl ?? null,
|
||||||
|
games: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: attach games
|
||||||
|
for (const row of allRows) {
|
||||||
|
if (row.item_type !== 'game') continue
|
||||||
|
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
const idPart = row.item_key.split(':game:')[1] ?? row.item_key
|
||||||
|
const game: Game = {
|
||||||
|
id: idPart,
|
||||||
|
title: row.title ?? decodeURIComponent(idPart),
|
||||||
|
coverUrl: meta.coverUrl ?? null,
|
||||||
|
wideCoverUrl: meta.wideCoverUrl ?? null,
|
||||||
|
zipFiles: meta.zipFiles ?? [],
|
||||||
|
}
|
||||||
|
if (row.parent_key && seriesMap.has(row.parent_key)) {
|
||||||
|
seriesMap.get(row.parent_key)!.games.push(game)
|
||||||
|
} else {
|
||||||
|
standaloneGames.push(game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: (Game | GameSeries)[] = [
|
||||||
|
...Array.from(seriesMap.values()),
|
||||||
|
...standaloneGames,
|
||||||
|
]
|
||||||
|
return results.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type { Movie } from '@/types'
|
import type { Movie } from '@/types'
|
||||||
import { parseMovieNfo } from './nfo'
|
import { getDb } from './db'
|
||||||
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
||||||
|
|
||||||
function findVideoFile(dir: string): string | null {
|
function findVideoFile(dir: string): string | null {
|
||||||
@@ -16,7 +16,7 @@ function findVideoFile(dir: string): string | null {
|
|||||||
) ?? null
|
) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function findNfoFile(dir: string, dirName: string): string | null {
|
export function findNfoFile(dir: string, dirName: string): string | null {
|
||||||
// Try {dirName}.nfo first, then movie.nfo, then any .nfo
|
// Try {dirName}.nfo first, then movie.nfo, then any .nfo
|
||||||
const candidates = [`${dirName}.nfo`, 'movie.nfo']
|
const candidates = [`${dirName}.nfo`, 'movie.nfo']
|
||||||
let entries: string[]
|
let entries: string[]
|
||||||
@@ -26,9 +26,8 @@ function findNfoFile(dir: string, dirName: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (entries.find((e) => e.toLowerCase() === candidate.toLowerCase())) {
|
const match = entries.find((e) => e.toLowerCase() === candidate.toLowerCase())
|
||||||
return entries.find((e) => e.toLowerCase() === candidate.toLowerCase())!
|
if (match) return match
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return entries.find((e) => path.extname(e).toLowerCase() === '.nfo') ?? null
|
return entries.find((e) => path.extname(e).toLowerCase() === '.nfo') ?? null
|
||||||
}
|
}
|
||||||
@@ -52,9 +51,6 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
|
|||||||
const videoFile = findVideoFile(moviePath)
|
const videoFile = findVideoFile(moviePath)
|
||||||
if (!videoFile) continue
|
if (!videoFile) continue
|
||||||
|
|
||||||
const nfoFile = findNfoFile(moviePath, dirName)
|
|
||||||
const nfo = nfoFile ? parseMovieNfo(path.join(moviePath, nfoFile)) : null
|
|
||||||
|
|
||||||
const posterFile = findFile(moviePath, /^(poster|cover|folder)$/i)
|
const posterFile = findFile(moviePath, /^(poster|cover|folder)$/i)
|
||||||
const backdropFile = findFile(moviePath, /^(backdrop|fanart|background)$/i)
|
const backdropFile = findFile(moviePath, /^(backdrop|fanart|background)$/i)
|
||||||
|
|
||||||
@@ -63,12 +59,12 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
|
|||||||
|
|
||||||
movies.push({
|
movies.push({
|
||||||
id,
|
id,
|
||||||
title: nfo?.title ?? dirName,
|
title: dirName,
|
||||||
year: nfo?.year ?? null,
|
year: null,
|
||||||
plot: nfo?.plot ?? null,
|
plot: null,
|
||||||
rating: nfo?.rating ?? null,
|
rating: null,
|
||||||
genres: nfo?.genres ?? [],
|
genres: [],
|
||||||
runtime: nfo?.runtime ?? null,
|
runtime: null,
|
||||||
posterUrl: posterFile
|
posterUrl: posterFile
|
||||||
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
||||||
: null,
|
: null,
|
||||||
@@ -81,3 +77,35 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
|
|||||||
|
|
||||||
return movies.sort((a, b) => a.title.localeCompare(b.title))
|
return movies.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function moviesFromDb(libraryId: string): Movie[] {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare(`SELECT * FROM media_items WHERE library_id = ? AND item_type = 'movie' ORDER BY title`)
|
||||||
|
.all(libraryId) as Array<{
|
||||||
|
item_key: string
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
genres: string | null
|
||||||
|
metadata: string | null
|
||||||
|
file_path: string | null
|
||||||
|
}>
|
||||||
|
|
||||||
|
return rows.map((row) => {
|
||||||
|
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
const idPart = row.item_key.split(':movie:')[1] ?? row.item_key
|
||||||
|
return {
|
||||||
|
id: idPart,
|
||||||
|
title: row.title ?? decodeURIComponent(idPart),
|
||||||
|
year: row.year ?? null,
|
||||||
|
plot: row.plot ?? null,
|
||||||
|
rating: meta.rating ?? null,
|
||||||
|
genres: row.genres ? JSON.parse(row.genres) : [],
|
||||||
|
runtime: meta.runtime ?? null,
|
||||||
|
posterUrl: meta.posterUrl ?? null,
|
||||||
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
|
videoPath: row.file_path ?? '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { scanMoviesLibrary } from './movies'
|
|||||||
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
|
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
|
||||||
import { scanGamesLibrary } from './games'
|
import { scanGamesLibrary } from './games'
|
||||||
import { getThumbnailPath } from './thumbnails'
|
import { getThumbnailPath } from './thumbnails'
|
||||||
|
import { computeFingerprint } from './fingerprint'
|
||||||
|
import { reKeyMediaItem } from './tags'
|
||||||
import { VIDEO_EXTENSIONS } from './media-utils'
|
import { VIDEO_EXTENSIONS } from './media-utils'
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'])
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'])
|
||||||
@@ -68,22 +70,38 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
|
|||||||
const db = getDb()
|
const db = getDb()
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
clearLibraryItems(db, library.id)
|
// Build new items map: item_key → { fingerprint, movie }
|
||||||
|
type MovieEntry = { fingerprint: string | null; movie: Movie }
|
||||||
const upsert = db.prepare(`
|
const newItems = new Map<string, MovieEntry>()
|
||||||
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, scanned_at)
|
|
||||||
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @scanned_at)
|
|
||||||
ON CONFLICT(item_key) DO UPDATE SET
|
|
||||||
title = excluded.title,
|
|
||||||
year = excluded.year,
|
|
||||||
plot = excluded.plot,
|
|
||||||
genres = excluded.genres,
|
|
||||||
metadata = excluded.metadata,
|
|
||||||
scanned_at = excluded.scanned_at
|
|
||||||
`)
|
|
||||||
|
|
||||||
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
|
||||||
|
? computeFingerprint(path.join(libraryRoot, movie.videoPath))
|
||||||
|
: null
|
||||||
|
newItems.set(itemKey, { fingerprint, movie })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect moves using fingerprints
|
||||||
|
const moves = detectMoves(db, library.id, newItems)
|
||||||
|
|
||||||
|
// Apply renames + prune stale rows
|
||||||
|
reconcileAndPrune(db, library.id, new Set(newItems.keys()), moves)
|
||||||
|
|
||||||
|
const upsert = db.prepare(`
|
||||||
|
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, fingerprint, scanned_at)
|
||||||
|
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @fingerprint, @scanned_at)
|
||||||
|
ON CONFLICT(item_key) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
year = excluded.year,
|
||||||
|
plot = excluded.plot,
|
||||||
|
genres = excluded.genres,
|
||||||
|
metadata = excluded.metadata,
|
||||||
|
file_path = excluded.file_path,
|
||||||
|
fingerprint = excluded.fingerprint,
|
||||||
|
scanned_at = excluded.scanned_at
|
||||||
|
`)
|
||||||
|
|
||||||
|
for (const [itemKey, { fingerprint, movie }] of newItems) {
|
||||||
upsert.run({
|
upsert.run({
|
||||||
library_id: library.id,
|
library_id: library.id,
|
||||||
item_key: itemKey,
|
item_key: itemKey,
|
||||||
@@ -92,11 +110,17 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
|
|||||||
year: movie.year ?? null,
|
year: movie.year ?? null,
|
||||||
plot: movie.plot ?? null,
|
plot: movie.plot ?? null,
|
||||||
genres: JSON.stringify(movie.genres),
|
genres: JSON.stringify(movie.genres),
|
||||||
metadata: JSON.stringify({ rating: movie.rating, runtime: movie.runtime }),
|
metadata: JSON.stringify({
|
||||||
|
rating: movie.rating,
|
||||||
|
runtime: movie.runtime,
|
||||||
|
posterUrl: movie.posterUrl,
|
||||||
|
backdropUrl: movie.backdropUrl,
|
||||||
|
}),
|
||||||
|
file_path: movie.videoPath,
|
||||||
|
fingerprint,
|
||||||
scanned_at: now,
|
scanned_at: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pre-generate poster thumbnail
|
|
||||||
if (movie.posterUrl) {
|
if (movie.posterUrl) {
|
||||||
await prewarmThumbnailFromUrl(movie.posterUrl, library.id, libraryRoot, 'image')
|
await prewarmThumbnailFromUrl(movie.posterUrl, library.id, libraryRoot, 'image')
|
||||||
}
|
}
|
||||||
@@ -110,41 +134,80 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
||||||
const series = scanTvLibrary(libraryRoot, library.id)
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
clearLibraryItems(db, library.id)
|
// Single filesystem pass — collect everything before touching the DB
|
||||||
|
type SeasonRow = { season: TvSeason; seasonKey: string; episodes: EpisodeRow[] }
|
||||||
|
type EpisodeRow = { episode: TvEpisode; episodeKey: string; fingerprint: string | null }
|
||||||
|
type SeriesRow = { show: TvSeries; seriesKey: string; seasons: SeasonRow[] }
|
||||||
|
|
||||||
|
const allSeries: SeriesRow[] = []
|
||||||
|
const newKeys = new Set<string>()
|
||||||
|
const newEpisodes = new Map<string, { fingerprint: string | null }>()
|
||||||
|
|
||||||
|
for (const show of scanTvLibrary(libraryRoot, library.id)) {
|
||||||
|
const seriesKey = `${library.id}:tv_series:${show.id}`
|
||||||
|
newKeys.add(seriesKey)
|
||||||
|
|
||||||
|
const seasonRows: SeasonRow[] = []
|
||||||
|
for (const season of scanTvSeasons(libraryRoot, library.id, show.id)) {
|
||||||
|
const seasonKey = `${library.id}:tv_season:${show.id}:${season.id}`
|
||||||
|
newKeys.add(seasonKey)
|
||||||
|
|
||||||
|
const episodeRows: EpisodeRow[] = []
|
||||||
|
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
|
||||||
|
episodeRows.push({ episode, episodeKey, fingerprint })
|
||||||
|
newEpisodes.set(episodeKey, { fingerprint })
|
||||||
|
}
|
||||||
|
seasonRows.push({ season, seasonKey, episodes: episodeRows })
|
||||||
|
}
|
||||||
|
allSeries.push({ show, seriesKey, seasons: seasonRows })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect moves among episodes (only episodes have fingerprints)
|
||||||
|
const moves = detectMoves(db, library.id, newEpisodes)
|
||||||
|
|
||||||
|
// Apply renames + prune stale rows (series, seasons, and episodes)
|
||||||
|
reconcileAndPrune(db, library.id, newKeys, moves)
|
||||||
|
|
||||||
const upsertSeries = db.prepare(`
|
const upsertSeries = db.prepare(`
|
||||||
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, scanned_at)
|
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, fingerprint, scanned_at)
|
||||||
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @scanned_at)
|
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @fingerprint, @scanned_at)
|
||||||
ON CONFLICT(item_key) DO UPDATE SET
|
ON CONFLICT(item_key) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
year = excluded.year,
|
year = excluded.year,
|
||||||
plot = excluded.plot,
|
plot = excluded.plot,
|
||||||
genres = excluded.genres,
|
genres = excluded.genres,
|
||||||
metadata = excluded.metadata,
|
metadata = excluded.metadata,
|
||||||
scanned_at = excluded.scanned_at
|
file_path = excluded.file_path,
|
||||||
|
fingerprint = excluded.fingerprint,
|
||||||
|
scanned_at = excluded.scanned_at
|
||||||
`)
|
`)
|
||||||
|
|
||||||
const upsertChild = db.prepare(`
|
const upsertChild = db.prepare(`
|
||||||
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, year, plot, genres, metadata, scanned_at)
|
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, year, plot, genres, metadata, file_path, fingerprint, scanned_at)
|
||||||
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @year, @plot, @genres, @metadata, @scanned_at)
|
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @year, @plot, @genres, @metadata, @file_path, @fingerprint, @scanned_at)
|
||||||
ON CONFLICT(item_key) DO UPDATE SET
|
ON CONFLICT(item_key) DO UPDATE SET
|
||||||
parent_key = excluded.parent_key,
|
parent_key = excluded.parent_key,
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
year = excluded.year,
|
year = excluded.year,
|
||||||
plot = excluded.plot,
|
plot = excluded.plot,
|
||||||
genres = excluded.genres,
|
genres = excluded.genres,
|
||||||
metadata = excluded.metadata,
|
metadata = excluded.metadata,
|
||||||
scanned_at = excluded.scanned_at
|
file_path = excluded.file_path,
|
||||||
|
fingerprint = excluded.fingerprint,
|
||||||
|
scanned_at = excluded.scanned_at
|
||||||
`)
|
`)
|
||||||
|
|
||||||
let episodeCount = 0
|
let episodeCount = 0
|
||||||
|
|
||||||
for (const show of series) {
|
for (const { show, seriesKey, seasons } of allSeries) {
|
||||||
const seriesKey = `${library.id}:tv_series:${show.id}`
|
|
||||||
upsertSeries.run({
|
upsertSeries.run({
|
||||||
library_id: library.id,
|
library_id: library.id,
|
||||||
item_key: seriesKey,
|
item_key: seriesKey,
|
||||||
@@ -153,7 +216,14 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
year: show.year ?? null,
|
year: show.year ?? null,
|
||||||
plot: show.plot ?? null,
|
plot: show.plot ?? null,
|
||||||
genres: JSON.stringify(show.genres),
|
genres: JSON.stringify(show.genres),
|
||||||
metadata: JSON.stringify({ status: show.status, seasonCount: show.seasonCount }),
|
metadata: JSON.stringify({
|
||||||
|
status: show.status,
|
||||||
|
seasonCount: show.seasonCount,
|
||||||
|
posterUrl: show.posterUrl,
|
||||||
|
backdropUrl: show.backdropUrl,
|
||||||
|
}),
|
||||||
|
file_path: null,
|
||||||
|
fingerprint: null,
|
||||||
scanned_at: now,
|
scanned_at: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -161,9 +231,7 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
await prewarmThumbnailFromUrl(show.posterUrl, library.id, libraryRoot, 'image')
|
await prewarmThumbnailFromUrl(show.posterUrl, library.id, libraryRoot, 'image')
|
||||||
}
|
}
|
||||||
|
|
||||||
const seasons = scanTvSeasons(libraryRoot, library.id, show.id)
|
for (const { season, seasonKey, episodes } of seasons) {
|
||||||
for (const season of seasons) {
|
|
||||||
const seasonKey = `${library.id}:tv_season:${show.id}:${season.id}`
|
|
||||||
upsertChild.run({
|
upsertChild.run({
|
||||||
library_id: library.id,
|
library_id: library.id,
|
||||||
item_key: seasonKey,
|
item_key: seasonKey,
|
||||||
@@ -173,7 +241,13 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
year: null,
|
year: null,
|
||||||
plot: null,
|
plot: null,
|
||||||
genres: JSON.stringify([]),
|
genres: JSON.stringify([]),
|
||||||
metadata: JSON.stringify({ seasonNumber: season.seasonNumber, episodeCount: season.episodeCount }),
|
metadata: JSON.stringify({
|
||||||
|
seasonNumber: season.seasonNumber,
|
||||||
|
episodeCount: season.episodeCount,
|
||||||
|
posterUrl: season.posterUrl,
|
||||||
|
}),
|
||||||
|
file_path: null,
|
||||||
|
fingerprint: null,
|
||||||
scanned_at: now,
|
scanned_at: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -181,9 +255,7 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
await prewarmThumbnailFromUrl(season.posterUrl, library.id, libraryRoot, 'image')
|
await prewarmThumbnailFromUrl(season.posterUrl, library.id, libraryRoot, 'image')
|
||||||
}
|
}
|
||||||
|
|
||||||
const episodes = scanTvEpisodes(libraryRoot, library.id, show.id, season.id)
|
for (const { episode, episodeKey, fingerprint } of episodes) {
|
||||||
for (const episode of episodes) {
|
|
||||||
const episodeKey = `${library.id}:tv_episode:${show.id}:${season.id}:${episode.id}`
|
|
||||||
upsertChild.run({
|
upsertChild.run({
|
||||||
library_id: library.id,
|
library_id: library.id,
|
||||||
item_key: episodeKey,
|
item_key: episodeKey,
|
||||||
@@ -198,11 +270,13 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
seasonNumber: episode.seasonNumber,
|
seasonNumber: episode.seasonNumber,
|
||||||
aired: episode.aired,
|
aired: episode.aired,
|
||||||
rating: episode.rating,
|
rating: episode.rating,
|
||||||
|
thumbnailUrl: episode.thumbnailUrl,
|
||||||
}),
|
}),
|
||||||
|
file_path: episode.videoPath,
|
||||||
|
fingerprint,
|
||||||
scanned_at: now,
|
scanned_at: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pre-generate video thumbnail (seek-based frame extraction)
|
|
||||||
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')
|
||||||
@@ -214,11 +288,11 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[scanner] tv: indexed ${series.length} series, ${episodeCount} episodes`)
|
console.log(`[scanner] tv: indexed ${allSeries.length} series, ${episodeCount} episodes`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Games
|
// Games (v1: no fingerprinting — clear+upsert pattern retained)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
||||||
@@ -229,29 +303,32 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
clearLibraryItems(db, library.id)
|
clearLibraryItems(db, library.id)
|
||||||
|
|
||||||
const upsertGame = db.prepare(`
|
const upsertGame = db.prepare(`
|
||||||
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, scanned_at)
|
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, file_path, fingerprint, scanned_at)
|
||||||
VALUES (@library_id, @item_key, @item_type, @title, @metadata, @scanned_at)
|
VALUES (@library_id, @item_key, @item_type, @title, @metadata, @file_path, @fingerprint, @scanned_at)
|
||||||
ON CONFLICT(item_key) DO UPDATE SET
|
ON CONFLICT(item_key) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
metadata = excluded.metadata,
|
metadata = excluded.metadata,
|
||||||
scanned_at = excluded.scanned_at
|
file_path = excluded.file_path,
|
||||||
|
fingerprint = excluded.fingerprint,
|
||||||
|
scanned_at = excluded.scanned_at
|
||||||
`)
|
`)
|
||||||
|
|
||||||
const upsertChildGame = db.prepare(`
|
const upsertChildGame = db.prepare(`
|
||||||
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, scanned_at)
|
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, file_path, fingerprint, scanned_at)
|
||||||
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @scanned_at)
|
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @file_path, @fingerprint, @scanned_at)
|
||||||
ON CONFLICT(item_key) DO UPDATE SET
|
ON CONFLICT(item_key) DO UPDATE SET
|
||||||
parent_key = excluded.parent_key,
|
parent_key = excluded.parent_key,
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
metadata = excluded.metadata,
|
metadata = excluded.metadata,
|
||||||
scanned_at = excluded.scanned_at
|
file_path = excluded.file_path,
|
||||||
|
fingerprint = excluded.fingerprint,
|
||||||
|
scanned_at = excluded.scanned_at
|
||||||
`)
|
`)
|
||||||
|
|
||||||
let gameCount = 0
|
let gameCount = 0
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if ('games' in item) {
|
if ('games' in item) {
|
||||||
// GameSeries
|
|
||||||
const series = item as GameSeries
|
const series = item as GameSeries
|
||||||
const seriesKey = `${library.id}:game_series:${series.id}`
|
const seriesKey = `${library.id}:game_series:${series.id}`
|
||||||
upsertGame.run({
|
upsertGame.run({
|
||||||
@@ -259,7 +336,13 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
item_key: seriesKey,
|
item_key: seriesKey,
|
||||||
item_type: 'game_series',
|
item_type: 'game_series',
|
||||||
title: series.title,
|
title: series.title,
|
||||||
metadata: JSON.stringify({ gameCount: series.games.length }),
|
metadata: JSON.stringify({
|
||||||
|
gameCount: series.games.length,
|
||||||
|
coverUrl: series.coverUrl,
|
||||||
|
wideCoverUrl: series.wideCoverUrl,
|
||||||
|
}),
|
||||||
|
file_path: null,
|
||||||
|
fingerprint: null,
|
||||||
scanned_at: now,
|
scanned_at: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -275,7 +358,13 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
item_type: 'game',
|
item_type: 'game',
|
||||||
parent_key: seriesKey,
|
parent_key: seriesKey,
|
||||||
title: game.title,
|
title: game.title,
|
||||||
metadata: JSON.stringify({ zipFiles: game.zipFiles }),
|
metadata: JSON.stringify({
|
||||||
|
zipFiles: game.zipFiles,
|
||||||
|
coverUrl: game.coverUrl,
|
||||||
|
wideCoverUrl: game.wideCoverUrl,
|
||||||
|
}),
|
||||||
|
file_path: null,
|
||||||
|
fingerprint: null,
|
||||||
scanned_at: now,
|
scanned_at: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -285,7 +374,6 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
gameCount++
|
gameCount++
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Standalone Game
|
|
||||||
const game = item as Game
|
const game = item as Game
|
||||||
const gameKey = `${library.id}:game:${game.id}`
|
const gameKey = `${library.id}:game:${game.id}`
|
||||||
upsertGame.run({
|
upsertGame.run({
|
||||||
@@ -293,7 +381,13 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
item_key: gameKey,
|
item_key: gameKey,
|
||||||
item_type: 'game',
|
item_type: 'game',
|
||||||
title: game.title,
|
title: game.title,
|
||||||
metadata: JSON.stringify({ zipFiles: game.zipFiles }),
|
metadata: JSON.stringify({
|
||||||
|
zipFiles: game.zipFiles,
|
||||||
|
coverUrl: game.coverUrl,
|
||||||
|
wideCoverUrl: game.wideCoverUrl,
|
||||||
|
}),
|
||||||
|
file_path: null,
|
||||||
|
fingerprint: null,
|
||||||
scanned_at: now,
|
scanned_at: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -308,38 +402,84 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mixed (thumbnail pre-generation only — no DB indexing)
|
// Mixed
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
|
async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
|
||||||
const fs = await import('fs')
|
const fsSync = await import('fs') as typeof import('fs')
|
||||||
let entries: string[]
|
const db = getDb()
|
||||||
try {
|
const now = Date.now()
|
||||||
entries = fs.readdirSync(libraryRoot, { withFileTypes: true })
|
|
||||||
.filter((d) => d.isFile() && !d.name.startsWith('.'))
|
|
||||||
.map((d) => d.name) as unknown as string[]
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = 0
|
// Collect all new items with fingerprints
|
||||||
for (const filename of entries) {
|
type MixedEntry = { fingerprint: string | null; relPath: string; title: string }
|
||||||
const ext = path.extname(filename).toLowerCase()
|
const newItems = new Map<string, MixedEntry>()
|
||||||
let mediaType: 'image' | 'video' | null = null
|
|
||||||
if (IMAGE_EXTENSIONS.has(ext)) mediaType = 'image'
|
|
||||||
else if (VIDEO_EXTENSIONS.has(ext)) mediaType = 'video'
|
|
||||||
if (!mediaType) continue
|
|
||||||
|
|
||||||
const absPath = path.join(libraryRoot, filename)
|
function walk(absDir: string, relDir: string): void {
|
||||||
|
let dirents: import('fs').Dirent[]
|
||||||
try {
|
try {
|
||||||
await getThumbnailPath(absPath, library.id, mediaType)
|
dirents = fsSync.readdirSync(absDir, { withFileTypes: true, encoding: 'utf-8' }) as import('fs').Dirent[]
|
||||||
count++
|
} catch {
|
||||||
} catch (err) {
|
return
|
||||||
console.warn(`[scanner] Could not generate thumbnail for ${filename}:`, err instanceof Error ? err.message : err)
|
}
|
||||||
|
for (const d of dirents) {
|
||||||
|
const name = d.name as string
|
||||||
|
if (name.startsWith('.')) continue
|
||||||
|
const relPath = relDir ? path.join(relDir, name) : name
|
||||||
|
if (d.isDirectory()) {
|
||||||
|
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)
|
||||||
|
newItems.set(itemKey, {
|
||||||
|
fingerprint,
|
||||||
|
relPath,
|
||||||
|
title: path.basename(name, path.extname(name)),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[scanner] mixed: pre-generated thumbnails for ${count} files`)
|
walk(libraryRoot, '')
|
||||||
|
|
||||||
|
// Detect moves + reconcile
|
||||||
|
const moves = detectMoves(db, library.id, newItems)
|
||||||
|
reconcileAndPrune(db, library.id, new Set(newItems.keys()), moves)
|
||||||
|
|
||||||
|
const upsert = db.prepare(`
|
||||||
|
INSERT INTO media_items (library_id, item_key, item_type, title, file_path, fingerprint, scanned_at)
|
||||||
|
VALUES (@library_id, @item_key, @item_type, @title, @file_path, @fingerprint, @scanned_at)
|
||||||
|
ON CONFLICT(item_key) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
file_path = excluded.file_path,
|
||||||
|
fingerprint = excluded.fingerprint,
|
||||||
|
scanned_at = excluded.scanned_at
|
||||||
|
`)
|
||||||
|
|
||||||
|
for (const [itemKey, { fingerprint, relPath, title }] of newItems) {
|
||||||
|
upsert.run({
|
||||||
|
library_id: library.id,
|
||||||
|
item_key: itemKey,
|
||||||
|
item_type: 'mixed_file',
|
||||||
|
title,
|
||||||
|
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`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -350,6 +490,94 @@ function clearLibraryItems(db: Database.Database, libraryId: string): void {
|
|||||||
db.prepare('DELETE FROM media_items WHERE library_id = ?').run(libraryId)
|
db.prepare('DELETE FROM media_items WHERE library_id = ?').run(libraryId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a map of new items (item_key → { fingerprint }), compare against
|
||||||
|
* existing DB rows for this library to find items that moved (same fingerprint,
|
||||||
|
* different item_key). Returns an array of { oldKey, newKey } pairs.
|
||||||
|
*
|
||||||
|
* Only items that have a non-null fingerprint and whose old key is NOT already
|
||||||
|
* present in the new scan (i.e. the file truly moved, not a hash collision)
|
||||||
|
* are treated as moves.
|
||||||
|
*/
|
||||||
|
function detectMoves(
|
||||||
|
db: Database.Database,
|
||||||
|
libraryId: string,
|
||||||
|
newItems: Map<string, { fingerprint: string | null }>
|
||||||
|
): Array<{ oldKey: string; newKey: string }> {
|
||||||
|
const existing = db
|
||||||
|
.prepare('SELECT item_key, fingerprint FROM media_items WHERE library_id = ? AND fingerprint IS NOT NULL')
|
||||||
|
.all(libraryId) as Array<{ item_key: string; fingerprint: string }>
|
||||||
|
|
||||||
|
const fingerprintToOldKey = new Map<string, string>()
|
||||||
|
for (const row of existing) {
|
||||||
|
fingerprintToOldKey.set(row.fingerprint, row.item_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const moves: Array<{ oldKey: string; newKey: string }> = []
|
||||||
|
for (const [newKey, { fingerprint }] of newItems) {
|
||||||
|
if (!fingerprint) continue
|
||||||
|
const oldKey = fingerprintToOldKey.get(fingerprint)
|
||||||
|
if (oldKey && oldKey !== newKey && !newItems.has(oldKey)) {
|
||||||
|
// File moved: same fingerprint, different key, old key is no longer present
|
||||||
|
moves.push({ oldKey, newKey })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies detected moves to the DB (renames item_key and updates media_tags),
|
||||||
|
* then deletes any rows for this library whose item_key is not in newKeys.
|
||||||
|
* Tags on deleted items are intentionally left as orphans — harmless and
|
||||||
|
* recoverable if the file reappears.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Converts an item_key (used in media_items) to the media_key format used in
|
||||||
|
* media_tags. The UI constructs media_keys as `${libraryId}:${shortId}` where
|
||||||
|
* shortId is only the terminal path segment — e.g.:
|
||||||
|
* "lib1:movie:Inception%20(2010)" → "lib1:Inception%20(2010)"
|
||||||
|
* "lib1:tv_episode:Show:S1:ep.mkv" → "lib1:ep.mkv"
|
||||||
|
* "lib1:mixed_file:dir%2Ffile.mp4" → "lib1:dir%2Ffile.mp4"
|
||||||
|
*/
|
||||||
|
function itemKeyToMediaKey(itemKey: string): string {
|
||||||
|
const firstColon = itemKey.indexOf(':')
|
||||||
|
const lastColon = itemKey.lastIndexOf(':')
|
||||||
|
const libraryId = itemKey.slice(0, firstColon)
|
||||||
|
const shortId = itemKey.slice(lastColon + 1)
|
||||||
|
return `${libraryId}:${shortId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileAndPrune(
|
||||||
|
db: Database.Database,
|
||||||
|
libraryId: string,
|
||||||
|
newKeys: Set<string>,
|
||||||
|
moves: Array<{ oldKey: string; newKey: string }>
|
||||||
|
): void {
|
||||||
|
const renameItem = db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?')
|
||||||
|
|
||||||
|
for (const { oldKey, newKey } of moves) {
|
||||||
|
renameItem.run(newKey, oldKey)
|
||||||
|
// Convert item_keys to the media_key format actually used in media_tags
|
||||||
|
const oldMediaKey = itemKeyToMediaKey(oldKey)
|
||||||
|
const newMediaKey = itemKeyToMediaKey(newKey)
|
||||||
|
if (oldMediaKey !== newMediaKey) {
|
||||||
|
reKeyMediaItem(oldMediaKey, newMediaKey)
|
||||||
|
}
|
||||||
|
console.log(`[scanner] fingerprint match: renamed "${oldKey}" → "${newKey}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db
|
||||||
|
.prepare('SELECT item_key FROM media_items WHERE library_id = ?')
|
||||||
|
.all(libraryId) as Array<{ item_key: string }>
|
||||||
|
|
||||||
|
const deleteItem = db.prepare('DELETE FROM media_items WHERE item_key = ?')
|
||||||
|
for (const { item_key } of existing) {
|
||||||
|
if (!newKeys.has(item_key)) {
|
||||||
|
deleteItem.run(item_key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the `path` query param from an /api/thumbnail URL and pre-warm
|
* Extract the `path` query param from an /api/thumbnail URL and pre-warm
|
||||||
* the thumbnail cache for that file.
|
* the thumbnail cache for that file.
|
||||||
|
|||||||
@@ -260,3 +260,9 @@ export function removeAllAssignmentsForItem(mediaKey: string): void {
|
|||||||
const db = getDb()
|
const db = getDb()
|
||||||
db.prepare("DELETE FROM media_tags WHERE media_key = ?").run(mediaKey)
|
db.prepare("DELETE FROM media_tags WHERE media_key = ?").run(mediaKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reKeyMediaItem(oldKey: string, newKey: string): void {
|
||||||
|
getDb()
|
||||||
|
.prepare('UPDATE media_tags SET media_key = ? WHERE media_key = ?')
|
||||||
|
.run(newKey, oldKey)
|
||||||
|
}
|
||||||
|
|||||||
137
src/lib/tv.ts
137
src/lib/tv.ts
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
||||||
import { parseTvShowNfo, parseEpisodeNfo } from './nfo'
|
import { getDb } from './db'
|
||||||
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
||||||
|
|
||||||
function isVideoFile(name: string): boolean {
|
function isVideoFile(name: string): boolean {
|
||||||
@@ -49,8 +49,6 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
|
|||||||
|
|
||||||
for (const dirName of seriesDirs) {
|
for (const dirName of seriesDirs) {
|
||||||
const seriesPath = path.join(libraryRoot, dirName)
|
const seriesPath = path.join(libraryRoot, dirName)
|
||||||
const nfoFile = path.join(seriesPath, 'tvshow.nfo')
|
|
||||||
const nfo = parseTvShowNfo(nfoFile)
|
|
||||||
|
|
||||||
const posterFile = findFile(seriesPath, /^(poster|folder)$/i)
|
const posterFile = findFile(seriesPath, /^(poster|folder)$/i)
|
||||||
const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i)
|
const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i)
|
||||||
@@ -69,11 +67,11 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
|
|||||||
|
|
||||||
series.push({
|
series.push({
|
||||||
id,
|
id,
|
||||||
title: nfo?.title ?? dirName,
|
title: dirName,
|
||||||
year: nfo?.year ?? null,
|
year: null,
|
||||||
plot: nfo?.plot ?? null,
|
plot: null,
|
||||||
genres: nfo?.genres ?? [],
|
genres: [],
|
||||||
status: nfo?.status ?? null,
|
status: null,
|
||||||
posterUrl: posterFile
|
posterUrl: posterFile
|
||||||
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
|
||||||
: null,
|
: null,
|
||||||
@@ -168,24 +166,17 @@ export function scanTvEpisodes(
|
|||||||
|
|
||||||
for (const videoFile of videoFiles) {
|
for (const videoFile of videoFiles) {
|
||||||
const baseName = path.basename(videoFile, path.extname(videoFile))
|
const baseName = path.basename(videoFile, path.extname(videoFile))
|
||||||
const nfoFileName = files.find(
|
|
||||||
(f) => path.basename(f, path.extname(f)) === baseName && path.extname(f).toLowerCase() === '.nfo'
|
|
||||||
)
|
|
||||||
const nfo = nfoFileName
|
|
||||||
? parseEpisodeNfo(path.join(seasonPath, nfoFileName))
|
|
||||||
: null
|
|
||||||
|
|
||||||
const videoRelPath = path.join(seriesDirName, seasonDirName, videoFile)
|
const videoRelPath = path.join(seriesDirName, seasonDirName, videoFile)
|
||||||
const id = encodeURIComponent(videoFile)
|
const id = encodeURIComponent(videoFile)
|
||||||
|
|
||||||
episodes.push({
|
episodes.push({
|
||||||
id,
|
id,
|
||||||
title: nfo?.title ?? baseName,
|
title: baseName,
|
||||||
episodeNumber: nfo?.episode ?? null,
|
episodeNumber: null,
|
||||||
seasonNumber: nfo?.season ?? null,
|
seasonNumber: null,
|
||||||
plot: nfo?.plot ?? null,
|
plot: null,
|
||||||
aired: nfo?.aired ?? null,
|
aired: null,
|
||||||
rating: nfo?.rating ?? null,
|
rating: null,
|
||||||
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
|
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
|
||||||
videoPath: videoRelPath,
|
videoPath: videoRelPath,
|
||||||
})
|
})
|
||||||
@@ -198,3 +189,107 @@ export function scanTvEpisodes(
|
|||||||
return (a.title ?? '').localeCompare(b.title ?? '')
|
return (a.title ?? '').localeCompare(b.title ?? '')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB readers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type DbRow = {
|
||||||
|
item_key: string
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
genres: string | null
|
||||||
|
metadata: string | null
|
||||||
|
file_path: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tvSeriesFromDb(libraryId: string): TvSeries[] {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare(`SELECT * FROM media_items WHERE library_id = ? AND item_type = 'tv_series' ORDER BY title`)
|
||||||
|
.all(libraryId) as DbRow[]
|
||||||
|
|
||||||
|
return rows.map((row) => {
|
||||||
|
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
const idPart = row.item_key.split(':tv_series:')[1] ?? row.item_key
|
||||||
|
return {
|
||||||
|
id: idPart,
|
||||||
|
title: row.title ?? decodeURIComponent(idPart),
|
||||||
|
year: row.year ?? null,
|
||||||
|
plot: row.plot ?? null,
|
||||||
|
genres: row.genres ? JSON.parse(row.genres) : [],
|
||||||
|
status: meta.status ?? null,
|
||||||
|
posterUrl: meta.posterUrl ?? null,
|
||||||
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
|
seasonCount: meta.seasonCount ?? 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tvSeasonsFromDb(libraryId: string, seriesId: string): TvSeason[] {
|
||||||
|
const db = getDb()
|
||||||
|
const parentKey = `${libraryId}:tv_series:${seriesId}`
|
||||||
|
const rows = db
|
||||||
|
.prepare(`SELECT * FROM media_items WHERE parent_key = ? AND item_type = 'tv_season'`)
|
||||||
|
.all(parentKey) as DbRow[]
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((row) => {
|
||||||
|
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
// item_key format: {libraryId}:tv_season:{seriesId}:{seasonId}
|
||||||
|
const parts = row.item_key.split(':tv_season:')
|
||||||
|
const seasonId = parts[1]?.split(':').slice(1).join(':') ?? row.item_key
|
||||||
|
return {
|
||||||
|
id: seasonId,
|
||||||
|
seriesId,
|
||||||
|
title: row.title ?? seasonId,
|
||||||
|
seasonNumber: meta.seasonNumber ?? null,
|
||||||
|
posterUrl: meta.posterUrl ?? null,
|
||||||
|
episodeCount: meta.episodeCount ?? 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.seasonNumber !== null && b.seasonNumber !== null) {
|
||||||
|
return a.seasonNumber - b.seasonNumber
|
||||||
|
}
|
||||||
|
return a.title.localeCompare(b.title)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tvEpisodesFromDb(
|
||||||
|
libraryId: string,
|
||||||
|
seriesId: string,
|
||||||
|
seasonId: string
|
||||||
|
): TvEpisode[] {
|
||||||
|
const db = getDb()
|
||||||
|
const parentKey = `${libraryId}:tv_season:${seriesId}:${seasonId}`
|
||||||
|
const rows = db
|
||||||
|
.prepare(`SELECT * FROM media_items WHERE parent_key = ? AND item_type = 'tv_episode'`)
|
||||||
|
.all(parentKey) as DbRow[]
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((row) => {
|
||||||
|
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
// item_key format: {libraryId}:tv_episode:{seriesId}:{seasonId}:{episodeId}
|
||||||
|
const suffix = row.item_key.split(':tv_episode:')[1] ?? ''
|
||||||
|
const episodeId = suffix.split(':').slice(2).join(':')
|
||||||
|
return {
|
||||||
|
id: episodeId,
|
||||||
|
title: row.title ?? decodeURIComponent(episodeId),
|
||||||
|
episodeNumber: meta.episodeNumber ?? null,
|
||||||
|
seasonNumber: meta.seasonNumber ?? null,
|
||||||
|
plot: row.plot ?? null,
|
||||||
|
aired: meta.aired ?? null,
|
||||||
|
rating: meta.rating ?? null,
|
||||||
|
thumbnailUrl: meta.thumbnailUrl ?? null,
|
||||||
|
videoPath: row.file_path ?? '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.episodeNumber !== null && b.episodeNumber !== null) {
|
||||||
|
return a.episodeNumber - b.episodeNumber
|
||||||
|
}
|
||||||
|
return (a.title ?? '').localeCompare(b.title ?? '')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user