diff --git a/package-lock.json b/package-lock.json index 9dc18cb..225cb21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 1eca2bf..7c93d65 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/comics/page/route.ts b/src/app/api/comics/page/route.ts new file mode 100644 index 0000000..20099a8 --- /dev/null +++ b/src/app/api/comics/page/route.ts @@ -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 = { + '.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', + }, + }) +} diff --git a/src/app/api/comics/route.ts b/src/app/api/comics/route.ts new file mode 100644 index 0000000..9fc0ec9 --- /dev/null +++ b/src/app/api/comics/route.ts @@ -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 }) +} diff --git a/src/app/api/comics/series-issue-tags/route.ts b/src/app/api/comics/series-issue-tags/route.ts new file mode 100644 index 0000000..af07e38 --- /dev/null +++ b/src/app/api/comics/series-issue-tags/route.ts @@ -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)) +} diff --git a/src/app/api/file/route.ts b/src/app/api/file/route.ts index cb97190..010813d 100644 --- a/src/app/api/file/route.ts +++ b/src/app/api/file/route.ts @@ -20,6 +20,7 @@ const MIME_TYPES: Record = { '.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') || diff --git a/src/app/api/libraries/route.ts b/src/app/api/libraries/route.ts index c4eb58d..0c293ad 100644 --- a/src/app/api/libraries/route.ts +++ b/src/app/api/libraries/route.ts @@ -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 }) } diff --git a/src/app/api/thumbnail/route.ts b/src/app/api/thumbnail/route.ts index cf2ced4..1a85a87 100644 --- a/src/app/api/thumbnail/route.ts +++ b/src/app/api/thumbnail/route.ts @@ -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) diff --git a/src/app/library/[id]/page.tsx b/src/app/library/[id]/page.tsx index f5aaf36..9884852 100644 --- a/src/app/library/[id]/page.tsx +++ b/src/app/library/[id]/page.tsx @@ -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) { )} + {library.type === 'comics' && } {library.type === 'games' && } {library.type === 'mixed' && } {library.type === 'movies' && } diff --git a/src/app/manage/page.tsx b/src/app/manage/page.tsx index 50c16fe..f6d26ba 100644 --- a/src/app/manage/page.tsx +++ b/src/app/manage/page.tsx @@ -5,6 +5,7 @@ import Image from 'next/image' import type { Library, LibraryType } from '@/types' const TYPE_ICONS: Record = { + comics: '📚', games: '🎮', mixed: '🗂️', movies: '🎬', @@ -12,6 +13,7 @@ const TYPE_ICONS: Record = { } const TYPE_LABELS: Record = { + 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)')} > + diff --git a/src/components/comics/ComicIssueView.tsx b/src/components/comics/ComicIssueView.tsx new file mode 100644 index 0000000..4232ded --- /dev/null +++ b/src/components/comics/ComicIssueView.tsx @@ -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(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(null) + + return ( + <> +
+
+
+
e.stopPropagation()} + > + {/* Header */} +
+
+

+ {issue.title} +

+

+ {pageCount} {pageCount === 1 ? 'page' : 'pages'} +

