add manga library

This commit is contained in:
Garret Patti
2026-04-19 20:25:06 -04:00
parent fbcd592609
commit b0e9c9790c
19 changed files with 1654 additions and 52 deletions

106
package-lock.json generated
View File

@@ -9,6 +9,8 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@types/adm-zip": "^0.5.8",
"adm-zip": "^0.5.17",
"archiver": "^7.0.1",
"better-sqlite3": "^12.8.0",
"fast-xml-parser": "^5.5.10",
@@ -1144,9 +1146,9 @@
}
},
"node_modules/@next/env": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.14.tgz",
"integrity": "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA==",
"version": "15.5.15",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz",
"integrity": "sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -1160,9 +1162,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.14.tgz",
"integrity": "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg==",
"version": "15.5.15",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.15.tgz",
"integrity": "sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==",
"cpu": [
"arm64"
],
@@ -1176,9 +1178,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.14.tgz",
"integrity": "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw==",
"version": "15.5.15",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.15.tgz",
"integrity": "sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==",
"cpu": [
"x64"
],
@@ -1192,9 +1194,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.14.tgz",
"integrity": "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q==",
"version": "15.5.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.15.tgz",
"integrity": "sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==",
"cpu": [
"arm64"
],
@@ -1211,9 +1213,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.14.tgz",
"integrity": "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ==",
"version": "15.5.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.15.tgz",
"integrity": "sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==",
"cpu": [
"arm64"
],
@@ -1230,9 +1232,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.14.tgz",
"integrity": "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ==",
"version": "15.5.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.15.tgz",
"integrity": "sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==",
"cpu": [
"x64"
],
@@ -1249,9 +1251,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.14.tgz",
"integrity": "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw==",
"version": "15.5.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.15.tgz",
"integrity": "sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==",
"cpu": [
"x64"
],
@@ -1268,9 +1270,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.14.tgz",
"integrity": "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g==",
"version": "15.5.15",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.15.tgz",
"integrity": "sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==",
"cpu": [
"arm64"
],
@@ -1284,9 +1286,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.14.tgz",
"integrity": "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg==",
"version": "15.5.15",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.15.tgz",
"integrity": "sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==",
"cpu": [
"x64"
],
@@ -1667,6 +1669,15 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/adm-zip": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz",
"integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/archiver": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
@@ -1712,7 +1723,6 @@
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@@ -2365,6 +2375,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adm-zip": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -2958,9 +2977,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6057,12 +6076,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
"integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
"version": "15.5.15",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.14",
"@next/env": "15.5.15",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -6075,14 +6094,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.14",
"@next/swc-darwin-x64": "15.5.14",
"@next/swc-linux-arm64-gnu": "15.5.14",
"@next/swc-linux-arm64-musl": "15.5.14",
"@next/swc-linux-x64-gnu": "15.5.14",
"@next/swc-linux-x64-musl": "15.5.14",
"@next/swc-win32-arm64-msvc": "15.5.14",
"@next/swc-win32-x64-msvc": "15.5.14",
"@next/swc-darwin-arm64": "15.5.15",
"@next/swc-darwin-x64": "15.5.15",
"@next/swc-linux-arm64-gnu": "15.5.15",
"@next/swc-linux-arm64-musl": "15.5.15",
"@next/swc-linux-x64-gnu": "15.5.15",
"@next/swc-linux-x64-musl": "15.5.15",
"@next/swc-win32-arm64-msvc": "15.5.15",
"@next/swc-win32-x64-msvc": "15.5.15",
"sharp": "^0.34.3"
},
"peerDependencies": {
@@ -7954,7 +7973,6 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {

View File

@@ -12,6 +12,8 @@
"author": "",
"license": "ISC",
"dependencies": {
"@types/adm-zip": "^0.5.8",
"adm-zip": "^0.5.17",
"archiver": "^7.0.1",
"better-sqlite3": "^12.8.0",
"fast-xml-parser": "^5.5.10",

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

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

View File

@@ -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') ||

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { getLibrary } from '@/lib/libraries'
import { notFound, redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
import { getLibraryAccessLevel } from '@/lib/users'
import ComicsView from '@/components/comics/ComicsView'
import GamesView from '@/components/games/GamesView'
import MixedView from '@/components/mixed/MixedView'
import MoviesView from '@/components/movies/MoviesView'
@@ -54,6 +55,7 @@ export default async function LibraryPage({ params, searchParams }: Props) {
</div>
)}
{library.type === 'comics' && <ComicsView libraryId={id} readOnly={readOnly} />}
{library.type === 'games' && <GamesView libraryId={id} readOnly={readOnly} />}
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />}
{library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />}

View File

@@ -5,6 +5,7 @@ import Image from 'next/image'
import type { Library, LibraryType } from '@/types'
const TYPE_ICONS: Record<string, string> = {
comics: '📚',
games: '🎮',
mixed: '🗂️',
movies: '🎬',
@@ -12,6 +13,7 @@ const TYPE_ICONS: Record<string, string> = {
}
const TYPE_LABELS: Record<LibraryType, string> = {
comics: 'Comics / Manga',
games: 'Games',
mixed: 'Mixed Media',
movies: 'Movies',
@@ -334,6 +336,7 @@ function AddLibraryForm({ onAdded }: { onAdded: () => void }) {
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
>
<option value="comics">Comics / Manga</option>
<option value="games">Games</option>
<option value="mixed">Mixed Media</option>
<option value="movies">Movies</option>

View File

@@ -0,0 +1,179 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { ComicIssue } from '@/types'
import ImageLightbox from '@/components/mixed/ImageLightbox'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
function fileApiUrl(libraryId: string, relativePath: string): string {
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
}
interface Props {
libraryId: string
issue: ComicIssue
onClose: () => void
onTagsChanged?: () => void
readOnly?: boolean
}
function pageUrl(libraryId: string, issueKey: string, pageIndex: number): string {
return `/api/comics/page?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}&pageIndex=${pageIndex}`
}
export default function ComicIssueView({ libraryId, issue, onClose, onTagsChanged, readOnly }: Props) {
const [lightboxPage, setLightboxPage] = useState<number | null>(null)
const [showTagPanel, setShowTagPanel] = useState(false)
const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
// Close on Escape
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && lightboxPage === null && !showTagPanel) onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose, lightboxPage, showTagPanel])
const pageCount = issue.pageCount
const downloadUrl = fileApiUrl(libraryId, issue.filePath)
const gridRef = useRef<HTMLDivElement>(null)
return (
<>
<div
className="fixed inset-0 z-50 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={onClose}
>
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
<div
className={`${showTagPanel ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-4xl'}`}
onClick={showTagPanel ? undefined : undefined}
>
<div
className="w-full max-w-4xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
style={{
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
maxHeight: '90vh',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div className="min-w-0">
<p className="font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{issue.title}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{pageCount} {pageCount === 1 ? 'page' : 'pages'}
</p>
</div>
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
{issue.item_key && !readOnly && !showTagPanel && (
<button
onClick={(e) => { e.stopPropagation(); setShowTagPanel(true) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
title="Tags"
aria-label="Show tags"
>
🏷
</button>
)}
<a
href={downloadUrl}
download
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
backgroundColor: 'var(--surface)',
color: 'var(--text-secondary)',
border: '1px solid var(--border)',
}}
onClick={(e) => e.stopPropagation()}
>
Download
</a>
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
aria-label="Close"
>
</button>
</div>
</div>
{/* Page grid */}
<div className="overflow-y-auto flex-1 p-4" ref={gridRef}>
{pageCount === 0 ? (
<div
className="flex items-center justify-center py-16 text-sm"
style={{ color: 'var(--text-secondary)' }}
>
No pages found in this issue.
</div>
) : (
<div className="grid gap-2 grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{Array.from({ length: pageCount }, (_, i) => (
<button
key={i}
className="relative rounded overflow-hidden focus:outline-none focus:ring-2 focus:ring-offset-1 group"
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
onClick={() => setLightboxPage(i)}
aria-label={`Page ${i + 1}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={pageUrl(libraryId, issueKey, i)}
alt={`Page ${i + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
<div
className="absolute bottom-0 inset-x-0 py-0.5 text-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
>
{i + 1}
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
{showTagPanel && issue.item_key && (
<MediaTagPanel
itemKey={issueKey}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
readOnly={readOnly}
/>
)}
</div>
</div>
{lightboxPage !== null && (
<ImageLightbox
url={pageUrl(libraryId, issueKey, lightboxPage)}
name={`Page ${lightboxPage + 1} of ${pageCount}`}
onClose={() => setLightboxPage(null)}
onPrev={lightboxPage > 0 ? () => setLightboxPage((p) => (p ?? 1) - 1) : undefined}
onNext={lightboxPage < pageCount - 1 ? () => setLightboxPage((p) => (p ?? 0) + 1) : undefined}
itemKey={issueKey}
onTagsChanged={onTagsChanged}
readOnly={readOnly}
/>
)}
</>
)
}

View File

@@ -0,0 +1,233 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { ComicIssue, ComicSeries } from '@/types'
import ComicIssueView from './ComicIssueView'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
interface Props {
libraryId: string
series: ComicSeries
onClose: () => void
onTagsChanged?: () => void
readOnly?: boolean
}
export default function ComicSeriesView({ libraryId, series, onClose, onTagsChanged, readOnly }: Props) {
const [issues, setIssues] = useState<ComicIssue[]>([])
const [loading, setLoading] = useState(true)
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
const [tagItemKey, setTagItemKey] = useState<string | null>(null)
const fetchIssues = useCallback(() => {
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(series.id)}`)
.then((r) => r.json())
.then((data: ComicIssue[]) => {
setIssues(data)
setLoading(false)
})
.catch(() => setLoading(false))
}, [libraryId, series.id])
useEffect(() => { fetchIssues() }, [fetchIssues])
// Escape closes tag panel first, then series view
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && !selectedIssue && !tagItemKey) onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose, selectedIssue, tagItemKey])
return (
<>
<div
className="fixed inset-0 z-40 overflow-hidden"
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={onClose}
>
<div className={`flex h-full w-full ${tagItemKey ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
<div className={tagItemKey ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-3xl'}>
<div
className="w-full max-w-3xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
style={{
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
maxHeight: '90vh',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div className="min-w-0">
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
{series.title}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{series.issueCount} {series.issueCount === 1 ? 'issue' : 'issues'}
</p>
</div>
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
{series.item_key && !readOnly && !tagItemKey && (
<button
onClick={(e) => { e.stopPropagation(); setTagItemKey(series.item_key!) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
title="Tag series"
aria-label="Tag series"
>
🏷
</button>
)}
<button
onClick={onClose}
className="w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
aria-label="Close"
>
</button>
</div>
</div>
{/* Issue grid */}
<div className="overflow-y-auto flex-1 p-4">
{loading ? (
<LoadingGrid />
) : issues.length === 0 ? (
<div
className="flex items-center justify-center py-16 text-sm"
style={{ color: 'var(--text-secondary)' }}
>
No issues found.
</div>
) : (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{issues.map((issue) => (
<IssueCard
key={issue.id}
issue={issue}
readOnly={readOnly}
onClick={() => setSelectedIssue(issue)}
onTagClick={issue.item_key && !readOnly
? () => setTagItemKey(issue.item_key!)
: undefined}
/>
))}
</div>
)}
</div>
</div>
</div>
{tagItemKey && (
<MediaTagPanel
itemKey={tagItemKey}
onHide={() => setTagItemKey(null)}
onClose={onClose}
onTagsChanged={onTagsChanged}
readOnly={readOnly}
/>
)}
</div>
</div>
{selectedIssue && (
<ComicIssueView
libraryId={libraryId}
issue={selectedIssue}
onClose={() => setSelectedIssue(null)}
onTagsChanged={onTagsChanged}
readOnly={readOnly}
/>
)}
</>
)
}
function IssueCard({
issue,
onClick,
onTagClick,
readOnly,
}: {
issue: ComicIssue
onClick: () => void
onTagClick?: () => void
readOnly?: boolean
}) {
return (
<div
className="relative rounded-xl overflow-hidden group"
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
>
<button
className="text-left w-full focus:outline-none focus:ring-2"
onClick={onClick}
>
<div
className="relative w-full overflow-hidden"
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
>
{issue.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={issue.coverUrl}
alt={issue.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-3xl">📖</div>
)}
{issue.issueNumber !== null && (
<div
className="absolute top-1 left-1 px-1.5 py-0.5 rounded text-xs font-bold leading-none"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
>
#{issue.issueNumber}
</div>
)}
</div>
<div className="px-2 pt-1.5 pb-1">
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
{issue.title}
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{issue.pageCount} {issue.pageCount === 1 ? 'pg' : 'pgs'}
</p>
</div>
</button>
{onTagClick && !readOnly && (
<button
onClick={(e) => { e.stopPropagation(); onTagClick() }}
className="absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
title="Tag issue"
aria-label="Tag issue"
>
🏷
</button>
)}
</div>
)
}
function LoadingGrid() {
return (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="rounded-xl overflow-hidden animate-pulse" style={{ border: '1px solid var(--border)' }}>
<div style={{ aspectRatio: '2/3', background: 'var(--border)' }} />
<div className="p-2 space-y-1">
<div className="h-3 rounded" style={{ background: 'var(--border)', width: '80%' }} />
<div className="h-2 rounded" style={{ background: 'var(--border)', width: '40%' }} />
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,390 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { ComicIssue, ComicSeries } from '@/types'
import ComicSeriesView from './ComicSeriesView'
import ComicIssueView from './ComicIssueView'
import FilterPanel from '@/components/FilterPanel'
import TagSelector from '@/components/tags/TagSelector'
interface Props {
libraryId: string
readOnly?: boolean
}
export default function ComicsView({ libraryId, readOnly }: Props) {
const [items, setItems] = useState<(ComicIssue | ComicSeries)[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedSeries, setSelectedSeries] = useState<ComicSeries | null>(null)
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [seriesIssueMeta, setSeriesIssueMeta] = useState<
Record<string, { tagIds: string[]; issueTitles: string[] }>
>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
)
const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => {
const next = new Set(prev)
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
return next
})
const fetchItems = useCallback(() => {
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then((data: (ComicIssue | ComicSeries)[]) => {
setItems(data)
setLoading(false)
})
.catch(() => {
setError('Failed to load comics')
setLoading(false)
})
}, [libraryId])
useEffect(() => { fetchItems() }, [fetchItems])
const fetchAssignments = useCallback(() => {
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then(setAssignments)
.catch(() => {})
}, [libraryId])
useEffect(() => { fetchAssignments() }, [fetchAssignments])
const fetchSeriesIssueMeta = useCallback(() => {
fetch(`/api/comics/series-issue-tags?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then(setSeriesIssueMeta)
.catch(() => {})
}, [libraryId])
useEffect(() => { fetchSeriesIssueMeta() }, [fetchSeriesIssueMeta])
const onTagsChanged = useCallback(() => {
setFilterRefreshKey((k) => k + 1)
fetchAssignments()
fetchSeriesIssueMeta()
}, [fetchAssignments, fetchSeriesIssueMeta])
const filtered = items.filter((item) => {
const isSeries = 'issueCount' in item
if (isSeries) {
const meta = seriesIssueMeta[item.item_key ?? ''] ?? { tagIds: [], issueTitles: [] }
if (search) {
const q = search.toLowerCase()
const titleMatch = item.title.toLowerCase().includes(q)
const issueMatch = meta.issueTitles.some((t) => t.toLowerCase().includes(q))
if (!titleMatch && !issueMatch) return false
}
if (selectedTagIds.size > 0) {
const seriesTags = assignments[item.item_key ?? ''] ?? []
const allTags = [...new Set([...seriesTags, ...meta.tagIds])]
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
}
return true
}
// Standalone issue
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0) {
const tags = assignments[item.item_key ?? ''] ?? []
if (![...selectedTagIds].every((id) => tags.includes(id))) return false
}
return true
})
const filtersActive = search !== '' || selectedTagIds.size > 0
return (
<>
<div className="flex items-center gap-2 mb-4">
<button
onClick={() => setShowFilters((v) => !v)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
border: '1px solid var(--border)',
}}
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
>
Filters{filtersActive ? ' ●' : ''}
</button>
</div>
<div className="flex flex-col md:flex-row gap-6 md:items-start">
{showFilters && (
<div className="w-full md:w-52 md:flex-shrink-0">
<FilterPanel
libraryId={libraryId}
assignments={assignments}
search={search}
onSearchChange={setSearch}
selectedTagIds={selectedTagIds}
onTagToggle={toggleTag}
refreshKey={filterRefreshKey}
/>
</div>
)}
<div className="flex-1 min-w-0">
{loading ? (
<LoadingGrid />
) : error ? (
<div
className="rounded-lg border p-8 text-center"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
{error}
</div>
) : items.length === 0 ? (
<div
className="rounded-lg border p-12 text-center"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
<p className="text-lg mb-1">No comics found</p>
<p className="text-sm">Add .cbz files or folders of .cbz files to this library and scan.</p>
</div>
) : (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{filtered.map((item) =>
'issueCount' in item ? (
<SeriesCard
key={item.id}
series={item as ComicSeries}
readOnly={readOnly}
onClick={() => setSelectedSeries(item as ComicSeries)}
onTagClick={(item as ComicSeries).item_key && !readOnly
? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title })
: undefined}
/>
) : (
<IssueCard
key={item.id}
issue={item as ComicIssue}
readOnly={readOnly}
onClick={() => setSelectedIssue(item as ComicIssue)}
onTagClick={(item as ComicIssue).item_key && !readOnly
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title })
: undefined}
/>
)
)}
</div>
)}
</div>
</div>
{/* Tag panel modal */}
{tagPanel && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
>
<div
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{tagPanel.title}
</p>
</div>
<button
onClick={() => setTagPanel(null)}
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
aria-label="Close"
>
</button>
</div>
<div className="px-5 py-4">
<TagSelector
itemKey={tagPanel.itemKey}
onTagsChanged={onTagsChanged}
readOnly={readOnly}
/>
</div>
</div>
</div>
)}
{selectedSeries && (
<ComicSeriesView
libraryId={libraryId}
series={selectedSeries}
onClose={() => setSelectedSeries(null)}
onTagsChanged={onTagsChanged}
readOnly={readOnly}
/>
)}
{selectedIssue && (
<ComicIssueView
libraryId={libraryId}
issue={selectedIssue}
onClose={() => setSelectedIssue(null)}
onTagsChanged={onTagsChanged}
readOnly={readOnly}
/>
)}
</>
)
}
function SeriesCard({
series,
onClick,
onTagClick,
readOnly,
}: {
series: ComicSeries
onClick: () => void
onTagClick?: () => void
readOnly?: boolean
}) {
return (
<div
className="relative rounded-xl overflow-hidden group"
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
>
<button className="text-left w-full focus:outline-none focus:ring-2" onClick={onClick}>
<div
className="relative w-full overflow-hidden"
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
>
{series.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={series.coverUrl}
alt={series.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-4xl">📚</div>
)}
<div
className="absolute top-1 right-1 px-1.5 py-0.5 rounded text-xs font-bold leading-none"
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
>
{series.issueCount}
</div>
</div>
<div className="px-2 pt-1.5 pb-1">
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
{series.title}
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{series.issueCount} {series.issueCount === 1 ? 'issue' : 'issues'}
</p>
</div>
</button>
{onTagClick && !readOnly && (
<button
onClick={(e) => { e.stopPropagation(); onTagClick() }}
className="absolute top-1 left-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
title="Tag series"
aria-label="Tag series"
>
🏷
</button>
)}
</div>
)
}
function IssueCard({
issue,
onClick,
onTagClick,
readOnly,
}: {
issue: ComicIssue
onClick: () => void
onTagClick?: () => void
readOnly?: boolean
}) {
return (
<div
className="relative rounded-xl overflow-hidden group"
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
>
<button className="text-left w-full focus:outline-none focus:ring-2" onClick={onClick}>
<div
className="relative w-full overflow-hidden"
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
>
{issue.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={issue.coverUrl}
alt={issue.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-4xl">📖</div>
)}
</div>
<div className="px-2 pt-1.5 pb-1">
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
{issue.title}
</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
{issue.pageCount} {issue.pageCount === 1 ? 'pg' : 'pgs'}
</p>
</div>
</button>
{onTagClick && !readOnly && (
<button
onClick={(e) => { e.stopPropagation(); onTagClick() }}
className="absolute top-1 left-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
title="Tag issue"
aria-label="Tag issue"
>
🏷
</button>
)}
</div>
)
}
function LoadingGrid() {
return (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{Array.from({ length: 12 }, (_, i) => (
<div key={i} className="rounded-xl overflow-hidden animate-pulse" style={{ border: '1px solid var(--border)' }}>
<div style={{ aspectRatio: '2/3', background: 'var(--border)' }} />
<div className="p-2 space-y-1">
<div className="h-3 rounded" style={{ background: 'var(--border)', width: '75%' }} />
<div className="h-2 rounded" style={{ background: 'var(--border)', width: '40%' }} />
</div>
</div>
))}
</div>
)
}

278
src/lib/comics.ts Normal file
View File

@@ -0,0 +1,278 @@
import fs from 'fs'
import path from 'path'
import AdmZip from 'adm-zip'
import type { ComicIssue, ComicSeries } from '@/types'
import { getDb } from './db'
import { HIDDEN_FILES, thumbnailApiUrl } from './media-utils'
const CBZ_EXTENSIONS = new Set(['.cbz'])
const CBZ_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
function isCbzFile(name: string): boolean {
return CBZ_EXTENSIONS.has(path.extname(name).toLowerCase())
}
function naturalCompare(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
}
function parseIssueNumber(filename: string): number | null {
const base = path.basename(filename, path.extname(filename))
const matches = base.match(/\d+/g)
if (!matches) return null
return parseInt(matches[matches.length - 1], 10)
}
function getPageCount(absoluteCbzPath: string): number {
try {
const zip = new AdmZip(absoluteCbzPath)
return zip
.getEntries()
.filter(
(e) =>
!e.isDirectory &&
CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase())
).length
} catch {
return 0
}
}
function buildIssue(
absFilePath: string,
filename: string,
filePath: string,
libraryId: string,
isStandalone: boolean
): ComicIssue {
const title = path.basename(filename, path.extname(filename))
const issueNumber = parseIssueNumber(filename)
const pageCount = getPageCount(absFilePath)
const coverUrl = thumbnailApiUrl(libraryId, filePath)
return {
id: encodeURIComponent(filePath),
title,
issueNumber,
pageCount,
coverUrl,
filePath,
isStandalone,
}
}
export interface ScannedComicSeries extends ComicSeries {
issues: ComicIssue[]
}
export function scanComicsLibrary(
libraryRoot: string,
libraryId: string
): (ComicIssue | ScannedComicSeries)[] {
let topEntries: fs.Dirent[]
try {
topEntries = fs.readdirSync(libraryRoot, { withFileTypes: true })
} catch {
return []
}
const results: (ComicIssue | ScannedComicSeries)[] = []
for (const entry of topEntries) {
if (HIDDEN_FILES.test(entry.name)) continue
if (entry.isFile() && isCbzFile(entry.name)) {
// Standalone one-shot comic
const absPath = path.join(libraryRoot, entry.name)
results.push(buildIssue(absPath, entry.name, entry.name, libraryId, true))
continue
}
if (entry.isDirectory()) {
const dirAbsPath = path.join(libraryRoot, entry.name)
let subEntries: fs.Dirent[]
try {
subEntries = fs.readdirSync(dirAbsPath, { withFileTypes: true })
} catch {
continue
}
const cbzFiles = subEntries.filter(
(e) => e.isFile() && isCbzFile(e.name) && !HIDDEN_FILES.test(e.name)
)
if (cbzFiles.length === 0) continue
// It's a series
const issues: ComicIssue[] = cbzFiles
.sort((a, b) => naturalCompare(a.name, b.name))
.map((f) => {
const relPath = path.join(entry.name, f.name)
return buildIssue(path.join(dirAbsPath, f.name), f.name, relPath, libraryId, false)
})
const seriesCoverUrl = issues[0]?.coverUrl ?? null
results.push({
id: encodeURIComponent(entry.name),
title: entry.name,
coverUrl: seriesCoverUrl,
issueCount: issues.length,
issues,
})
}
}
return results.sort((a, b) => naturalCompare(a.title, b.title))
}
// comicsFromDb returns series + standalone issues for the top-level grid.
// Series issues are retrieved separately via comicIssuesFromDb.
export function comicsFromDb(libraryId: string): (ComicIssue | ComicSeries)[] {
const db = getDb()
type DbRow = {
item_key: string
item_type: string
parent_key: string | null
title: string | null
metadata: string | null
file_path: string | null
}
const allRows = db
.prepare(
`SELECT item_key, item_type, parent_key, title, metadata, file_path
FROM media_items
WHERE library_id = ? AND item_type IN ('comic_series','comic_issue')
ORDER BY title`
)
.all(libraryId) as DbRow[]
const seriesMap = new Map<string, ComicSeries>()
const standaloneIssues: ComicIssue[] = []
for (const row of allRows) {
if (row.item_type !== 'comic_series') continue
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':comic_series:')[1] ?? row.item_key
seriesMap.set(row.item_key, {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? null,
issueCount: meta.issueCount ?? 0,
})
}
for (const row of allRows) {
if (row.item_type !== 'comic_issue') continue
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
const issue: ComicIssue = {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart.split(':').pop() ?? idPart),
issueNumber: meta.issueNumber ?? null,
pageCount: meta.pageCount ?? 0,
coverUrl: meta.coverUrl ?? null,
filePath: row.file_path ?? '',
isStandalone: meta.isStandalone ?? false,
}
if (row.parent_key && seriesMap.has(row.parent_key)) {
// Series issues are not included in the top-level grid — series card represents them
// We only include series cards + standalone issues in the grid
} else {
standaloneIssues.push(issue)
}
}
const results: (ComicIssue | ComicSeries)[] = [
...Array.from(seriesMap.values()),
...standaloneIssues,
]
return results.sort((a, b) => naturalCompare(a.title, b.title))
}
export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIssue[] {
const db = getDb()
const seriesKey = `${libraryId}:comic_series:${seriesId}`
type DbRow = {
item_key: string
title: string | null
metadata: string | null
file_path: string | null
}
const rows = db
.prepare(
`SELECT item_key, title, metadata, file_path
FROM media_items
WHERE parent_key = ? AND item_type = 'comic_issue'`
)
.all(seriesKey) as DbRow[]
const issues: ComicIssue[] = rows.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
return {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart.split(':').pop() ?? idPart),
issueNumber: meta.issueNumber ?? null,
pageCount: meta.pageCount ?? 0,
coverUrl: meta.coverUrl ?? null,
filePath: row.file_path ?? '',
isStandalone: false,
}
})
return issues.sort((a, b) => {
if (a.issueNumber !== null && b.issueNumber !== null) return a.issueNumber - b.issueNumber
if (a.issueNumber !== null) return -1
if (b.issueNumber !== null) return 1
return naturalCompare(a.title, b.title)
})
}
export function getComicPages(absoluteCbzPath: string): string[] {
try {
const zip = new AdmZip(absoluteCbzPath)
return zip
.getEntries()
.filter(
(e) =>
!e.isDirectory &&
CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase())
)
.sort((a, b) => naturalCompare(a.entryName, b.entryName))
.map((e) => e.entryName)
} catch {
return []
}
}
export function getComicPageBuffer(absoluteCbzPath: string, pageIndex: number): { buffer: Buffer; ext: string } | null {
try {
const zip = new AdmZip(absoluteCbzPath)
const entries = zip
.getEntries()
.filter(
(e) =>
!e.isDirectory &&
CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase())
)
.sort((a, b) => naturalCompare(a.entryName, b.entryName))
if (pageIndex < 0 || pageIndex >= entries.length) return null
const entry = entries[pageIndex]
const buffer = entry.getData()
const ext = path.extname(entry.entryName).toLowerCase()
return { buffer, ext }
} catch {
return null
}
}

View File

@@ -107,6 +107,8 @@ function initDb(db: Database.Database): void {
migrateLibraryAiSettings(db)
migrateAiJobs(db)
migrateLibraryPermissionsAccessLevel(db)
migrateLibrariesAddComics(db)
migrateComicItemTypes(db)
seedAppSettings(db)
}
@@ -319,6 +321,68 @@ function migrateLibrariesType(db: Database.Database): void {
}
}
function migrateLibrariesAddComics(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
.get() as { sql: string } | undefined
if (!row || row.sql.includes("'comics'")) return
db.exec(`
BEGIN TRANSACTION;
CREATE TABLE libraries_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('comics','games','mixed','movies','tv')),
cover_ext TEXT NULL
);
INSERT INTO libraries_new SELECT * FROM libraries;
DROP TABLE libraries;
ALTER TABLE libraries_new RENAME TO libraries;
COMMIT;
`)
}
function migrateComicItemTypes(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
.get() as { sql: string } | undefined
if (!row || row.sql.includes("'comic_series'")) return
db.exec(`
BEGIN TRANSACTION;
CREATE TABLE media_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
item_key TEXT NOT NULL UNIQUE,
item_type TEXT NOT NULL CHECK(item_type IN (
'movie','tv_series','tv_season','tv_episode',
'game','game_series','mixed_file',
'comic_series','comic_issue')),
parent_key TEXT,
title TEXT,
year INTEGER,
plot TEXT,
genres TEXT,
metadata TEXT,
file_path TEXT,
fingerprint TEXT,
scanned_at INTEGER NOT NULL,
ai_tagged_at INTEGER,
ai_description TEXT,
extracted_text TEXT,
extracted_text_translated TEXT
);
INSERT INTO media_items_new SELECT * FROM media_items;
DROP TABLE media_items;
ALTER TABLE media_items_new RENAME TO media_items;
CREATE INDEX media_items_library_id ON media_items(library_id);
CREATE INDEX media_items_parent_key ON media_items(parent_key);
CREATE INDEX media_items_fingerprint ON media_items(fingerprint);
COMMIT;
`)
}
function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")

View File

@@ -1,13 +1,14 @@
import path from 'path'
import type Database from 'better-sqlite3'
import type { Library, Movie, TvSeries, TvSeason, TvEpisode, Game, GameSeries } from '@/types'
import type { Library, Movie, TvSeries, TvSeason, TvEpisode, Game, GameSeries, ComicIssue } from '@/types'
import { getDb } from './db'
import { getLibraries, resolveLibraryRoot } from './libraries'
import { setScanLastRan } from './app-settings'
import { scanMoviesLibrary } from './movies'
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
import { scanGamesLibrary } from './games'
import { getThumbnailPath } from './thumbnails'
import { scanComicsLibrary, type ScannedComicSeries } from './comics'
import { getThumbnailPath, getCbzThumbnailPath } from './thumbnails'
import { computeFingerprint } from './fingerprint'
import { reKeyMediaItem } from './tags'
import { runAiTagging } from './ai-tagger'
@@ -70,6 +71,9 @@ export async function runLibraryScan(library: Library): Promise<void> {
case 'mixed':
await scanMixed(library, libraryRoot)
break
case 'comics':
await scanComics(library, libraryRoot)
break
}
await runAiTagging(library, libraryRoot).catch((err) =>
@@ -536,6 +540,119 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
console.log(`[scanner] mixed: indexed ${newItems.size} files`)
}
// ---------------------------------------------------------------------------
// Comics (clear+upsert pattern — CBZ files are immutable archives)
// ---------------------------------------------------------------------------
async function scanComics(library: Library, libraryRoot: string): Promise<void> {
const items = scanComicsLibrary(libraryRoot, library.id)
const db = getDb()
const now = Date.now()
clearLibraryItems(db, library.id)
const upsertSeries = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @title, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET
title = excluded.title,
metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at
`)
const upsertIssue = db.prepare(`
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, file_path, scanned_at)
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @file_path, @scanned_at)
ON CONFLICT(item_key) DO UPDATE SET
parent_key = excluded.parent_key,
title = excluded.title,
metadata = excluded.metadata,
file_path = excluded.file_path,
scanned_at = excluded.scanned_at
`)
let issueCount = 0
db.transaction(() => {
for (const item of items) {
if ('issues' in item) {
const series = item as ScannedComicSeries
const seriesKey = `${library.id}:comic_series:${series.id}`
upsertSeries.run({
library_id: library.id,
item_key: seriesKey,
item_type: 'comic_series',
title: series.title,
metadata: JSON.stringify({
issueCount: series.issueCount,
coverUrl: series.coverUrl,
}),
file_path: null,
scanned_at: now,
})
for (const issue of series.issues) {
const issueKey = `${library.id}:comic_issue:${issue.id}`
upsertIssue.run({
library_id: library.id,
item_key: issueKey,
item_type: 'comic_issue',
parent_key: seriesKey,
title: issue.title,
metadata: JSON.stringify({
issueNumber: issue.issueNumber,
pageCount: issue.pageCount,
coverUrl: issue.coverUrl,
isStandalone: false,
}),
file_path: issue.filePath,
scanned_at: now,
})
issueCount++
}
} else {
const issue = item as ComicIssue
const issueKey = `${library.id}:comic_issue:${issue.id}`
upsertIssue.run({
library_id: library.id,
item_key: issueKey,
item_type: 'comic_issue',
parent_key: null,
title: issue.title,
metadata: JSON.stringify({
issueNumber: issue.issueNumber,
pageCount: issue.pageCount,
coverUrl: issue.coverUrl,
isStandalone: true,
}),
file_path: issue.filePath,
scanned_at: now,
})
issueCount++
}
}
})()
// Prewarm CBZ cover thumbnails
for (const item of items) {
const issuesToWarm: ComicIssue[] = 'issues' in item
? (item as ScannedComicSeries).issues.slice(0, 1)
: [item as ComicIssue]
for (const issue of issuesToWarm) {
const absPath = path.join(libraryRoot, issue.filePath)
try {
await getCbzThumbnailPath(absPath, library.id)
} catch (err) {
console.warn(`[scanner] Could not generate CBZ thumbnail for ${issue.filePath}:`, err instanceof Error ? err.message : err)
}
}
}
console.log(`[scanner] comics: indexed ${items.filter((i) => 'issues' in i).length} series, ${issueCount} issues`)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

View File

@@ -263,6 +263,52 @@ export function getSeriesEpisodeTagMap(libraryId: string): Record<string, string
return result
}
// Returns seriesItemKey -> { tagIds, issueTitles } aggregated from all issues in each series.
export function getComicsSeriesIssueMeta(
libraryId: string
): Record<string, { tagIds: string[]; issueTitles: string[] }> {
const db = getDb()
// All issues belonging to a series (parent_key is not null)
const issues = db
.prepare(
`SELECT item_key, parent_key, title
FROM media_items
WHERE library_id = ? AND item_type = 'comic_issue' AND parent_key IS NOT NULL`
)
.all(libraryId) as { item_key: string; parent_key: string; title: string | null }[]
const result: Record<string, { tagIds: string[]; issueTitles: string[] }> = {}
const issueKeyToParent = new Map<string, string>()
for (const { item_key, parent_key, title } of issues) {
issueKeyToParent.set(item_key, parent_key)
const entry = (result[parent_key] ??= { tagIds: [], issueTitles: [] })
if (title) entry.issueTitles.push(title)
}
if (issueKeyToParent.size === 0) return result
// Tag assignments for those issues
const tagRows = db
.prepare(
`SELECT mt.item_key, mt.tag_id
FROM media_tags mt
JOIN media_items mi ON mi.item_key = mt.item_key
WHERE mi.library_id = ? AND mi.item_type = 'comic_issue' AND mi.parent_key IS NOT NULL`
)
.all(libraryId) as { item_key: string; tag_id: string }[]
for (const { item_key, tag_id } of tagRows) {
const parentKey = issueKeyToParent.get(item_key)
if (!parentKey) continue
const entry = result[parentKey]
if (entry && !entry.tagIds.includes(tag_id)) entry.tagIds.push(tag_id)
}
return result
}
export function removeAllAssignmentsForLibrary(libraryId: string): void {
const db = getDb()
db.prepare('DELETE FROM media_tags WHERE item_key LIKE ?').run(`${libraryId}:%`)

View File

@@ -3,6 +3,7 @@ import fs from 'fs'
import path from 'path'
import { spawn } from 'child_process'
import sharp from 'sharp'
import AdmZip from 'adm-zip'
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
const THUMBNAIL_WIDTH = 400
@@ -221,6 +222,41 @@ export async function getOcrImagePath(
return cacheFile
}
const CBZ_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
/**
* Returns the absolute path to a cached thumbnail JPEG for a CBZ archive.
* Extracts the first image entry (natural sort) from the ZIP and resizes it.
* Throws on failure — callers should map this to a 404.
*/
export async function getCbzThumbnailPath(
absoluteFilePath: string,
libraryId: string
): Promise<string> {
ensureCacheDir()
const key = cacheKey(libraryId, absoluteFilePath)
const cacheFile = path.join(CACHE_DIR, key + '.jpg')
const cached = getCachedPath(cacheFile, absoluteFilePath)
if (cached) return cached
const zip = new AdmZip(absoluteFilePath)
const entries = zip
.getEntries()
.filter((e) => !e.isDirectory && CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase()))
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true, sensitivity: 'base' }))
if (entries.length === 0) throw new Error('No image entries found in CBZ')
const buffer = entries[0].getData()
const tmp = cacheFile + '.tmp'
await sharp(buffer).resize(THUMBNAIL_WIDTH).jpeg({ quality: JPEG_QUALITY }).toFile(tmp)
fs.renameSync(tmp, cacheFile)
return cacheFile
}
/**
* Returns the absolute path to a cached thumbnail JPEG for the given file.
* Generates it on first call (or when the source has been modified).

View File

@@ -1,4 +1,23 @@
export type LibraryType = 'games' | 'mixed' | 'movies' | 'tv'
export type LibraryType = 'comics' | 'games' | 'mixed' | 'movies' | 'tv'
export interface ComicSeries {
id: string
item_key?: string
title: string
coverUrl: string | null
issueCount: number
}
export interface ComicIssue {
id: string
item_key?: string
title: string
issueNumber: number | null
pageCount: number
coverUrl: string | null
filePath: string
isStandalone: boolean
}
export interface Library {
id: string