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>
This commit is contained in:
124
src/lib/games.ts
124
src/lib/games.ts
@@ -1,6 +1,6 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { Game } from '@/types'
|
||||
import type { Game, GameSeries } from '@/types'
|
||||
|
||||
const HIDDEN_FILES = /^\./
|
||||
|
||||
@@ -25,10 +25,56 @@ function fileApiUrl(libraryId: string, relativePath: string): string {
|
||||
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
||||
}
|
||||
|
||||
export function scanGamesLibrary(libraryRoot: string, libraryId: string): Game[] {
|
||||
let gameDirs: string[]
|
||||
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 {
|
||||
gameDirs = fs
|
||||
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)
|
||||
@@ -36,42 +82,66 @@ export function scanGamesLibrary(libraryRoot: string, libraryId: string): Game[]
|
||||
return []
|
||||
}
|
||||
|
||||
const games: Game[] = []
|
||||
const results: (Game | GameSeries)[] = []
|
||||
|
||||
for (const dirName of gameDirs) {
|
||||
const gamePath = path.join(libraryRoot, dirName)
|
||||
for (const dirName of topDirs) {
|
||||
const absPath = path.join(libraryRoot, dirName)
|
||||
|
||||
// Find the .zip file (first match)
|
||||
let zipFile: string | null = null
|
||||
let allFiles: string[]
|
||||
try {
|
||||
const allFiles = fs.readdirSync(gamePath)
|
||||
zipFile = allFiles.find((f) => f.toLowerCase().endsWith('.zip')) ?? null
|
||||
allFiles = fs.readdirSync(absPath)
|
||||
} catch {
|
||||
// skip unreadable dirs
|
||||
continue
|
||||
}
|
||||
|
||||
if (!zipFile) 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
|
||||
}
|
||||
|
||||
// Case-insensitive cover matching
|
||||
const coverFile = findFile(gamePath, /^cover$/i)
|
||||
const wideCoverFile = findFile(gamePath, /^widecover$/i)
|
||||
// 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 id = encodeURIComponent(dirName)
|
||||
const zipRelPath = path.join(dirName, zipFile)
|
||||
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)
|
||||
}
|
||||
|
||||
games.push({
|
||||
id,
|
||||
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: coverFile
|
||||
? fileApiUrl(libraryId, path.join(dirName, coverFile))
|
||||
coverUrl: seriesCoverFile
|
||||
? thumbnailApiUrl(libraryId, path.join(dirName, seriesCoverFile))
|
||||
: seriesGames[0].coverUrl,
|
||||
wideCoverUrl: seriesWideCoverFile
|
||||
? fileApiUrl(libraryId, path.join(dirName, seriesWideCoverFile))
|
||||
: null,
|
||||
wideCoverUrl: wideCoverFile
|
||||
? fileApiUrl(libraryId, path.join(dirName, wideCoverFile))
|
||||
: null,
|
||||
zipPath: zipRelPath,
|
||||
games: seriesGames.sort((a, b) => a.title.localeCompare(b.title)),
|
||||
})
|
||||
}
|
||||
|
||||
return games.sort((a, b) => a.title.localeCompare(b.title))
|
||||
return results.sort((a, b) => a.title.localeCompare(b.title))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user