361 lines
11 KiB
JavaScript
361 lines
11 KiB
JavaScript
'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 " <digits>" (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,
|
|
};
|