media_key was a lossy shortening of item_key (libraryId:lastSegment) that introduced a real collision bug: two TV episodes from different series with the same filename would share the same media_key and each other's tags. - DB migration converts existing media_tags rows from short format to full item_key by joining against media_items; ambiguous/orphaned rows are dropped - media_tags column renamed media_key → item_key - Removed itemKeyToMediaKey() from scanner; reconcileAndPrune now passes item_key directly to reKeyMediaItem - DB reader functions (tv, movies, games) now expose item_key on returned entities; frontend components use entity.item_key instead of constructing the short libraryId:id form - MixedView now constructs the full mixed_file: item_key format - Tag API renamed mediaKey param → itemKey throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
5.4 KiB
TypeScript
186 lines
5.4 KiB
TypeScript
import fs from 'fs'
|
|
import path from 'path'
|
|
import type { Game, GameSeries } from '@/types'
|
|
import { getDb } from './db'
|
|
import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
|
|
|
/**
|
|
* Attempts to build a Game from a directory.
|
|
* @param absPath Absolute path to the game directory.
|
|
* @param dirName The directory's own name (used as title).
|
|
* @param relPath Path relative to the library root (used for IDs and file URLs).
|
|
* @param libraryId Library identifier.
|
|
* @returns Game, or null if the directory contains no .zip file.
|
|
*/
|
|
function buildGame(
|
|
absPath: string,
|
|
dirName: string,
|
|
relPath: string,
|
|
libraryId: string
|
|
): Game | null {
|
|
let allFiles: string[]
|
|
try {
|
|
allFiles = fs.readdirSync(absPath)
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
const zipFiles = allFiles
|
|
.filter((f) => f.toLowerCase().endsWith('.zip'))
|
|
.sort((a, b) => a.localeCompare(b))
|
|
if (zipFiles.length === 0) return null
|
|
|
|
const coverFile = findFile(absPath, /^cover$/i)
|
|
const wideCoverFile = findFile(absPath, /^widecover$/i)
|
|
|
|
return {
|
|
id: encodeURIComponent(relPath),
|
|
title: dirName,
|
|
coverUrl: coverFile
|
|
? thumbnailApiUrl(libraryId, path.join(relPath, coverFile))
|
|
: null,
|
|
wideCoverUrl: wideCoverFile
|
|
? fileApiUrl(libraryId, path.join(relPath, wideCoverFile))
|
|
: null,
|
|
zipFiles: zipFiles.map((f) => path.join(relPath, f)),
|
|
}
|
|
}
|
|
|
|
export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] {
|
|
let topDirs: string[]
|
|
try {
|
|
topDirs = fs
|
|
.readdirSync(libraryRoot, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
|
|
.map((d) => d.name)
|
|
} catch {
|
|
return []
|
|
}
|
|
|
|
const results: (Game | GameSeries)[] = []
|
|
|
|
for (const dirName of topDirs) {
|
|
const absPath = path.join(libraryRoot, dirName)
|
|
|
|
let allFiles: string[]
|
|
try {
|
|
allFiles = fs.readdirSync(absPath)
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
// Standalone game: directory directly contains a .zip
|
|
const hasZip = allFiles.some((f) => f.toLowerCase().endsWith('.zip'))
|
|
if (hasZip) {
|
|
const game = buildGame(absPath, dirName, dirName, libraryId)
|
|
if (game) results.push(game)
|
|
continue
|
|
}
|
|
|
|
// No .zip here — check subdirectories (series detection)
|
|
let subDirs: string[]
|
|
try {
|
|
subDirs = fs
|
|
.readdirSync(absPath, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
|
|
.map((d) => d.name)
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
const seriesGames: Game[] = []
|
|
for (const subDir of subDirs) {
|
|
const game = buildGame(
|
|
path.join(absPath, subDir),
|
|
subDir,
|
|
path.join(dirName, subDir),
|
|
libraryId
|
|
)
|
|
if (game) seriesGames.push(game)
|
|
}
|
|
|
|
if (seriesGames.length === 0) continue
|
|
|
|
// It's a series — check for an optional series-level cover
|
|
const seriesCoverFile = findFile(absPath, /^cover$/i)
|
|
const seriesWideCoverFile = findFile(absPath, /^widecover$/i)
|
|
|
|
results.push({
|
|
id: encodeURIComponent(dirName),
|
|
title: dirName,
|
|
coverUrl: seriesCoverFile
|
|
? thumbnailApiUrl(libraryId, path.join(dirName, seriesCoverFile))
|
|
: seriesGames[0].coverUrl,
|
|
wideCoverUrl: seriesWideCoverFile
|
|
? fileApiUrl(libraryId, path.join(dirName, seriesWideCoverFile))
|
|
: null,
|
|
games: seriesGames.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,
|
|
item_key: row.item_key,
|
|
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,
|
|
item_key: row.item_key,
|
|
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))
|
|
}
|