Compare commits

...

4 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
5 changed files with 91 additions and 38 deletions

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"
@@ -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) => { listing.entries = listing.entries.map((e) => {
if (e.type === 'file') { 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 itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
const metadata = metadataByItemKey.get(itemKey)
return { return {
...e, ...e,
...(e.mediaType === 'image' ? { hasExtractedText: withText.has(itemKey) } : {}), ...(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') { if (e.type === 'directory') {

View File

@@ -170,7 +170,16 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? []) const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
const filteredEntries = useMemo(() => sourceEntries.filter((entry) => { 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') { 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

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
} }
@@ -113,6 +118,7 @@ function initDb(db: Database.Database): void {
migrateComicsIndex(db) migrateComicsIndex(db)
migrateTagMappingsIndexes(db) migrateTagMappingsIndexes(db)
migrateUserRating(db) migrateUserRating(db)
migrateParentKeyItemTypeIndex(db)
seedAppSettings(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 { function migrateUserRating(db: Database.Database): void {
const cols = db.pragma('table_info(media_items)') as { name: string }[] const cols = db.pragma('table_info(media_items)') as { name: string }[]
if (!cols.some((c) => c.name === 'user_rating')) { if (!cols.some((c) => c.name === 'user_rating')) {

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

@@ -80,6 +80,9 @@ export interface FileEntry {
thumbnailUrl: string | null thumbnailUrl: string | null
hasExtractedText?: boolean hasExtractedText?: boolean
userRating?: number | null userRating?: number | null
aiDescription?: string | null
extractedText?: string | null
extractedTextTranslated?: string | null
} }
export interface Movie { export interface Movie {