initial version

This commit is contained in:
2026-03-25 16:18:23 -04:00
parent aeec7cae36
commit 88595bee90
27 changed files with 7959 additions and 1 deletions

118
src/app/api/file/route.ts Normal file
View File

@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
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',
}
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
return MIME_TYPES[ext] ?? 'application/octet-stream'
}
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 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 })
}
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')
// Handle ZIP as a download
const isZip = path.extname(filePath).toLowerCase() === '.zip'
const contentDisposition = isZip
? `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',
},
})
}