263 lines
8.1 KiB
TypeScript
263 lines
8.1 KiB
TypeScript
import fs from 'fs'
|
|
import path from 'path'
|
|
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('.tar.bz2')) return 'linux'
|
|
if (lower.endsWith('.tar.xz')) return 'linux'
|
|
if (lower.endsWith('.tar.zst')) return 'linux'
|
|
if (lower.endsWith('.tgz')) return 'linux'
|
|
if (lower.endsWith('.dmg')) return 'macos'
|
|
if (lower.endsWith('.apk')) return 'android'
|
|
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 known game files.
|
|
*/
|
|
function buildGame(
|
|
absPath: string,
|
|
dirName: string,
|
|
relPath: string,
|
|
libraryId: string
|
|
): Game | null {
|
|
let entries: fs.Dirent[]
|
|
try {
|
|
entries = fs.readdirSync(absPath, { withFileTypes: true })
|
|
} catch {
|
|
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)
|
|
|
|
return {
|
|
id: encodeURIComponent(relPath),
|
|
title: dirName,
|
|
coverUrl: coverFile
|
|
? thumbnailApiUrl(libraryId, path.join(relPath, coverFile))
|
|
: null,
|
|
wideCoverUrl: wideCoverFile
|
|
? fileApiUrl(libraryId, path.join(relPath, wideCoverFile))
|
|
: null,
|
|
gameFiles,
|
|
platforms,
|
|
userRating: null,
|
|
aiDescription: null,
|
|
extractedText: null,
|
|
extractedTextTranslated: null,
|
|
}
|
|
}
|
|
|
|
export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] {
|
|
let topEntries: fs.Dirent[]
|
|
try {
|
|
topEntries = fs
|
|
.readdirSync(libraryRoot, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
|
|
} catch {
|
|
return []
|
|
}
|
|
|
|
const results: (Game | GameSeries)[] = []
|
|
|
|
for (const topEntry of topEntries) {
|
|
const dirName = topEntry.name
|
|
const absPath = path.join(libraryRoot, dirName)
|
|
|
|
let entries: fs.Dirent[]
|
|
try {
|
|
entries = fs.readdirSync(absPath, { withFileTypes: true })
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
// 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 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.name),
|
|
subDir.name,
|
|
path.join(dirName, subDir.name),
|
|
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
|
|
user_rating: number | null
|
|
ai_description: string | null
|
|
extracted_text: string | null
|
|
extracted_text_translated: string | null
|
|
}
|
|
|
|
const allRows = db
|
|
.prepare(`SELECT item_key, item_type, parent_key, title, metadata,
|
|
user_rating, ai_description, extracted_text, extracted_text_translated
|
|
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) : {}
|
|
|
|
// 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,
|
|
item_key: row.item_key,
|
|
title: row.title ?? decodeURIComponent(idPart),
|
|
coverUrl: meta.coverUrl ?? null,
|
|
wideCoverUrl: meta.wideCoverUrl ?? null,
|
|
gameFiles,
|
|
platforms,
|
|
userRating: row.user_rating ?? null,
|
|
aiDescription: row.ai_description ?? null,
|
|
extractedText: row.extracted_text ?? null,
|
|
extractedTextTranslated: row.extracted_text_translated ?? null,
|
|
}
|
|
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))
|
|
}
|