Group duplicate effects and add loop variants
This commit is contained in:
223
public/app.js
223
public/app.js
@@ -1,7 +1,9 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'arcana-fx-sets-v2';
|
||||
// Bumped: v3 stores group ids (with optional variant id) instead of raw paths.
|
||||
const STORAGE_KEY = 'arcana-fx-sets-v3';
|
||||
const LEGACY_KEY = 'arcana-fx-sets-v2';
|
||||
|
||||
const els = {
|
||||
grid: document.getElementById('grid'),
|
||||
@@ -35,19 +37,22 @@
|
||||
};
|
||||
|
||||
const state = {
|
||||
effects: [],
|
||||
byPath: new Map(),
|
||||
groups: [],
|
||||
groupsById: new Map(),
|
||||
pathToGroup: new Map(), // legacy path -> { groupId, variantId }
|
||||
categories: [],
|
||||
activeCategory: 'all',
|
||||
search: '',
|
||||
busy: false,
|
||||
streamOn: false,
|
||||
active: [],
|
||||
sets: loadSets(),
|
||||
sets: [],
|
||||
currentSetId: null,
|
||||
lastVariantPerGroup: new Map(), // anti-immediate-repeat memory
|
||||
};
|
||||
|
||||
if (!state.sets.length) state.sets = [{ id: uid(), name: 'Session Favorites', paths: [] }];
|
||||
state.sets = loadSets();
|
||||
if (!state.sets.length) state.sets = [{ id: uid(), name: 'Session Favorites', items: [] }];
|
||||
state.currentSetId = state.sets[0].id;
|
||||
|
||||
function uid() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; }
|
||||
@@ -64,14 +69,54 @@
|
||||
}
|
||||
|
||||
function loadSets() {
|
||||
// Prefer v3. If absent but v2 exists, migrate later (after groups load).
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
return Array.isArray(parsed) ? parsed.filter((s) => s && s.id && s.name && Array.isArray(s.paths)) : [];
|
||||
} catch { return []; }
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((s) => s && s.id && s.name && Array.isArray(s.items));
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveSets() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.sets)); }
|
||||
|
||||
/**
|
||||
* Migrate v2 (path-based) favourites to v3 (group/variant-based) using
|
||||
* the path index from the freshly-loaded groups. Drops items that no
|
||||
* longer resolve (effect was renamed or removed from the share).
|
||||
*/
|
||||
function migrateLegacySetsIfNeeded() {
|
||||
if (state.sets.some((s) => s.items && s.items.length)) return; // already populated
|
||||
if (localStorage.getItem(STORAGE_KEY)) return; // v3 already present
|
||||
let legacy;
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_KEY);
|
||||
if (!raw) return;
|
||||
legacy = JSON.parse(raw);
|
||||
} catch { return; }
|
||||
if (!Array.isArray(legacy) || !legacy.length) return;
|
||||
|
||||
const migrated = legacy.map((s) => {
|
||||
const items = Array.isArray(s.paths) ? s.paths
|
||||
.map((p) => state.pathToGroup.get(p))
|
||||
.filter(Boolean)
|
||||
// de-dup: same group+variant pair shouldn't appear twice
|
||||
.filter((ref, i, arr) => arr.findIndex((r) => r.groupId === ref.groupId && r.variantId === ref.variantId) === i)
|
||||
: [];
|
||||
return { id: s.id || uid(), name: s.name || 'Set', items };
|
||||
}).filter((s) => s.name);
|
||||
|
||||
if (migrated.length) {
|
||||
state.sets = migrated;
|
||||
state.currentSetId = state.sets[0].id;
|
||||
saveSets();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url, opts) {
|
||||
const res = await fetch(url, opts);
|
||||
const text = await res.text();
|
||||
@@ -94,39 +139,68 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function visibleEffects() {
|
||||
function groupMatches(group, q) {
|
||||
if (!q) return true;
|
||||
const variantNames = group.variants.map((v) => v.name).join(' ');
|
||||
const variantPaths = group.variants.flatMap((v) => v.allPaths || []).join(' ');
|
||||
const tagText = (group.tags || []).join(' ');
|
||||
const haystack = `${group.name} ${group.category} ${tagText} ${variantNames} ${variantPaths}`
|
||||
.replace(/[/.\\_-]+/g, ' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}
|
||||
|
||||
function visibleGroups() {
|
||||
const q = state.search.trim().toLowerCase();
|
||||
return state.effects.filter((e) => {
|
||||
if (state.activeCategory !== 'all' && e.category !== state.activeCategory) return false;
|
||||
if (!q) return true;
|
||||
const haystack = `${e.name} ${e.category} ${e.type} ${e.path.replace(/[/.\\_-]+/g, ' ')}`.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
return state.groups.filter((g) => {
|
||||
if (state.activeCategory !== 'all' && g.category !== state.activeCategory) return false;
|
||||
return groupMatches(g, q);
|
||||
}).slice(0, 240);
|
||||
}
|
||||
|
||||
function tile(effect, { inSetView = false } = {}) {
|
||||
function groupInCurrentSet(groupId) {
|
||||
const set = currentSet();
|
||||
const inSet = set.paths.includes(effect.path);
|
||||
return set.items.some((it) => it.groupId === groupId);
|
||||
}
|
||||
|
||||
function variantsButtons(group) {
|
||||
if (!group.hasNumberedVariants) return '';
|
||||
const buttons = group.variants
|
||||
.filter((v) => v.number != null)
|
||||
.map((v) => `<button class="variant-btn" type="button" data-action="play-variant" data-variant="${escapeHtml(v.id)}" title="Play take ${v.number}">${v.number}</button>`)
|
||||
.join('');
|
||||
return `<div class="variant-row">${buttons}</div>`;
|
||||
}
|
||||
|
||||
function tile(group, { inSetView = false } = {}) {
|
||||
const inSet = groupInCurrentSet(group.id);
|
||||
const variantTagText = group.hasNumberedVariants ? `${group.variantCount} takes` : null;
|
||||
const tags = [
|
||||
group.category,
|
||||
group.isLoop ? 'LOOP' : null,
|
||||
variantTagText,
|
||||
].filter(Boolean);
|
||||
const tagHtml = tags.map((t) => `<span class="tag${t === 'LOOP' ? ' tag-loop' : ''}">${escapeHtml(t)}</span>`).join('');
|
||||
return `
|
||||
<article class="tile" data-path="${escapeHtml(effect.path)}">
|
||||
<button class="play-hit" type="button" data-action="play" title="Play ${escapeHtml(effect.name)}">
|
||||
<div class="name">${escapeHtml(effect.name)}</div>
|
||||
<div class="meta">
|
||||
<span class="tag">${escapeHtml(effect.category)}</span>
|
||||
<span class="tag">${escapeHtml(effect.type.toUpperCase())}</span>
|
||||
</div>
|
||||
<article class="tile${group.isLoop ? ' is-loop' : ''}" data-group-id="${escapeHtml(group.id)}">
|
||||
<button class="play-hit" type="button" data-action="play" title="Play ${escapeHtml(group.name)}">
|
||||
<div class="name">${escapeHtml(group.name)}${group.isLoop ? ' <span class="loop-glyph" aria-hidden="true">∞</span>' : ''}</div>
|
||||
<div class="meta">${tagHtml}</div>
|
||||
</button>
|
||||
${variantsButtons(group)}
|
||||
<div class="tile-actions">
|
||||
${inSetView ? `<button class="mini danger" data-action="remove-set" type="button">Remove</button>` : `<button class="mini ${inSet ? 'ok' : ''}" data-action="toggle-set" type="button">${inSet ? 'In set' : '+ Set'}</button>`}
|
||||
${inSetView
|
||||
? `<button class="mini danger" data-action="remove-set" type="button">Remove</button>`
|
||||
: `<button class="mini ${inSet ? 'ok' : ''}" data-action="toggle-set" type="button">${inSet ? 'In set' : '+ Set'}</button>`}
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const filtered = visibleEffects();
|
||||
const filtered = visibleGroups();
|
||||
els.empty.hidden = filtered.length !== 0;
|
||||
els.countLabel.textContent = `${state.effects.length} effects · showing ${filtered.length}`;
|
||||
els.grid.innerHTML = filtered.map((e) => tile(e)).join('');
|
||||
els.countLabel.textContent = `${state.groups.length} sounds · showing ${filtered.length}`;
|
||||
els.grid.innerHTML = filtered.map((g) => tile(g)).join('');
|
||||
renderSet();
|
||||
}
|
||||
|
||||
@@ -138,21 +212,23 @@
|
||||
function renderSet() {
|
||||
renderSetsSelect();
|
||||
const set = currentSet();
|
||||
const effects = set.paths.map((p) => state.byPath.get(p)).filter(Boolean);
|
||||
const groups = set.items
|
||||
.map((it) => state.groupsById.get(it.groupId))
|
||||
.filter(Boolean);
|
||||
els.setTitle.textContent = set.name;
|
||||
els.setCount.textContent = `${effects.length} item${effects.length === 1 ? '' : 's'}`;
|
||||
els.setEmpty.hidden = effects.length > 0;
|
||||
els.setGrid.innerHTML = effects.map((e) => tile(e, { inSetView: true })).join('');
|
||||
els.setCount.textContent = `${groups.length} item${groups.length === 1 ? '' : 's'}`;
|
||||
els.setEmpty.hidden = groups.length > 0;
|
||||
els.setGrid.innerHTML = groups.map((g) => tile(g, { inSetView: true })).join('');
|
||||
}
|
||||
|
||||
function renderActive() {
|
||||
els.nowCount.textContent = String(state.active.length);
|
||||
els.activeEmpty.hidden = state.active.length > 0;
|
||||
els.activeList.innerHTML = state.active.map((a) => `
|
||||
<div class="active-row">
|
||||
<div class="active-row${a.loop ? ' is-loop' : ''}">
|
||||
<div>
|
||||
<strong>${escapeHtml(a.name)}</strong>
|
||||
<span>${escapeHtml(a.category)} · ${Math.round((a.ageMs || 0) / 1000)}s</span>
|
||||
<strong>${escapeHtml(a.name)}${a.loop ? ' <span class="loop-glyph">∞</span>' : ''}</strong>
|
||||
<span>${escapeHtml(a.category || '')} · ${Math.round((a.ageMs || 0) / 1000)}s${a.loop ? ' · looping' : ''}</span>
|
||||
${a.error ? `<em>${escapeHtml(a.error)}</em>` : ''}
|
||||
</div>
|
||||
<button class="mini danger" data-stop-id="${escapeHtml(a.id)}">Stop</button>
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
227
src/effects.js
vendored
227
src/effects.js
vendored
@@ -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 " <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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user