'use strict'; const path = require('node:path'); /** * Walks the Filestash share recursively, collects audio files, folds them * into logical groups (numbered variants, format duplicates, loop markers) * and caches the result in memory with a TTL. * * Effects are identified internally by their share-relative path (e.g. * "/Combat/Sword.mp3"). The public API also exposes stable group IDs * derived from category + base name. Clients may play by groupId/variantId * (preferred) or path (legacy). The media proxy always validates by path. */ // Format preference when multiple extensions exist for the same logical sound. // Earlier = preferred. Adjust here if data suggests otherwise. const FORMAT_PREFERENCE = ['ogg', 'mp3', 'm4a', 'wav', 'flac']; class EffectsService { constructor({ client, extensions, ignoreNames, cacheTtlSeconds, maxDepth }) { this.client = client; this.extensions = new Set(extensions.map((e) => e.toLowerCase())); this.ignore = new Set(ignoreNames.map((n) => n.toLowerCase())); this.cacheTtlMs = Math.max(0, cacheTtlSeconds * 1000); this.maxDepth = Math.max(1, maxDepth); this.cache = null; this.groupsCache = null; this.groupsById = null; this.cacheLoadedAt = 0; this.inflight = null; } isAudioFile(name) { const lower = name.toLowerCase(); if (this.ignore.has(lower)) return false; const ext = path.extname(lower); return this.extensions.has(ext); } contentTypeFor(name) { switch (path.extname(name).toLowerCase()) { case '.mp3': return 'audio/mpeg'; case '.ogg': return 'audio/ogg'; case '.flac': return 'audio/flac'; case '.wav': return 'audio/wav'; case '.m4a': return 'audio/mp4'; default: return 'application/octet-stream'; } } /** * Returns the cached flat effects list, refreshing if expired or forced. * Single-flight: concurrent callers share the same refresh promise. */ async list({ force = false } = {}) { const fresh = this.cache && !force && Date.now() - this.cacheLoadedAt < this.cacheTtlMs; if (fresh) return this.cache; if (!this.inflight) { this.inflight = this._refresh().finally(() => { this.inflight = null; }); } return this.inflight; } async listGroups({ force = false } = {}) { await this.list({ force }); return this.groupsCache || []; } async _refresh() { const effects = []; const stack = [{ rel: '/', depth: 0 }]; const visited = new Set(); while (stack.length) { const { rel, depth } = stack.pop(); if (visited.has(rel)) continue; visited.add(rel); let entries; try { entries = await this.client.listDir(rel); } catch (err) { if (rel === '/') throw err; // Skip unreadable sub-folders instead of aborting the whole scan. // eslint-disable-next-line no-console console.warn(`[effects] failed to list ${rel}: ${err.message}`); continue; } for (const entry of entries) { if (!entry.name || entry.name === '.' || entry.name === '..') continue; if (this.ignore.has(entry.name.toLowerCase())) continue; const childRel = joinRel(rel, entry.name); if (entry.type === 'directory') { if (depth + 1 < this.maxDepth) { stack.push({ rel: childRel + '/', depth: depth + 1 }); } continue; } if (!this.isAudioFile(entry.name)) continue; const category = deriveCategory(childRel); const display = stripExt(entry.name); const parsed = parseDisplayName(display); effects.push({ id: childRel, path: childRel, name: display, fileName: entry.name, category, type: path.extname(entry.name).slice(1).toLowerCase(), size: entry.size, baseName: parsed.baseName, variantNumber: parsed.variantNumber, isLoop: parsed.isLoop, }); } } effects.sort((a, b) => { const c = a.category.localeCompare(b.category); if (c !== 0) return c; const bn = a.baseName.localeCompare(b.baseName); if (bn !== 0) return bn; const an = (a.variantNumber || 0) - (b.variantNumber || 0); if (an !== 0) return an; return a.name.localeCompare(b.name); }); const { groups, groupsById } = buildGroups(effects); this.cache = effects; this.groupsCache = groups; this.groupsById = groupsById; this.cacheLoadedAt = Date.now(); return effects; } async findByPath(relPath) { if (!isSafeRel(relPath)) return null; const list = await this.list(); return list.find((e) => e.path === relPath) || null; } async findGroup(groupId) { if (typeof groupId !== 'string' || !groupId) return null; await this.list(); return (this.groupsById && this.groupsById.get(groupId)) || null; } /** * Resolves a group + optional variantId to a concrete effect (one file path). * If variantId is omitted, picks one variant (rotating to avoid immediate * repeat on the server side as a fallback when the client doesn't specify). */ async resolveGroupVariant(groupId, variantId) { const group = await this.findGroup(groupId); if (!group) return null; let variant = null; if (variantId) { variant = group.variants.find((v) => v.id === variantId) || null; if (!variant) return null; } else { if (!group.variants.length) return null; const idx = group._nextPick % group.variants.length; variant = group.variants[idx]; group._nextPick = (group._nextPick + 1) % Math.max(1, group.variants.length); } const effect = await this.findByPath(variant.path); if (!effect) return null; return { group, variant, effect }; } } function joinRel(parent, name) { const p = parent.endsWith('/') ? parent.slice(0, -1) : parent; return `${p}/${name}`; } function deriveCategory(relPath) { const parts = relPath.split('/').filter(Boolean); if (parts.length <= 1) return 'Uncategorized'; return parts[parts.length - 2]; } function stripExt(name) { const ext = path.extname(name); return ext ? name.slice(0, -ext.length) : name; } /** * Parses a display name (without extension) into { baseName, variantNumber, isLoop }. * Rules: * - "(loop)" anywhere is stripped and sets isLoop. Case insensitive. * - A trailing " " (after optional whitespace) is parsed as a variant * number and stripped from the base name. "Sword Hit 2" -> base "Sword Hit", n=2. * - Multiple internal spaces collapsed. */ function parseDisplayName(name) { let working = String(name || '').trim(); let isLoop = false; if (/\(\s*loop\s*\)/i.test(working)) { isLoop = true; working = working.replace(/\(\s*loop\s*\)/ig, ' ').replace(/\s+/g, ' ').trim(); } let variantNumber = null; const m = working.match(/^(.*?)[\s_-]+(\d{1,4})$/); if (m) { variantNumber = Number(m[2]); working = m[1].trim(); } if (!working) working = name; // fallback so empties don't collapse return { baseName: working, variantNumber, isLoop }; } function groupKey(category, baseName, isLoop) { return [ 'g', slug(category), slug(baseName), isLoop ? 'loop' : 'one', ].join('::'); } function variantKey(number) { return number == null ? 'default' : `n${number}`; } function slug(s) { return String(s || '') .toLowerCase() .normalize('NFKD') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'x'; } /** * Picks the preferred file when several formats represent the same variant. */ function pickPreferred(files) { const sorted = [...files].sort((a, b) => { const ai = FORMAT_PREFERENCE.indexOf(a.type); const bi = FORMAT_PREFERENCE.indexOf(b.type); const ar = ai === -1 ? FORMAT_PREFERENCE.length : ai; const br = bi === -1 ? FORMAT_PREFERENCE.length : bi; if (ar !== br) return ar - br; return a.path.localeCompare(b.path); }); return sorted[0]; } function buildGroups(effects) { const byGroup = new Map(); for (const e of effects) { const key = groupKey(e.category, e.baseName, e.isLoop); let group = byGroup.get(key); if (!group) { group = { id: key, name: e.baseName, category: e.category, isLoop: e.isLoop, _variantBuckets: new Map(), }; byGroup.set(key, group); } const vKey = variantKey(e.variantNumber); let bucket = group._variantBuckets.get(vKey); if (!bucket) { bucket = { id: vKey, number: e.variantNumber, files: [] }; group._variantBuckets.set(vKey, bucket); } bucket.files.push(e); } const groups = []; const groupsById = new Map(); for (const group of byGroup.values()) { const variants = []; for (const bucket of group._variantBuckets.values()) { const preferred = pickPreferred(bucket.files); variants.push({ id: bucket.id, number: bucket.number, name: preferred.name, path: preferred.path, type: preferred.type, formats: [...new Set(bucket.files.map((f) => f.type))], allPaths: bucket.files.map((f) => f.path), }); } variants.sort((a, b) => { if (a.number == null && b.number == null) return a.name.localeCompare(b.name); if (a.number == null) return -1; if (b.number == null) return 1; return a.number - b.number; }); const numberedVariants = variants.filter((v) => v.number != null); const hasNumberedVariants = numberedVariants.length >= 2; const tags = [group.category]; if (group.isLoop) tags.push('loop'); if (hasNumberedVariants) tags.push(`${numberedVariants.length} takes`); const out = { id: group.id, name: group.name, category: group.category, isLoop: group.isLoop, tags, hasNumberedVariants, variantCount: variants.length, variants, // legacy: flat list of every path under this group, used by clients // (and tests) that want to discover all underlying files. paths: variants.flatMap((v) => v.allPaths), _nextPick: 0, }; delete group._variantBuckets; groups.push(out); groupsById.set(out.id, out); } groups.sort((a, b) => { const c = a.category.localeCompare(b.category); if (c !== 0) return c; return a.name.localeCompare(b.name); }); return { groups, groupsById }; } function isSafeRel(p) { if (typeof p !== 'string' || !p.length) return false; if (!p.startsWith('/')) return false; if (p.includes('..')) return false; if (p.includes('\0')) return false; return true; } module.exports = { EffectsService, isSafeRel, // exported for internal use / tests parseDisplayName, buildGroups, FORMAT_PREFERENCE, };