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 122d7aa332 add series grouping, cover upload, and multi-zip download to games library
- Series grouping: a top-level folder with no .zip but game subfolders is
  now treated as a GameSeries. Clicking a series drills into it with a
  breadcrumb; a game-count badge distinguishes series cards from game cards.
  Series fall back to the first game's cover when no series-level cover exists.
- Cover upload: new POST /api/game-cover endpoint writes cover.jpg or
  widecover.jpg directly into the game/series folder (re-encoded via sharp).
  A kebab menu on GameDetailModal opens an Edit Images panel showing previews
  and upload/replace buttons for both cover and wide cover.
- Multi-zip download: Game.zipFiles replaces zipPath and includes all .zip
  files in the folder. A single zip shows the existing download button; multiple
  zips render a split button — primary action downloads the first file, a
  dropdown arrow lists all files by name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:49:42 -04:00

148 lines
4.2 KiB
TypeScript

import fs from 'fs'
import path from 'path'
import type { Game, GameSeries } from '@/types'
const HIDDEN_FILES = /^\./
/**
* Finds the first file in a directory whose basename (without extension)
* matches the given pattern (case-insensitive).
*/
function findFile(dir: string, pattern: RegExp): string | null {
let entries: string[]
try {
entries = fs.readdirSync(dir)
} catch {
return null
}
const match = entries.find(
(entry) => !HIDDEN_FILES.test(entry) && pattern.test(path.basename(entry, path.extname(entry)))
)
return match ?? null
}
function fileApiUrl(libraryId: string, relativePath: string): string {
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
function thumbnailApiUrl(libraryId: string, relativePath: string): string {
return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
/**
* 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))
}