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:
Garret Patti
2026-04-12 09:47:09 -04:00
parent ebc35d7184
commit 53205d4a19
9 changed files with 1273 additions and 87 deletions

1020
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"archiver": "^7.0.1",
"better-sqlite3": "^12.8.0",
"fast-xml-parser": "^5.5.10",
"iron-session": "^8.0.4",
@@ -23,6 +24,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/archiver": "^7.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
import archiver from 'archiver'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { requireLibraryAccess } from '@/lib/auth'
@@ -20,13 +21,22 @@ const MIME_TYPES: Record<string, string> = {
'.tiff': 'image/tiff',
'.tif': 'image/tiff',
'.zip': 'application/zip',
'.dmg': 'application/x-apple-diskimage',
'.gz': 'application/gzip',
}
function getMimeType(filePath: string): string {
// Special-case .tar.gz before checking the last extension
if (filePath.toLowerCase().endsWith('.tar.gz')) return 'application/gzip'
const ext = path.extname(filePath).toLowerCase()
return MIME_TYPES[ext] ?? 'application/octet-stream'
}
function isDownloadAttachment(filePath: string): boolean {
const lower = filePath.toLowerCase()
return lower.endsWith('.zip') || lower.endsWith('.tar.gz') || lower.endsWith('.dmg')
}
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
@@ -60,6 +70,25 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
// .app bundle: stream the directory as a zip archive on the fly
if (stat.isDirectory() && subpath.toLowerCase().endsWith('.app')) {
const bundleName = path.basename(filePath)
const zipName = `${bundleName}.zip`
const archive = archiver('zip', { zlib: { level: 6 } })
archive.directory(filePath, bundleName)
archive.finalize()
return new NextResponse(archive as unknown as ReadableStream, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${encodeURIComponent(zipName)}"`,
'Cache-Control': 'no-store',
},
})
}
if (!stat.isFile()) {
return NextResponse.json({ error: 'Not a file' }, { status: 400 })
}
@@ -68,9 +97,7 @@ export async function GET(request: NextRequest) {
const fileSize = stat.size
const rangeHeader = request.headers.get('range')
// Handle ZIP as a download
const isZip = path.extname(filePath).toLowerCase() === '.zip'
const contentDisposition = isZip
const contentDisposition = isDownloadAttachment(filePath)
? `attachment; filename="${encodeURIComponent(path.basename(filePath))}"`
: `inline; filename="${encodeURIComponent(path.basename(filePath))}"`

View File

@@ -4,6 +4,7 @@ import sharp from 'sharp'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
@@ -99,5 +100,16 @@ export async function POST(request: NextRequest) {
? `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
// Update DB metadata so the new cover is visible without a re-scan
const db = getDb()
const itemKey = `${libraryId}:game:${itemId}`
const row = db.prepare('SELECT metadata FROM media_items WHERE item_key = ?').get(itemKey) as { metadata: string | null } | undefined
if (row) {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
if (coverType === 'cover') meta.coverUrl = url
else meta.wideCoverUrl = url
db.prepare('UPDATE media_items SET metadata = ? WHERE item_key = ?').run(JSON.stringify(meta), itemKey)
}
return NextResponse.json({ url }, { status: 200 })
}

View File

@@ -1,9 +1,20 @@
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import type { Game } from '@/types'
import type { Game, GameFile, GamePlatform } from '@/types'
import TagSelector from '@/components/tags/TagSelector'
const PLATFORM_LABELS: Record<GamePlatform, string> = {
windows: 'WIN',
linux: 'LIN',
macos: 'MAC',
}
const PLATFORM_COLORS: Record<GamePlatform, string> = {
windows: '#0078d4',
linux: '#e95420',
macos: '#6e6e73',
}
interface Props {
game: Game
libraryId: string
@@ -59,8 +70,16 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
if (e.target === overlayRef.current) onClose()
}
const zipDownloadUrl = (zipPath: string) =>
`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(zipPath)}`
const [clientPlatform, setClientPlatform] = useState<GamePlatform | null>(null)
useEffect(() => {
const p = navigator.platform.toLowerCase()
if (p.startsWith('win')) setClientPlatform('windows')
else if (p.startsWith('mac') || p.includes('iphone') || p.includes('ipad')) setClientPlatform('macos')
else setClientPlatform('linux')
}, [])
const fileDownloadUrl = (filePath: string) =>
`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(filePath)}`
const heroImage = game.wideCoverUrl ?? game.coverUrl
return (
@@ -277,7 +296,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
</div>
)}
<DownloadButton zipFiles={game.zipFiles} downloadUrl={zipDownloadUrl} />
<DownloadButton gameFiles={game.gameFiles} clientPlatform={clientPlatform} downloadUrl={fileDownloadUrl} />
{/* Tags */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
@@ -296,12 +315,25 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
// ─── Download Button ──────────────────────────────────────────────────────────
function PlatformPill({ platform }: { platform: GamePlatform }) {
return (
<span
className="px-1.5 py-0.5 rounded text-xs font-bold leading-none flex-shrink-0"
style={{ backgroundColor: PLATFORM_COLORS[platform], color: '#fff' }}
>
{PLATFORM_LABELS[platform]}
</span>
)
}
function DownloadButton({
zipFiles,
gameFiles,
clientPlatform,
downloadUrl,
}: {
zipFiles: string[]
downloadUrl: (zipPath: string) => string
gameFiles: GameFile[]
clientPlatform: GamePlatform | null
downloadUrl: (filePath: string) => string
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
@@ -317,13 +349,17 @@ function DownloadButton({
return () => document.removeEventListener('mousedown', handler)
}, [open, close])
const primary = zipFiles[0]
const primaryName = primary.split('/').pop() ?? primary
if (gameFiles.length === 0) return null
if (zipFiles.length === 1) {
// Pick primary: first file matching clientPlatform, or first overall
const primary =
(clientPlatform ? gameFiles.find((f) => f.platform === clientPlatform) : null) ??
gameFiles[0]
if (gameFiles.length === 1) {
return (
<a
href={downloadUrl(primary)}
href={downloadUrl(primary.path)}
download
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
@@ -331,7 +367,8 @@ function DownloadButton({
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
>
<span></span>
Download .zip
<PlatformPill platform={primary.platform} />
Download {primary.filename}
</a>
)
}
@@ -341,15 +378,16 @@ function DownloadButton({
<div className="flex rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--accent)' }}>
{/* Primary download */}
<a
href={downloadUrl(primary)}
href={downloadUrl(primary.path)}
download
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors"
className="flex items-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors min-w-0"
style={{ color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
<span></span>
{primaryName}
<span className="flex-shrink-0"></span>
<PlatformPill platform={primary.platform} />
<span className="truncate">{primary.filename}</span>
</a>
{/* Divider */}
@@ -358,7 +396,7 @@ function DownloadButton({
{/* Dropdown toggle */}
<button
onClick={() => setOpen((o) => !o)}
className="px-3 flex items-center justify-center text-sm transition-colors"
className="px-3 flex items-center justify-center text-sm transition-colors flex-shrink-0"
style={{ color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
@@ -373,12 +411,10 @@ function DownloadButton({
className="absolute left-0 right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{zipFiles.map((zipPath) => {
const name = zipPath.split('/').pop() ?? zipPath
return (
{gameFiles.map((file) => (
<a
key={zipPath}
href={downloadUrl(zipPath)}
key={file.path}
href={downloadUrl(file.path)}
download
onClick={close}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
@@ -386,11 +422,11 @@ function DownloadButton({
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
<span style={{ color: 'var(--text-secondary)' }}></span>
{name}
<span style={{ color: 'var(--text-secondary)' }} className="flex-shrink-0"></span>
<PlatformPill platform={file.platform} />
<span className="truncate">{file.filename}</span>
</a>
)
})}
))}
</div>
)}
</div>

View File

@@ -1,10 +1,38 @@
'use client'
import { useEffect, useState, useCallback, useRef } from 'react'
import type { Game, GameSeries } from '@/types'
import type { Game, GamePlatform, GameSeries } from '@/types'
import GameDetailModal from './GameDetailModal'
import FilterPanel from '@/components/FilterPanel'
const PLATFORM_LABELS: Record<GamePlatform, string> = {
windows: 'WIN',
linux: 'LIN',
macos: 'MAC',
}
const PLATFORM_COLORS: Record<GamePlatform, string> = {
windows: '#0078d4',
linux: '#e95420',
macos: '#6e6e73',
}
function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) {
if (platforms.length === 0) return null
return (
<div className="flex gap-1 flex-wrap">
{platforms.map((p) => (
<span
key={p}
className="px-1.5 py-0.5 rounded text-xs font-bold leading-none"
style={{ backgroundColor: PLATFORM_COLORS[p], color: '#fff' }}
>
{PLATFORM_LABELS[p]}
</span>
))}
</div>
)
}
interface Props {
libraryId: string
}
@@ -218,6 +246,11 @@ function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
)}
{game.platforms.length > 0 && (
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
<PlatformBadges platforms={game.platforms} />
</div>
)}
</div>
<div className="p-2">
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={game.title}>
@@ -229,6 +262,11 @@ function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
}
function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => void }) {
// Compute union of platforms across all games in the series
const seriesPlatforms: GamePlatform[] = [
...new Set(series.games.flatMap((g) => g.platforms)),
]
return (
<button
onClick={onClick}
@@ -250,7 +288,13 @@ function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => vo
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
)}
{/* Game count badge */}
{/* Platform badges (bottom-left) */}
{seriesPlatforms.length > 0 && (
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
<PlatformBadges platforms={seriesPlatforms} />
</div>
)}
{/* Game count badge (bottom-right) */}
<div
className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded text-xs font-semibold"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}

View File

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

View File

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

View File

@@ -8,13 +8,23 @@ export interface Library {
coverExt: string | null
}
export type GamePlatform = 'windows' | 'linux' | 'macos'
export interface GameFile {
path: string
platform: GamePlatform
filename: string
isAppBundle?: boolean
}
export interface Game {
id: string
item_key?: string
title: string
coverUrl: string | null
wideCoverUrl: string | null
zipFiles: string[]
gameFiles: GameFile[]
platforms: GamePlatform[]
}
export interface GameSeries {