+
+
+ {issue.item_key && !readOnly && !showTagPanel && ( + + )} + e.stopPropagation()} + > + Download + + +
+
+ + {/* Page grid */} +
+ {pageCount === 0 ? ( +
+ No pages found in this issue. +
+ ) : ( +
+ {Array.from({ length: pageCount }, (_, i) => ( + + ))} +
+ )} +
+
+
+ + {showTagPanel && issue.item_key && ( + setShowTagPanel(false)} + onClose={onClose} + onTagsChanged={onTagsChanged} + readOnly={readOnly} + /> + )} +
+
+ + {lightboxPage !== null && ( + 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} + /> + )} + + ) +} diff --git a/src/components/comics/ComicSeriesView.tsx b/src/components/comics/ComicSeriesView.tsx new file mode 100644 index 0000000..d537521 --- /dev/null +++ b/src/components/comics/ComicSeriesView.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [selectedIssue, setSelectedIssue] = useState(null) + const [tagItemKey, setTagItemKey] = useState(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 ( + <> +
+
+
+
e.stopPropagation()} + > + {/* Header */} +
+
+

+ {series.title} +

+

+ {series.issueCount} {series.issueCount === 1 ? 'issue' : 'issues'} +

+
+
+ {series.item_key && !readOnly && !tagItemKey && ( + + )} + +
+
+ + {/* Issue grid */} +
+ {loading ? ( + + ) : issues.length === 0 ? ( +
+ No issues found. +
+ ) : ( +
+ {issues.map((issue) => ( + setSelectedIssue(issue)} + onTagClick={issue.item_key && !readOnly + ? () => setTagItemKey(issue.item_key!) + : undefined} + /> + ))} +
+ )} +
+
+
+ + {tagItemKey && ( + setTagItemKey(null)} + onClose={onClose} + onTagsChanged={onTagsChanged} + readOnly={readOnly} + /> + )} +
+
+ + {selectedIssue && ( + setSelectedIssue(null)} + onTagsChanged={onTagsChanged} + readOnly={readOnly} + /> + )} + + ) +} + +function IssueCard({ + issue, + onClick, + onTagClick, + readOnly, +}: { + issue: ComicIssue + onClick: () => void + onTagClick?: () => void + readOnly?: boolean +}) { + return ( +
+ + {onTagClick && !readOnly && ( + + )} +
+ ) +} + +function LoadingGrid() { + return ( +
+ {Array.from({ length: 6 }, (_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) +} diff --git a/src/components/comics/ComicsView.tsx b/src/components/comics/ComicsView.tsx new file mode 100644 index 0000000..4e6b705 --- /dev/null +++ b/src/components/comics/ComicsView.tsx @@ -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(null) + const [selectedSeries, setSelectedSeries] = useState(null) + const [selectedIssue, setSelectedIssue] = useState(null) + const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null) + const [search, setSearch] = useState('') + const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) + const [assignments, setAssignments] = useState>({}) + const [seriesIssueMeta, setSeriesIssueMeta] = useState< + Record + >({}) + 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 ( + <> +
+ +
+ +
+ {showFilters && ( +
+ +
+ )} + +
+ {loading ? ( + + ) : error ? ( +
+ {error} +
+ ) : items.length === 0 ? ( +
+

No comics found

+

Add .cbz files or folders of .cbz files to this library and scan.

+
+ ) : ( +
+ {filtered.map((item) => + 'issueCount' in item ? ( + setSelectedSeries(item as ComicSeries)} + onTagClick={(item as ComicSeries).item_key && !readOnly + ? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title }) + : undefined} + /> + ) : ( + setSelectedIssue(item as ComicIssue)} + onTagClick={(item as ComicIssue).item_key && !readOnly + ? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title }) + : undefined} + /> + ) + )} +
+ )} +
+
+ + {/* Tag panel modal */} + {tagPanel && ( +
{ if (e.target === e.currentTarget) setTagPanel(null) }} + > +
+
+
+

+ Tags +

+

+ {tagPanel.title} +

+
+ +
+
+ +
+
+
+ )} + + {selectedSeries && ( + setSelectedSeries(null)} + onTagsChanged={onTagsChanged} + readOnly={readOnly} + /> + )} + + {selectedIssue && ( + setSelectedIssue(null)} + onTagsChanged={onTagsChanged} + readOnly={readOnly} + /> + )} + + ) +} + +function SeriesCard({ + series, + onClick, + onTagClick, + readOnly, +}: { + series: ComicSeries + onClick: () => void + onTagClick?: () => void + readOnly?: boolean +}) { + return ( +
+ + {onTagClick && !readOnly && ( + + )} +
+ ) +} + +function IssueCard({ + issue, + onClick, + onTagClick, + readOnly, +}: { + issue: ComicIssue + onClick: () => void + onTagClick?: () => void + readOnly?: boolean +}) { + return ( +
+ + {onTagClick && !readOnly && ( + + )} +
+ ) +} + +function LoadingGrid() { + return ( +
+ {Array.from({ length: 12 }, (_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) +} diff --git a/src/lib/comics.ts b/src/lib/comics.ts new file mode 100644 index 0000000..6a37bc3 --- /dev/null +++ b/src/lib/comics.ts @@ -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() + 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 + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index da3f9eb..195b0f8 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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'") diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 33a0ccb..810742b 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -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 { 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 { 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 { + 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 // --------------------------------------------------------------------------- diff --git a/src/lib/tags.ts b/src/lib/tags.ts index 2b9dca5..13b2109 100644 --- a/src/lib/tags.ts +++ b/src/lib/tags.ts @@ -263,6 +263,52 @@ export function getSeriesEpisodeTagMap(libraryId: string): Record { tagIds, issueTitles } aggregated from all issues in each series. +export function getComicsSeriesIssueMeta( + libraryId: string +): Record { + 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 = {} + const issueKeyToParent = new Map() + + 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}:%`) diff --git a/src/lib/thumbnails.ts b/src/lib/thumbnails.ts index c043121..421f908 100644 --- a/src/lib/thumbnails.ts +++ b/src/lib/thumbnails.ts @@ -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 { + 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). diff --git a/src/types/index.ts b/src/types/index.ts index 718a17e..3b1a9b5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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