diff --git a/src/app/api/browse/route.ts b/src/app/api/browse/route.ts index 63cf227..0433488 100644 --- a/src/app/api/browse/route.ts +++ b/src/app/api/browse/route.ts @@ -30,7 +30,7 @@ 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 diff --git a/src/lib/files.ts b/src/lib/files.ts index 73fa333..2a06ed7 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -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 { 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 { 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 } }