Compare commits

..

12 Commits

Author SHA1 Message Date
9f1ad4f5dd Merge pull request 'manual-cleanup' (#37) from manual-cleanup into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m50s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/37
2026-04-26 21:08:11 +00:00
Garret Patti
e283d03e95 update db pragma 2026-04-26 17:07:20 -04:00
Garret Patti
0e600e5f6c search mixed few text 2026-04-21 17:57:52 -04:00
Garret Patti
2cf8bc6d7d search-fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 56s
2026-04-21 14:55:28 -04:00
da3ad97d51 Merge pull request 'ratings' (#36) from ratings into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 57s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/36
2026-04-21 15:18:00 +00:00
Garret Patti
b5d144c8cc add ratings to doom scroll 2026-04-21 11:17:43 -04:00
Garret Patti
d854bbe99b add rating system 2026-04-21 10:57:08 -04:00
d2057fb81c Merge pull request 'comic library improvements' (#35) from comic-improv into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 57s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/35
2026-04-21 01:42:43 +00:00
Garret Patti
27430dbf52 comic library improvements 2026-04-20 21:42:23 -04:00
Garret Patti
bd028a7a5d scan fixes
All checks were successful
Build and Push Docker Image / build (push) Successful in 56s
2026-04-20 20:31:18 -04:00
Garret Patti
8f8f8c3001 mapping-tweaks
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m8s
2026-04-20 19:56:12 -04:00
Garret Patti
dee9356004 trash corrupt files
All checks were successful
Build and Push Docker Image / build (push) Successful in 57s
2026-04-20 11:44:30 -04:00
34 changed files with 2164 additions and 302 deletions

28
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

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

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

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

View 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 15' }, { 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 })
}

View File

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

View File

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

View File

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

View File

@@ -543,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) {
@@ -586,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">

View File

@@ -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 &amp; 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={{

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { useCallback, useEffect, useRef, 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'
@@ -22,11 +22,17 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [total, setTotal] = useState(0) 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[] }>
>({}) >({})
@@ -69,6 +75,16 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
useEffect(() => { fetchItems(1, '', true) }, [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 // IntersectionObserver: load next page when sentinel scrolls into view
useEffect(() => { useEffect(() => {
const sentinel = sentinelRef.current const sentinel = sentinelRef.current
@@ -120,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 (
<> <>
@@ -181,11 +235,31 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
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 ? (
@@ -205,38 +279,63 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
</div> </div>
) : ( ) : (
<> <>
{total > PAGE_SIZE && ( {!selectedSeries && total > PAGE_SIZE && (
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
Showing {filtered.length.toLocaleString()} of {total.toLocaleString()} Showing {filtered.length.toLocaleString()} of {total.toLocaleString()}
</p> </p>
)} )}
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> {seriesIssuesLoading ? (
{filtered.map((item) => <LoadingGrid />
'issueCount' in item ? ( ) : (
<SeriesCard <div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
key={item.id} {selectedSeries
series={item as ComicSeries} ? seriesIssues.map((issue) => (
readOnly={readOnly} <IssueCard
onClick={() => setSelectedSeries(item as ComicSeries)} key={issue.id}
onTagClick={(item as ComicSeries).item_key && !readOnly issue={issue}
? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title }) readOnly={readOnly}
: undefined} onClick={() => { setSelectedIssue(issue); setSelectedIssueIndex(seriesIssues.indexOf(issue)) }}
/> onTagClick={issue.item_key && !readOnly
) : ( ? () => setTagPanel({ itemKey: issue.item_key!, title: issue.title })
<IssueCard : undefined}
key={item.id} />
issue={item as ComicIssue} ))
readOnly={readOnly} : filtered.map((item) =>
onClick={() => setSelectedIssue(item as ComicIssue)} 'issueCount' in item ? (
onTagClick={(item as ComicIssue).item_key && !readOnly <SeriesCard
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title }) key={item.id}
: undefined} series={item as ComicSeries}
/> readOnly={readOnly}
) onClick={() => { setSelectedSeries(item as ComicSeries); setSearch('') }}
)} onTagClick={(item as ComicSeries).item_key && !readOnly
</div> ? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title })
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden /> : undefined}
{loadingMore && <LoadingMore />} />
) : (
<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>
@@ -285,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}
/> />
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,27 +18,24 @@ 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
@@ -214,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
}

View File

@@ -5,6 +5,7 @@ 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 { 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'])
@@ -28,6 +29,30 @@ export interface ScannedComicSeries extends ComicSeries {
issues: ComicIssue[] issues: ComicIssue[]
} }
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 { interface CollectedCbz {
absPath: string absPath: string
filename: string filename: string
@@ -93,25 +118,45 @@ export async function scanComicsLibrary(
// Phase 2: Count pages for all CBZ files concurrently (10 at a time) by reading // 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. // only each archive's central directory — no full-file reads.
const pageCounts = await mapConcurrent(collected, 10, (c) => const scanResults = await mapConcurrent(collected, 10, (c) =>
countZipImages(c.absPath, CBZ_IMAGE_EXTENSIONS) countZipImages(c.absPath, CBZ_IMAGE_EXTENSIONS)
) )
// Phase 3: Build the result array from collected metadata + page counts. // 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 seriesMap = new Map<string, ScannedComicSeries>()
const standaloneIssues: ComicIssue[] = [] const standaloneIssues: ComicIssue[] = []
for (let i = 0; i < collected.length; i++) { for (const { cbz: c, pageCount } of valid) {
const c = collected[i]
const coverUrl = thumbnailApiUrl(libraryId, c.relPath) const coverUrl = thumbnailApiUrl(libraryId, c.relPath)
const issue: ComicIssue = { const issue: ComicIssue = {
id: encodeURIComponent(c.relPath), id: encodeURIComponent(c.relPath),
title: path.basename(c.filename, path.extname(c.filename)), title: path.basename(c.filename, path.extname(c.filename)),
issueNumber: parseIssueNumber(c.filename), issueNumber: parseIssueNumber(c.filename),
pageCount: pageCounts[i], pageCount,
coverUrl, coverUrl,
filePath: c.relPath, filePath: c.relPath,
isStandalone: c.isStandalone, isStandalone: c.isStandalone,
userRating: null,
aiDescription: null,
extractedText: null,
extractedTextTranslated: null,
} }
if (c.isStandalone) { if (c.isStandalone) {
@@ -125,6 +170,10 @@ export async function scanComicsLibrary(
coverUrl, // first issue (sorted) becomes the series cover coverUrl, // first issue (sorted) becomes the series cover
issueCount: 0, issueCount: 0,
issues: [], issues: [],
userRating: null,
aiDescription: null,
extractedText: null,
extractedTextTranslated: null,
}) })
} }
const series = seriesMap.get(key)! const series = seriesMap.get(key)!
@@ -160,6 +209,10 @@ export function comicsFromDb(
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 baseWhere = ` const baseWhere = `
@@ -175,17 +228,20 @@ export function comicsFromDb(
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere}`) .prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere}`)
.get(libraryId) as { cnt: number }).cnt .get(libraryId) as { cnt: number }).cnt
const cols = `item_key, item_type, parent_key, title, metadata, file_path,
user_rating, ai_description, extracted_text, extracted_text_translated`
const rows: DbRow[] = opts.search const rows: DbRow[] = opts.search
? db ? db
.prepare( .prepare(
`SELECT item_key, item_type, parent_key, title, metadata, file_path `SELECT ${cols}
FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\' FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'
ORDER BY title LIMIT ? OFFSET ?` ORDER BY title LIMIT ? OFFSET ?`
) )
.all(libraryId, escapeLike(opts.search), opts.pageSize, offset) as DbRow[] .all(libraryId, escapeLike(opts.search), opts.pageSize, offset) as DbRow[]
: db : db
.prepare( .prepare(
`SELECT item_key, item_type, parent_key, title, metadata, file_path `SELECT ${cols}
FROM media_items ${baseWhere} FROM media_items ${baseWhere}
ORDER BY title LIMIT ? OFFSET ?` ORDER BY title LIMIT ? OFFSET ?`
) )
@@ -202,6 +258,10 @@ export function comicsFromDb(
title: row.title ?? decodeURIComponent(idPart), title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? null, coverUrl: meta.coverUrl ?? null,
issueCount: meta.issueCount ?? 0, issueCount: meta.issueCount ?? 0,
userRating: row.user_rating ?? null,
aiDescription: row.ai_description ?? null,
extractedText: row.extracted_text ?? null,
extractedTextTranslated: row.extracted_text_translated ?? null,
} as ComicSeries) } as ComicSeries)
} else { } else {
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
@@ -214,6 +274,10 @@ export function comicsFromDb(
coverUrl: meta.coverUrl ?? null, coverUrl: meta.coverUrl ?? null,
filePath: row.file_path ?? '', filePath: row.file_path ?? '',
isStandalone: meta.isStandalone ?? true, 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) } as ComicIssue)
} }
} }
@@ -230,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'`
) )
@@ -252,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,
} }
}) })

