initial version
This commit is contained in:
67
src/lib/files.ts
Normal file
67
src/lib/files.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { DirectoryListing, FileEntry, MediaType } from '@/types'
|
||||
|
||||
const HIDDEN_FILES = /^\./
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function fileApiUrl(libraryId: string, relativePath: string): string {
|
||||
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
||||
}
|
||||
|
||||
export function scanDirectory(
|
||||
libraryRoot: string,
|
||||
libraryId: string,
|
||||
subpath: string
|
||||
): DirectoryListing {
|
||||
const absPath = subpath
|
||||
? path.resolve(libraryRoot, subpath)
|
||||
: libraryRoot
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
const relPath = subpath ? path.join(subpath, d.name) : d.name
|
||||
const mediaType = getMediaType(d.name)
|
||||
|
||||
return {
|
||||
name: d.name,
|
||||
type: 'file',
|
||||
mediaType,
|
||||
url: fileApiUrl(libraryId, relPath),
|
||||
}
|
||||
})
|
||||
|
||||
// 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 }
|
||||
}
|
||||
77
src/lib/games.ts
Normal file
77
src/lib/games.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { Game } from '@/types'
|
||||
|
||||
const HIDDEN_FILES = /^\./
|
||||
|
||||
/**
|
||||
* Finds the first file in a directory whose basename (without extension)
|
||||
* matches the given pattern (case-insensitive).
|
||||
*/
|
||||
function findFile(dir: string, pattern: RegExp): string | null {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = fs.readdirSync(dir)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const match = entries.find(
|
||||
(entry) => !HIDDEN_FILES.test(entry) && pattern.test(path.basename(entry, path.extname(entry)))
|
||||
)
|
||||
return match ?? null
|
||||
}
|
||||
|
||||
function fileApiUrl(libraryId: string, relativePath: string): string {
|
||||
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
||||
}
|
||||
|
||||
export function scanGamesLibrary(libraryRoot: string, libraryId: string): Game[] {
|
||||
let gameDirs: string[]
|
||||
try {
|
||||
gameDirs = fs
|
||||
.readdirSync(libraryRoot, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
|
||||
.map((d) => d.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const games: Game[] = []
|
||||
|
||||
for (const dirName of gameDirs) {
|
||||
const gamePath = path.join(libraryRoot, dirName)
|
||||
|
||||
// Find the .zip file (first match)
|
||||
let zipFile: string | null = null
|
||||
try {
|
||||
const allFiles = fs.readdirSync(gamePath)
|
||||
zipFile = allFiles.find((f) => f.toLowerCase().endsWith('.zip')) ?? null
|
||||
} catch {
|
||||
// skip unreadable dirs
|
||||
continue
|
||||
}
|
||||
|
||||
if (!zipFile) continue
|
||||
|
||||
// Case-insensitive cover matching
|
||||
const coverFile = findFile(gamePath, /^cover$/i)
|
||||
const wideCoverFile = findFile(gamePath, /^widecover$/i)
|
||||
|
||||
const id = encodeURIComponent(dirName)
|
||||
const zipRelPath = path.join(dirName, zipFile)
|
||||
|
||||
games.push({
|
||||
id,
|
||||
title: dirName,
|
||||
coverUrl: coverFile
|
||||
? fileApiUrl(libraryId, path.join(dirName, coverFile))
|
||||
: null,
|
||||
wideCoverUrl: wideCoverFile
|
||||
? fileApiUrl(libraryId, path.join(dirName, wideCoverFile))
|
||||
: null,
|
||||
zipPath: zipRelPath,
|
||||
})
|
||||
}
|
||||
|
||||
return games.sort((a, b) => a.title.localeCompare(b.title))
|
||||
}
|
||||
38
src/lib/libraries.ts
Normal file
38
src/lib/libraries.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import type { Library } from '@/types'
|
||||
|
||||
const CONFIG_PATH = path.resolve(process.cwd(), 'libraries.json')
|
||||
|
||||
export function getLibraries(): Library[] {
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
|
||||
return JSON.parse(raw) as Library[]
|
||||
}
|
||||
|
||||
export function getLibrary(id: string): Library | undefined {
|
||||
return getLibraries().find((lib) => lib.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a library's configured path to an absolute filesystem path.
|
||||
* Paths in libraries.json may be relative (to project root) or absolute.
|
||||
*/
|
||||
export function resolveLibraryRoot(library: Library): string {
|
||||
if (path.isAbsolute(library.path)) {
|
||||
return library.path
|
||||
}
|
||||
return path.resolve(process.cwd(), library.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a relative subpath within a library root and verifies it doesn't
|
||||
* escape the root (prevents path traversal attacks).
|
||||
* Returns the absolute path, or throws if the path escapes the root.
|
||||
*/
|
||||
export function resolveAndJail(libraryRoot: string, subpath: string): string {
|
||||
const resolved = path.resolve(libraryRoot, subpath)
|
||||
if (!resolved.startsWith(libraryRoot + path.sep) && resolved !== libraryRoot) {
|
||||
throw new Error(`Path traversal attempt detected: ${subpath}`)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
Reference in New Issue
Block a user