This repository has been archived on 2026-06-15. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MediaLore/src/lib/games.ts
Garret Patti 6f86750a99 Unify media_key and item_key — use item_key everywhere
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>
2026-04-10 18:04:29 -04:00

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))
}