- Extract shared utilities (HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile) into new src/lib/media-utils.ts, removing identical copies from games.ts, movies.ts, tv.ts, files.ts, and scanner.ts - Add comment in files.ts clarifying why its VIDEO_EXTENSIONS set intentionally differs from the media library set (web-playable formats for the mixed browser) - Rewrite README to reflect the current feature set: Movies/TV libraries, auth system, tag system, background scanner, updated project structure, folder conventions for all four library types, and a complete API reference Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
124 lines
3.8 KiB
TypeScript
124 lines
3.8 KiB
TypeScript
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 }
|
|
}
|