Compare commits
17 Commits
fc9a7af7c3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f1ad4f5dd | |||
|
|
e283d03e95 | ||
|
|
0e600e5f6c | ||
|
|
2cf8bc6d7d | ||
| da3ad97d51 | |||
|
|
b5d144c8cc | ||
|
|
d854bbe99b | ||
| d2057fb81c | |||
|
|
27430dbf52 | ||
|
|
bd028a7a5d | ||
|
|
8f8f8c3001 | ||
|
|
dee9356004 | ||
| 7d2ae7e95c | |||
|
|
cedc012733 | ||
| a9461f9ae4 | |||
|
|
a6d657d87d | ||
|
|
71a026f01e |
28
package-lock.json
generated
28
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@types/adm-zip": "^0.5.8",
|
"@types/adm-zip": "^0.5.8",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
@@ -1658,6 +1659,33 @@
|
|||||||
"tailwindcss": "4.2.2"
|
"tailwindcss": "4.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.13.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
|
||||||
|
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.14.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@types/adm-zip": "^0.5.8",
|
"@types/adm-zip": "^0.5.8",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
|
|||||||
@@ -30,15 +30,37 @@ export async function GET(request: NextRequest) {
|
|||||||
const root = resolveLibraryRoot(library)
|
const root = resolveLibraryRoot(library)
|
||||||
const recursive = request.nextUrl.searchParams.get('recursive') === 'true'
|
const recursive = request.nextUrl.searchParams.get('recursive') === 'true'
|
||||||
const listing = recursive
|
const listing = recursive
|
||||||
? scanDirectoryRecursive(root, libraryId, subpath)
|
? await scanDirectoryRecursive(root, libraryId, subpath)
|
||||||
: scanDirectory(root, libraryId, subpath)
|
: scanDirectory(root, libraryId, subpath)
|
||||||
|
|
||||||
// Annotate image files with hasExtractedText, and directories if any descendant has extracted text
|
// Annotate entries with metadata used by search/filtering in mixed view.
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const rows = db
|
const metadataRows = db
|
||||||
.prepare('SELECT item_key FROM media_items WHERE library_id = ? AND extracted_text IS NOT NULL')
|
.prepare(`
|
||||||
.all(libraryId) as { item_key: string }[]
|
SELECT item_key, user_rating, ai_description, extracted_text, extracted_text_translated
|
||||||
const withText = new Set(rows.map((r) => r.item_key))
|
FROM media_items
|
||||||
|
WHERE library_id = ?
|
||||||
|
AND (
|
||||||
|
user_rating IS NOT NULL
|
||||||
|
OR ai_description IS NOT NULL
|
||||||
|
OR extracted_text IS NOT NULL
|
||||||
|
OR extracted_text_translated IS NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.all(libraryId) as {
|
||||||
|
item_key: string
|
||||||
|
user_rating: number | null
|
||||||
|
ai_description: string | null
|
||||||
|
extracted_text: string | null
|
||||||
|
extracted_text_translated: string | null
|
||||||
|
}[]
|
||||||
|
|
||||||
|
const metadataByItemKey = new Map(metadataRows.map((r) => [r.item_key, r]))
|
||||||
|
const withText = new Set(
|
||||||
|
metadataRows
|
||||||
|
.filter((r) => r.extracted_text !== null)
|
||||||
|
.map((r) => r.item_key)
|
||||||
|
)
|
||||||
|
|
||||||
// Build a set of all ancestor directory relative paths that contain at least one item with text
|
// Build a set of all ancestor directory relative paths that contain at least one item with text
|
||||||
// e.g. item_key "lib:mixed_file:manga%2Fch1%2Fp1.jpg" → ancestors "manga", "manga/ch1"
|
// e.g. item_key "lib:mixed_file:manga%2Fch1%2Fp1.jpg" → ancestors "manga", "manga/ch1"
|
||||||
@@ -54,10 +76,18 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
listing.entries = listing.entries.map((e) => {
|
listing.entries = listing.entries.map((e) => {
|
||||||
if (e.type === 'file') {
|
if (e.type === 'file') {
|
||||||
if (e.mediaType !== 'image') return e
|
// Recursive listing already uses full path from library root in e.name.
|
||||||
const relPath = subpath ? path.join(subpath, e.name) : e.name
|
const relPath = recursive ? e.name : (subpath ? path.join(subpath, e.name) : e.name)
|
||||||
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
|
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
|
||||||
return { ...e, hasExtractedText: withText.has(itemKey) }
|
const metadata = metadataByItemKey.get(itemKey)
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
...(e.mediaType === 'image' ? { hasExtractedText: withText.has(itemKey) } : {}),
|
||||||
|
userRating: metadata?.user_rating ?? null,
|
||||||
|
aiDescription: metadata?.ai_description ?? null,
|
||||||
|
extractedText: metadata?.extracted_text ?? null,
|
||||||
|
extractedTextTranslated: metadata?.extracted_text_translated ?? null,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (e.type === 'directory') {
|
if (e.type === 'directory') {
|
||||||
const dirRel = subpath ? `${subpath}/${e.name}` : e.name
|
const dirRel = subpath ? `${subpath}/${e.name}` : e.name
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json(comicIssuesFromDb(libraryId, seriesId))
|
return NextResponse.json(comicIssuesFromDb(libraryId, seriesId))
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(comicsFromDb(libraryId))
|
const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1)
|
||||||
|
const pageSize = Math.min(500, Math.max(1, parseInt(searchParams.get('pageSize') ?? '200', 10) || 200))
|
||||||
|
const search = (searchParams.get('search') ?? '').trim() || undefined
|
||||||
|
|
||||||
|
return NextResponse.json(comicsFromDb(libraryId, { page, pageSize, search }))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
|||||||
31
src/app/api/libraries/[id]/import-metadata-movies/route.ts
Normal file
31
src/app/api/libraries/[id]/import-metadata-movies/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getLibrary } from '@/lib/libraries'
|
||||||
|
import { importMovieMetadata } from '@/lib/movie-metadata'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { pathname } = new URL(request.url)
|
||||||
|
const libraryId = pathname.split('/')[3] // /api/libraries/[id]/import-metadata-movies
|
||||||
|
|
||||||
|
try {
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
|
||||||
|
if (!library || library.type !== 'movies') {
|
||||||
|
return NextResponse.json({ error: 'Movies library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform full metadata import for all items
|
||||||
|
const result = await importMovieMetadata(library, true)
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[import-metadata-movies]', err)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err instanceof Error ? err.message : 'Failed to import metadata' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/libraries/[id]/import-metadata-tv/route.ts
Normal file
31
src/app/api/libraries/[id]/import-metadata-tv/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getLibrary } from '@/lib/libraries'
|
||||||
|
import { importTvMetadata } from '@/lib/tv-metadata'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { pathname } = new URL(request.url)
|
||||||
|
const libraryId = pathname.split('/')[3] // /api/libraries/[id]/import-metadata-tv
|
||||||
|
|
||||||
|
try {
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
|
||||||
|
if (!library || library.type !== 'tv') {
|
||||||
|
return NextResponse.json({ error: 'TV library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform full metadata import for all items
|
||||||
|
const result = await importTvMetadata(library, true)
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[import-metadata-tv]', err)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err instanceof Error ? err.message : 'Failed to import metadata' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/app/api/ratings/route.ts
Normal file
64
src/app/api/ratings/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
|
function extractLibraryId(itemKey: string): string | null {
|
||||||
|
const colonIdx = itemKey.indexOf(':')
|
||||||
|
if (colonIdx === -1) return null
|
||||||
|
return itemKey.slice(0, colonIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const itemKey = searchParams.get('itemKey')
|
||||||
|
if (!itemKey) {
|
||||||
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const libraryId = extractLibraryId(itemKey)
|
||||||
|
if (!libraryId) {
|
||||||
|
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT user_rating FROM media_items WHERE item_key = ?')
|
||||||
|
.get(itemKey) as { user_rating: number | null } | undefined
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ userRating: row.user_rating ?? null })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
const body = await request.json()
|
||||||
|
const { itemKey, userRating } = body as { itemKey: string; userRating: number | null }
|
||||||
|
|
||||||
|
if (!itemKey) {
|
||||||
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (userRating !== null && (typeof userRating !== 'number' || !Number.isInteger(userRating) || userRating < 1 || userRating > 5)) {
|
||||||
|
return NextResponse.json({ error: 'userRating must be null or an integer 1–5' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryId = extractLibraryId(itemKey)
|
||||||
|
if (!libraryId) {
|
||||||
|
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare('UPDATE media_items SET user_rating = ? WHERE item_key = ?')
|
||||||
|
.run(userRating, itemKey)
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { updateCategory, deleteCategory, deleteCategoryForce, getTags } from '@/lib/tags'
|
import { updateCategory, deleteCategory, deleteCategoryForce, getTags, getCategories, mergeCategories } from '@/lib/tags'
|
||||||
import { requireAdmin } from '@/lib/auth'
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
@@ -11,9 +11,30 @@ export async function PATCH(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { name } = await request.json()
|
const { name, merge } = await request.json()
|
||||||
const category = updateCategory(id, name)
|
|
||||||
return NextResponse.json(category)
|
try {
|
||||||
|
const category = updateCategory(id, name)
|
||||||
|
return NextResponse.json(category)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message
|
||||||
|
if (!msg.includes('already exists')) throw err
|
||||||
|
|
||||||
|
// A category with this name already exists — find it
|
||||||
|
const trimmed = (name as string).trim()
|
||||||
|
const target = getCategories().find((c) => c.name.toLowerCase() === trimmed.toLowerCase())
|
||||||
|
if (!target) throw err
|
||||||
|
|
||||||
|
if (merge) {
|
||||||
|
mergeCategories(id, target.id)
|
||||||
|
return NextResponse.json(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: msg, conflict: true, targetCategoryId: target.id },
|
||||||
|
{ status: 409 }
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
return NextResponse.json({ error: (err as Error).message }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import fsPromises from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { getThumbnailPath, getCbzThumbnailPath } from '@/lib/thumbnails'
|
import { getThumbnailPath, getCbzThumbnailPath } from '@/lib/thumbnails'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
import { isCorruptZipError } from '@/lib/zip-utils'
|
||||||
|
|
||||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
|
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v'])
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
@@ -63,7 +65,30 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Thumbnail generation failed for ${filePath}:`, err)
|
if (isCorruptZipError(err)) {
|
||||||
|
// Move the corrupt archive to the library's .trash folder so it is excluded
|
||||||
|
// from future scans and hidden from the UI.
|
||||||
|
const trashDir = path.join(root, '.trash')
|
||||||
|
const filename = path.basename(filePath)
|
||||||
|
let dest = path.join(trashDir, filename)
|
||||||
|
fsPromises.mkdir(trashDir, { recursive: true })
|
||||||
|
.then(async () => {
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
const ext = path.extname(filename)
|
||||||
|
dest = path.join(trashDir, `${path.basename(filename, ext)}_${Date.now()}${ext}`)
|
||||||
|
}
|
||||||
|
await fsPromises.rename(filePath, dest).catch(async (e: NodeJS.ErrnoException) => {
|
||||||
|
if (e.code === 'EXDEV') {
|
||||||
|
await fsPromises.copyFile(filePath, dest)
|
||||||
|
await fsPromises.unlink(filePath)
|
||||||
|
} else throw e
|
||||||
|
})
|
||||||
|
console.log(`[thumbnail] Moved corrupt archive to trash: ${path.relative(root, filePath)}`)
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn(`[thumbnail] Could not move corrupt archive to trash:`, e))
|
||||||
|
} else {
|
||||||
|
console.error(`Thumbnail generation failed for ${filePath}:`, err)
|
||||||
|
}
|
||||||
return new NextResponse(null, { status: 404 })
|
return new NextResponse(null, { status: 404 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const TYPE_LABELS: Record<LibraryType, string> = {
|
|||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ManagePage() {
|
function ManagePage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -111,6 +111,8 @@ function LibraryRow({
|
|||||||
const [showBulkRename, setShowBulkRename] = useState(false)
|
const [showBulkRename, setShowBulkRename] = useState(false)
|
||||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [showImportWarning, setShowImportWarning] = useState(false)
|
||||||
|
const [importingMetadata, setImportingMetadata] = useState(false)
|
||||||
|
|
||||||
const handleRemoveClick = () => {
|
const handleRemoveClick = () => {
|
||||||
if (!confirming) {
|
if (!confirming) {
|
||||||
@@ -125,6 +127,26 @@ function LibraryRow({
|
|||||||
.catch(() => setRemoving(false))
|
.catch(() => setRemoving(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleImportMetadata = async () => {
|
||||||
|
setImportingMetadata(true)
|
||||||
|
setShowImportWarning(false)
|
||||||
|
try {
|
||||||
|
const endpoint =
|
||||||
|
library.type === 'tv'
|
||||||
|
? `/api/libraries/${encodeURIComponent(library.id)}/import-metadata-tv`
|
||||||
|
: `/api/libraries/${encodeURIComponent(library.id)}/import-metadata-movies`
|
||||||
|
const res = await fetch(endpoint, { method: 'POST' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
console.log(`[manage] Imported metadata: ${data.imported} items, skipped ${data.skipped}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[manage] Error importing metadata:', err)
|
||||||
|
} finally {
|
||||||
|
setImportingMetadata(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
if (cancelRef.current) clearTimeout(cancelRef.current)
|
if (cancelRef.current) clearTimeout(cancelRef.current)
|
||||||
setConfirming(false)
|
setConfirming(false)
|
||||||
@@ -213,38 +235,54 @@ function LibraryRow({
|
|||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{library.type === 'comics' && (
|
{library.type === 'comics' && (
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setImporting('running')
|
||||||
|
fetch(`/api/libraries/${encodeURIComponent(library.id)}/import-metadata`, { method: 'POST' })
|
||||||
|
.then(() => {
|
||||||
|
setImporting('done')
|
||||||
|
setTimeout(() => setImporting('idle'), 3000)
|
||||||
|
})
|
||||||
|
.catch(() => setImporting('idle'))
|
||||||
|
}}
|
||||||
|
disabled={importing === 'running'}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{importing === 'running' ? 'Importing…' : importing === 'done' ? 'Imported ✓' : 'Import Metadata'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkRename(true)}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
Bulk Rename
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(library.type === 'tv' || library.type === 'movies') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => setShowImportWarning(true)}
|
||||||
setImporting('running')
|
disabled={importingMetadata}
|
||||||
fetch(`/api/libraries/${encodeURIComponent(library.id)}/import-metadata`, { method: 'POST' })
|
|
||||||
.then(() => {
|
|
||||||
setImporting('done')
|
|
||||||
setTimeout(() => setImporting('idle'), 3000)
|
|
||||||
})
|
|
||||||
.catch(() => setImporting('idle'))
|
|
||||||
}}
|
|
||||||
disabled={importing === 'running'}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
if (!importingMetadata) (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
if (!importingMetadata) (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{importing === 'running' ? 'Importing…' : importing === 'done' ? 'Imported ✓' : 'Import Metadata'}
|
{importingMetadata ? 'Importing…' : 'Import Metadata'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setShowBulkRename(true)}
|
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
|
||||||
>
|
|
||||||
Bulk Rename
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{library.coverExt && (
|
{library.coverExt && (
|
||||||
<button
|
<button
|
||||||
@@ -296,6 +334,13 @@ function LibraryRow({
|
|||||||
{showBulkRename && (
|
{showBulkRename && (
|
||||||
<BulkRenameModal libraryId={library.id} onClose={() => setShowBulkRename(false)} />
|
<BulkRenameModal libraryId={library.id} onClose={() => setShowBulkRename(false)} />
|
||||||
)}
|
)}
|
||||||
|
{showImportWarning && (library.type === 'tv' || library.type === 'movies') && (
|
||||||
|
<ImportWarningModal
|
||||||
|
libraryType={library.type}
|
||||||
|
onConfirm={handleImportMetadata}
|
||||||
|
onCancel={() => setShowImportWarning(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -658,3 +703,57 @@ function LoadingRows() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ImportWarningModal({
|
||||||
|
libraryType,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
libraryType: 'tv' | 'movies'
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const label = libraryType === 'tv' ? 'TV' : 'Movie'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-2xl border p-5"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Import {label} Metadata
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Full metadata import will refresh metadata for ALL items in this library, overwriting any
|
||||||
|
existing data. Continue?
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs px-3 py-2 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="text-xs px-3 py-2 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ManagePage
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
import { memo, useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types'
|
import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types'
|
||||||
|
|
||||||
export default function TagMappingsPage() {
|
export default function TagMappingsPage() {
|
||||||
@@ -67,13 +68,27 @@ export default function TagMappingsPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [libraryId])
|
}, [libraryId])
|
||||||
|
|
||||||
const tagsByCategory = categories.map((cat) => ({
|
const tagsByCategory = useMemo(() => (
|
||||||
category: cat,
|
categories
|
||||||
tags: tags.filter((t) => t.categoryId === cat.id),
|
.map((cat) => ({
|
||||||
})).filter((g) => g.tags.length > 0)
|
category: cat,
|
||||||
|
tags: tags.filter((t) => t.categoryId === cat.id),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.tags.length > 0)
|
||||||
|
), [categories, tags])
|
||||||
|
|
||||||
const visibleTags = importedTags.filter((t) => !ignoredTags.has(t.name))
|
const visibleTags = useMemo(() => importedTags.filter((t) => !ignoredTags.has(t.name)), [importedTags, ignoredTags])
|
||||||
const hiddenTags = importedTags.filter((t) => ignoredTags.has(t.name))
|
const hiddenTags = useMemo(() => importedTags.filter((t) => ignoredTags.has(t.name)), [importedTags, ignoredTags])
|
||||||
|
|
||||||
|
const handleIgnoreTag = useCallback((name: string) => {
|
||||||
|
updateIgnoredTags(new Set([...ignoredTags, name]))
|
||||||
|
}, [ignoredTags, updateIgnoredTags])
|
||||||
|
|
||||||
|
const handleUnignoreTag = useCallback((name: string) => {
|
||||||
|
const next = new Set(ignoredTags)
|
||||||
|
next.delete(name)
|
||||||
|
updateIgnoredTags(next)
|
||||||
|
}, [ignoredTags, updateIgnoredTags])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
@@ -116,31 +131,22 @@ export default function TagMappingsPage() {
|
|||||||
: 'All unmapped tags are hidden. Check the ignored tags section below.'}
|
: 'All unmapped tags are hidden. Check the ignored tags section below.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
<VirtualizedImportedTagRows
|
||||||
{visibleTags.map((it) => (
|
tags={visibleTags}
|
||||||
<ImportedTagRow
|
libraryId={libraryId}
|
||||||
key={it.id}
|
tagsByCategory={tagsByCategory}
|
||||||
importedTag={it}
|
categories={categories}
|
||||||
libraryId={libraryId}
|
prefixMappings={prefixMappings}
|
||||||
tagsByCategory={tagsByCategory}
|
onMapped={refresh}
|
||||||
categories={categories}
|
onIgnore={handleIgnoreTag}
|
||||||
prefixMappings={prefixMappings}
|
/>
|
||||||
onMapped={refresh}
|
|
||||||
onIgnore={() => updateIgnoredTags(new Set([...ignoredTags, it.name]))}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{hiddenTags.length > 0 && (
|
{hiddenTags.length > 0 && (
|
||||||
<IgnoredTagsSection
|
<IgnoredTagsSection
|
||||||
tags={hiddenTags}
|
tags={hiddenTags}
|
||||||
onUnignore={(name) => {
|
onUnignore={handleUnignoreTag}
|
||||||
const next = new Set(ignoredTags)
|
|
||||||
next.delete(name)
|
|
||||||
updateIgnoredTags(next)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -318,7 +324,68 @@ function PrefixMappingsSection({
|
|||||||
|
|
||||||
// ─── Imported Tag Row ─────────────────────────────────────────────────────────
|
// ─── Imported Tag Row ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ImportedTagRow({
|
function VirtualizedImportedTagRows({
|
||||||
|
tags,
|
||||||
|
libraryId,
|
||||||
|
tagsByCategory,
|
||||||
|
categories,
|
||||||
|
prefixMappings,
|
||||||
|
onMapped,
|
||||||
|
onIgnore,
|
||||||
|
}: {
|
||||||
|
tags: ImportedTag[]
|
||||||
|
libraryId: string
|
||||||
|
tagsByCategory: { category: TagCategory; tags: Tag[] }[]
|
||||||
|
categories: TagCategory[]
|
||||||
|
prefixMappings: Record<string, string>
|
||||||
|
onMapped: () => void
|
||||||
|
onIgnore: (name: string) => void
|
||||||
|
}) {
|
||||||
|
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: tags.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 56,
|
||||||
|
overscan: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={parentRef} className="max-h-[560px] overflow-auto">
|
||||||
|
<div style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((row) => {
|
||||||
|
const importedTag = tags[row.index]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={importedTag.name}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
data-index={row.index}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${row.start}px)`,
|
||||||
|
borderTop: row.index === 0 ? 'none' : '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ImportedTagRow
|
||||||
|
importedTag={importedTag}
|
||||||
|
libraryId={libraryId}
|
||||||
|
tagsByCategory={tagsByCategory}
|
||||||
|
categories={categories}
|
||||||
|
prefixMappings={prefixMappings}
|
||||||
|
onMapped={onMapped}
|
||||||
|
onIgnore={() => onIgnore(importedTag.name)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImportedTagRow = memo(function ImportedTagRow({
|
||||||
importedTag,
|
importedTag,
|
||||||
libraryId,
|
libraryId,
|
||||||
tagsByCategory,
|
tagsByCategory,
|
||||||
@@ -399,6 +466,8 @@ function ImportedTagRow({
|
|||||||
setSaving(false)
|
setSaving(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setSaving(false)
|
||||||
|
setSelectedTagId('')
|
||||||
onMapped()
|
onMapped()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError('Network error')
|
||||||
@@ -439,6 +508,10 @@ function ImportedTagRow({
|
|||||||
setCreatingTag(false)
|
setCreatingTag(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setCreatingTag(false)
|
||||||
|
setCreating(false)
|
||||||
|
setNewTagName(importedTag.name)
|
||||||
|
setNewTagCategoryId('')
|
||||||
onMapped()
|
onMapped()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError('Network error')
|
||||||
@@ -594,7 +667,7 @@ function ImportedTagRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
// ─── Ignored Tags Section ─────────────────────────────────────────────────────
|
// ─── Ignored Tags Section ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -606,6 +679,13 @@ function IgnoredTagsSection({
|
|||||||
onUnignore: (name: string) => void
|
onUnignore: (name: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: tags.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 44,
|
||||||
|
overscan: 8,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
@@ -626,29 +706,45 @@ function IgnoredTagsSection({
|
|||||||
className="rounded-xl border"
|
className="rounded-xl border"
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
>
|
>
|
||||||
<div className="px-5 py-4">
|
<div ref={parentRef} className="px-5 py-4 max-h-[360px] overflow-auto">
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
<div style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
|
||||||
{tags.map((t) => (
|
{rowVirtualizer.getVirtualItems().map((row) => {
|
||||||
<div key={t.id} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
|
const t = tags[row.index]
|
||||||
<span
|
return (
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
|
<div
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
key={t.name}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
data-index={row.index}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${row.start}px)`,
|
||||||
|
borderTop: row.index === 0 ? 'none' : '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 py-2"
|
||||||
>
|
>
|
||||||
{t.name}
|
<span
|
||||||
<span className="text-xs" style={{ opacity: 0.6 }}>({t.itemCount})</span>
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
|
||||||
</span>
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
<div className="flex-1" />
|
>
|
||||||
<button
|
{t.name}
|
||||||
onClick={() => onUnignore(t.name)}
|
<span className="text-xs" style={{ opacity: 0.6 }}>({t.itemCount})</span>
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
</span>
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
<div className="flex-1" />
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
<button
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
onClick={() => onUnignore(t.name)}
|
||||||
>
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
Unignore
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
</button>
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
</div>
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
))}
|
>
|
||||||
|
Unignore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,11 +85,13 @@ function CategoryBlock({
|
|||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [mergeConflict, setMergeConflict] = useState<{ name: string } | null>(null)
|
||||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const handleRename = async (e: React.FormEvent) => {
|
const handleRename = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setMergeConflict(null)
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
|
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
|
||||||
@@ -98,8 +100,35 @@ function CategoryBlock({
|
|||||||
body: JSON.stringify({ name: editName }),
|
body: JSON.stringify({ name: editName }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 409 && data.conflict) {
|
||||||
|
setMergeConflict({ name: editName.trim() })
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(data.error); setSaving(false); return
|
||||||
|
}
|
||||||
|
setEditing(false)
|
||||||
|
onChanged()
|
||||||
|
} catch {
|
||||||
|
setError('Network error.')
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMerge = async () => {
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: editName, merge: true }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
if (!res.ok) { setError(data.error); setSaving(false); return }
|
if (!res.ok) { setError(data.error); setSaving(false); return }
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
|
setMergeConflict(null)
|
||||||
onChanged()
|
onChanged()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error.')
|
setError('Network error.')
|
||||||
@@ -158,7 +187,7 @@ function CategoryBlock({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditing(false); setEditName(category.name); setError(null) }}
|
onClick={() => { setEditing(false); setEditName(category.name); setError(null); setMergeConflict(null) }}
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
@@ -230,6 +259,32 @@ function CategoryBlock({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mergeConflict && (
|
||||||
|
<div className="mb-3 px-3 py-2 rounded-lg text-xs" style={{ backgroundColor: '#78350f33', color: '#fbbf24' }}>
|
||||||
|
<p className="mb-2">
|
||||||
|
A category named “{mergeConflict.name}” already exists. This will merge all tags from
|
||||||
|
“{category.name}” into it. Tags with the same name will be combined.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleMerge}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: '#b45309', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{saving ? 'Merging…' : 'Merge'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMergeConflict(null)}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tags list */}
|
{/* Tags list */}
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
@@ -488,27 +543,59 @@ function ImportedTagMappingsSection() {
|
|||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
const [tagCounts, setTagCounts] = useState<Record<string, number>>({})
|
const [tagCounts, setTagCounts] = useState<Record<string, number>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/libraries')
|
let cancelled = false
|
||||||
.then((r) => r.json())
|
|
||||||
.then(async (libs: Library[]) => {
|
|
||||||
const comicLibs = libs.filter((l) => l.type === 'comics')
|
|
||||||
setLibraries(comicLibs)
|
|
||||||
|
|
||||||
const counts: Record<string, number> = {}
|
const load = async () => {
|
||||||
await Promise.all(
|
try {
|
||||||
|
setError(null)
|
||||||
|
const libsRes = await fetch('/api/libraries')
|
||||||
|
const libsJson = await libsRes.json()
|
||||||
|
if (!Array.isArray(libsJson)) {
|
||||||
|
throw new Error('Failed to load libraries')
|
||||||
|
}
|
||||||
|
|
||||||
|
const comicLibs = libsJson.filter((l): l is Library => l?.type === 'comics')
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
setLibraries(comicLibs)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (comicLibs.length === 0) return
|
||||||
|
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
comicLibs.map(async (lib) => {
|
comicLibs.map(async (lib) => {
|
||||||
const tags: ImportedTag[] = await fetch(
|
const res = await fetch(`/api/imported-tags?libraryId=${encodeURIComponent(lib.id)}`)
|
||||||
`/api/imported-tags?libraryId=${encodeURIComponent(lib.id)}`
|
if (!res.ok) return { libraryId: lib.id, count: 0 }
|
||||||
).then((r) => r.json())
|
const json = await res.json()
|
||||||
counts[lib.id] = tags.length
|
const count = Array.isArray(json) ? json.length : 0
|
||||||
|
return { libraryId: lib.id, count }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (const result of settled) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
counts[result.value.libraryId] = result.value.count
|
||||||
|
}
|
||||||
|
}
|
||||||
setTagCounts(counts)
|
setTagCounts(counts)
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return
|
||||||
|
setError('Could not load imported tag mappings right now.')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
}
|
||||||
.catch(() => setLoading(false))
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -531,6 +618,11 @@ function ImportedTagMappingsSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Imported Tag Mappings">
|
<Section title="Imported Tag Mappings">
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs mb-3 px-3 py-1.5 rounded-lg" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
<div key={lib.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
<div key={lib.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
||||||
|
|||||||
@@ -41,14 +41,29 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
|
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
|
||||||
const [autoPlaySeconds, setAutoPlaySeconds] = useState(5)
|
const [autoPlaySeconds, setAutoPlaySeconds] = useState(5)
|
||||||
|
|
||||||
|
// Tools overlay visibility
|
||||||
|
const [showToolsOverlay, setShowToolsOverlay] = useState(false)
|
||||||
|
|
||||||
|
// Rating state
|
||||||
|
const [userRating, setUserRatingState] = useState<number | null>(null)
|
||||||
|
const [ratingHover, setRatingHover] = useState<number | null>(null)
|
||||||
|
const [savingRating, setSavingRating] = useState(false)
|
||||||
|
|
||||||
// Text overlay state
|
// Text overlay state
|
||||||
const [extractedText, setExtractedText] = useState<string | null>(null)
|
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||||||
|
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
||||||
|
const [savingText, setSavingText] = useState(false)
|
||||||
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||||||
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||||||
const [showOriginal, setShowOriginal] = useState(false)
|
const [showOriginal, setShowOriginal] = useState(false)
|
||||||
const [extracting, setExtracting] = useState(false)
|
const [extracting, setExtracting] = useState(false)
|
||||||
const [extractError, setExtractError] = useState<string | null>(null)
|
const [extractError, setExtractError] = useState<string | null>(null)
|
||||||
const [extractPending, setExtractPending] = useState(false)
|
const [extractPending, setExtractPending] = useState(false)
|
||||||
|
const [retranslating, setRetranslating] = useState(false)
|
||||||
|
const [translatePending, setTranslatePending] = useState(false)
|
||||||
|
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
||||||
|
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
||||||
|
const [sourceLanguage, setSourceLanguage] = useState('')
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
@@ -128,27 +143,50 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
return () => clearTimeout(id)
|
return () => clearTimeout(id)
|
||||||
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext])
|
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext])
|
||||||
|
|
||||||
// Fetch extracted text for current item; clear any in-flight poll on item change
|
// Fetch OCR settings once on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/ai-settings/ocr')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d: { ocrMode: string; ocrLanguages: string }) => {
|
||||||
|
setDefaultOcrLanguages(d.ocrLanguages)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Fetch extracted text + rating for current item; clear any in-flight poll on item change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (extractPollRef.current) {
|
if (extractPollRef.current) {
|
||||||
clearInterval(extractPollRef.current)
|
clearInterval(extractPollRef.current)
|
||||||
extractPollRef.current = null
|
extractPollRef.current = null
|
||||||
}
|
}
|
||||||
setExtractedText(null)
|
setExtractedText(null)
|
||||||
|
setEditedExtractedText('')
|
||||||
setTranslatedText(null)
|
setTranslatedText(null)
|
||||||
setShowTextOverlay(false)
|
setShowTextOverlay(false)
|
||||||
setShowOriginal(false)
|
setShowOriginal(false)
|
||||||
setExtracting(false)
|
setExtracting(false)
|
||||||
setExtractError(null)
|
setExtractError(null)
|
||||||
setExtractPending(false)
|
setExtractPending(false)
|
||||||
|
setRetranslating(false)
|
||||||
|
setTranslatePending(false)
|
||||||
|
setUserRatingState(null)
|
||||||
|
setRatingHover(null)
|
||||||
if (!current?.itemKey) return
|
if (!current?.itemKey) return
|
||||||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
|
const key = current.itemKey
|
||||||
|
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(key)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
||||||
setExtractedText(data.extractedText)
|
setExtractedText(data.extractedText)
|
||||||
|
setEditedExtractedText(data.extractedText ?? '')
|
||||||
setTranslatedText(data.extractedTextTranslated)
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
fetch(`/api/ratings?itemKey=${encodeURIComponent(key)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { userRating: number | null }) => {
|
||||||
|
setUserRatingState(data.userRating)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}, [current?.itemKey])
|
}, [current?.itemKey])
|
||||||
|
|
||||||
// Clean up poll on unmount
|
// Clean up poll on unmount
|
||||||
@@ -196,7 +234,58 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
}
|
}
|
||||||
}, [navigate, onClose, extractedText])
|
}, [navigate, onClose, extractedText])
|
||||||
|
|
||||||
const handleExtractText = async () => {
|
// ── Polling helper ──────────────────────────────────────────────────────────
|
||||||
|
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null) => {
|
||||||
|
if (!current?.itemKey) return
|
||||||
|
const itemKey = current.itemKey
|
||||||
|
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||||
|
const deadline = Date.now() + 5 * 60 * 1000
|
||||||
|
extractPollRef.current = setInterval(async () => {
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
clearInterval(extractPollRef.current!)
|
||||||
|
extractPollRef.current = null
|
||||||
|
setExtractPending(false)
|
||||||
|
setTranslatePending(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||||
|
const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json()
|
||||||
|
const textChanged = data.extractedText !== snapshotText
|
||||||
|
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
|
||||||
|
if (textChanged || translationChanged) {
|
||||||
|
clearInterval(extractPollRef.current!)
|
||||||
|
extractPollRef.current = null
|
||||||
|
setExtractedText(data.extractedText)
|
||||||
|
setEditedExtractedText(data.extractedText ?? '')
|
||||||
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
|
setExtractPending(false)
|
||||||
|
setTranslatePending(false)
|
||||||
|
if (data.extractedText) setShowTextOverlay(true)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 2000)
|
||||||
|
}, [current?.itemKey])
|
||||||
|
|
||||||
|
// ── Rating actions ───────────────────────────────────────────────────────────
|
||||||
|
const handleSetRating = useCallback(async (star: number) => {
|
||||||
|
if (!current?.itemKey) return
|
||||||
|
const next = userRating === star ? null : star
|
||||||
|
setSavingRating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ratings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey: current.itemKey, userRating: next }),
|
||||||
|
})
|
||||||
|
if (res.ok) setUserRatingState(next)
|
||||||
|
} finally {
|
||||||
|
setSavingRating(false)
|
||||||
|
}
|
||||||
|
}, [current?.itemKey, userRating])
|
||||||
|
|
||||||
|
// ── Text extraction ──────────────────────────────────────────────────────────
|
||||||
|
const callExtract = useCallback(async (modeOverride: string) => {
|
||||||
if (!current?.itemKey) return
|
if (!current?.itemKey) return
|
||||||
const itemKey = current.itemKey
|
const itemKey = current.itemKey
|
||||||
setExtracting(true)
|
setExtracting(true)
|
||||||
@@ -206,30 +295,15 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ itemKey }),
|
body: JSON.stringify({
|
||||||
|
itemKey,
|
||||||
|
ocrMode: modeOverride,
|
||||||
|
...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
if (res.status === 202) {
|
if (res.status === 202) {
|
||||||
// Job queued — poll until it completes (up to 5 min)
|
|
||||||
setExtractPending(true)
|
setExtractPending(true)
|
||||||
const deadline = Date.now() + 5 * 60 * 1000
|
startPolling(extractedText, translatedText)
|
||||||
extractPollRef.current = setInterval(async () => {
|
|
||||||
if (Date.now() > deadline) {
|
|
||||||
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
|
||||||
setExtractPending(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
|
||||||
const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json()
|
|
||||||
if (data.extractedText) {
|
|
||||||
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
|
||||||
setExtractPending(false)
|
|
||||||
setExtractedText(data.extractedText)
|
|
||||||
setTranslatedText(data.extractedTextTranslated)
|
|
||||||
setShowTextOverlay(true)
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, 2000)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -237,16 +311,67 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
||||||
}
|
}
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
setExtractedText(result.extractedText || null)
|
const newText: string | null = result.extractedText || null
|
||||||
setTranslatedText(result.translatedText || null)
|
const newTranslated: string | null = result.translatedText || null
|
||||||
if (result.extractedText) setShowTextOverlay(true)
|
setExtractedText(newText)
|
||||||
|
setEditedExtractedText(newText ?? '')
|
||||||
|
setTranslatedText(newTranslated)
|
||||||
|
if (newText) setShowTextOverlay(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setExtractError(err instanceof Error ? err.message : 'Extraction failed')
|
setExtractError(err instanceof Error ? err.message : 'Extraction failed')
|
||||||
setTimeout(() => setExtractError(null), 4000)
|
setTimeout(() => setExtractError(null), 4000)
|
||||||
} finally {
|
} finally {
|
||||||
setExtracting(false)
|
setExtracting(false)
|
||||||
}
|
}
|
||||||
}
|
}, [current?.itemKey, ocrLanguageInput, extractedText, translatedText, startPolling])
|
||||||
|
|
||||||
|
// ── Save edited extracted text ───────────────────────────────────────────────
|
||||||
|
const handleSaveExtractedText = useCallback(async () => {
|
||||||
|
if (!current?.itemKey) return
|
||||||
|
setSavingText(true)
|
||||||
|
try {
|
||||||
|
await fetch('/api/ai-tagging/fields', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey: current.itemKey, extractedText: editedExtractedText }),
|
||||||
|
})
|
||||||
|
setExtractedText(editedExtractedText)
|
||||||
|
} finally {
|
||||||
|
setSavingText(false)
|
||||||
|
}
|
||||||
|
}, [current?.itemKey, editedExtractedText])
|
||||||
|
|
||||||
|
// ── Translation ──────────────────────────────────────────────────────────────
|
||||||
|
const handleTranslate = useCallback(async () => {
|
||||||
|
if (!current?.itemKey) return
|
||||||
|
setRetranslating(true)
|
||||||
|
setTranslatePending(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-tagging/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
itemKey: current.itemKey,
|
||||||
|
...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.status === 202) {
|
||||||
|
setTranslatePending(true)
|
||||||
|
startPolling(extractedText, translatedText)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
||||||
|
}
|
||||||
|
const result = await res.json()
|
||||||
|
setTranslatedText(result.translatedText || null)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setRetranslating(false)
|
||||||
|
}
|
||||||
|
}, [current?.itemKey, sourceLanguage, extractedText, translatedText, startPolling])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
|
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
|
||||||
@@ -333,6 +458,193 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tools overlay — anchored lower-left, above the bottom bar */}
|
||||||
|
{showToolsOverlay && current?.itemKey && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-16 left-4 z-20 rounded-xl p-4 flex flex-col gap-3 overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(10,10,10,0.92)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
|
width: 'min(320px, calc(100vw - 2rem))',
|
||||||
|
maxHeight: 'calc(100vh - 8rem)',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* ── Rating ──────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'rgba(255,255,255,0.45)' }}>
|
||||||
|
Rating
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1" onMouseLeave={() => setRatingHover(null)}>
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => {
|
||||||
|
const filled = (ratingHover ?? userRating ?? 0) >= star
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
onClick={() => handleSetRating(star)}
|
||||||
|
onMouseEnter={() => setRatingHover(star)}
|
||||||
|
disabled={savingRating}
|
||||||
|
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
|
||||||
|
style={{
|
||||||
|
fontSize: '1.4rem',
|
||||||
|
color: filled ? '#f59e0b' : 'rgba(255,255,255,0.2)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: '0 2px',
|
||||||
|
cursor: savingRating ? 'wait' : 'pointer',
|
||||||
|
transition: 'color 0.1s',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Text Extraction (images only) ───────────────────── */}
|
||||||
|
{current.mediaType === 'image' && (
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '0.75rem' }}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'rgba(255,255,255,0.45)' }}>
|
||||||
|
Text Extraction
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => callExtract('llm')}
|
||||||
|
disabled={extracting || extractPending}
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center transition-opacity disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
backgroundColor: extractPending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
|
||||||
|
color: extractPending ? '#fff' : 'rgba(255,255,255,0.7)',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
}}
|
||||||
|
aria-label="Extract with AI"
|
||||||
|
title="Extract with AI (skips OCR)"
|
||||||
|
>
|
||||||
|
{extracting || extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => callExtract('tesseract')}
|
||||||
|
disabled={extracting || extractPending}
|
||||||
|
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-40 flex-shrink-0"
|
||||||
|
style={{ backgroundColor: 'rgba(255,255,255,0.1)', color: 'rgba(255,255,255,0.7)' }}
|
||||||
|
>
|
||||||
|
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ocrLanguageInput}
|
||||||
|
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
||||||
|
placeholder={defaultOcrLanguages}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
width: 120,
|
||||||
|
}}
|
||||||
|
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{extractError && (
|
||||||
|
<p className="text-xs mt-1" style={{ color: '#f87171' }}>{extractError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Extracted text editor */}
|
||||||
|
{extractedText !== null && (
|
||||||
|
<div className="flex flex-col gap-1 mt-2">
|
||||||
|
<p className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.45)' }}>Extracted Text</p>
|
||||||
|
<textarea
|
||||||
|
value={editedExtractedText}
|
||||||
|
onChange={(e) => setEditedExtractedText(e.target.value)}
|
||||||
|
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
minHeight: '3.5rem',
|
||||||
|
maxHeight: '8rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{editedExtractedText !== extractedText && (
|
||||||
|
<button
|
||||||
|
onClick={handleSaveExtractedText}
|
||||||
|
disabled={savingText}
|
||||||
|
className="self-start text-xs px-2 py-0.5 rounded-full transition-opacity disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{savingText ? '⟳ Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Translation display */}
|
||||||
|
{translatedText && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<p className="text-xs font-medium mb-1" style={{ color: 'rgba(255,255,255,0.45)' }}>Translation</p>
|
||||||
|
<pre
|
||||||
|
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-32 overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{translatedText}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Original / translation toggle */}
|
||||||
|
{extractedText && translatedText && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOriginal((v) => !v)}
|
||||||
|
className="self-start text-xs px-2 py-0.5 rounded-full mt-1"
|
||||||
|
style={{ backgroundColor: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.7)' }}
|
||||||
|
>
|
||||||
|
{showOriginal ? 'Show Translation in popover' : 'Show Original in popover'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Translate / re-translate */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sourceLanguage}
|
||||||
|
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||||
|
placeholder="Source lang…"
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
width: 100,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleTranslate}
|
||||||
|
disabled={retranslating || translatePending}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
backgroundColor: translatePending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
|
||||||
|
color: translatePending ? '#fff' : 'rgba(255,255,255,0.7)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Text overlay */}
|
{/* Text overlay */}
|
||||||
{showTextOverlay && displayText && (
|
{showTextOverlay && displayText && (
|
||||||
<div
|
<div
|
||||||
@@ -357,9 +669,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom bar: mute | filename | action buttons */}
|
{/* Bottom bar: [mute + tools] | filename | action buttons */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
|
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
|
||||||
<div className="w-9 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{isVideo && (
|
{isVideo && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLocalMuted((v) => !v)}
|
onClick={() => setLocalMuted((v) => !v)}
|
||||||
@@ -382,6 +694,27 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{current?.itemKey && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowToolsOverlay((v) => !v)}
|
||||||
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||||||
|
style={{
|
||||||
|
backgroundColor: showToolsOverlay ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.5)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
aria-label={showToolsOverlay ? 'Close tools' : 'Open tools'}
|
||||||
|
title="Rating & text tools"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||||
|
<line x1="12" y1="2" x2="12" y2="5"/>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="22"/>
|
||||||
|
<line x1="2" y1="12" x2="5" y2="12"/>
|
||||||
|
<line x1="19" y1="12" x2="22" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
||||||
{current?.name}
|
{current?.name}
|
||||||
@@ -405,7 +738,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
</button>
|
</button>
|
||||||
) : current?.itemKey && current?.mediaType === 'image' ? (
|
) : current?.itemKey && current?.mediaType === 'image' ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleExtractText}
|
onClick={() => callExtract('tesseract')}
|
||||||
disabled={extracting || extractPending}
|
disabled={extracting || extractPending}
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Tag, TagCategory } from '@/types'
|
import type { Tag, TagCategory, RatingOperator } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
@@ -11,9 +11,24 @@ interface Props {
|
|||||||
selectedTagIds: Set<string>
|
selectedTagIds: Set<string>
|
||||||
onTagToggle: (tagId: string) => void
|
onTagToggle: (tagId: string) => void
|
||||||
refreshKey?: number
|
refreshKey?: number
|
||||||
|
ratingValue: number | null
|
||||||
|
ratingOperator: RatingOperator
|
||||||
|
onRatingChange: (value: number | null, operator: RatingOperator) => void
|
||||||
|
showRatingFilter?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FilterPanel({ assignments, search, onSearchChange, selectedTagIds, onTagToggle, refreshKey }: Props) {
|
export default function FilterPanel({
|
||||||
|
assignments,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
selectedTagIds,
|
||||||
|
onTagToggle,
|
||||||
|
refreshKey,
|
||||||
|
ratingValue,
|
||||||
|
ratingOperator,
|
||||||
|
onRatingChange,
|
||||||
|
showRatingFilter = true,
|
||||||
|
}: Props) {
|
||||||
const [categories, setCategories] = useState<TagCategory[]>([])
|
const [categories, setCategories] = useState<TagCategory[]>([])
|
||||||
const [tags, setTags] = useState<Tag[]>([])
|
const [tags, setTags] = useState<Tag[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -53,6 +68,59 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Rating filter */}
|
||||||
|
{showRatingFilter && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>Rating</p>
|
||||||
|
{/* Operator toggle */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['gte', 'eq', 'lte'] as RatingOperator[]).map((op) => {
|
||||||
|
const label = op === 'gte' ? '≥' : op === 'eq' ? '=' : '≤'
|
||||||
|
const active = ratingValue !== null && ratingOperator === op
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={op}
|
||||||
|
onClick={() => onRatingChange(active ? null : (ratingValue ?? 3), op)}
|
||||||
|
className="flex-1 py-0.5 rounded text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: active ? 'var(--accent)' : 'var(--border)',
|
||||||
|
color: active ? '#fff' : 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Star picker */}
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => {
|
||||||
|
const lit =
|
||||||
|
ratingValue !== null &&
|
||||||
|
((ratingOperator === 'gte' && star <= ratingValue) ||
|
||||||
|
(ratingOperator === 'eq' && star === ratingValue) ||
|
||||||
|
(ratingOperator === 'lte' && star >= ratingValue))
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
onClick={() => onRatingChange(ratingValue === star ? null : star, ratingOperator)}
|
||||||
|
className="flex-1 text-base py-0.5 rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
color: lit ? '#f59e0b' : 'var(--border)',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
aria-label={`${star} star${star !== 1 ? 's' : ''}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tag filters */}
|
{/* Tag filters */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -62,7 +130,7 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
|
|||||||
className="h-3 w-16 rounded animate-pulse"
|
className="h-3 w-16 rounded animate-pulse"
|
||||||
style={{ backgroundColor: 'var(--border)' }}
|
style={{ backgroundColor: 'var(--border)' }}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
||||||
{[50, 65, 42].map((w) => (
|
{[50, 65, 42].map((w) => (
|
||||||
<div
|
<div
|
||||||
key={w}
|
key={w}
|
||||||
@@ -84,7 +152,7 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
|
|||||||
<p className="text-xs mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
||||||
{catTags.map((tag) => {
|
{catTags.map((tag) => {
|
||||||
const active = selectedTagIds.has(tag.id)
|
const active = selectedTagIds.has(tag.id)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ interface Props {
|
|||||||
libraryId: string
|
libraryId: string
|
||||||
issue: ComicIssue
|
issue: ComicIssue
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onPrev?: () => void
|
||||||
|
onNext?: () => void
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
|
onDeleted?: () => void
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,20 +25,52 @@ function pageUrl(libraryId: string, issueKey: string, pageIndex: number): string
|
|||||||
return `/api/comics/page?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}&pageIndex=${pageIndex}`
|
return `/api/comics/page?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}&pageIndex=${pageIndex}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ComicIssueView({ libraryId, issue, onClose, onTagsChanged, readOnly }: Props) {
|
export default function ComicIssueView({ libraryId, issue, onClose, onPrev, onNext, onTagsChanged, onDeleted, readOnly }: Props) {
|
||||||
const [lightboxPage, setLightboxPage] = useState<number | null>(null)
|
const [lightboxPage, setLightboxPage] = useState<number | null>(null)
|
||||||
const [showTagPanel, setShowTagPanel] = useState(false)
|
const [showTagPanel, setShowTagPanel] = useState(false)
|
||||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
|
const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
|
||||||
|
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape' && lightboxPage === null && !showTagPanel) onClose()
|
if (lightboxPage !== null) return
|
||||||
|
if (e.key === 'ArrowLeft') { onPrev?.(); return }
|
||||||
|
if (e.key === 'ArrowRight') { onNext?.(); return }
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (menuOpen) { setMenuOpen(false); return }
|
||||||
|
if (confirming) { setConfirming(false); return }
|
||||||
|
if (showTagPanel) { setShowTagPanel(false); return }
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [onClose, lightboxPage, showTagPanel])
|
}, [onClose, onPrev, onNext, lightboxPage, showTagPanel, menuOpen, confirming])
|
||||||
|
|
||||||
|
// Close menu on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [menuOpen])
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}`, { method: 'DELETE' })
|
||||||
|
onDeleted?.()
|
||||||
|
} catch {
|
||||||
|
setDeleting(false)
|
||||||
|
setConfirming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pageCount = issue.pageCount
|
const pageCount = issue.pageCount
|
||||||
const downloadUrl = fileApiUrl(libraryId, issue.filePath)
|
const downloadUrl = fileApiUrl(libraryId, issue.filePath)
|
||||||
@@ -49,10 +84,31 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
|
|||||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
|
{/* Floating prev/next arrows */}
|
||||||
|
{onPrev && !showTagPanel && (
|
||||||
|
<button
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||||
|
aria-label="Previous issue"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onNext && !showTagPanel && (
|
||||||
|
<button
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full flex items-center justify-center transition-colors"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||||
|
aria-label="Next issue"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
|
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
|
||||||
<div
|
<div
|
||||||
className={`${showTagPanel ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-4xl'}`}
|
className={`${showTagPanel ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-4xl'}`}
|
||||||
onClick={showTagPanel ? undefined : undefined}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-4xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
className="w-full max-w-4xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
||||||
@@ -88,19 +144,43 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
|
|||||||
🏷
|
🏷
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<a
|
{/* Kebab menu */}
|
||||||
href={downloadUrl}
|
<div className="relative" ref={menuRef}>
|
||||||
download
|
<button
|
||||||
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
onClick={(e) => { e.stopPropagation(); setMenuOpen((v) => !v) }}
|
||||||
style={{
|
className="w-8 h-8 rounded-full flex items-center justify-center text-base font-bold transition-colors"
|
||||||
backgroundColor: 'var(--surface)',
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
color: 'var(--text-secondary)',
|
aria-label="More options"
|
||||||
border: '1px solid var(--border)',
|
title="More options"
|
||||||
}}
|
>
|
||||||
onClick={(e) => e.stopPropagation()}
|
⋮
|
||||||
>
|
</button>
|
||||||
Download
|
{menuOpen && (
|
||||||
</a>
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-10 min-w-[120px]"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={downloadUrl}
|
||||||
|
download
|
||||||
|
className="flex items-center px-3 py-2 text-xs transition-colors hover:bg-black/10"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setMenuOpen(false) }}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
className="w-full text-left flex items-center px-3 py-2 text-xs transition-colors hover:bg-black/10"
|
||||||
|
style={{ color: '#fca5a5' }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirming(true) }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
@@ -112,6 +192,33 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
{confirming && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 mx-5 mt-3 px-3 py-2.5 rounded-lg text-sm flex-shrink-0"
|
||||||
|
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
|
||||||
|
>
|
||||||
|
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
|
||||||
|
Permanently delete this issue and its file?
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="px-2 py-1 rounded text-xs transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-2 py-1 rounded text-xs font-medium transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Yes, delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cover + tags */}
|
{/* Cover + tags */}
|
||||||
<div
|
<div
|
||||||
className="flex gap-5 px-5 py-4 flex-shrink-0"
|
className="flex gap-5 px-5 py-4 flex-shrink-0"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
|
||||||
import type { ComicIssue, ComicSeries } from '@/types'
|
import type { ComicIssue, ComicSeries, RatingOperator } from '@/types'
|
||||||
import ComicSeriesView from './ComicSeriesView'
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
import ComicIssueView from './ComicIssueView'
|
import ComicIssueView from './ComicIssueView'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
@@ -12,16 +12,27 @@ interface Props {
|
|||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 200
|
||||||
|
|
||||||
export default function ComicsView({ libraryId, readOnly }: Props) {
|
export default function ComicsView({ libraryId, readOnly }: Props) {
|
||||||
const [items, setItems] = useState<(ComicIssue | ComicSeries)[]>([])
|
const [items, setItems] = useState<(ComicIssue | ComicSeries)[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
const [selectedSeries, setSelectedSeries] = useState<ComicSeries | null>(null)
|
const [selectedSeries, setSelectedSeries] = useState<ComicSeries | null>(null)
|
||||||
|
const [seriesIssues, setSeriesIssues] = useState<ComicIssue[]>([])
|
||||||
|
const [seriesIssuesLoading, setSeriesIssuesLoading] = useState(false)
|
||||||
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
|
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
|
||||||
|
const [selectedIssueIndex, setSelectedIssueIndex] = useState<number | null>(null)
|
||||||
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
|
const [ratingValue, setRatingValue] = useState<number | null>(null)
|
||||||
|
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
|
||||||
|
const debouncedSearch = useDebounce(search, 200)
|
||||||
const [seriesIssueMeta, setSeriesIssueMeta] = useState<
|
const [seriesIssueMeta, setSeriesIssueMeta] = useState<
|
||||||
Record<string, { tagIds: string[]; issueTitles: string[] }>
|
Record<string, { tagIds: string[]; issueTitles: string[] }>
|
||||||
>({})
|
>({})
|
||||||
@@ -29,6 +40,8 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
)
|
)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const toggleTag = (tagId: string) =>
|
const toggleTag = (tagId: string) =>
|
||||||
setSelectedTagIds((prev) => {
|
setSelectedTagIds((prev) => {
|
||||||
@@ -37,12 +50,22 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
const fetchItems = useCallback(() => {
|
const fetchItems = useCallback((pageNum: number, searchVal: string, replace: boolean) => {
|
||||||
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}`)
|
const params = new URLSearchParams({
|
||||||
|
libraryId,
|
||||||
|
page: String(pageNum),
|
||||||
|
pageSize: String(PAGE_SIZE),
|
||||||
|
})
|
||||||
|
if (searchVal) params.set('search', searchVal)
|
||||||
|
if (pageNum === 1) setLoading(true)
|
||||||
|
else setLoadingMore(true)
|
||||||
|
fetch(`/api/comics?${params}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: (ComicIssue | ComicSeries)[]) => {
|
.then((data: { items: (ComicIssue | ComicSeries)[]; total: number }) => {
|
||||||
setItems(data)
|
setItems((prev) => (replace ? data.items : [...prev, ...data.items]))
|
||||||
setLoading(false)
|
setTotal(data.total)
|
||||||
|
if (pageNum === 1) setLoading(false)
|
||||||
|
else setLoadingMore(false)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setError('Failed to load comics')
|
setError('Failed to load comics')
|
||||||
@@ -50,7 +73,44 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
})
|
})
|
||||||
}, [libraryId])
|
}, [libraryId])
|
||||||
|
|
||||||
useEffect(() => { fetchItems() }, [fetchItems])
|
useEffect(() => { fetchItems(1, '', true) }, [fetchItems])
|
||||||
|
|
||||||
|
// Fetch issues when a series is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSeries) { setSeriesIssues([]); return }
|
||||||
|
setSeriesIssuesLoading(true)
|
||||||
|
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: ComicIssue[]) => { setSeriesIssues(data); setSeriesIssuesLoading(false) })
|
||||||
|
.catch(() => setSeriesIssuesLoading(false))
|
||||||
|
}, [selectedSeries, libraryId])
|
||||||
|
|
||||||
|
// IntersectionObserver: load next page when sentinel scrolls into view
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current
|
||||||
|
if (!sentinel || items.length >= total || total === 0) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting && !loadingMore) {
|
||||||
|
const next = page + 1
|
||||||
|
setPage(next)
|
||||||
|
fetchItems(next, search, false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '400px' }
|
||||||
|
)
|
||||||
|
observer.observe(sentinel)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [items.length, total, loadingMore, page, search, fetchItems])
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((val: string) => {
|
||||||
|
setSearch(val)
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setPage(1)
|
||||||
|
fetchItems(1, val, true)
|
||||||
|
}, 300)
|
||||||
|
}, [fetchItems])
|
||||||
|
|
||||||
const fetchAssignments = useCallback(() => {
|
const fetchAssignments = useCallback(() => {
|
||||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||||
@@ -76,38 +136,76 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
fetchSeriesIssueMeta()
|
fetchSeriesIssueMeta()
|
||||||
}, [fetchAssignments, fetchSeriesIssueMeta])
|
}, [fetchAssignments, fetchSeriesIssueMeta])
|
||||||
|
|
||||||
const filtered = items.filter((item) => {
|
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
||||||
|
if (value === ratingValue && operator === ratingOperator) {
|
||||||
|
setRatingValue(null)
|
||||||
|
} else {
|
||||||
|
setRatingValue(value)
|
||||||
|
setRatingOperator(operator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = useMemo(() => items.filter((item) => {
|
||||||
const isSeries = 'issueCount' in item
|
const isSeries = 'issueCount' in item
|
||||||
|
const series = isSeries ? (item as ComicSeries) : null
|
||||||
|
const issue = isSeries ? null : (item as ComicIssue)
|
||||||
|
|
||||||
if (isSeries) {
|
if (series) {
|
||||||
const meta = seriesIssueMeta[item.item_key ?? ''] ?? { tagIds: [], issueTitles: [] }
|
const meta = seriesIssueMeta[series.item_key ?? ''] ?? { tagIds: [], issueTitles: [] }
|
||||||
|
|
||||||
if (search) {
|
if (debouncedSearch) {
|
||||||
const q = search.toLowerCase()
|
const q = debouncedSearch.toLowerCase()
|
||||||
const titleMatch = item.title.toLowerCase().includes(q)
|
const titleMatch = series.title.toLowerCase().includes(q)
|
||||||
const issueMatch = meta.issueTitles.some((t) => t.toLowerCase().includes(q))
|
const issueMatch = meta.issueTitles.some((t) => t.toLowerCase().includes(q))
|
||||||
if (!titleMatch && !issueMatch) return false
|
const aiMatch = series.aiDescription?.toLowerCase().includes(q) ?? false
|
||||||
|
const textMatch = series.extractedText?.toLowerCase().includes(q) ?? false
|
||||||
|
const translatedMatch = series.extractedTextTranslated?.toLowerCase().includes(q) ?? false
|
||||||
|
if (!titleMatch && !issueMatch && !aiMatch && !textMatch && !translatedMatch) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
const seriesTags = assignments[item.item_key ?? ''] ?? []
|
const seriesTags = assignments[series.item_key ?? ''] ?? []
|
||||||
const allTags = [...new Set([...seriesTags, ...meta.tagIds])]
|
const allTags = [...new Set([...seriesTags, ...meta.tagIds])]
|
||||||
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ratingValue !== null) {
|
||||||
|
const r = series.userRating
|
||||||
|
if (r === null) return false
|
||||||
|
if (ratingOperator === 'gte' && r < ratingValue) return false
|
||||||
|
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
||||||
|
if (ratingOperator === 'lte' && r > ratingValue) return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standalone issue
|
// Standalone issue
|
||||||
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
|
if (debouncedSearch) {
|
||||||
|
const q = debouncedSearch.toLowerCase()
|
||||||
|
if (![issue!.title, issue!.aiDescription, issue!.extractedText, issue!.extractedTextTranslated]
|
||||||
|
.some((f) => f?.toLowerCase().includes(q))) return false
|
||||||
|
}
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
const tags = assignments[item.item_key ?? ''] ?? []
|
const tags = assignments[issue!.item_key ?? ''] ?? []
|
||||||
if (![...selectedTagIds].every((id) => tags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => tags.includes(id))) return false
|
||||||
}
|
}
|
||||||
|
if (ratingValue !== null) {
|
||||||
|
const r = issue!.userRating
|
||||||
|
if (r === null) return false
|
||||||
|
if (ratingOperator === 'gte' && r < ratingValue) return false
|
||||||
|
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
||||||
|
if (ratingOperator === 'lte' && r > ratingValue) return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
}), [items, debouncedSearch, selectedTagIds, assignments, seriesIssueMeta, ratingValue, ratingOperator])
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
// Flat list of issues at the current navigation level for prev/next
|
||||||
|
const filteredIssues: ComicIssue[] = selectedSeries
|
||||||
|
? seriesIssues
|
||||||
|
: filtered.filter((item): item is ComicIssue => !('issueCount' in item))
|
||||||
|
|
||||||
|
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -133,15 +231,35 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={handleSearchChange}
|
||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Breadcrumb when inside a series */}
|
||||||
|
{selectedSeries && (
|
||||||
|
<div className="flex items-center gap-2 mb-4 text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedSeries(null); setSeriesIssues([]); setSearch('') }}
|
||||||
|
className="transition-colors"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
All Comics
|
||||||
|
</button>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||||||
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{selectedSeries.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoadingGrid />
|
<LoadingGrid />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
@@ -160,31 +278,65 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
<p className="text-sm">Add .cbz files or folders of .cbz files to this library and scan.</p>
|
<p className="text-sm">Add .cbz files or folders of .cbz files to this library and scan.</p>
|
||||||
</div>
|
</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) =>
|
{!selectedSeries && total > PAGE_SIZE && (
|
||||||
'issueCount' in item ? (
|
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<SeriesCard
|
Showing {filtered.length.toLocaleString()} of {total.toLocaleString()}
|
||||||
key={item.id}
|
</p>
|
||||||
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>
|
{seriesIssuesLoading ? (
|
||||||
|
<LoadingGrid />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{selectedSeries
|
||||||
|
? seriesIssues.map((issue) => (
|
||||||
|
<IssueCard
|
||||||
|
key={issue.id}
|
||||||
|
issue={issue}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onClick={() => { setSelectedIssue(issue); setSelectedIssueIndex(seriesIssues.indexOf(issue)) }}
|
||||||
|
onTagClick={issue.item_key && !readOnly
|
||||||
|
? () => setTagPanel({ itemKey: issue.item_key!, title: issue.title })
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: filtered.map((item) =>
|
||||||
|
'issueCount' in item ? (
|
||||||
|
<SeriesCard
|
||||||
|
key={item.id}
|
||||||
|
series={item as ComicSeries}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onClick={() => { setSelectedSeries(item as ComicSeries); setSearch('') }}
|
||||||
|
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={() => {
|
||||||
|
const issue = item as ComicIssue
|
||||||
|
setSelectedIssue(issue)
|
||||||
|
setSelectedIssueIndex(filteredIssues.indexOf(issue))
|
||||||
|
}}
|
||||||
|
onTagClick={(item as ComicIssue).item_key && !readOnly
|
||||||
|
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title })
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!selectedSeries && (
|
||||||
|
<>
|
||||||
|
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden />
|
||||||
|
{loadingMore && <LoadingMore />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,22 +384,30 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedSeries && (
|
|
||||||
<ComicSeriesView
|
|
||||||
libraryId={libraryId}
|
|
||||||
series={selectedSeries}
|
|
||||||
onClose={() => setSelectedSeries(null)}
|
|
||||||
onTagsChanged={onTagsChanged}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedIssue && (
|
{selectedIssue && (
|
||||||
<ComicIssueView
|
<ComicIssueView
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
issue={selectedIssue}
|
issue={selectedIssue}
|
||||||
onClose={() => setSelectedIssue(null)}
|
onClose={() => { setSelectedIssue(null); setSelectedIssueIndex(null) }}
|
||||||
|
onPrev={selectedIssueIndex !== null && selectedIssueIndex > 0
|
||||||
|
? () => { setSelectedIssue(filteredIssues[selectedIssueIndex - 1]); setSelectedIssueIndex(selectedIssueIndex - 1) }
|
||||||
|
: undefined}
|
||||||
|
onNext={selectedIssueIndex !== null && selectedIssueIndex < filteredIssues.length - 1
|
||||||
|
? () => { setSelectedIssue(filteredIssues[selectedIssueIndex + 1]); setSelectedIssueIndex(selectedIssueIndex + 1) }
|
||||||
|
: undefined}
|
||||||
onTagsChanged={onTagsChanged}
|
onTagsChanged={onTagsChanged}
|
||||||
|
onDeleted={() => {
|
||||||
|
setSelectedIssue(null)
|
||||||
|
setSelectedIssueIndex(null)
|
||||||
|
fetchItems(1, search, true)
|
||||||
|
fetchAssignments()
|
||||||
|
if (selectedSeries) {
|
||||||
|
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: ComicIssue[]) => setSeriesIssues(data))
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -373,6 +533,17 @@ function IssueCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingMore() {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border-2 animate-spin"
|
||||||
|
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LoadingGrid() {
|
function LoadingGrid() {
|
||||||
return (
|
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">
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||||
import type { Game, GamePlatform, GameSeries } from '@/types'
|
import type { Game, GamePlatform, GameSeries, RatingOperator } from '@/types'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
import GameDetailModal from './GameDetailModal'
|
import GameDetailModal from './GameDetailModal'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
|
|
||||||
@@ -72,6 +73,9 @@ export default function GamesView({ libraryId, readOnly }: Props) {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
|
const [ratingValue, setRatingValue] = useState<number | null>(null)
|
||||||
|
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
|
||||||
|
const debouncedSearch = useDebounce(search, 200)
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
@@ -128,29 +132,69 @@ export default function GamesView({ libraryId, readOnly }: Props) {
|
|||||||
? selectedSeries.games
|
? selectedSeries.games
|
||||||
: items
|
: items
|
||||||
|
|
||||||
const filtered = visibleItems.filter((item) => {
|
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
||||||
|
if (value === ratingValue && operator === ratingOperator) {
|
||||||
|
setRatingValue(null)
|
||||||
|
} else {
|
||||||
|
setRatingValue(value)
|
||||||
|
setRatingOperator(operator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = useMemo(() => visibleItems.filter((item) => {
|
||||||
if ('games' in item) {
|
if ('games' in item) {
|
||||||
const searchMatch = !search ||
|
if (debouncedSearch) {
|
||||||
item.title.toLowerCase().includes(search.toLowerCase()) ||
|
const q = debouncedSearch.toLowerCase()
|
||||||
item.games.some((g) => g.title.toLowerCase().includes(search.toLowerCase()))
|
const searchMatch =
|
||||||
if (!searchMatch) return false
|
item.title.toLowerCase().includes(q) ||
|
||||||
|
item.games.some((g) =>
|
||||||
|
g.title.toLowerCase().includes(q) ||
|
||||||
|
(g.aiDescription?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(g.extractedText?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(g.extractedTextTranslated?.toLowerCase().includes(q) ?? false)
|
||||||
|
)
|
||||||
|
if (!searchMatch) return false
|
||||||
|
}
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
return item.games.some((g) => {
|
if (!item.games.some((g) => {
|
||||||
const gameTags = assignments[g.item_key!] ?? []
|
const gameTags = assignments[g.item_key!] ?? []
|
||||||
return [...selectedTagIds].every((id) => gameTags.includes(id))
|
return [...selectedTagIds].every((id) => gameTags.includes(id))
|
||||||
})
|
})) return false
|
||||||
|
}
|
||||||
|
if (ratingValue !== null) {
|
||||||
|
if (!item.games.some((g) => {
|
||||||
|
const r = g.userRating
|
||||||
|
if (r === null) return false
|
||||||
|
if (ratingOperator === 'gte') return r >= ratingValue
|
||||||
|
if (ratingOperator === 'eq') return r === ratingValue
|
||||||
|
if (ratingOperator === 'lte') return r <= ratingValue
|
||||||
|
return false
|
||||||
|
})) return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
|
// Standalone Game
|
||||||
|
if (debouncedSearch) {
|
||||||
|
const q = debouncedSearch.toLowerCase()
|
||||||
|
const g = item as Game
|
||||||
|
if (![g.title, g.aiDescription, g.extractedText, g.extractedTextTranslated]
|
||||||
|
.some((f) => f?.toLowerCase().includes(q))) return false
|
||||||
|
}
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
const gameTags = assignments[item.item_key!] ?? []
|
const gameTags = assignments[item.item_key!] ?? []
|
||||||
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
|
if (ratingValue !== null) {
|
||||||
|
const r = (item as Game).userRating
|
||||||
|
if (r === null) return false
|
||||||
|
if (ratingOperator === 'gte' && r < ratingValue) return false
|
||||||
|
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
||||||
|
if (ratingOperator === 'lte' && r > ratingValue) return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
}), [visibleItems, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||||
const filteredGames: Game[] = filtered.flatMap((item) =>
|
const filteredGames: Game[] = filtered.flatMap((item) =>
|
||||||
'games' in item ? item.games : [item as Game]
|
'games' in item ? item.games : [item as Game]
|
||||||
)
|
)
|
||||||
@@ -182,6 +226,9 @@ export default function GamesView({ libraryId, readOnly }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
|
|
||||||
// Polling ref
|
// Polling ref
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const touchStartX = useRef<number | null>(null)
|
||||||
|
|
||||||
// Determine if this is an image file (for text extraction controls)
|
// Determine if this is an image file (for text extraction controls)
|
||||||
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
|
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
|
||||||
@@ -131,10 +132,24 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
if (e.key === 'ArrowLeft') onPrev?.()
|
if (e.key === 'ArrowLeft') onPrev?.()
|
||||||
if (e.key === 'ArrowRight') onNext?.()
|
if (e.key === 'ArrowRight') onNext?.()
|
||||||
}
|
}
|
||||||
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
|
touchStartX.current = e.touches[0].clientX
|
||||||
|
}
|
||||||
|
const handleTouchEnd = (e: TouchEvent) => {
|
||||||
|
if (touchStartX.current === null) return
|
||||||
|
const delta = touchStartX.current - e.changedTouches[0].clientX
|
||||||
|
if (delta > 50) onNext?.()
|
||||||
|
else if (delta < -50) onPrev?.()
|
||||||
|
touchStartX.current = null
|
||||||
|
}
|
||||||
document.addEventListener('keydown', handleKey)
|
document.addEventListener('keydown', handleKey)
|
||||||
|
document.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||||
|
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKey)
|
document.removeEventListener('keydown', handleKey)
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart)
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [onClose, onPrev, onNext])
|
}, [onClose, onPrev, onNext])
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||||
import type { DirectoryListing, FileEntry } from '@/types'
|
import type { DirectoryListing, FileEntry, RatingOperator } from '@/types'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
import VideoPlayerModal from './VideoPlayerModal'
|
import VideoPlayerModal from './VideoPlayerModal'
|
||||||
import ImageLightbox from './ImageLightbox'
|
import ImageLightbox from './ImageLightbox'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
@@ -34,6 +35,7 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
|
const debouncedSearch = useDebounce(search, 200)
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
@@ -110,7 +112,19 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const [ratingValue, setRatingValue] = useState<number | null>(null)
|
||||||
|
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
|
||||||
|
|
||||||
|
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
||||||
|
if (value === ratingValue && operator === ratingOperator) {
|
||||||
|
setRatingValue(null)
|
||||||
|
} else {
|
||||||
|
setRatingValue(value)
|
||||||
|
setRatingOperator(operator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||||
|
|
||||||
const fetchRecursive = useCallback(() => {
|
const fetchRecursive = useCallback(() => {
|
||||||
if (recursiveLoaded || recursiveLoading) return
|
if (recursiveLoaded || recursiveLoading) return
|
||||||
@@ -155,14 +169,31 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
|
|
||||||
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
||||||
|
|
||||||
const filteredEntries = sourceEntries.filter((entry) => {
|
const filteredEntries = useMemo(() => sourceEntries.filter((entry) => {
|
||||||
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
|
if (debouncedSearch) {
|
||||||
|
const q = debouncedSearch.toLowerCase()
|
||||||
|
const matchesSearch = [
|
||||||
|
entry.name,
|
||||||
|
entry.aiDescription,
|
||||||
|
entry.extractedText,
|
||||||
|
entry.extractedTextTranslated,
|
||||||
|
].some((field) => field?.toLowerCase().includes(q))
|
||||||
|
if (!matchesSearch) return false
|
||||||
|
}
|
||||||
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
|
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
|
||||||
const entryTags = assignments[itemKeyFor(entry)] ?? []
|
const entryTags = assignments[itemKeyFor(entry)] ?? []
|
||||||
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
|
if (ratingValue !== null && entry.type !== 'directory') {
|
||||||
|
const r = entry.userRating ?? null
|
||||||
|
if (r === null) return false
|
||||||
|
if (ratingOperator === 'gte' && r < ratingValue) return false
|
||||||
|
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
||||||
|
if (ratingOperator === 'lte' && r > ratingValue) return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}), [sourceEntries, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
||||||
|
|
||||||
const mediaEntries = filteredEntries.filter(
|
const mediaEntries = filteredEntries.filter(
|
||||||
(e) => e.mediaType === 'video' || e.mediaType === 'image'
|
(e) => e.mediaType === 'video' || e.mediaType === 'image'
|
||||||
@@ -337,6 +368,9 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import type { Movie } from '@/types'
|
import type { Movie, RatingOperator } from '@/types'
|
||||||
import MovieDetailModal from './MovieDetailModal'
|
import MovieDetailModal from './MovieDetailModal'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
||||||
import { isBrowserPlayable } from '@/lib/browser-media'
|
import { isBrowserPlayable } from '@/lib/browser-media'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
@@ -20,6 +21,9 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
|
const [ratingValue, setRatingValue] = useState<number | null>(null)
|
||||||
|
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
|
||||||
|
const debouncedSearch = useDebounce(search, 200)
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
@@ -58,14 +62,34 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||||
|
|
||||||
const filtered = movies.filter((movie) => {
|
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
||||||
if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false
|
if (value === ratingValue && operator === ratingOperator) {
|
||||||
|
setRatingValue(null)
|
||||||
|
} else {
|
||||||
|
setRatingValue(value)
|
||||||
|
setRatingOperator(operator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = useMemo(() => movies.filter((movie) => {
|
||||||
|
if (debouncedSearch) {
|
||||||
|
const q = debouncedSearch.toLowerCase()
|
||||||
|
if (![movie.title, movie.plot, movie.aiDescription, movie.extractedText, movie.extractedTextTranslated]
|
||||||
|
.some((f) => f?.toLowerCase().includes(q))) return false
|
||||||
|
}
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
const movieTags = assignments[movie.item_key!] ?? []
|
const movieTags = assignments[movie.item_key!] ?? []
|
||||||
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
|
if (ratingValue !== null) {
|
||||||
|
const r = movie.userRating
|
||||||
|
if (r === null) return false
|
||||||
|
if (ratingOperator === 'gte' && r < ratingValue) return false
|
||||||
|
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
||||||
|
if (ratingOperator === 'lte' && r > ratingValue) return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
}), [movies, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
||||||
|
|
||||||
const selected = selectedIndex !== null ? filtered[selectedIndex] ?? null : null
|
const selected = selectedIndex !== null ? filtered[selectedIndex] ?? null : null
|
||||||
|
|
||||||
@@ -74,7 +98,7 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||||
|
|
||||||
const handleDoomScroll = () => {
|
const handleDoomScroll = () => {
|
||||||
// Use filtered movies — respects any active search/tag filters automatically
|
// Use filtered movies — respects any active search/tag filters automatically
|
||||||
@@ -135,6 +159,9 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import TagSelector from './TagSelector'
|
import TagSelector from './TagSelector'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -33,6 +33,35 @@ export default function MediaTagPanel({
|
|||||||
const [aiTagging, setAiTagging] = useState(false)
|
const [aiTagging, setAiTagging] = useState(false)
|
||||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||||
const [internalRefreshKey, setInternalRefreshKey] = useState(0)
|
const [internalRefreshKey, setInternalRefreshKey] = useState(0)
|
||||||
|
const [userRating, setUserRatingState] = useState<number | null>(null)
|
||||||
|
const [ratingHover, setRatingHover] = useState<number | null>(null)
|
||||||
|
const [savingRating, setSavingRating] = useState(false)
|
||||||
|
|
||||||
|
const fetchRating = useCallback(async () => {
|
||||||
|
if (!itemKey) return
|
||||||
|
const res = await fetch(`/api/ratings?itemKey=${encodeURIComponent(itemKey)}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const { userRating: r } = await res.json()
|
||||||
|
setUserRatingState(r)
|
||||||
|
}
|
||||||
|
}, [itemKey])
|
||||||
|
|
||||||
|
useEffect(() => { fetchRating() }, [fetchRating])
|
||||||
|
|
||||||
|
const setRating = async (star: number) => {
|
||||||
|
const next = userRating === star ? null : star
|
||||||
|
setSavingRating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ratings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey, userRating: next }),
|
||||||
|
})
|
||||||
|
if (res.ok) setUserRatingState(next)
|
||||||
|
} finally {
|
||||||
|
setSavingRating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAiTag = async () => {
|
const handleAiTag = async () => {
|
||||||
if (!onAiTag) return
|
if (!onAiTag) return
|
||||||
@@ -94,8 +123,44 @@ export default function MediaTagPanel({
|
|||||||
) : null
|
) : null
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Rating section */}
|
||||||
|
<div className="mt-4 mb-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Rating
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1" onMouseLeave={() => setRatingHover(null)}>
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => {
|
||||||
|
const filled = (ratingHover ?? userRating ?? 0) >= star
|
||||||
|
return readOnly ? (
|
||||||
|
<span
|
||||||
|
key={star}
|
||||||
|
style={{ fontSize: '1.1rem', color: (userRating ?? 0) >= star ? '#f59e0b' : 'var(--border)' }}
|
||||||
|
aria-label={`${star} star`}
|
||||||
|
>★</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
onClick={() => setRating(star)}
|
||||||
|
onMouseEnter={() => setRatingHover(star)}
|
||||||
|
disabled={savingRating}
|
||||||
|
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
|
||||||
|
style={{
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
color: filled ? '#f59e0b' : 'var(--border)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: '0 1px',
|
||||||
|
cursor: savingRating ? 'wait' : 'pointer',
|
||||||
|
transition: 'color 0.1s',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>★</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/* Tags section heading + optional AI button */}
|
{/* Tags section heading + optional AI button */}
|
||||||
<div className="flex items-center justify-between mt-4 mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Tags
|
Tags
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||||
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
import type { TvSeries, TvSeason, TvEpisode, RatingOperator } from '@/types'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
|
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||||
@@ -33,6 +34,9 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
|
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
|
||||||
|
const [ratingValue, setRatingValue] = useState<number | null>(null)
|
||||||
|
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
|
||||||
|
const debouncedSearch = useDebounce(search, 200)
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
@@ -375,29 +379,58 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
}
|
}
|
||||||
}, [view, menuOpen, showTagPanel, selectedSeries])
|
}, [view, menuOpen, showTagPanel, selectedSeries])
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||||
|
|
||||||
const filteredSeries = series.filter((s) => {
|
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
||||||
if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false
|
if (value === ratingValue && operator === ratingOperator) {
|
||||||
|
setRatingValue(null)
|
||||||
|
} else {
|
||||||
|
setRatingValue(value)
|
||||||
|
setRatingOperator(operator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredSeries = useMemo(() => series.filter((s) => {
|
||||||
|
if (debouncedSearch) {
|
||||||
|
const q = debouncedSearch.toLowerCase()
|
||||||
|
if (![s.title, s.plot, s.aiDescription, s.extractedText, s.extractedTextTranslated]
|
||||||
|
.some((f) => f?.toLowerCase().includes(q))) return false
|
||||||
|
}
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
const seriesTags = assignments[s.item_key!] ?? []
|
const seriesTags = assignments[s.item_key!] ?? []
|
||||||
const episodeTags = seriesEpisodeTags[s.id] ?? []
|
const episodeTags = seriesEpisodeTags[s.id] ?? []
|
||||||
const allTags = seriesTags.length === 0 ? episodeTags
|
const allTags = [...new Set([...seriesTags, ...episodeTags])]
|
||||||
: episodeTags.length === 0 ? seriesTags
|
|
||||||
: [...new Set([...seriesTags, ...episodeTags])]
|
|
||||||
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
|
if (ratingValue !== null) {
|
||||||
|
const r = s.userRating
|
||||||
|
if (r === null) return false
|
||||||
|
if (ratingOperator === 'gte' && r < ratingValue) return false
|
||||||
|
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
||||||
|
if (ratingOperator === 'lte' && r > ratingValue) return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
}), [series, debouncedSearch, selectedTagIds, assignments, seriesEpisodeTags, ratingValue, ratingOperator])
|
||||||
|
|
||||||
const filteredEpisodes = episodes.filter((ep) => {
|
const filteredEpisodes = useMemo(() => episodes.filter((ep) => {
|
||||||
if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false
|
if (debouncedSearch) {
|
||||||
|
const q = debouncedSearch.toLowerCase()
|
||||||
|
if (![ep.title, ep.plot, ep.aiDescription, ep.extractedText, ep.extractedTextTranslated]
|
||||||
|
.some((f) => f?.toLowerCase().includes(q))) return false
|
||||||
|
}
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
const epTags = assignments[ep.item_key!] ?? []
|
const epTags = assignments[ep.item_key!] ?? []
|
||||||
if (![...selectedTagIds].every((id) => epTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => epTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
|
if (ratingValue !== null) {
|
||||||
|
const r = ep.userRating
|
||||||
|
if (r === null) return false
|
||||||
|
if (ratingOperator === 'gte' && r < ratingValue) return false
|
||||||
|
if (ratingOperator === 'eq' && r !== ratingValue) return false
|
||||||
|
if (ratingOperator === 'lte' && r > ratingValue) return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
}), [episodes, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
||||||
|
|
||||||
// Arrow key navigation for series/season levels (mirrors the prev/next UI buttons)
|
// Arrow key navigation for series/season levels (mirrors the prev/next UI buttons)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -524,6 +557,9 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
14
src/hooks/useDebounce.ts
Normal file
14
src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delayMs: number): T {
|
||||||
|
const [debounced, setDebounced] = useState<T>(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setDebounced(value), delayMs)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}, [value, delayMs])
|
||||||
|
|
||||||
|
return debounced
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import AdmZip from 'adm-zip'
|
import AdmZip from 'adm-zip'
|
||||||
import { XMLParser } from 'fast-xml-parser'
|
import { XMLParser } from 'fast-xml-parser'
|
||||||
import type { ComicInfoData } from '@/types'
|
import type { ComicInfoData } from '@/types'
|
||||||
|
import { findZipEntry, extractZipEntry } from './zip-utils'
|
||||||
|
|
||||||
const parser = new XMLParser()
|
const parser = new XMLParser()
|
||||||
|
|
||||||
@@ -70,3 +71,50 @@ export function parseComicInfo(absoluteCbzPath: string): ComicInfoData | null {
|
|||||||
web: toString(info.Web),
|
web: toString(info.Web),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async version of parseComicInfo — reads only the ComicInfo.xml entry from the
|
||||||
|
* archive without loading the entire CBZ into memory. This is significantly faster
|
||||||
|
* for large libraries since it reads only the ZIP's central directory + the XML entry.
|
||||||
|
*/
|
||||||
|
export async function parseComicInfoAsync(absoluteCbzPath: string): Promise<ComicInfoData | null> {
|
||||||
|
try {
|
||||||
|
const entry = await findZipEntry(absoluteCbzPath, 'comicinfo.xml')
|
||||||
|
if (!entry) return null
|
||||||
|
const buf = await extractZipEntry(absoluteCbzPath, entry)
|
||||||
|
if (!buf) return null
|
||||||
|
return parseXml(buf.toString('utf-8'))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXml(xml: string): ComicInfoData | null {
|
||||||
|
let doc: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
doc = parser.parse(xml) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = (doc.ComicInfo ?? doc.ComicInfoXml ?? doc.comicinfo) as Record<string, unknown> | undefined
|
||||||
|
if (!info) return null
|
||||||
|
|
||||||
|
const rawTags = toString(info.Tags)
|
||||||
|
const tags: string[] = rawTags
|
||||||
|
? rawTags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: toString(info.Title),
|
||||||
|
year: toNumber(info.Year),
|
||||||
|
month: toNumber(info.Month),
|
||||||
|
day: toNumber(info.Day),
|
||||||
|
writer: toString(info.Writer),
|
||||||
|
translator: toString(info.Translator),
|
||||||
|
publisher: toString(info.Publisher),
|
||||||
|
genre: toString(info.Genre),
|
||||||
|
tags,
|
||||||
|
web: toString(info.Web),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import crypto from 'crypto'
|
|||||||
import type { Library, ImportedTag, TagMapping } from '@/types'
|
import type { Library, ImportedTag, TagMapping } from '@/types'
|
||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { resolveLibraryRoot } from './libraries'
|
import { resolveLibraryRoot } from './libraries'
|
||||||
import { parseComicInfo } from './comic-info'
|
import { parseComicInfoAsync } from './comic-info'
|
||||||
|
import { mapConcurrent } from './zip-utils'
|
||||||
|
|
||||||
// ─── Metadata Import ──────────────────────────────────────────────────────────
|
// ─── Metadata Import ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -13,31 +14,28 @@ import { parseComicInfo } from './comic-info'
|
|||||||
* - For each tag: if a mapping exists, assigns the real tag; otherwise creates
|
* - For each tag: if a mapping exists, assigns the real tag; otherwise creates
|
||||||
* an imported tag entry.
|
* an imported tag entry.
|
||||||
*/
|
*/
|
||||||
export function importComicMetadata(library: Library): void {
|
export async function importComicMetadata(library: Library): Promise<void> {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const libraryRoot = resolveLibraryRoot(library)
|
const libraryRoot = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
// Only process issues that have not had ComicInfo.xml imported yet.
|
||||||
|
// Issues restored from a previous scan will already have year/genres set.
|
||||||
const issues = db
|
const issues = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT item_key, file_path, metadata FROM media_items
|
`SELECT item_key, file_path, metadata FROM media_items
|
||||||
WHERE library_id = ? AND item_type = 'comic_issue' AND file_path IS NOT NULL`
|
WHERE library_id = ? AND item_type = 'comic_issue' AND file_path IS NOT NULL
|
||||||
|
AND year IS NULL AND genres IS NULL`
|
||||||
)
|
)
|
||||||
.all(library.id) as { item_key: string; file_path: string; metadata: string | null }[]
|
.all(library.id) as { item_key: string; file_path: string; metadata: string | null }[]
|
||||||
|
|
||||||
|
if (issues.length === 0) return
|
||||||
|
|
||||||
// Load existing mappings for this library
|
// Load existing mappings for this library
|
||||||
const mappingRows = db
|
const mappingRows = db
|
||||||
.prepare('SELECT imported_tag_name, tag_id FROM tag_mappings WHERE library_id = ?')
|
.prepare('SELECT imported_tag_name, tag_id FROM tag_mappings WHERE library_id = ?')
|
||||||
.all(library.id) as { imported_tag_name: string; tag_id: string }[]
|
.all(library.id) as { imported_tag_name: string; tag_id: string }[]
|
||||||
const mappings = new Map(mappingRows.map((r) => [r.imported_tag_name, r.tag_id]))
|
const mappings = new Map(mappingRows.map((r) => [r.imported_tag_name, r.tag_id]))
|
||||||
|
|
||||||
// Clear existing imported tag associations for this library (they'll be re-created)
|
|
||||||
db.prepare(
|
|
||||||
`DELETE FROM item_imported_tags WHERE imported_tag_id IN (
|
|
||||||
SELECT id FROM imported_tags WHERE library_id = ?
|
|
||||||
)`
|
|
||||||
).run(library.id)
|
|
||||||
db.prepare('DELETE FROM imported_tags WHERE library_id = ?').run(library.id)
|
|
||||||
|
|
||||||
const updateItem = db.prepare(`
|
const updateItem = db.prepare(`
|
||||||
UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata
|
UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata
|
||||||
WHERE item_key = @item_key
|
WHERE item_key = @item_key
|
||||||
@@ -56,53 +54,65 @@ export function importComicMetadata(library: Library): void {
|
|||||||
|
|
||||||
let importedCount = 0
|
let importedCount = 0
|
||||||
|
|
||||||
db.transaction(() => {
|
// Process in batches: async file reads (10 concurrent) followed by batch DB writes,
|
||||||
for (const issue of issues) {
|
// with an event-loop yield between batches to keep the app responsive.
|
||||||
const absPath = path.join(libraryRoot, issue.file_path)
|
const BATCH_SIZE = 50
|
||||||
const info = parseComicInfo(absPath)
|
for (let i = 0; i < issues.length; i += BATCH_SIZE) {
|
||||||
if (!info) continue
|
const batch = issues.slice(i, i + BATCH_SIZE)
|
||||||
|
|
||||||
// Merge with existing metadata JSON (preserve pageCount, coverUrl, etc.)
|
// Async: read ComicInfo.xml from each archive concurrently (10 at a time).
|
||||||
const existingMeta = issue.metadata ? JSON.parse(issue.metadata) : {}
|
// Uses async ZIP central-directory reader — no full-file reads.
|
||||||
const mergedMeta = {
|
const infos = await mapConcurrent(batch, 10, (issue) =>
|
||||||
...existingMeta,
|
parseComicInfoAsync(path.join(libraryRoot, issue.file_path))
|
||||||
writer: info.writer,
|
)
|
||||||
publisher: info.publisher,
|
|
||||||
translator: info.translator,
|
|
||||||
web: info.web,
|
|
||||||
month: info.month,
|
|
||||||
day: info.day,
|
|
||||||
}
|
|
||||||
|
|
||||||
updateItem.run({
|
// Sync: write this batch to the DB in one transaction.
|
||||||
item_key: issue.item_key,
|
db.transaction(() => {
|
||||||
title: info.title ?? existingMeta.title ?? null,
|
for (let j = 0; j < batch.length; j++) {
|
||||||
year: info.year,
|
const issue = batch[j]
|
||||||
genres: info.genre,
|
const info = infos[j]
|
||||||
metadata: JSON.stringify(mergedMeta),
|
if (!info) continue
|
||||||
})
|
|
||||||
|
|
||||||
// Process tags
|
const existingMeta = issue.metadata ? JSON.parse(issue.metadata) : {}
|
||||||
for (const tagName of info.tags) {
|
const mergedMeta = {
|
||||||
const mappedTagId = mappings.get(tagName)
|
...existingMeta,
|
||||||
if (mappedTagId) {
|
writer: info.writer,
|
||||||
// Mapping exists — assign the real tag
|
publisher: info.publisher,
|
||||||
addMediaTag.run(issue.item_key, mappedTagId)
|
translator: info.translator,
|
||||||
} else {
|
web: info.web,
|
||||||
// No mapping — create imported tag
|
month: info.month,
|
||||||
const importedTagId = crypto.randomUUID()
|
day: info.day,
|
||||||
const row = upsertImportedTag.get({
|
|
||||||
id: importedTagId,
|
|
||||||
library_id: library.id,
|
|
||||||
name: tagName,
|
|
||||||
}) as { id: string }
|
|
||||||
addItemImportedTag.run(issue.item_key, row.id)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
importedCount++
|
updateItem.run({
|
||||||
}
|
item_key: issue.item_key,
|
||||||
})()
|
title: info.title ?? existingMeta.title ?? null,
|
||||||
|
year: info.year,
|
||||||
|
genres: info.genre,
|
||||||
|
metadata: JSON.stringify(mergedMeta),
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const tagName of info.tags) {
|
||||||
|
const mappedTagId = mappings.get(tagName)
|
||||||
|
if (mappedTagId) {
|
||||||
|
addMediaTag.run(issue.item_key, mappedTagId)
|
||||||
|
} else {
|
||||||
|
const importedTagId = crypto.randomUUID()
|
||||||
|
const row = upsertImportedTag.get({
|
||||||
|
id: importedTagId,
|
||||||
|
library_id: library.id,
|
||||||
|
name: tagName,
|
||||||
|
}) as { id: string }
|
||||||
|
addItemImportedTag.run(issue.item_key, row.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importedCount++
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
await new Promise<void>((r) => setImmediate(r))
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[comic-metadata] Imported metadata for ${importedCount}/${issues.length} issues in "${library.name}"`)
|
console.log(`[comic-metadata] Imported metadata for ${importedCount}/${issues.length} issues in "${library.name}"`)
|
||||||
}
|
}
|
||||||
@@ -201,3 +211,20 @@ export function deleteTagMapping(id: string): void {
|
|||||||
const result = db.prepare('DELETE FROM tag_mappings WHERE id = ?').run(id)
|
const result = db.prepare('DELETE FROM tag_mappings WHERE id = ?').run(id)
|
||||||
if (result.changes === 0) throw new Error('Mapping not found')
|
if (result.changes === 0) throw new Error('Mapping not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a media item already has metadata populated.
|
||||||
|
* Returns true if ANY of: title, year, plot, or genres are populated.
|
||||||
|
*/
|
||||||
|
function hasMetadata(item: {
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
genres: string | null
|
||||||
|
}): boolean {
|
||||||
|
if (item.title) return true
|
||||||
|
if (item.year) return true
|
||||||
|
if (item.plot) return true
|
||||||
|
if (item.genres) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import AdmZip from 'adm-zip'
|
|||||||
import type { ComicIssue, ComicSeries } from '@/types'
|
import type { ComicIssue, ComicSeries } from '@/types'
|
||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { HIDDEN_FILES, thumbnailApiUrl } from './media-utils'
|
import { HIDDEN_FILES, thumbnailApiUrl } from './media-utils'
|
||||||
|
import { countZipImages, mapConcurrent } from './zip-utils'
|
||||||
|
import fsPromises from 'fs/promises'
|
||||||
|
|
||||||
const CBZ_EXTENSIONS = new Set(['.cbz'])
|
const CBZ_EXTENSIONS = new Set(['.cbz'])
|
||||||
const CBZ_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
const CBZ_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||||
@@ -23,52 +25,46 @@ function parseIssueNumber(filename: string): number | null {
|
|||||||
return parseInt(matches[matches.length - 1], 10)
|
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 {
|
export interface ScannedComicSeries extends ComicSeries {
|
||||||
issues: ComicIssue[]
|
issues: ComicIssue[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scanComicsLibrary(
|
const TRASH_DIR = '.trash'
|
||||||
|
|
||||||
|
async function moveToTrash(absPath: string, libraryRoot: string): Promise<void> {
|
||||||
|
const trashDir = path.join(libraryRoot, TRASH_DIR)
|
||||||
|
await fsPromises.mkdir(trashDir, { recursive: true })
|
||||||
|
const filename = path.basename(absPath)
|
||||||
|
let dest = path.join(trashDir, filename)
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
const ext = path.extname(filename)
|
||||||
|
const base = path.basename(filename, ext)
|
||||||
|
dest = path.join(trashDir, `${base}_${Date.now()}${ext}`)
|
||||||
|
}
|
||||||
|
await fsPromises.rename(absPath, dest).catch(async (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === 'EXDEV') {
|
||||||
|
// Source and destination are on different filesystems — copy then delete.
|
||||||
|
await fsPromises.copyFile(absPath, dest)
|
||||||
|
await fsPromises.unlink(absPath)
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(`[scanner] Moved corrupt archive to trash: ${path.relative(libraryRoot, absPath)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollectedCbz {
|
||||||
|
absPath: string
|
||||||
|
filename: string
|
||||||
|
relPath: string
|
||||||
|
isStandalone: boolean
|
||||||
|
seriesDirName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanComicsLibrary(
|
||||||
libraryRoot: string,
|
libraryRoot: string,
|
||||||
libraryId: string
|
libraryId: string
|
||||||
): (ComicIssue | ScannedComicSeries)[] {
|
): Promise<(ComicIssue | ScannedComicSeries)[]> {
|
||||||
let topEntries: fs.Dirent[]
|
let topEntries: fs.Dirent[]
|
||||||
try {
|
try {
|
||||||
topEntries = fs.readdirSync(libraryRoot, { withFileTypes: true })
|
topEntries = fs.readdirSync(libraryRoot, { withFileTypes: true })
|
||||||
@@ -76,15 +72,20 @@ export function scanComicsLibrary(
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: (ComicIssue | ScannedComicSeries)[] = []
|
// Phase 1: Collect all CBZ paths via fast directory listing (no archive opens).
|
||||||
|
const collected: CollectedCbz[] = []
|
||||||
|
|
||||||
for (const entry of topEntries) {
|
for (const entry of topEntries) {
|
||||||
if (HIDDEN_FILES.test(entry.name)) continue
|
if (HIDDEN_FILES.test(entry.name)) continue
|
||||||
|
|
||||||
if (entry.isFile() && isCbzFile(entry.name)) {
|
if (entry.isFile() && isCbzFile(entry.name)) {
|
||||||
// Standalone one-shot comic
|
collected.push({
|
||||||
const absPath = path.join(libraryRoot, entry.name)
|
absPath: path.join(libraryRoot, entry.name),
|
||||||
results.push(buildIssue(absPath, entry.name, entry.name, libraryId, true))
|
filename: entry.name,
|
||||||
|
relPath: entry.name,
|
||||||
|
isStandalone: true,
|
||||||
|
seriesDirName: null,
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,39 +98,109 @@ export function scanComicsLibrary(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const cbzFiles = subEntries.filter(
|
const cbzFiles = subEntries
|
||||||
(e) => e.isFile() && isCbzFile(e.name) && !HIDDEN_FILES.test(e.name)
|
.filter((e) => e.isFile() && isCbzFile(e.name) && !HIDDEN_FILES.test(e.name))
|
||||||
)
|
.sort((a, b) => naturalCompare(a.name, b.name))
|
||||||
|
|
||||||
if (cbzFiles.length === 0) continue
|
if (cbzFiles.length === 0) continue
|
||||||
|
|
||||||
// It's a series
|
for (const f of cbzFiles) {
|
||||||
const issues: ComicIssue[] = cbzFiles
|
collected.push({
|
||||||
.sort((a, b) => naturalCompare(a.name, b.name))
|
absPath: path.join(dirAbsPath, f.name),
|
||||||
.map((f) => {
|
filename: f.name,
|
||||||
const relPath = path.join(entry.name, f.name)
|
relPath: path.join(entry.name, f.name),
|
||||||
return buildIssue(path.join(dirAbsPath, f.name), f.name, relPath, libraryId, false)
|
isStandalone: false,
|
||||||
|
seriesDirName: entry.name,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
const seriesCoverUrl = issues[0]?.coverUrl ?? null
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
id: encodeURIComponent(entry.name),
|
|
||||||
title: entry.name,
|
|
||||||
coverUrl: seriesCoverUrl,
|
|
||||||
issueCount: issues.length,
|
|
||||||
issues,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 2: Count pages for all CBZ files concurrently (10 at a time) by reading
|
||||||
|
// only each archive's central directory — no full-file reads.
|
||||||
|
const scanResults = await mapConcurrent(collected, 10, (c) =>
|
||||||
|
countZipImages(c.absPath, CBZ_IMAGE_EXTENSIONS)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Move corrupt archives to the library's .trash folder and exclude them from indexing.
|
||||||
|
const movePromises: Promise<void>[] = []
|
||||||
|
const valid: Array<{ cbz: CollectedCbz; pageCount: number }> = []
|
||||||
|
for (let i = 0; i < collected.length; i++) {
|
||||||
|
const result = scanResults[i]
|
||||||
|
if (!result.valid) {
|
||||||
|
movePromises.push(
|
||||||
|
moveToTrash(collected[i].absPath, libraryRoot).catch((err) =>
|
||||||
|
console.warn(`[scanner] Could not move corrupt archive to trash: ${collected[i].absPath}`, err)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valid.push({ cbz: collected[i], pageCount: result.pageCount })
|
||||||
|
}
|
||||||
|
if (movePromises.length > 0) await Promise.all(movePromises)
|
||||||
|
|
||||||
|
// Phase 3: Build the result array from valid files only.
|
||||||
|
const seriesMap = new Map<string, ScannedComicSeries>()
|
||||||
|
const standaloneIssues: ComicIssue[] = []
|
||||||
|
|
||||||
|
for (const { cbz: c, pageCount } of valid) {
|
||||||
|
const coverUrl = thumbnailApiUrl(libraryId, c.relPath)
|
||||||
|
const issue: ComicIssue = {
|
||||||
|
id: encodeURIComponent(c.relPath),
|
||||||
|
title: path.basename(c.filename, path.extname(c.filename)),
|
||||||
|
issueNumber: parseIssueNumber(c.filename),
|
||||||
|
pageCount,
|
||||||
|
coverUrl,
|
||||||
|
filePath: c.relPath,
|
||||||
|
isStandalone: c.isStandalone,
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.isStandalone) {
|
||||||
|
standaloneIssues.push(issue)
|
||||||
|
} else {
|
||||||
|
const key = c.seriesDirName!
|
||||||
|
if (!seriesMap.has(key)) {
|
||||||
|
seriesMap.set(key, {
|
||||||
|
id: encodeURIComponent(key),
|
||||||
|
title: key,
|
||||||
|
coverUrl, // first issue (sorted) becomes the series cover
|
||||||
|
issueCount: 0,
|
||||||
|
issues: [],
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const series = seriesMap.get(key)!
|
||||||
|
series.issues.push(issue)
|
||||||
|
series.issueCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: (ComicIssue | ScannedComicSeries)[] = [
|
||||||
|
...Array.from(seriesMap.values()),
|
||||||
|
...standaloneIssues,
|
||||||
|
]
|
||||||
return results.sort((a, b) => naturalCompare(a.title, b.title))
|
return results.sort((a, b) => naturalCompare(a.title, b.title))
|
||||||
}
|
}
|
||||||
|
|
||||||
// comicsFromDb returns series + standalone issues for the top-level grid.
|
function escapeLike(s: string): string {
|
||||||
|
return `%${s.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// comicsFromDb returns series + standalone issues for the top-level grid, paginated.
|
||||||
// Series issues are retrieved separately via comicIssuesFromDb.
|
// Series issues are retrieved separately via comicIssuesFromDb.
|
||||||
export function comicsFromDb(libraryId: string): (ComicIssue | ComicSeries)[] {
|
export function comicsFromDb(
|
||||||
|
libraryId: string,
|
||||||
|
opts: { page: number; pageSize: number; search?: string }
|
||||||
|
): { items: (ComicIssue | ComicSeries)[]; total: number } {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
const offset = (opts.page - 1) * opts.pageSize
|
||||||
|
|
||||||
type DbRow = {
|
type DbRow = {
|
||||||
item_key: string
|
item_key: string
|
||||||
@@ -138,61 +209,80 @@ export function comicsFromDb(libraryId: string): (ComicIssue | ComicSeries)[] {
|
|||||||
title: string | null
|
title: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
file_path: string | null
|
file_path: string | null
|
||||||
|
user_rating: number | null
|
||||||
|
ai_description: string | null
|
||||||
|
extracted_text: string | null
|
||||||
|
extracted_text_translated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const allRows = db
|
const baseWhere = `
|
||||||
.prepare(
|
WHERE library_id = ?
|
||||||
`SELECT item_key, item_type, parent_key, title, metadata, file_path
|
AND (item_type = 'comic_series' OR (item_type = 'comic_issue' AND parent_key IS NULL))
|
||||||
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 total: number = opts.search
|
||||||
const standaloneIssues: ComicIssue[] = []
|
? (db
|
||||||
|
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'`)
|
||||||
|
.get(libraryId, escapeLike(opts.search)) as { cnt: number }).cnt
|
||||||
|
: (db
|
||||||
|
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere}`)
|
||||||
|
.get(libraryId) as { cnt: number }).cnt
|
||||||
|
|
||||||
for (const row of allRows) {
|
const cols = `item_key, item_type, parent_key, title, metadata, file_path,
|
||||||
if (row.item_type !== 'comic_series') continue
|
user_rating, ai_description, extracted_text, extracted_text_translated`
|
||||||
|
|
||||||
|
const rows: DbRow[] = opts.search
|
||||||
|
? db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${cols}
|
||||||
|
FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'
|
||||||
|
ORDER BY title LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.all(libraryId, escapeLike(opts.search), opts.pageSize, offset) as DbRow[]
|
||||||
|
: db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${cols}
|
||||||
|
FROM media_items ${baseWhere}
|
||||||
|
ORDER BY title LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.all(libraryId, opts.pageSize, offset) as DbRow[]
|
||||||
|
|
||||||
|
const items: (ComicIssue | ComicSeries)[] = []
|
||||||
|
for (const row of rows) {
|
||||||
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
const idPart = row.item_key.split(':comic_series:')[1] ?? row.item_key
|
if (row.item_type === 'comic_series') {
|
||||||
seriesMap.set(row.item_key, {
|
const idPart = row.item_key.split(':comic_series:')[1] ?? row.item_key
|
||||||
id: idPart,
|
items.push({
|
||||||
item_key: row.item_key,
|
id: idPart,
|
||||||
title: row.title ?? decodeURIComponent(idPart),
|
item_key: row.item_key,
|
||||||
coverUrl: meta.coverUrl ?? null,
|
title: row.title ?? decodeURIComponent(idPart),
|
||||||
issueCount: meta.issueCount ?? 0,
|
coverUrl: meta.coverUrl ?? null,
|
||||||
})
|
issueCount: meta.issueCount ?? 0,
|
||||||
}
|
userRating: row.user_rating ?? null,
|
||||||
|
aiDescription: row.ai_description ?? null,
|
||||||
for (const row of allRows) {
|
extractedText: row.extracted_text ?? null,
|
||||||
if (row.item_type !== 'comic_issue') continue
|
extractedTextTranslated: row.extracted_text_translated ?? null,
|
||||||
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
} as ComicSeries)
|
||||||
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 {
|
} else {
|
||||||
standaloneIssues.push(issue)
|
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
|
||||||
|
items.push({
|
||||||
|
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 ?? true,
|
||||||
|
userRating: row.user_rating ?? null,
|
||||||
|
aiDescription: row.ai_description ?? null,
|
||||||
|
extractedText: row.extracted_text ?? null,
|
||||||
|
extractedTextTranslated: row.extracted_text_translated ?? null,
|
||||||
|
} as ComicIssue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: (ComicIssue | ComicSeries)[] = [
|
return { items, total }
|
||||||
...Array.from(seriesMap.values()),
|
|
||||||
...standaloneIssues,
|
|
||||||
]
|
|
||||||
return results.sort((a, b) => naturalCompare(a.title, b.title))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIssue[] {
|
export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIssue[] {
|
||||||
@@ -204,11 +294,16 @@ export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIss
|
|||||||
title: string | null
|
title: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
file_path: string | null
|
file_path: string | null
|
||||||
|
user_rating: number | null
|
||||||
|
ai_description: string | null
|
||||||
|
extracted_text: string | null
|
||||||
|
extracted_text_translated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT item_key, title, metadata, file_path
|
`SELECT item_key, title, metadata, file_path,
|
||||||
|
user_rating, ai_description, extracted_text, extracted_text_translated
|
||||||
FROM media_items
|
FROM media_items
|
||||||
WHERE parent_key = ? AND item_type = 'comic_issue'`
|
WHERE parent_key = ? AND item_type = 'comic_issue'`
|
||||||
)
|
)
|
||||||
@@ -226,6 +321,10 @@ export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIss
|
|||||||
coverUrl: meta.coverUrl ?? null,
|
coverUrl: meta.coverUrl ?? null,
|
||||||
filePath: row.file_path ?? '',
|
filePath: row.file_path ?? '',
|
||||||
isStandalone: false,
|
isStandalone: false,
|
||||||
|
userRating: row.user_rating ?? null,
|
||||||
|
aiDescription: row.ai_description ?? null,
|
||||||
|
extractedText: row.extracted_text ?? null,
|
||||||
|
extractedTextTranslated: row.extracted_text_translated ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ export function getDb(): Database.Database {
|
|||||||
_db = new Database(DB_PATH)
|
_db = new Database(DB_PATH)
|
||||||
_db.pragma('journal_mode = WAL')
|
_db.pragma('journal_mode = WAL')
|
||||||
_db.pragma('foreign_keys = ON')
|
_db.pragma('foreign_keys = ON')
|
||||||
|
_db.pragma('busy_timeout = 5000')
|
||||||
|
_db.pragma('synchronous = NORMAL')
|
||||||
|
_db.pragma('cache_size = -65536')
|
||||||
|
_db.pragma('wal_autocheckpoint = 1000')
|
||||||
initDb(_db)
|
initDb(_db)
|
||||||
|
_db.pragma('wal_checkpoint(PASSIVE)')
|
||||||
return _db
|
return _db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +115,10 @@ function initDb(db: Database.Database): void {
|
|||||||
migrateLibrariesAddComics(db)
|
migrateLibrariesAddComics(db)
|
||||||
migrateComicItemTypes(db)
|
migrateComicItemTypes(db)
|
||||||
migrateImportedTags(db)
|
migrateImportedTags(db)
|
||||||
|
migrateComicsIndex(db)
|
||||||
|
migrateTagMappingsIndexes(db)
|
||||||
|
migrateUserRating(db)
|
||||||
|
migrateParentKeyItemTypeIndex(db)
|
||||||
seedAppSettings(db)
|
seedAppSettings(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,3 +456,33 @@ function migrateImportedTags(db: Database.Database): void {
|
|||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateComicsIndex(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS media_items_library_type_title
|
||||||
|
ON media_items(library_id, item_type, title);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateTagMappingsIndexes(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS tag_mappings_library_id ON tag_mappings(library_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS tag_mappings_tag_id ON tag_mappings(tag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS imported_tags_library_id ON imported_tags(library_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS item_imported_tags_imported_tag_id ON item_imported_tags(imported_tag_id);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateParentKeyItemTypeIndex(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS media_items_parent_key_type
|
||||||
|
ON media_items(parent_key, item_type);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateUserRating(db: Database.Database): void {
|
||||||
|
const cols = db.pragma('table_info(media_items)') as { name: string }[]
|
||||||
|
if (!cols.some((c) => c.name === 'user_rating')) {
|
||||||
|
db.exec('ALTER TABLE media_items ADD COLUMN user_rating INTEGER')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,12 +74,16 @@ export function scanDirectory(
|
|||||||
* Recursively walks every subdirectory under `subpath` and returns a flat list
|
* Recursively walks every subdirectory under `subpath` and returns a flat list
|
||||||
* of all files. Directory entries are omitted. Each FileEntry.name is the full
|
* of all files. Directory entries are omitted. Each FileEntry.name is the full
|
||||||
* relative path from the library root (e.g. FolderA/SubFolder/video.mp4).
|
* relative path from the library root (e.g. FolderA/SubFolder/video.mp4).
|
||||||
|
*
|
||||||
|
* Uses async I/O so the Node.js event loop is not blocked during large
|
||||||
|
* directory trees (blocking stalls streaming responses and causes
|
||||||
|
* "ReadableStream is already closed" errors on concurrent requests).
|
||||||
*/
|
*/
|
||||||
export function scanDirectoryRecursive(
|
export async function scanDirectoryRecursive(
|
||||||
libraryRoot: string,
|
libraryRoot: string,
|
||||||
libraryId: string,
|
libraryId: string,
|
||||||
subpath: string
|
subpath: string
|
||||||
): DirectoryListing {
|
): Promise<DirectoryListing> {
|
||||||
let rootAbsPath: string
|
let rootAbsPath: string
|
||||||
try {
|
try {
|
||||||
rootAbsPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot
|
rootAbsPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot
|
||||||
@@ -89,35 +93,37 @@ export function scanDirectoryRecursive(
|
|||||||
|
|
||||||
const entries: FileEntry[] = []
|
const entries: FileEntry[] = []
|
||||||
|
|
||||||
function walk(absDir: string, relDir: string): void {
|
async function walk(absDir: string, relDir: string): Promise<void> {
|
||||||
let dirents: fs.Dirent[]
|
let dirents: fs.Dirent[]
|
||||||
try {
|
try {
|
||||||
dirents = fs.readdirSync(absDir, { withFileTypes: true })
|
dirents = await fs.promises.readdir(absDir, { withFileTypes: true })
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const d of dirents) {
|
await Promise.all(
|
||||||
if (HIDDEN_FILES.test(d.name)) continue
|
dirents.map(async (d) => {
|
||||||
const relPath = relDir ? path.join(relDir, d.name) : d.name
|
if (HIDDEN_FILES.test(d.name)) return
|
||||||
if (d.isDirectory()) {
|
const relPath = relDir ? path.join(relDir, d.name) : d.name
|
||||||
walk(path.join(absDir, d.name), relPath)
|
if (d.isDirectory()) {
|
||||||
} else {
|
await walk(path.join(absDir, d.name), relPath)
|
||||||
const mediaType = getMediaType(d.name)
|
} else {
|
||||||
const hasThumbnail = mediaType === 'image' || mediaType === 'video'
|
const mediaType = getMediaType(d.name)
|
||||||
// name = full relative path from library root so media keys match
|
const hasThumbnail = mediaType === 'image' || mediaType === 'video'
|
||||||
const fullRelPath = subpath ? path.join(subpath, relPath) : relPath
|
// name = full relative path from library root so media keys match
|
||||||
entries.push({
|
const fullRelPath = subpath ? path.join(subpath, relPath) : relPath
|
||||||
name: fullRelPath,
|
entries.push({
|
||||||
type: 'file',
|
name: fullRelPath,
|
||||||
mediaType,
|
type: 'file',
|
||||||
url: fileApiUrl(libraryId, fullRelPath),
|
mediaType,
|
||||||
thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, fullRelPath) : null,
|
url: fileApiUrl(libraryId, fullRelPath),
|
||||||
})
|
thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, fullRelPath) : null,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
walk(rootAbsPath, '')
|
await walk(rootAbsPath, '')
|
||||||
entries.sort((a, b) => a.name.localeCompare(b.name))
|
entries.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
return { path: subpath, entries }
|
return { path: subpath, entries }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ function buildGame(
|
|||||||
: null,
|
: null,
|
||||||
gameFiles,
|
gameFiles,
|
||||||
platforms,
|
platforms,
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,10 +179,15 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
|
|||||||
parent_key: string | null
|
parent_key: string | null
|
||||||
title: string | null
|
title: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
|
user_rating: number | null
|
||||||
|
ai_description: string | null
|
||||||
|
extracted_text: string | null
|
||||||
|
extracted_text_translated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const allRows = db
|
const allRows = db
|
||||||
.prepare(`SELECT item_key, item_type, parent_key, title, metadata
|
.prepare(`SELECT item_key, item_type, parent_key, title, metadata,
|
||||||
|
user_rating, ai_description, extracted_text, extracted_text_translated
|
||||||
FROM media_items
|
FROM media_items
|
||||||
WHERE library_id = ? AND item_type IN ('game', 'game_series')
|
WHERE library_id = ? AND item_type IN ('game', 'game_series')
|
||||||
ORDER BY title`)
|
ORDER BY title`)
|
||||||
@@ -233,6 +242,10 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
|
|||||||
wideCoverUrl: meta.wideCoverUrl ?? null,
|
wideCoverUrl: meta.wideCoverUrl ?? null,
|
||||||
gameFiles,
|
gameFiles,
|
||||||
platforms,
|
platforms,
|
||||||
|
userRating: row.user_rating ?? null,
|
||||||
|
aiDescription: row.ai_description ?? null,
|
||||||
|
extractedText: row.extracted_text ?? null,
|
||||||
|
extractedTextTranslated: row.extracted_text_translated ?? null,
|
||||||
}
|
}
|
||||||
if (row.parent_key && seriesMap.has(row.parent_key)) {
|
if (row.parent_key && seriesMap.has(row.parent_key)) {
|
||||||
seriesMap.get(row.parent_key)!.games.push(game)
|
seriesMap.get(row.parent_key)!.games.push(game)
|
||||||
|
|||||||
103
src/lib/movie-metadata.ts
Normal file
103
src/lib/movie-metadata.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import type { Library } from '@/types'
|
||||||
|
import { getDb } from './db'
|
||||||
|
import { resolveLibraryRoot } from './libraries'
|
||||||
|
import { parseMovieNfo } from './nfo'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import NFO metadata for Movie items in a library.
|
||||||
|
* - Reads .nfo file matching each movie file
|
||||||
|
* - If importMetadataOnly=false: skip items that already have metadata (title/year/plot/genres)
|
||||||
|
* - If importMetadataOnly=true: update all items regardless of existing metadata
|
||||||
|
*/
|
||||||
|
export async function importMovieMetadata(
|
||||||
|
library: Library,
|
||||||
|
importMetadataOnly: boolean = false
|
||||||
|
): Promise<{ imported: number; skipped: number }> {
|
||||||
|
const db = getDb()
|
||||||
|
const libraryRoot = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
let imported = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
// Get all movies in the library
|
||||||
|
const movies = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, file_path, title, year, plot, genres FROM media_items
|
||||||
|
WHERE library_id = ? AND item_type = 'movie' AND file_path IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }>
|
||||||
|
|
||||||
|
const updateItem = db.prepare(`
|
||||||
|
UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres
|
||||||
|
WHERE item_key = @item_key
|
||||||
|
`)
|
||||||
|
|
||||||
|
const BATCH_SIZE = 50
|
||||||
|
for (let i = 0; i < movies.length; i += BATCH_SIZE) {
|
||||||
|
const batch = movies.slice(i, i + BATCH_SIZE)
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const item of batch) {
|
||||||
|
// Check if we should skip this item
|
||||||
|
if (!importMetadataOnly && hasMetadata(item)) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoPath = path.join(libraryRoot, item.file_path)
|
||||||
|
const dir = path.dirname(videoPath)
|
||||||
|
const baseNameWithoutExt = path.basename(videoPath, path.extname(videoPath))
|
||||||
|
const nfoPath = path.join(dir, `${baseNameWithoutExt}.nfo`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(nfoPath)) {
|
||||||
|
const nfoData = parseMovieNfo(nfoPath)
|
||||||
|
if (nfoData) {
|
||||||
|
updateItem.run({
|
||||||
|
item_key: item.item_key,
|
||||||
|
title: nfoData.title ?? item.title,
|
||||||
|
year: nfoData.year ?? item.year,
|
||||||
|
plot: nfoData.plot ?? item.plot,
|
||||||
|
genres: nfoData.genres.length > 0 ? JSON.stringify(nfoData.genres) : item.genres,
|
||||||
|
})
|
||||||
|
imported++
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
await new Promise<void>((r) => setImmediate(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[movie-metadata] Imported metadata for ${imported} movies in "${library.name}" (${importMetadataOnly ? 'full' : 'incremental'})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return { imported, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a media item already has metadata populated.
|
||||||
|
* Returns true if ANY of: title, year, plot, or genres are populated.
|
||||||
|
*/
|
||||||
|
function hasMetadata(item: {
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
genres: string | null
|
||||||
|
}): boolean {
|
||||||
|
if (item.title) return true
|
||||||
|
if (item.year) return true
|
||||||
|
if (item.plot) return true
|
||||||
|
if (item.genres) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -72,6 +72,10 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
|
|||||||
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
||||||
: null,
|
: null,
|
||||||
videoPath: videoRelPath,
|
videoPath: videoRelPath,
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +94,10 @@ export function moviesFromDb(libraryId: string): Movie[] {
|
|||||||
genres: string | null
|
genres: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
file_path: string | null
|
file_path: string | null
|
||||||
|
user_rating: number | null
|
||||||
|
ai_description: string | null
|
||||||
|
extracted_text: string | null
|
||||||
|
extracted_text_translated: string | null
|
||||||
}>
|
}>
|
||||||
|
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
@@ -108,6 +116,10 @@ export function moviesFromDb(libraryId: string): Movie[] {
|
|||||||
backdropUrl: meta.backdropUrl ?? null,
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
videoPath: row.file_path ?? '',
|
videoPath: row.file_path ?? '',
|
||||||
manuallyEdited: meta.manuallyEdited === true,
|
manuallyEdited: meta.manuallyEdited === true,
|
||||||
|
userRating: row.user_rating ?? null,
|
||||||
|
aiDescription: row.ai_description ?? null,
|
||||||
|
extractedText: row.extracted_text ?? null,
|
||||||
|
extractedTextTranslated: row.extracted_text_translated ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { computeFingerprint } from './fingerprint'
|
|||||||
import { reKeyMediaItem } from './tags'
|
import { reKeyMediaItem } from './tags'
|
||||||
import { runAiTagging } from './ai-tagger'
|
import { runAiTagging } from './ai-tagger'
|
||||||
import { importComicMetadata } from './comic-metadata'
|
import { importComicMetadata } from './comic-metadata'
|
||||||
|
import { importTvMetadata } from './tv-metadata'
|
||||||
|
import { importMovieMetadata } from './movie-metadata'
|
||||||
|
|
||||||
let scanRunning = false
|
let scanRunning = false
|
||||||
|
|
||||||
@@ -546,10 +548,40 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function scanComics(library: Library, libraryRoot: string): Promise<void> {
|
async function scanComics(library: Library, libraryRoot: string): Promise<void> {
|
||||||
const items = scanComicsLibrary(libraryRoot, library.id)
|
const items = await scanComicsLibrary(libraryRoot, library.id)
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Save ComicInfo metadata for issues that were already imported so we can
|
||||||
|
// restore it after the clear+upsert without re-reading any CBZ files.
|
||||||
|
type SavedInfo = { title: string | null; year: number | null; genres: string | null; comicFields: Record<string, unknown> }
|
||||||
|
const savedComicInfo = new Map<string, SavedInfo>()
|
||||||
|
{
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, title, year, genres, metadata FROM media_items
|
||||||
|
WHERE library_id = ? AND item_type = 'comic_issue'
|
||||||
|
AND (year IS NOT NULL OR genres IS NOT NULL)`
|
||||||
|
)
|
||||||
|
.all(library.id) as { item_key: string; title: string | null; year: number | null; genres: string | null; metadata: string | null }[]
|
||||||
|
for (const row of rows) {
|
||||||
|
const meta: Record<string, unknown> = row.metadata ? (JSON.parse(row.metadata) as Record<string, unknown>) : {}
|
||||||
|
savedComicInfo.set(row.item_key, {
|
||||||
|
title: row.title,
|
||||||
|
year: row.year,
|
||||||
|
genres: row.genres,
|
||||||
|
comicFields: {
|
||||||
|
writer: meta.writer,
|
||||||
|
publisher: meta.publisher,
|
||||||
|
translator: meta.translator,
|
||||||
|
web: meta.web,
|
||||||
|
month: meta.month,
|
||||||
|
day: meta.day,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clearLibraryItems(db, library.id)
|
clearLibraryItems(db, library.id)
|
||||||
|
|
||||||
const upsertSeries = db.prepare(`
|
const upsertSeries = db.prepare(`
|
||||||
@@ -573,29 +605,37 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
|||||||
scanned_at = excluded.scanned_at
|
scanned_at = excluded.scanned_at
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
type SeriesRec = Parameters<typeof upsertSeries.run>[0]
|
||||||
|
type IssueRec = Parameters<typeof upsertIssue.run>[0]
|
||||||
|
type BatchEntry = { type: 'series'; rec: SeriesRec } | { type: 'issue'; rec: IssueRec }
|
||||||
|
|
||||||
|
// Collect all records before touching the DB so we can batch-insert with event-loop yields.
|
||||||
|
// Note: between clearLibraryItems and the final batch, the library will appear partially
|
||||||
|
// populated — acceptable for a background scan.
|
||||||
|
const allRecords: BatchEntry[] = []
|
||||||
let issueCount = 0
|
let issueCount = 0
|
||||||
|
|
||||||
db.transaction(() => {
|
for (const item of items) {
|
||||||
for (const item of items) {
|
if ('issues' in item) {
|
||||||
if ('issues' in item) {
|
const series = item as ScannedComicSeries
|
||||||
const series = item as ScannedComicSeries
|
const seriesKey = `${library.id}:comic_series:${series.id}`
|
||||||
const seriesKey = `${library.id}:comic_series:${series.id}`
|
allRecords.push({
|
||||||
upsertSeries.run({
|
type: 'series',
|
||||||
|
rec: {
|
||||||
library_id: library.id,
|
library_id: library.id,
|
||||||
item_key: seriesKey,
|
item_key: seriesKey,
|
||||||
item_type: 'comic_series',
|
item_type: 'comic_series',
|
||||||
title: series.title,
|
title: series.title,
|
||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({ issueCount: series.issueCount, coverUrl: series.coverUrl }),
|
||||||
issueCount: series.issueCount,
|
|
||||||
coverUrl: series.coverUrl,
|
|
||||||
}),
|
|
||||||
file_path: null,
|
file_path: null,
|
||||||
scanned_at: now,
|
scanned_at: now,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
for (const issue of series.issues) {
|
for (const issue of series.issues) {
|
||||||
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
||||||
upsertIssue.run({
|
allRecords.push({
|
||||||
|
type: 'issue',
|
||||||
|
rec: {
|
||||||
library_id: library.id,
|
library_id: library.id,
|
||||||
item_key: issueKey,
|
item_key: issueKey,
|
||||||
item_type: 'comic_issue',
|
item_type: 'comic_issue',
|
||||||
@@ -609,13 +649,16 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
|||||||
}),
|
}),
|
||||||
file_path: issue.filePath,
|
file_path: issue.filePath,
|
||||||
scanned_at: now,
|
scanned_at: now,
|
||||||
})
|
},
|
||||||
issueCount++
|
})
|
||||||
}
|
issueCount++
|
||||||
} else {
|
}
|
||||||
const issue = item as ComicIssue
|
} else {
|
||||||
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
const issue = item as ComicIssue
|
||||||
upsertIssue.run({
|
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
||||||
|
allRecords.push({
|
||||||
|
type: 'issue',
|
||||||
|
rec: {
|
||||||
library_id: library.id,
|
library_id: library.id,
|
||||||
item_key: issueKey,
|
item_key: issueKey,
|
||||||
item_type: 'comic_issue',
|
item_type: 'comic_issue',
|
||||||
@@ -629,13 +672,56 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
|||||||
}),
|
}),
|
||||||
file_path: issue.filePath,
|
file_path: issue.filePath,
|
||||||
scanned_at: now,
|
scanned_at: now,
|
||||||
})
|
},
|
||||||
issueCount++
|
})
|
||||||
}
|
issueCount++
|
||||||
}
|
}
|
||||||
})()
|
}
|
||||||
|
|
||||||
// Prewarm CBZ cover thumbnails
|
// Build a map of item_key → fresh scan metadata (needed for ComicInfo restore below).
|
||||||
|
const freshMetaMap = new Map<string, Record<string, unknown>>()
|
||||||
|
for (const entry of allRecords) {
|
||||||
|
if (entry.type === 'issue') {
|
||||||
|
const rec = entry.rec as { item_key: unknown; metadata: unknown }
|
||||||
|
freshMetaMap.set(
|
||||||
|
String(rec.item_key),
|
||||||
|
JSON.parse(String(rec.metadata)) as Record<string, unknown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert in batches of 500, yielding the event loop between batches so the app
|
||||||
|
// remains responsive to HTTP requests during a large scan.
|
||||||
|
const BATCH_SIZE = 500
|
||||||
|
for (let i = 0; i < allRecords.length; i += BATCH_SIZE) {
|
||||||
|
const batch = allRecords.slice(i, i + BATCH_SIZE)
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const entry of batch) {
|
||||||
|
if (entry.type === 'series') upsertSeries.run(entry.rec)
|
||||||
|
else upsertIssue.run(entry.rec)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
await new Promise<void>((r) => setImmediate(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore previously-imported ComicInfo data for issues that still exist on disk.
|
||||||
|
// Merges scan-derived fields (pageCount, coverUrl) with the saved ComicInfo fields
|
||||||
|
// so neither set of data is lost. Title from ComicInfo is also preserved.
|
||||||
|
if (savedComicInfo.size > 0) {
|
||||||
|
const restoreStmt = db.prepare(
|
||||||
|
'UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata WHERE item_key = @item_key'
|
||||||
|
)
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const [item_key, saved] of savedComicInfo) {
|
||||||
|
const freshMeta = freshMetaMap.get(item_key)
|
||||||
|
if (!freshMeta) continue // file was removed from disk
|
||||||
|
const merged = { ...freshMeta, ...saved.comicFields }
|
||||||
|
restoreStmt.run({ item_key, title: saved.title, year: saved.year, genres: saved.genres, metadata: JSON.stringify(merged) })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prewarm CBZ cover thumbnails — fire-and-forget so they don't block scan completion.
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const issuesToWarm: ComicIssue[] = 'issues' in item
|
const issuesToWarm: ComicIssue[] = 'issues' in item
|
||||||
? (item as ScannedComicSeries).issues.slice(0, 1)
|
? (item as ScannedComicSeries).issues.slice(0, 1)
|
||||||
@@ -643,11 +729,9 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
|||||||
|
|
||||||
for (const issue of issuesToWarm) {
|
for (const issue of issuesToWarm) {
|
||||||
const absPath = path.join(libraryRoot, issue.filePath)
|
const absPath = path.join(libraryRoot, issue.filePath)
|
||||||
try {
|
getCbzThumbnailPath(absPath, library.id).catch((err) => {
|
||||||
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.warn(`[scanner] Could not generate CBZ thumbnail for ${issue.filePath}:`, err instanceof Error ? err.message : err)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,7 +739,7 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
|||||||
|
|
||||||
// Import ComicInfo.xml metadata (title, year, genres, tags)
|
// Import ComicInfo.xml metadata (title, year, genres, tags)
|
||||||
try {
|
try {
|
||||||
importComicMetadata(library)
|
await importComicMetadata(library)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[scanner] Error importing comic metadata for "${library.name}":`, err)
|
console.error(`[scanner] Error importing comic metadata for "${library.name}":`, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,62 @@ export function deleteCategoryForce(id: string): void {
|
|||||||
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
|
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge all tags from `sourceId` category into `targetId` category, then
|
||||||
|
* delete the source category. Tags with conflicting names (case-insensitive)
|
||||||
|
* are combined: their media_tags and tag_mappings rows are re-pointed to the
|
||||||
|
* target tag, and the source tag is deleted.
|
||||||
|
*/
|
||||||
|
export function mergeCategories(sourceId: string, targetId: string): void {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
const source = db.prepare('SELECT id, name FROM tag_categories WHERE id = ?').get(sourceId) as TagCategory | undefined
|
||||||
|
if (!source) throw new Error(`Source category not found: ${sourceId}`)
|
||||||
|
const target = db.prepare('SELECT id, name FROM tag_categories WHERE id = ?').get(targetId) as TagCategory | undefined
|
||||||
|
if (!target) throw new Error(`Target category not found: ${targetId}`)
|
||||||
|
|
||||||
|
const sourceTags = db
|
||||||
|
.prepare('SELECT id, name, category_id as categoryId FROM tags WHERE category_id = ?')
|
||||||
|
.all(sourceId) as Tag[]
|
||||||
|
const targetTags = db
|
||||||
|
.prepare('SELECT id, name, category_id as categoryId FROM tags WHERE category_id = ?')
|
||||||
|
.all(targetId) as Tag[]
|
||||||
|
|
||||||
|
const targetTagsByNameLower = new Map(targetTags.map((t) => [t.name.toLowerCase(), t]))
|
||||||
|
|
||||||
|
const txn = db.transaction(() => {
|
||||||
|
for (const srcTag of sourceTags) {
|
||||||
|
const conflict = targetTagsByNameLower.get(srcTag.name.toLowerCase())
|
||||||
|
if (conflict) {
|
||||||
|
// Re-point media_tags from source tag to target tag (ignore duplicates)
|
||||||
|
db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO media_tags (item_key, tag_id)
|
||||||
|
SELECT item_key, ? FROM media_tags WHERE tag_id = ?`
|
||||||
|
).run(conflict.id, srcTag.id)
|
||||||
|
db.prepare('DELETE FROM media_tags WHERE tag_id = ?').run(srcTag.id)
|
||||||
|
|
||||||
|
// Re-point tag_mappings from source tag to target tag (ignore duplicates)
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE OR IGNORE tag_mappings SET tag_id = ? WHERE tag_id = ?`
|
||||||
|
).run(conflict.id, srcTag.id)
|
||||||
|
// Delete any remaining (were duplicates that couldn't be updated)
|
||||||
|
db.prepare('DELETE FROM tag_mappings WHERE tag_id = ?').run(srcTag.id)
|
||||||
|
|
||||||
|
// Delete the source tag
|
||||||
|
db.prepare('DELETE FROM tags WHERE id = ?').run(srcTag.id)
|
||||||
|
} else {
|
||||||
|
// No conflict — just move the tag to the target category
|
||||||
|
db.prepare('UPDATE tags SET category_id = ? WHERE id = ?').run(targetId, srcTag.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the now-empty source category
|
||||||
|
db.prepare('DELETE FROM tag_categories WHERE id = ?').run(sourceId)
|
||||||
|
})
|
||||||
|
|
||||||
|
txn()
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getTags(categoryId?: string): Tag[] {
|
export function getTags(categoryId?: string): Tag[] {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import fs from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import AdmZip from 'adm-zip'
|
import { extractFirstZipImage } from './zip-utils'
|
||||||
|
|
||||||
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
|
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
|
||||||
const THUMBNAIL_WIDTH = 400
|
const THUMBNAIL_WIDTH = 400
|
||||||
@@ -241,15 +241,7 @@ export async function getCbzThumbnailPath(
|
|||||||
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const zip = new AdmZip(absoluteFilePath)
|
const buffer = await extractFirstZipImage(absoluteFilePath, CBZ_IMAGE_EXTENSIONS)
|
||||||
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'
|
const tmp = cacheFile + '.tmp'
|
||||||
await sharp(buffer).resize(THUMBNAIL_WIDTH).jpeg({ quality: JPEG_QUALITY }).toFile(tmp)
|
await sharp(buffer).resize(THUMBNAIL_WIDTH).jpeg({ quality: JPEG_QUALITY }).toFile(tmp)
|
||||||
fs.renameSync(tmp, cacheFile)
|
fs.renameSync(tmp, cacheFile)
|
||||||
|
|||||||
142
src/lib/tv-metadata.ts
Normal file
142
src/lib/tv-metadata.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import type { Library } from '@/types'
|
||||||
|
import { getDb } from './db'
|
||||||
|
import { resolveLibraryRoot } from './libraries'
|
||||||
|
import { parseEpisodeNfo } from './nfo'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import NFO metadata for TV items (series, seasons, episodes) in a library.
|
||||||
|
* - For series: reads tvshow.nfo in the series folder
|
||||||
|
* - For episodes: reads .nfo file matching the video file
|
||||||
|
* - If importMetadataOnly=false: skip items that already have metadata (title/year/plot/genres)
|
||||||
|
* - If importMetadataOnly=true: update all items regardless of existing metadata
|
||||||
|
*/
|
||||||
|
export async function importTvMetadata(
|
||||||
|
library: Library,
|
||||||
|
importMetadataOnly: boolean = false
|
||||||
|
): Promise<{ imported: number; skipped: number }> {
|
||||||
|
const db = getDb()
|
||||||
|
const libraryRoot = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
let imported = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
// Process TV series
|
||||||
|
const series = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, file_path, title, year, plot, genres FROM media_items
|
||||||
|
WHERE library_id = ? AND item_type = 'tv_series' AND file_path IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }>
|
||||||
|
|
||||||
|
const updateSeriesItem = db.prepare(`
|
||||||
|
UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres
|
||||||
|
WHERE item_key = @item_key
|
||||||
|
`)
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const item of series) {
|
||||||
|
// Check if we should skip this item
|
||||||
|
if (!importMetadataOnly && hasMetadata(item)) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesPath = path.join(libraryRoot, item.file_path)
|
||||||
|
const nfoPath = path.join(seriesPath, 'tvshow.nfo')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(nfoPath)) {
|
||||||
|
const nfoData = parseEpisodeNfo(nfoPath) // Use episode parser as fallback, but mainly we need tvshow parser
|
||||||
|
// For now, we'll just mark as processed; series metadata comes from episodes usually
|
||||||
|
imported++
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Process TV episodes
|
||||||
|
const episodes = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT item_key, file_path, title, year, plot, genres FROM media_items
|
||||||
|
WHERE library_id = ? AND item_type = 'tv_episode' AND file_path IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all(library.id) as Array<{ item_key: string; file_path: string; title: string | null; year: number | null; plot: string | null; genres: string | null }>
|
||||||
|
|
||||||
|
const updateEpisodeItem = db.prepare(`
|
||||||
|
UPDATE media_items SET title = @title, year = @year, plot = @plot, genres = @genres
|
||||||
|
WHERE item_key = @item_key
|
||||||
|
`)
|
||||||
|
|
||||||
|
const BATCH_SIZE = 50
|
||||||
|
for (let i = 0; i < episodes.length; i += BATCH_SIZE) {
|
||||||
|
const batch = episodes.slice(i, i + BATCH_SIZE)
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const item of batch) {
|
||||||
|
// Check if we should skip this item
|
||||||
|
if (!importMetadataOnly && hasMetadata(item)) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoPath = path.join(libraryRoot, item.file_path)
|
||||||
|
const dir = path.dirname(videoPath)
|
||||||
|
const baseNameWithoutExt = path.basename(videoPath, path.extname(videoPath))
|
||||||
|
const nfoPath = path.join(dir, `${baseNameWithoutExt}.nfo`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(nfoPath)) {
|
||||||
|
const nfoData = parseEpisodeNfo(nfoPath)
|
||||||
|
if (nfoData) {
|
||||||
|
updateEpisodeItem.run({
|
||||||
|
item_key: item.item_key,
|
||||||
|
title: nfoData.title ?? item.title,
|
||||||
|
year: nfoData.aired ? new Date(nfoData.aired).getFullYear() : null,
|
||||||
|
plot: nfoData.plot ?? item.plot,
|
||||||
|
genres: item.genres, // Keep existing genres for episodes
|
||||||
|
})
|
||||||
|
imported++
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
await new Promise<void>((r) => setImmediate(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[tv-metadata] Imported metadata for ${imported} episodes in "${library.name}" (${importMetadataOnly ? 'full' : 'incremental'})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return { imported, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a media item already has metadata populated.
|
||||||
|
* Returns true if ANY of: title, year, plot, or genres are populated.
|
||||||
|
*/
|
||||||
|
function hasMetadata(item: {
|
||||||
|
title: string | null
|
||||||
|
year: number | null
|
||||||
|
plot: string | null
|
||||||
|
genres: string | null
|
||||||
|
}): boolean {
|
||||||
|
if (item.title) return true
|
||||||
|
if (item.year) return true
|
||||||
|
if (item.plot) return true
|
||||||
|
if (item.genres) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -81,6 +81,10 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
|
|||||||
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
||||||
: null,
|
: null,
|
||||||
seasonCount,
|
seasonCount,
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +185,10 @@ export function scanTvEpisodes(
|
|||||||
rating: null,
|
rating: null,
|
||||||
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
|
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
|
||||||
videoPath: videoRelPath,
|
videoPath: videoRelPath,
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +212,10 @@ type DbRow = {
|
|||||||
genres: string | null
|
genres: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
file_path: string | null
|
file_path: string | null
|
||||||
|
user_rating: number | null
|
||||||
|
ai_description: string | null
|
||||||
|
extracted_text: string | null
|
||||||
|
extracted_text_translated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tvSeriesFromDb(libraryId: string): TvSeries[] {
|
export function tvSeriesFromDb(libraryId: string): TvSeries[] {
|
||||||
@@ -227,6 +239,10 @@ export function tvSeriesFromDb(libraryId: string): TvSeries[] {
|
|||||||
backdropUrl: meta.backdropUrl ?? null,
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
seasonCount: meta.seasonCount ?? 0,
|
seasonCount: meta.seasonCount ?? 0,
|
||||||
manuallyEdited: meta.manuallyEdited === true,
|
manuallyEdited: meta.manuallyEdited === true,
|
||||||
|
userRating: row.user_rating ?? null,
|
||||||
|
aiDescription: row.ai_description ?? null,
|
||||||
|
extractedText: row.extracted_text ?? null,
|
||||||
|
extractedTextTranslated: row.extracted_text_translated ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -290,6 +306,10 @@ export function tvEpisodesFromDb(
|
|||||||
rating: meta.rating ?? null,
|
rating: meta.rating ?? null,
|
||||||
thumbnailUrl: meta.thumbnailUrl ?? null,
|
thumbnailUrl: meta.thumbnailUrl ?? null,
|
||||||
videoPath: row.file_path ?? '',
|
videoPath: row.file_path ?? '',
|
||||||
|
userRating: row.user_rating ?? null,
|
||||||
|
aiDescription: row.ai_description ?? null,
|
||||||
|
extractedText: row.extracted_text ?? null,
|
||||||
|
extractedTextTranslated: row.extracted_text_translated ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|||||||
232
src/lib/zip-utils.ts
Normal file
232
src/lib/zip-utils.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { open } from 'fs/promises'
|
||||||
|
import type { FileHandle } from 'fs/promises'
|
||||||
|
import zlib from 'zlib'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
|
const inflateRaw = promisify(zlib.inflateRaw)
|
||||||
|
|
||||||
|
const EOCD_SIG = 0x06054b50
|
||||||
|
const CD_SIG = 0x02014b50
|
||||||
|
const LFH_SIG = 0x04034b50
|
||||||
|
|
||||||
|
export interface CdEntry {
|
||||||
|
name: string
|
||||||
|
compressionMethod: number
|
||||||
|
compressedSize: number
|
||||||
|
uncompressedSize: number
|
||||||
|
localHeaderOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a ZIP file's central directory without loading the entire archive.
|
||||||
|
* Returns null if no EOCD record is found (corrupt/non-ZIP file).
|
||||||
|
* Returns an empty array for a valid but empty archive.
|
||||||
|
*/
|
||||||
|
async function readCentralDirectory(fd: FileHandle, fileSize: number): Promise<CdEntry[] | null> {
|
||||||
|
if (fileSize < 22) return null
|
||||||
|
|
||||||
|
// The EOCD record is within the last 65558 bytes (22-byte record + 65535-byte max comment).
|
||||||
|
const tailLen = Math.min(65558, fileSize)
|
||||||
|
const tailBuf = Buffer.allocUnsafe(tailLen)
|
||||||
|
await fd.read(tailBuf, 0, tailLen, fileSize - tailLen)
|
||||||
|
|
||||||
|
// Scan backwards for the EOCD signature.
|
||||||
|
let eocdOff = -1
|
||||||
|
for (let i = tailLen - 22; i >= 0; i--) {
|
||||||
|
if (tailBuf.readUInt32LE(i) === EOCD_SIG) { eocdOff = i; break }
|
||||||
|
}
|
||||||
|
if (eocdOff === -1) return null // no EOCD → corrupt
|
||||||
|
|
||||||
|
const entryCount = tailBuf.readUInt16LE(eocdOff + 10)
|
||||||
|
const cdSize = tailBuf.readUInt32LE(eocdOff + 12)
|
||||||
|
const cdOffset = tailBuf.readUInt32LE(eocdOff + 16)
|
||||||
|
if (entryCount === 0) return [] // valid empty archive
|
||||||
|
if (cdOffset + cdSize > fileSize || cdSize === 0) return null // malformed
|
||||||
|
|
||||||
|
const cdBuf = Buffer.allocUnsafe(cdSize)
|
||||||
|
await fd.read(cdBuf, 0, cdSize, cdOffset)
|
||||||
|
|
||||||
|
const entries: CdEntry[] = []
|
||||||
|
let pos = 0
|
||||||
|
for (let i = 0; i < entryCount && pos + 46 <= cdBuf.length; i++) {
|
||||||
|
if (cdBuf.readUInt32LE(pos) !== CD_SIG) break
|
||||||
|
const compressionMethod = cdBuf.readUInt16LE(pos + 10)
|
||||||
|
const compressedSize = cdBuf.readUInt32LE(pos + 20)
|
||||||
|
const uncompressedSize = cdBuf.readUInt32LE(pos + 24)
|
||||||
|
const filenameLen = cdBuf.readUInt16LE(pos + 28)
|
||||||
|
const extraLen = cdBuf.readUInt16LE(pos + 30)
|
||||||
|
const commentLen = cdBuf.readUInt16LE(pos + 32)
|
||||||
|
const localHeaderOffset = cdBuf.readUInt32LE(pos + 42)
|
||||||
|
const name = cdBuf.toString('utf8', pos + 46, pos + 46 + filenameLen)
|
||||||
|
entries.push({ name, compressionMethod, compressedSize, uncompressedSize, localHeaderOffset })
|
||||||
|
pos += 46 + filenameLen + extraLen + commentLen
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when a ZIP archive has no valid End-of-Central-Directory record. */
|
||||||
|
export class CorruptZipError extends Error {
|
||||||
|
readonly code = 'ERR_CORRUPT_ZIP'
|
||||||
|
constructor(absolutePath: string) {
|
||||||
|
super(`Corrupt or invalid ZIP archive: ${absolutePath}`)
|
||||||
|
this.name = 'CorruptZipError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCorruptZipError(err: unknown): err is CorruptZipError {
|
||||||
|
return err instanceof CorruptZipError ||
|
||||||
|
(err instanceof Error && (err as CorruptZipError).code === 'ERR_CORRUPT_ZIP')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the number of image entries inside a ZIP/CBZ archive by reading
|
||||||
|
* only its central directory — no full-file read required.
|
||||||
|
* Returns { pageCount, valid } where valid=false means the archive has no
|
||||||
|
* valid EOCD record (corrupt file).
|
||||||
|
*/
|
||||||
|
export async function countZipImages(
|
||||||
|
absolutePath: string,
|
||||||
|
imageExtensions: Set<string>
|
||||||
|
): Promise<{ pageCount: number; valid: boolean }> {
|
||||||
|
let fd: FileHandle | null = null
|
||||||
|
try {
|
||||||
|
fd = await open(absolutePath, 'r')
|
||||||
|
const { size } = await fd.stat()
|
||||||
|
const entries = await readCentralDirectory(fd, size)
|
||||||
|
if (entries === null) return { pageCount: 0, valid: false }
|
||||||
|
const pageCount = entries.filter((e) => {
|
||||||
|
if (e.name.endsWith('/')) return false
|
||||||
|
const dot = e.name.lastIndexOf('.')
|
||||||
|
return dot !== -1 && imageExtensions.has(e.name.slice(dot).toLowerCase())
|
||||||
|
}).length
|
||||||
|
return { pageCount, valid: true }
|
||||||
|
} catch {
|
||||||
|
return { pageCount: 0, valid: false }
|
||||||
|
} finally {
|
||||||
|
await fd?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the raw bytes of a specific entry from a ZIP archive.
|
||||||
|
* Reads only the local file header + compressed data for that entry.
|
||||||
|
* Supports stored (method 0) and deflate (method 8).
|
||||||
|
*/
|
||||||
|
export async function extractZipEntry(absolutePath: string, entry: CdEntry): Promise<Buffer | null> {
|
||||||
|
let fd: FileHandle | null = null
|
||||||
|
try {
|
||||||
|
fd = await open(absolutePath, 'r')
|
||||||
|
|
||||||
|
// Read local file header (30 bytes) to get exact data offset.
|
||||||
|
const lfhBuf = Buffer.allocUnsafe(30)
|
||||||
|
await fd.read(lfhBuf, 0, 30, entry.localHeaderOffset)
|
||||||
|
if (lfhBuf.readUInt32LE(0) !== LFH_SIG) return null
|
||||||
|
const localFilenameLen = lfhBuf.readUInt16LE(26)
|
||||||
|
const localExtraLen = lfhBuf.readUInt16LE(28)
|
||||||
|
const dataOffset = entry.localHeaderOffset + 30 + localFilenameLen + localExtraLen
|
||||||
|
|
||||||
|
const compressedBuf = Buffer.allocUnsafe(entry.compressedSize)
|
||||||
|
await fd.read(compressedBuf, 0, entry.compressedSize, dataOffset)
|
||||||
|
|
||||||
|
if (entry.compressionMethod === 0) return compressedBuf
|
||||||
|
if (entry.compressionMethod === 8) return await inflateRaw(compressedBuf) as Buffer
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
await fd?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a named entry (case-insensitive) in a ZIP archive's central directory.
|
||||||
|
* Returns null if not found or on error.
|
||||||
|
*/
|
||||||
|
export async function findZipEntry(absolutePath: string, entryName: string): Promise<CdEntry | null> {
|
||||||
|
let fd: FileHandle | null = null
|
||||||
|
try {
|
||||||
|
fd = await open(absolutePath, 'r')
|
||||||
|
const { size } = await fd.stat()
|
||||||
|
const entries = await readCentralDirectory(fd, size)
|
||||||
|
if (!entries) return null
|
||||||
|
const lower = entryName.toLowerCase()
|
||||||
|
return entries.find((e) => {
|
||||||
|
const n = e.name.toLowerCase()
|
||||||
|
return n === lower || n.endsWith('/' + lower)
|
||||||
|
}) ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
await fd?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the first image entry (natural sort) from a ZIP/CBZ archive.
|
||||||
|
* Reads only the central directory and the single chosen entry — no full-file load.
|
||||||
|
* Throws CorruptZipError if the archive has no valid structure.
|
||||||
|
*/
|
||||||
|
export async function extractFirstZipImage(
|
||||||
|
absolutePath: string,
|
||||||
|
imageExtensions: Set<string>
|
||||||
|
): Promise<Buffer> {
|
||||||
|
let fd: FileHandle | null = null
|
||||||
|
try {
|
||||||
|
fd = await open(absolutePath, 'r')
|
||||||
|
const { size } = await fd.stat()
|
||||||
|
const entries = await readCentralDirectory(fd, size)
|
||||||
|
if (entries === null) throw new CorruptZipError(absolutePath)
|
||||||
|
|
||||||
|
const imageEntries = entries
|
||||||
|
.filter((e) => {
|
||||||
|
if (e.name.endsWith('/')) return false
|
||||||
|
const dot = e.name.lastIndexOf('.')
|
||||||
|
return dot !== -1 && imageExtensions.has(e.name.slice(dot).toLowerCase())
|
||||||
|
})
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
)
|
||||||
|
|
||||||
|
if (imageEntries.length === 0) throw new Error(`No image entries in archive: ${absolutePath}`)
|
||||||
|
|
||||||
|
const entry = imageEntries[0]
|
||||||
|
|
||||||
|
// Read local file header to get the exact data offset.
|
||||||
|
const lfhBuf = Buffer.allocUnsafe(30)
|
||||||
|
await fd.read(lfhBuf, 0, 30, entry.localHeaderOffset)
|
||||||
|
if (lfhBuf.readUInt32LE(0) !== LFH_SIG) throw new CorruptZipError(absolutePath)
|
||||||
|
const localFilenameLen = lfhBuf.readUInt16LE(26)
|
||||||
|
const localExtraLen = lfhBuf.readUInt16LE(28)
|
||||||
|
const dataOffset = entry.localHeaderOffset + 30 + localFilenameLen + localExtraLen
|
||||||
|
|
||||||
|
const compressedBuf = Buffer.allocUnsafe(entry.compressedSize)
|
||||||
|
await fd.read(compressedBuf, 0, entry.compressedSize, dataOffset)
|
||||||
|
|
||||||
|
if (entry.compressionMethod === 0) return compressedBuf
|
||||||
|
if (entry.compressionMethod === 8) return await inflateRaw(compressedBuf) as Buffer
|
||||||
|
throw new Error(`Unsupported compression method ${entry.compressionMethod}: ${absolutePath}`)
|
||||||
|
} finally {
|
||||||
|
await fd?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an array of items concurrently with a concurrency limit.
|
||||||
|
* Preserves index order in results.
|
||||||
|
*/
|
||||||
|
export async function mapConcurrent<T, U>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T) => Promise<U>
|
||||||
|
): Promise<U[]> {
|
||||||
|
const results: U[] = new Array(items.length)
|
||||||
|
let next = 0
|
||||||
|
async function worker(): Promise<void> {
|
||||||
|
while (next < items.length) {
|
||||||
|
const i = next++
|
||||||
|
results[i] = await fn(items[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
export type LibraryType = 'comics' | 'games' | 'mixed' | 'movies' | 'tv'
|
export type LibraryType = 'comics' | 'games' | 'mixed' | 'movies' | 'tv'
|
||||||
|
|
||||||
|
export type RatingOperator = 'gte' | 'eq' | 'lte'
|
||||||
|
|
||||||
export interface ComicSeries {
|
export interface ComicSeries {
|
||||||
id: string
|
id: string
|
||||||
item_key?: string
|
item_key?: string
|
||||||
title: string
|
title: string
|
||||||
coverUrl: string | null
|
coverUrl: string | null
|
||||||
issueCount: number
|
issueCount: number
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComicIssue {
|
export interface ComicIssue {
|
||||||
@@ -17,6 +23,10 @@ export interface ComicIssue {
|
|||||||
coverUrl: string | null
|
coverUrl: string | null
|
||||||
filePath: string
|
filePath: string
|
||||||
isStandalone: boolean
|
isStandalone: boolean
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Library {
|
export interface Library {
|
||||||
@@ -45,6 +55,10 @@ export interface Game {
|
|||||||
wideCoverUrl: string | null
|
wideCoverUrl: string | null
|
||||||
gameFiles: GameFile[]
|
gameFiles: GameFile[]
|
||||||
platforms: GamePlatform[]
|
platforms: GamePlatform[]
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameSeries {
|
export interface GameSeries {
|
||||||
@@ -65,6 +79,10 @@ export interface FileEntry {
|
|||||||
url: string | null
|
url: string | null
|
||||||
thumbnailUrl: string | null
|
thumbnailUrl: string | null
|
||||||
hasExtractedText?: boolean
|
hasExtractedText?: boolean
|
||||||
|
userRating?: number | null
|
||||||
|
aiDescription?: string | null
|
||||||
|
extractedText?: string | null
|
||||||
|
extractedTextTranslated?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Movie {
|
export interface Movie {
|
||||||
@@ -80,6 +98,10 @@ export interface Movie {
|
|||||||
backdropUrl: string | null
|
backdropUrl: string | null
|
||||||
videoPath: string
|
videoPath: string
|
||||||
manuallyEdited?: boolean
|
manuallyEdited?: boolean
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TvSeries {
|
export interface TvSeries {
|
||||||
@@ -94,6 +116,10 @@ export interface TvSeries {
|
|||||||
backdropUrl: string | null
|
backdropUrl: string | null
|
||||||
seasonCount: number
|
seasonCount: number
|
||||||
manuallyEdited?: boolean
|
manuallyEdited?: boolean
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TvSeason {
|
export interface TvSeason {
|
||||||
@@ -117,6 +143,10 @@ export interface TvEpisode {
|
|||||||
rating: number | null
|
rating: number | null
|
||||||
thumbnailUrl: string | null
|
thumbnailUrl: string | null
|
||||||
videoPath: string
|
videoPath: string
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirectoryListing {
|
export interface DirectoryListing {
|
||||||
|
|||||||
Reference in New Issue
Block a user