import { randomUUID } from "node:crypto"; import { readdir, stat } from "node:fs/promises"; import path from "node:path"; import { config } from "../config.js"; import { logger } from "../logger.js"; import { UserFacingError, type Requester, type Track } from "../types.js"; const log = logger.child({ mod: "local" }); const AUDIO_EXTENSIONS = new Set([".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wav", ".wma", ".webm"]); const CACHE_TTL_MS = 60_000; let cache: { files: string[]; at: number } | null = null; function libraryRoot(): string { if (!config.LOCAL_MEDIA_DIR) throw new UserFacingError("Локальная медиатека не настроена (LOCAL_MEDIA_DIR)"); return path.resolve(config.LOCAL_MEDIA_DIR); } async function walk(dir: string, out: string[], depth = 0): Promise { if (depth > 6) return; let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch (err) { log.warn({ err, dir }, "cannot read media directory"); return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { await walk(full, out, depth + 1); } else if (entry.isFile() && AUDIO_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { out.push(full); } } } export async function listFiles(force = false): Promise { const root = libraryRoot(); if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.files; const files: string[] = []; await walk(root, files); files.sort((a, b) => a.localeCompare(b)); cache = { files, at: Date.now() }; return files; } export function isEnabled(): boolean { return Boolean(config.LOCAL_MEDIA_DIR); } /** Guards against path traversal — only files inside the library may be played. */ export async function assertInsideLibrary(filePath: string): Promise { const root = libraryRoot(); const resolved = path.resolve(filePath); const relative = path.relative(root, resolved); if (relative.startsWith("..") || path.isAbsolute(relative)) { throw new UserFacingError("Файл вне медиатеки"); } const info = await stat(resolved).catch(() => null); if (!info?.isFile()) throw new UserFacingError("Файл не найден"); return resolved; } function toTrack(filePath: string, requestedBy: Requester): Track { const root = libraryRoot(); const relative = path.relative(root, filePath); const parsed = path.parse(relative); const parentDir = path.basename(parsed.dir); return { id: randomUUID(), title: parsed.name, author: parentDir || null, // Filled in from ffmpeg's codecData once playback starts. duration: 0, isLive: false, url: filePath, thumbnail: null, source: "local", requestedBy, }; } export async function search(query: string, limit: number, requestedBy: Requester): Promise { const files = await listFiles(); const needle = query.trim().toLowerCase(); const matches = needle ? files.filter((file) => path.basename(file).toLowerCase().includes(needle)) : files; return matches.slice(0, limit).map((file) => toTrack(file, requestedBy)); } export async function resolvePath(filePath: string, requestedBy: Requester): Promise { const resolved = await assertInsideLibrary(filePath); return toTrack(resolved, requestedBy); }