Add multi-platform game support with per-OS download detection
- Detect Windows (.zip), Linux (.tar.gz), and macOS (.dmg / .app bundle) game archives during scan - Store GameFile[] with platform metadata in DB instead of plain zipFiles[] - Stream .app bundles as on-the-fly zip archives via archiver - Show WIN/LIN/MAC platform badge pills on GameCard and SeriesCard - Auto-select the download matching the user's OS in GameDetailModal - Persist cover URL to DB immediately on upload (no re-scan needed) - Backward-compatible: legacy zipFiles entries map to platform 'windows' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
123
src/lib/games.ts
123
src/lib/games.ts
@@ -1,16 +1,36 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { Game, GameSeries } from '@/types'
|
||||
import type { Game, GameFile, GamePlatform, GameSeries } from '@/types'
|
||||
import { getDb } from './db'
|
||||
import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
||||
|
||||
/**
|
||||
* Returns the platform for a given filename, or null if not a known game archive.
|
||||
*/
|
||||
function platformForFile(name: string): GamePlatform | null {
|
||||
const lower = name.toLowerCase()
|
||||
if (lower.endsWith('.zip')) return 'windows'
|
||||
if (lower.endsWith('.tar.gz')) return 'linux'
|
||||
if (lower.endsWith('.dmg')) return 'macos'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the Dirent is a game archive file or .app bundle directory.
|
||||
*/
|
||||
function isGameArchiveEntry(entry: fs.Dirent): boolean {
|
||||
if (entry.isFile()) return platformForFile(entry.name) !== null
|
||||
if (entry.isDirectory()) return entry.name.toLowerCase().endsWith('.app')
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns Game, or null if the directory contains no known game files.
|
||||
*/
|
||||
function buildGame(
|
||||
absPath: string,
|
||||
@@ -18,17 +38,41 @@ function buildGame(
|
||||
relPath: string,
|
||||
libraryId: string
|
||||
): Game | null {
|
||||
let allFiles: string[]
|
||||
let entries: fs.Dirent[]
|
||||
try {
|
||||
allFiles = fs.readdirSync(absPath)
|
||||
entries = fs.readdirSync(absPath, { withFileTypes: true })
|
||||
} 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 gameFiles: GameFile[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (HIDDEN_FILES.test(entry.name)) continue
|
||||
if (entry.isFile()) {
|
||||
const platform = platformForFile(entry.name)
|
||||
if (platform) {
|
||||
gameFiles.push({
|
||||
path: path.join(relPath, entry.name),
|
||||
platform,
|
||||
filename: entry.name,
|
||||
})
|
||||
}
|
||||
} else if (entry.isDirectory() && entry.name.toLowerCase().endsWith('.app')) {
|
||||
gameFiles.push({
|
||||
path: path.join(relPath, entry.name),
|
||||
platform: 'macos',
|
||||
filename: entry.name,
|
||||
isAppBundle: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (gameFiles.length === 0) return null
|
||||
|
||||
gameFiles.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||
|
||||
const platforms: GamePlatform[] = [...new Set(gameFiles.map((f) => f.platform))]
|
||||
|
||||
const coverFile = findFile(absPath, /^cover$/i)
|
||||
const wideCoverFile = findFile(absPath, /^widecover$/i)
|
||||
@@ -42,58 +86,54 @@ function buildGame(
|
||||
wideCoverUrl: wideCoverFile
|
||||
? fileApiUrl(libraryId, path.join(relPath, wideCoverFile))
|
||||
: null,
|
||||
zipFiles: zipFiles.map((f) => path.join(relPath, f)),
|
||||
gameFiles,
|
||||
platforms,
|
||||
}
|
||||
}
|
||||
|
||||
export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] {
|
||||
let topDirs: string[]
|
||||
let topEntries: fs.Dirent[]
|
||||
try {
|
||||
topDirs = fs
|
||||
topEntries = 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) {
|
||||
for (const topEntry of topEntries) {
|
||||
const dirName = topEntry.name
|
||||
const absPath = path.join(libraryRoot, dirName)
|
||||
|
||||
let allFiles: string[]
|
||||
let entries: fs.Dirent[]
|
||||
try {
|
||||
allFiles = fs.readdirSync(absPath)
|
||||
entries = fs.readdirSync(absPath, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
// Standalone game: directory directly contains a .zip
|
||||
const hasZip = allFiles.some((f) => f.toLowerCase().endsWith('.zip'))
|
||||
if (hasZip) {
|
||||
// Standalone game: directory directly contains a game archive or .app bundle
|
||||
const hasGameFiles = entries.some((e) => isGameArchiveEntry(e))
|
||||
if (hasGameFiles) {
|
||||
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
|
||||
}
|
||||
// No game files here — check subdirectories (series detection).
|
||||
// Exclude .app-suffixed directories from series candidates — those belong to the parent game.
|
||||
const subDirs = entries.filter(
|
||||
(e) => e.isDirectory() && !HIDDEN_FILES.test(e.name) && !e.name.toLowerCase().endsWith('.app')
|
||||
)
|
||||
|
||||
const seriesGames: Game[] = []
|
||||
for (const subDir of subDirs) {
|
||||
const game = buildGame(
|
||||
path.join(absPath, subDir),
|
||||
subDir,
|
||||
path.join(dirName, subDir),
|
||||
path.join(absPath, subDir.name),
|
||||
subDir.name,
|
||||
path.join(dirName, subDir.name),
|
||||
libraryId
|
||||
)
|
||||
if (game) seriesGames.push(game)
|
||||
@@ -161,6 +201,24 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
|
||||
for (const row of allRows) {
|
||||
if (row.item_type !== 'game') continue
|
||||
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||
|
||||
// Build gameFiles with backward-compat for old zipFiles format
|
||||
let gameFiles: GameFile[]
|
||||
if (meta.gameFiles) {
|
||||
gameFiles = meta.gameFiles
|
||||
} else if (meta.zipFiles) {
|
||||
// Legacy: map old zipFiles to GameFile with platform 'windows'
|
||||
gameFiles = (meta.zipFiles as string[]).map((p: string) => ({
|
||||
path: p,
|
||||
platform: 'windows' as GamePlatform,
|
||||
filename: p.split('/').pop() ?? p,
|
||||
}))
|
||||
} else {
|
||||
gameFiles = []
|
||||
}
|
||||
|
||||
const platforms: GamePlatform[] = [...new Set(gameFiles.map((f) => f.platform))]
|
||||
|
||||
const idPart = row.item_key.split(':game:')[1] ?? row.item_key
|
||||
const game: Game = {
|
||||
id: idPart,
|
||||
@@ -168,7 +226,8 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
|
||||
title: row.title ?? decodeURIComponent(idPart),
|
||||
coverUrl: meta.coverUrl ?? null,
|
||||
wideCoverUrl: meta.wideCoverUrl ?? null,
|
||||
zipFiles: meta.zipFiles ?? [],
|
||||
gameFiles,
|
||||
platforms,
|
||||
}
|
||||
if (row.parent_key && seriesMap.has(row.parent_key)) {
|
||||
seriesMap.get(row.parent_key)!.games.push(game)
|
||||
|
||||
@@ -391,7 +391,7 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
||||
parent_key: seriesKey,
|
||||
title: game.title,
|
||||
metadata: JSON.stringify({
|
||||
zipFiles: game.zipFiles,
|
||||
gameFiles: game.gameFiles,
|
||||
coverUrl: game.coverUrl,
|
||||
wideCoverUrl: game.wideCoverUrl,
|
||||
}),
|
||||
@@ -414,7 +414,7 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
||||
item_type: 'game',
|
||||
title: game.title,
|
||||
metadata: JSON.stringify({
|
||||
zipFiles: game.zipFiles,
|
||||
gameFiles: game.gameFiles,
|
||||
coverUrl: game.coverUrl,
|
||||
wideCoverUrl: game.wideCoverUrl,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user