import fs from 'fs' import path from 'path' import type { DirectoryListing, FileEntry, MediaType } from '@/types' import { resolveAndJail } from '@/lib/libraries' import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl } from './media-utils' // Web-playable video formats for the Mixed Media browser (distinct from the // broader set used by dedicated movie/TV scanners). const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v']) const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) function getMediaType(filename: string): MediaType { const ext = path.extname(filename).toLowerCase() if (VIDEO_EXTENSIONS.has(ext)) return 'video' if (IMAGE_EXTENSIONS.has(ext)) return 'image' return 'other' } export function scanDirectory( libraryRoot: string, libraryId: string, subpath: string ): DirectoryListing { let absPath: string try { absPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot } catch { return { path: subpath, entries: [] } } let dirents: fs.Dirent[] try { dirents = fs.readdirSync(absPath, { withFileTypes: true }) } catch { return { path: subpath, entries: [] } } const entries: FileEntry[] = dirents .filter((d) => !HIDDEN_FILES.test(d.name)) .map((d): FileEntry => { if (d.isDirectory()) { return { name: d.name, type: 'directory', mediaType: null, url: null, thumbnailUrl: null, } } const relPath = subpath ? path.join(subpath, d.name) : d.name const mediaType = getMediaType(d.name) const hasThumbnail = mediaType === 'image' || mediaType === 'video' return { name: d.name, type: 'file', mediaType, url: fileApiUrl(libraryId, relPath), thumbnailUrl: hasThumbnail ? thumbnailApiUrl(libraryId, relPath) : null, } }) // Sort: directories first, then files; each group sorted alphabetically entries.sort((a, b) => { if (a.type !== b.type) return a.type === 'directory' ? -1 : 1 return a.name.localeCompare(b.name) }) return { path: subpath, entries } } /** * 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). */ export function scanDirectoryRecursive( libraryRoot: string, libraryId: string, subpath: string ): DirectoryListing { let rootAbsPath: string try { rootAbsPath = subpath ? resolveAndJail(libraryRoot, subpath) : libraryRoot } catch { return { path: subpath, entries: [] } } const entries: FileEntry[] = [] function walk(absDir: string, relDir: string): void { let dirents: fs.Dirent[] try { dirents = fs.readdirSync(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, }) } } } walk(rootAbsPath, '') entries.sort((a, b) => a.name.localeCompare(b.name)) return { path: subpath, entries } }