View File

@@ -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
} }
@@ -111,6 +116,9 @@ function initDb(db: Database.Database): void {
migrateComicItemTypes(db) migrateComicItemTypes(db)
migrateImportedTags(db) migrateImportedTags(db)
migrateComicsIndex(db) migrateComicsIndex(db)
migrateTagMappingsIndexes(db)
migrateUserRating(db)
migrateParentKeyItemTypeIndex(db)
seedAppSettings(db) seedAppSettings(db)
} }
@@ -455,3 +463,26 @@ function migrateComicsIndex(db: Database.Database): void {
ON media_items(library_id, item_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')
}
}

View File

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

View File

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

View File

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

View File

@@ -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
@@ -550,6 +552,36 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
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(`
@@ -646,6 +678,18 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
} }
} }
// 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 // Insert in batches of 500, yielding the event loop between batches so the app
// remains responsive to HTTP requests during a large scan. // remains responsive to HTTP requests during a large scan.
const BATCH_SIZE = 500 const BATCH_SIZE = 500
@@ -660,6 +704,23 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
await new Promise<void>((r) => setImmediate(r)) 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. // 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

View File

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

View File

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

View File

@@ -19,10 +19,11 @@ export interface CdEntry {
/** /**
* Read a ZIP file's central directory without loading the entire archive. * Read a ZIP file's central directory without loading the entire archive.
* Opens only the last ~2264KB of the file (EOCD + central directory). * 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[]> { async function readCentralDirectory(fd: FileHandle, fileSize: number): Promise<CdEntry[] | null> {
if (fileSize < 22) return [] if (fileSize < 22) return null
// The EOCD record is within the last 65558 bytes (22-byte record + 65535-byte max comment). // The EOCD record is within the last 65558 bytes (22-byte record + 65535-byte max comment).
const tailLen = Math.min(65558, fileSize) const tailLen = Math.min(65558, fileSize)
@@ -34,12 +35,13 @@ async function readCentralDirectory(fd: FileHandle, fileSize: number): Promise<C
for (let i = tailLen - 22; i >= 0; i--) { for (let i = tailLen - 22; i >= 0; i--) {
if (tailBuf.readUInt32LE(i) === EOCD_SIG) { eocdOff = i; break } if (tailBuf.readUInt32LE(i) === EOCD_SIG) { eocdOff = i; break }
} }
if (eocdOff === -1) return [] if (eocdOff === -1) return null // no EOCD → corrupt
const entryCount = tailBuf.readUInt16LE(eocdOff + 10) const entryCount = tailBuf.readUInt16LE(eocdOff + 10)
const cdSize = tailBuf.readUInt32LE(eocdOff + 12) const cdSize = tailBuf.readUInt32LE(eocdOff + 12)
const cdOffset = tailBuf.readUInt32LE(eocdOff + 16) const cdOffset = tailBuf.readUInt32LE(eocdOff + 16)
if (cdOffset + cdSize > fileSize || cdSize === 0) return [] if (entryCount === 0) return [] // valid empty archive
if (cdOffset + cdSize > fileSize || cdSize === 0) return null // malformed
const cdBuf = Buffer.allocUnsafe(cdSize) const cdBuf = Buffer.allocUnsafe(cdSize)
await fd.read(cdBuf, 0, cdSize, cdOffset) await fd.read(cdBuf, 0, cdSize, cdOffset)
@@ -62,26 +64,44 @@ async function readCentralDirectory(fd: FileHandle, fileSize: number): Promise<C
return entries 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 * Count the number of image entries inside a ZIP/CBZ archive by reading
* only its central directory — no full-file read required. * 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( export async function countZipImages(
absolutePath: string, absolutePath: string,
imageExtensions: Set<string> imageExtensions: Set<string>
): Promise<number> { ): Promise<{ pageCount: number; valid: boolean }> {
let fd: FileHandle | null = null let fd: FileHandle | null = null
try { try {
fd = await open(absolutePath, 'r') fd = await open(absolutePath, 'r')
const { size } = await fd.stat() const { size } = await fd.stat()
const entries = await readCentralDirectory(fd, size) const entries = await readCentralDirectory(fd, size)
return entries.filter((e) => { if (entries === null) return { pageCount: 0, valid: false }
const pageCount = entries.filter((e) => {
if (e.name.endsWith('/')) return false if (e.name.endsWith('/')) return false
const dot = e.name.lastIndexOf('.') const dot = e.name.lastIndexOf('.')
return dot !== -1 && imageExtensions.has(e.name.slice(dot).toLowerCase()) return dot !== -1 && imageExtensions.has(e.name.slice(dot).toLowerCase())
}).length }).length
return { pageCount, valid: true }
} catch { } catch {
return 0 return { pageCount: 0, valid: false }
} finally { } finally {
await fd?.close() await fd?.close()
} }
@@ -128,6 +148,7 @@ export async function findZipEntry(absolutePath: string, entryName: string): Pro
fd = await open(absolutePath, 'r') fd = await open(absolutePath, 'r')
const { size } = await fd.stat() const { size } = await fd.stat()
const entries = await readCentralDirectory(fd, size) const entries = await readCentralDirectory(fd, size)
if (!entries) return null
const lower = entryName.toLowerCase() const lower = entryName.toLowerCase()
return entries.find((e) => { return entries.find((e) => {
const n = e.name.toLowerCase() const n = e.name.toLowerCase()
@@ -140,6 +161,55 @@ export async function findZipEntry(absolutePath: string, entryName: string): Pro
} }
} }
/**
* 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. * Process an array of items concurrently with a concurrency limit.
* Preserves index order in results. * Preserves index order in results.

View File

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