Compare commits
4 Commits
da3ad97d51
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f1ad4f5dd | |||
|
|
e283d03e95 | ||
|
|
0e600e5f6c | ||
|
|
2cf8bc6d7d |
@@ -30,15 +30,37 @@ export async function GET(request: NextRequest) {
|
||||
const root = resolveLibraryRoot(library)
|
||||
const recursive = request.nextUrl.searchParams.get('recursive') === 'true'
|
||||
const listing = recursive
|
||||
? scanDirectoryRecursive(root, libraryId, subpath)
|
||||
? await scanDirectoryRecursive(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 rows = db
|
||||
.prepare('SELECT item_key FROM media_items WHERE library_id = ? AND extracted_text IS NOT NULL')
|
||||
.all(libraryId) as { item_key: string }[]
|
||||
const withText = new Set(rows.map((r) => r.item_key))
|
||||
const metadataRows = db
|
||||
.prepare(`
|
||||
SELECT item_key, user_rating, ai_description, extracted_text, extracted_text_translated
|
||||
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
|
||||
// e.g. item_key "lib:mixed_file:manga%2Fch1%2Fp1.jpg" → ancestors "manga", "manga/ch1"
|
||||
@@ -52,19 +74,19 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const ratingRows = db
|
||||
.prepare('SELECT item_key, user_rating FROM media_items WHERE library_id = ? AND user_rating IS NOT NULL')
|
||||
.all(libraryId) as { item_key: string; user_rating: number }[]
|
||||
const ratingMap = new Map(ratingRows.map((r) => [r.item_key, r.user_rating]))
|
||||
|
||||
listing.entries = listing.entries.map((e) => {
|
||||
if (e.type === 'file') {
|
||||
const relPath = subpath ? path.join(subpath, e.name) : e.name
|
||||
// Recursive listing already uses full path from library root in e.name.
|
||||
const relPath = recursive ? e.name : (subpath ? path.join(subpath, e.name) : e.name)
|
||||
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
|
||||
const metadata = metadataByItemKey.get(itemKey)
|
||||
return {
|
||||
...e,
|
||||
...(e.mediaType === 'image' ? { hasExtractedText: withText.has(itemKey) } : {}),
|
||||
userRating: ratingMap.get(itemKey) ?? null,
|
||||
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') {
|
||||
|
||||
@@ -170,7 +170,16 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
||||
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
||||
|
||||
const filteredEntries = useMemo(() => sourceEntries.filter((entry) => {
|
||||
if (debouncedSearch && !entry.name.toLowerCase().includes(debouncedSearch.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') {
|
||||
const entryTags = assignments[itemKeyFor(entry)] ?? []
|
||||
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
|
||||
|
||||
@@ -12,7 +12,12 @@ export function getDb(): Database.Database {
|
||||
_db = new Database(DB_PATH)
|
||||
_db.pragma('journal_mode = WAL')
|
||||
_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)
|
||||
_db.pragma('wal_checkpoint(PASSIVE)')
|
||||
return _db
|
||||
}
|
||||
|
||||
@@ -113,6 +118,7 @@ function initDb(db: Database.Database): void {
|
||||
migrateComicsIndex(db)
|
||||
migrateTagMappingsIndexes(db)
|
||||
migrateUserRating(db)
|
||||
migrateParentKeyItemTypeIndex(db)
|
||||
seedAppSettings(db)
|
||||
}
|
||||
|
||||
@@ -467,6 +473,13 @@ function migrateTagMappingsIndexes(db: Database.Database): void {
|
||||
`)
|
||||
}
|
||||
|
||||
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')) {
|
||||
|
||||
@@ -74,12 +74,16 @@ export function scanDirectory(
|
||||
* Recursively walks every subdirectory under `subpath` and returns a flat list
|
||||
* 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).
|
||||
*
|
||||
* 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,
|
||||
libraryId: string,
|
||||
subpath: string
|
||||
): DirectoryListing {
|
||||
): Promise<DirectoryListing> {
|
||||
let rootAbsPath: string
|
||||
try {
|
||||
rootAbsPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot
|
||||
@@ -89,35 +93,37 @@ export function scanDirectoryRecursive(
|
||||
|
||||
const entries: FileEntry[] = []
|
||||
|
||||
function walk(absDir: string, relDir: string): void {
|
||||
async function walk(absDir: string, relDir: string): Promise<void> {
|
||||
let dirents: fs.Dirent[]
|
||||
try {
|
||||
dirents = fs.readdirSync(absDir, { withFileTypes: true })
|
||||
dirents = await fs.promises.readdir(absDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const d of dirents) {
|
||||
if (HIDDEN_FILES.test(d.name)) continue
|
||||
const relPath = relDir ? path.join(relDir, d.name) : d.name
|
||||
if (d.isDirectory()) {
|
||||
walk(path.join(absDir, d.name), relPath)
|
||||
} else {
|
||||
const mediaType = getMediaType(d.name)
|
||||
const hasThumbnail = mediaType === 'image' || mediaType === 'video'
|
||||
// name = full relative path from library root so media keys match
|
||||
const fullRelPath = subpath ? path.join(subpath, relPath) : relPath
|
||||
entries.push({
|
||||
name: fullRelPath,
|
||||
type: 'file',
|
||||
mediaType,
|
||||
url: fileApiUrl(libraryId, fullRelPath),
|
||||
thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, fullRelPath) : null,
|
||||
})
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
dirents.map(async (d) => {
|
||||
if (HIDDEN_FILES.test(d.name)) return
|
||||
const relPath = relDir ? path.join(relDir, d.name) : d.name
|
||||
if (d.isDirectory()) {
|
||||
await walk(path.join(absDir, d.name), relPath)
|
||||
} else {
|
||||
const mediaType = getMediaType(d.name)
|
||||
const hasThumbnail = mediaType === 'image' || mediaType === 'video'
|
||||
// name = full relative path from library root so media keys match
|
||||
const fullRelPath = subpath ? path.join(subpath, relPath) : relPath
|
||||
entries.push({
|
||||
name: fullRelPath,
|
||||
type: 'file',
|
||||
mediaType,
|
||||
url: fileApiUrl(libraryId, fullRelPath),
|
||||
thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, fullRelPath) : null,
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
walk(rootAbsPath, '')
|
||||
await walk(rootAbsPath, '')
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return { path: subpath, entries }
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ export interface FileEntry {
|
||||
thumbnailUrl: string | null
|
||||
hasExtractedText?: boolean
|
||||
userRating?: number | null
|
||||
aiDescription?: string | null
|
||||
extractedText?: string | null
|
||||
extractedTextTranslated?: string | null
|
||||
}
|
||||
|
||||
export interface Movie {
|
||||
|
||||
Reference in New Issue
Block a user