add manga library
This commit is contained in:
71
src/app/api/comics/page/route.ts
Normal file
71
src/app/api/comics/page/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||
import { getComicPageBuffer } from '@/lib/comics'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
const issueKey = searchParams.get('issueKey')
|
||||
const pageIndexStr = searchParams.get('pageIndex')
|
||||
|
||||
if (!libraryId || !issueKey || pageIndexStr === null) {
|
||||
return NextResponse.json({ error: 'Missing libraryId, issueKey, or pageIndex' }, { status: 400 })
|
||||
}
|
||||
|
||||
const pageIndex = parseInt(pageIndexStr, 10)
|
||||
if (isNaN(pageIndex) || pageIndex < 0) {
|
||||
return NextResponse.json({ error: 'Invalid pageIndex' }, { 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 db = getDb()
|
||||
const row = db
|
||||
.prepare('SELECT file_path FROM media_items WHERE item_key = ? AND item_type = ?')
|
||||
.get(issueKey, 'comic_issue') as { file_path: string | null } | undefined
|
||||
|
||||
if (!row?.file_path) {
|
||||
return NextResponse.json({ error: 'Issue not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const root = resolveLibraryRoot(library)
|
||||
|
||||
let absPath: string
|
||||
try {
|
||||
absPath = resolveAndJail(root, row.file_path)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = getComicPageBuffer(absPath, pageIndex)
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: 'Page not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const mimeType = EXT_TO_MIME[result.ext] ?? 'image/jpeg'
|
||||
|
||||
return new NextResponse(result.buffer as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': String(result.buffer.length),
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
})
|
||||
}
|
||||
113
src/app/api/comics/route.ts
Normal file
113
src/app/api/comics/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||
import { comicsFromDb, comicIssuesFromDb } from '@/lib/comics'
|
||||
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
||||
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
const seriesId = searchParams.get('seriesId')
|
||||
|
||||
if (!libraryId) {
|
||||
return NextResponse.json({ error: 'Missing libraryId' }, { 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 })
|
||||
}
|
||||
if (library.type !== 'comics') {
|
||||
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (seriesId) {
|
||||
return NextResponse.json(comicIssuesFromDb(libraryId, seriesId))
|
||||
}
|
||||
|
||||
return NextResponse.json(comicsFromDb(libraryId))
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
const issueKey = searchParams.get('issueKey')
|
||||
const seriesId = searchParams.get('seriesId')
|
||||
|
||||
if (!libraryId) {
|
||||
return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 })
|
||||
}
|
||||
|
||||
const library = getLibrary(libraryId)
|
||||
if (!library) {
|
||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||
}
|
||||
if (library.type !== 'comics') {
|
||||
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
|
||||
}
|
||||
|
||||
const root = resolveLibraryRoot(library)
|
||||
|
||||
if (issueKey) {
|
||||
const db = getDb()
|
||||
const row = db
|
||||
.prepare('SELECT file_path FROM media_items WHERE item_key = ? AND item_type = ?')
|
||||
.get(issueKey, 'comic_issue') as { file_path: string | null } | undefined
|
||||
|
||||
if (!row?.file_path) {
|
||||
return NextResponse.json({ error: 'Issue not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let issuePath: string
|
||||
try {
|
||||
issuePath = resolveAndJail(root, row.file_path)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid issue path' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(issuePath)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to delete issue file' }, { status: 500 })
|
||||
}
|
||||
|
||||
removeAllAssignmentsForItem(issueKey)
|
||||
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(issueKey)
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
}
|
||||
|
||||
if (seriesId) {
|
||||
const dirName = decodeURIComponent(seriesId)
|
||||
|
||||
let seriesDir: string
|
||||
try {
|
||||
seriesDir = resolveAndJail(root, dirName)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid series path' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(seriesDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 })
|
||||
}
|
||||
|
||||
removeAllAssignmentsForItem(`${libraryId}:comic_series:${seriesId}`)
|
||||
const db = getDb()
|
||||
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(`${libraryId}:comic_series:${seriesId}`)
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Missing issueKey or seriesId' }, { status: 400 })
|
||||
}
|
||||
26
src/app/api/comics/series-issue-tags/route.ts
Normal file
26
src/app/api/comics/series-issue-tags/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLibrary } from '@/lib/libraries'
|
||||
import { getComicsSeriesIssueMeta } from '@/lib/tags'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const libraryId = searchParams.get('libraryId')
|
||||
|
||||
if (!libraryId) {
|
||||
return NextResponse.json({ error: 'Missing libraryId' }, { 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 })
|
||||
}
|
||||
if (library.type !== 'comics') {
|
||||
return NextResponse.json({ error: 'Library is not a comics library' }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json(getComicsSeriesIssueMeta(libraryId))
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||
'.bmp': 'image/bmp',
|
||||
'.tiff': 'image/tiff',
|
||||
'.tif': 'image/tiff',
|
||||
'.cbz': 'application/zip',
|
||||
'.zip': 'application/zip',
|
||||
'.dmg': 'application/x-apple-diskimage',
|
||||
'.gz': 'application/gzip',
|
||||
@@ -43,6 +44,7 @@ function getMimeType(filePath: string): string {
|
||||
function isDownloadAttachment(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase()
|
||||
return (
|
||||
lower.endsWith('.cbz') ||
|
||||
lower.endsWith('.zip') ||
|
||||
lower.endsWith('.tar.gz') ||
|
||||
lower.endsWith('.tar.bz2') ||
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'name, path, and type are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validTypes: LibraryType[] = ['games', 'mixed', 'movies', 'tv']
|
||||
const validTypes: LibraryType[] = ['comics', 'games', 'mixed', 'movies', 'tv']
|
||||
if (!validTypes.includes(type as LibraryType)) {
|
||||
return NextResponse.json({ error: `type must be one of: ${validTypes.join(', ')}` }, { status: 400 })
|
||||
}
|
||||
|
||||
@@ -2,16 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||
import { getThumbnailPath } from '@/lib/thumbnails'
|
||||
import { getThumbnailPath, getCbzThumbnailPath } from '@/lib/thumbnails'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
|
||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||
|
||||
function getMediaType(filePath: string): 'image' | 'video' | null {
|
||||
function getMediaType(filePath: string): 'image' | 'video' | 'cbz' | null {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
|
||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video'
|
||||
if (ext === '.cbz') return 'cbz'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -43,11 +44,13 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const mediaType = getMediaType(filePath)
|
||||
if (!mediaType) {
|
||||
return NextResponse.json({ error: 'Thumbnails are only supported for image and video files' }, { status: 400 })
|
||||
return NextResponse.json({ error: 'Thumbnails are only supported for image, video, and CBZ files' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const thumbnailPath = await getThumbnailPath(filePath, libraryId, mediaType)
|
||||
const thumbnailPath = mediaType === 'cbz'
|
||||
? await getCbzThumbnailPath(filePath, libraryId)
|
||||
: await getThumbnailPath(filePath, libraryId, mediaType)
|
||||
const stat = fs.statSync(thumbnailPath)
|
||||
const stream = fs.createReadStream(thumbnailPath)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user