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
2026-04-21 10:57:08 -04:00

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