- 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>
150 lines
4.5 KiB
TypeScript
150 lines
4.5 KiB
TypeScript
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'
|
|
|
|
const MIME_TYPES: Record<string, string> = {
|
|
'.mp4': 'video/mp4',
|
|
'.mov': 'video/quicktime',
|
|
'.mkv': 'video/x-matroska',
|
|
'.avi': 'video/x-msvideo',
|
|
'.webm': 'video/webm',
|
|
'.m4v': 'video/x-m4v',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.png': 'image/png',
|
|
'.gif': 'image/gif',
|
|
'.webp': 'image/webp',
|
|
'.bmp': 'image/bmp',
|
|
'.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')
|
|
const subpath = searchParams.get('path')
|
|
|
|
if (!libraryId || !subpath) {
|
|
return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 })
|
|
}
|
|
|
|
const auth = await requireLibraryAccess(request, libraryId)
|
|
if (auth instanceof NextResponse) return auth
|
|
|
|
const library = getLibrary(libraryId)
|
|
if (!library) {
|
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
|
}
|
|
|
|
const root = resolveLibraryRoot(library)
|
|
|
|
let filePath: string
|
|
try {
|
|
filePath = resolveAndJail(root, subpath)
|
|
} catch {
|
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
}
|
|
|
|
let stat: fs.Stats
|
|
try {
|
|
stat = fs.statSync(filePath)
|
|
} catch {
|
|
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 })
|
|
}
|
|
|
|
const mimeType = getMimeType(filePath)
|
|
const fileSize = stat.size
|
|
const rangeHeader = request.headers.get('range')
|
|
|
|
const contentDisposition = isDownloadAttachment(filePath)
|
|
? `attachment; filename="${encodeURIComponent(path.basename(filePath))}"`
|
|
: `inline; filename="${encodeURIComponent(path.basename(filePath))}"`
|
|
|
|
if (rangeHeader) {
|
|
// Parse "bytes=start-end"
|
|
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/)
|
|
if (!match) {
|
|
return new NextResponse('Invalid Range', { status: 416 })
|
|
}
|
|
|
|
const start = match[1] ? parseInt(match[1], 10) : 0
|
|
const end = match[2] ? parseInt(match[2], 10) : fileSize - 1
|
|
|
|
if (start > end || end >= fileSize) {
|
|
return new NextResponse('Range Not Satisfiable', {
|
|
status: 416,
|
|
headers: { 'Content-Range': `bytes */${fileSize}` },
|
|
})
|
|
}
|
|
|
|
const chunkSize = end - start + 1
|
|
const stream = fs.createReadStream(filePath, { start, end })
|
|
|
|
return new NextResponse(stream as unknown as ReadableStream, {
|
|
status: 206,
|
|
headers: {
|
|
'Content-Type': mimeType,
|
|
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
'Content-Length': String(chunkSize),
|
|
'Accept-Ranges': 'bytes',
|
|
'Content-Disposition': contentDisposition,
|
|
'Cache-Control': 'public, max-age=3600',
|
|
},
|
|
})
|
|
}
|
|
|
|
// Full file response
|
|
const stream = fs.createReadStream(filePath)
|
|
return new NextResponse(stream as unknown as ReadableStream, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': mimeType,
|
|
'Content-Length': String(fileSize),
|
|
'Accept-Ranges': 'bytes',
|
|
'Content-Disposition': contentDisposition,
|
|
'Cache-Control': 'public, max-age=3600',
|
|
},
|
|
})
|
|
}
|