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

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