- ${escapeHtml(a.name)}
- ${escapeHtml(a.category)} · ${Math.round((a.ageMs || 0) / 1000)}s
+ ${escapeHtml(a.name)}${a.loop ? ' ∞' : ''}
+ ${escapeHtml(a.category || '')} · ${Math.round((a.ageMs || 0) / 1000)}s${a.loop ? ' · looping' : ''}
${a.error ? `${escapeHtml(a.error)}` : ''}
@@ -166,32 +242,73 @@
} catch (err) { console.warn('config load failed', err); }
}
+ function indexGroups(groups) {
+ state.groups = groups;
+ state.groupsById = new Map(groups.map((g) => [g.id, g]));
+ state.pathToGroup = new Map();
+ for (const g of groups) {
+ for (const v of g.variants) {
+ for (const p of (v.allPaths || [])) {
+ state.pathToGroup.set(p, { groupId: g.id, variantId: v.id });
+ }
+ }
+ }
+ }
+
async function loadEffects({ force = false } = {}) {
setStatus('busy', force ? 'Rescanning library…' : 'Loading effects…');
try {
const data = await fetchJson(force ? '/api/effects?refresh=1' : '/api/effects');
- state.effects = Array.isArray(data.effects) ? data.effects : [];
- state.byPath = new Map(state.effects.map((e) => [e.path, e]));
+ indexGroups(Array.isArray(data.groups) ? data.groups : []);
state.categories = Array.isArray(data.categories) ? data.categories : [];
if (!state.categories.includes(state.activeCategory) && state.activeCategory !== 'all') state.activeCategory = 'all';
+ migrateLegacySetsIfNeeded();
renderCategories();
renderGrid();
- setStatus('ok', `Ready — ${state.effects.length} effects`);
+ setStatus('ok', `Ready — ${state.groups.length} sounds (${data.count} files)`);
} catch (err) {
console.error(err);
setStatus('err', `Load failed: ${err.message}`);
}
}
- async function play(effectPath) {
- setStatus('busy', `Injecting — ${effectPath.split('/').pop()}`);
+ /**
+ * Picks a variant for a group, biased away from the last one played from
+ * the same group, so consecutive presses don't trigger the same take.
+ */
+ function pickVariantForGroup(group) {
+ const variants = group.variants;
+ if (!variants.length) return null;
+ if (variants.length === 1) return variants[0];
+ const last = state.lastVariantPerGroup.get(group.id);
+ const pool = variants.filter((v) => v.id !== last);
+ const picked = pool[Math.floor(Math.random() * pool.length)] || variants[0];
+ state.lastVariantPerGroup.set(group.id, picked.id);
+ return picked;
+ }
+
+ async function playGroup(groupId, variantId) {
+ const group = state.groupsById.get(groupId);
+ if (!group) { setStatus('err', 'Group not found'); return; }
+ let chosenVariantId = variantId;
+ if (!chosenVariantId) {
+ const picked = pickVariantForGroup(group);
+ if (!picked) { setStatus('err', 'No variants'); return; }
+ chosenVariantId = picked.id;
+ } else {
+ state.lastVariantPerGroup.set(groupId, chosenVariantId);
+ }
+ const label = group.name + (group.isLoop ? ' (loop)' : '');
+ setStatus('busy', `Injecting — ${label}`);
try {
const res = await fetchJson('/api/play', {
- method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: effectPath }),
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ groupId, variantId: chosenVariantId }),
});
state.active = (res.stream && res.stream.active) || state.active;
renderActive();
- setStatus('ok', `Injected — ${(res.injected && res.injected.name) || effectPath}`);
+ setStatus('ok', `Injected — ${(res.injected && res.injected.name) || label}`);
} catch (err) {
console.error(err);
setStatus('err', `Play failed: ${err.message}`);
@@ -242,17 +359,18 @@
} catch { /* status polling is best-effort */ }
}
- function toggleSet(effectPath) {
+ function toggleSet(groupId) {
const set = currentSet();
- const idx = set.paths.indexOf(effectPath);
- if (idx >= 0) set.paths.splice(idx, 1); else set.paths.push(effectPath);
+ const idx = set.items.findIndex((it) => it.groupId === groupId);
+ if (idx >= 0) set.items.splice(idx, 1);
+ else set.items.push({ groupId });
saveSets();
renderGrid();
}
- function removeFromSet(effectPath) {
+ function removeFromSet(groupId) {
const set = currentSet();
- set.paths = set.paths.filter((p) => p !== effectPath);
+ set.items = set.items.filter((it) => it.groupId !== groupId);
saveSets();
renderGrid();
}
@@ -260,7 +378,7 @@
function newSet() {
const name = prompt('Set name?', 'New Encounter');
if (!name) return;
- const set = { id: uid(), name: name.trim(), paths: [] };
+ const set = { id: uid(), name: name.trim(), items: [] };
state.sets.push(set);
state.currentSetId = set.id;
saveSets();
@@ -293,10 +411,13 @@
const action = e.target.closest('[data-action]');
const tileEl = e.target.closest('.tile');
if (!action || !tileEl) return;
- const path = tileEl.dataset.path;
- if (action.dataset.action === 'play') play(path);
- if (action.dataset.action === 'toggle-set') toggleSet(path);
- if (action.dataset.action === 'remove-set') removeFromSet(path);
+ const groupId = tileEl.dataset.groupId;
+ if (!groupId) return;
+ const a = action.dataset.action;
+ if (a === 'play') playGroup(groupId);
+ else if (a === 'play-variant') playGroup(groupId, action.dataset.variant);
+ else if (a === 'toggle-set') toggleSet(groupId);
+ else if (a === 'remove-set') removeFromSet(groupId);
}
els.grid.addEventListener('click', gridClick);
diff --git a/public/styles.css b/public/styles.css
index 6e4bfed..e191318 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -67,6 +67,15 @@ input[type="search"]:focus { border-color: var(--accent); }
.tile-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 0 10px 10px; }
.mini { padding: 5px 9px; font-size: 11px; border-radius: 8px; }
.mini.ok { color: var(--accent-2); border-color: rgba(110,255,200,.5); }
+.tag-loop { background: rgba(255,180,84,.14); color: var(--warn); border-color: rgba(255,180,84,.5); }
+.tile.is-loop { border-color: rgba(255,180,84,.45); box-shadow: inset 0 0 0 1px rgba(255,180,84,.10); }
+.tile.is-loop:hover { border-color: rgba(255,180,84,.8); }
+.loop-glyph { color: var(--warn); margin-left: 4px; font-weight: 700; }
+.variant-row { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 10px 6px; }
+.variant-btn { padding: 3px 8px; font-size: 11px; border-radius: 6px; min-width: 26px; background: rgba(179,136,255,.08); border: 1px solid rgba(179,136,255,.28); color: var(--ink-dim); }
+.variant-btn:hover { background: rgba(179,136,255,.20); color: var(--ink); border-color: var(--accent); }
+.active-row.is-loop { border-color: rgba(255,180,84,.45); background: rgba(255,180,84,.06); }
+.active-row .loop-glyph { font-weight: 700; }
.empty { margin: 16px 4px 0; color: var(--ink-dim); font-style: italic; }
footer { position: relative; z-index: 1; padding: 14px 28px 26px; color: var(--ink-dim); font-size: 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
footer .sep { opacity: .5; } footer code { background: rgba(255,255,255,.05); border-radius: 6px; padding: 2px 6px; font-size: 11.5px; }
diff --git a/src/effects.js b/src/effects.js
index ebe5c71..bbb1a4f 100644
--- a/src/effects.js
+++ b/src/effects.js
@@ -3,13 +3,20 @@
const path = require('node:path');
/**
- * Walks the Filestash share recursively, collects audio files,
+ * 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 by their share-relative path (e.g. "/Combat/Sword.mp3").
- * This identity is what the API accepts to play or proxy a file — clients
- * must never pass an arbitrary absolute Filestash path.
+ * 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;
@@ -18,6 +25,8 @@ class EffectsService {
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;
}
@@ -41,7 +50,7 @@ class EffectsService {
}
/**
- * Returns the cached effects list, refreshing if expired or forced.
+ * Returns the cached flat effects list, refreshing if expired or forced.
* Single-flight: concurrent callers share the same refresh promise.
*/
async list({ force = false } = {}) {
@@ -59,6 +68,11 @@ class EffectsService {
return this.inflight;
}
+ async listGroups({ force = false } = {}) {
+ await this.list({ force });
+ return this.groupsCache || [];
+ }
+
async _refresh() {
const effects = [];
const stack = [{ rel: '/', depth: 0 }];
@@ -93,24 +107,38 @@ class EffectsService {
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: stripExt(entry.name),
+ 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);
- return c !== 0 ? c : a.name.localeCompare(b.name);
+ 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;
}
@@ -120,6 +148,37 @@ class EffectsService {
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) {
@@ -138,6 +197,151 @@ function stripExt(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;
@@ -146,4 +350,11 @@ function isSafeRel(p) {
return true;
}
-module.exports = { EffectsService, isSafeRel };
+module.exports = {
+ EffectsService,
+ isSafeRel,
+ // exported for internal use / tests
+ parseDisplayName,
+ buildGroups,
+ FORMAT_PREFERENCE,
+};
diff --git a/src/routes.js b/src/routes.js
index ab840f4..811cdca 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -30,8 +30,15 @@ function createApiRouter({ config, effects, filestash, ha, mixer }) {
try {
const force = req.query.refresh === '1' || req.query.refresh === 'true';
const list = await effects.list({ force });
+ const groups = await effects.listGroups();
const categories = [...new Set(list.map((e) => e.category))].sort();
- res.json({ count: list.length, categories, effects: list });
+ res.json({
+ count: list.length,
+ groupCount: groups.length,
+ categories,
+ effects: list,
+ groups: groups.map(publicGroup),
+ });
} catch (err) {
next(err);
}
@@ -81,12 +88,44 @@ function createApiRouter({ config, effects, filestash, ha, mixer }) {
router.post('/api/play', express.json({ limit: '8kb' }), async (req, res, next) => {
try {
- const { path: effectPath, volume } = req.body || {};
- if (!isSafeRel(effectPath)) return res.status(400).json({ error: 'invalid effect path' });
- const effect = await effects.findByPath(effectPath);
- if (!effect) return res.status(404).json({ error: 'effect not found' });
+ const body = req.body || {};
+ const { groupId, variantId, path: effectPath, volume, loop: loopOverride } = body;
- const injected = await mixer.play(effect, { volume });
+ let effect = null;
+ let resolvedGroupId = null;
+ let resolvedVariantId = null;
+ let isLoop = false;
+ let displayName = null;
+
+ if (typeof groupId === 'string' && groupId) {
+ const resolved = await effects.resolveGroupVariant(
+ groupId,
+ typeof variantId === 'string' ? variantId : null,
+ );
+ if (!resolved) return res.status(404).json({ error: 'group or variant not found' });
+ effect = resolved.effect;
+ resolvedGroupId = resolved.group.id;
+ resolvedVariantId = resolved.variant.id;
+ isLoop = resolved.group.isLoop;
+ displayName = resolved.variant.number != null
+ ? `${resolved.group.name} ${resolved.variant.number}`
+ : resolved.group.name;
+ } else {
+ if (!isSafeRel(effectPath)) return res.status(400).json({ error: 'invalid effect path' });
+ effect = await effects.findByPath(effectPath);
+ if (!effect) return res.status(404).json({ error: 'effect not found' });
+ isLoop = Boolean(effect.isLoop);
+ }
+
+ const loop = typeof loopOverride === 'boolean' ? loopOverride : isLoop;
+
+ const injected = await mixer.play(effect, {
+ volume,
+ loop,
+ groupId: resolvedGroupId,
+ variantId: resolvedVariantId,
+ displayName,
+ });
res.json({ ok: true, injected, stream: mixer.stats(), entity: allowedEntity });
} catch (err) {
next(err);
@@ -154,4 +193,32 @@ function createApiRouter({ config, effects, filestash, ha, mixer }) {
return router;
}
+/**
+ * Strips internal-only fields from a group before sending it to clients.
+ * Underlying paths stay in `variants[].allPaths` because the UI uses them
+ * for search and best-effort migration of pre-grouping favourites. The
+ * media proxy still validates by path against the indexed effect list, so
+ * exposing already-indexed paths does not widen access.
+ */
+function publicGroup(g) {
+ return {
+ id: g.id,
+ name: g.name,
+ category: g.category,
+ isLoop: g.isLoop,
+ tags: g.tags,
+ hasNumberedVariants: g.hasNumberedVariants,
+ variantCount: g.variantCount,
+ variants: g.variants.map((v) => ({
+ id: v.id,
+ number: v.number,
+ name: v.name,
+ path: v.path,
+ type: v.type,
+ formats: v.formats,
+ allPaths: v.allPaths,
+ })),
+ };
+}
+
module.exports = { createApiRouter };
diff --git a/src/streamMixer.js b/src/streamMixer.js
index e8899b4..2329a61 100644
--- a/src/streamMixer.js
+++ b/src/streamMixer.js
@@ -42,7 +42,7 @@ class StreamMixer {
});
}
- async play(effect, { volume = 1 } = {}) {
+ async play(effect, { volume = 1, loop = false, groupId = null, variantId = null, displayName = null } = {}) {
if (!effect || !effect.path) {
const err = new Error('effect is required');
err.status = 400;
@@ -58,9 +58,12 @@ class StreamMixer {
const item = {
id,
path: effect.path,
- name: effect.name,
+ name: displayName || effect.name,
category: effect.category,
type: effect.type,
+ groupId,
+ variantId,
+ loop: Boolean(loop),
volume: clamp(Number(volume) || 1, 0, 2),
startedAt: Date.now(),
queue: [],
@@ -72,6 +75,7 @@ class StreamMixer {
const args = [
'-hide_banner', '-loglevel', 'error',
+ ...(item.loop ? ['-stream_loop', '-1'] : []),
'-i', mediaUrl,
'-f', 's16le',
'-acodec', 'pcm_s16le',
@@ -238,6 +242,9 @@ function publicItem(item) {
name: item.name,
category: item.category,
type: item.type,
+ groupId: item.groupId || null,
+ variantId: item.variantId || null,
+ loop: Boolean(item.loop),
volume: item.volume,
startedAt: item.startedAt,
ageMs: Date.now() - item.startedAt,