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:
@@ -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))}"`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user