From fda46887ede1532a92aa9a6cb579c56eab05f409 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 21 May 2026 00:31:53 +0200 Subject: [PATCH] Group duplicate effects and add loop variants --- public/app.js | 223 ++++++++++++++++++++++++++++++++++---------- public/styles.css | 9 ++ src/effects.js | 227 +++++++++++++++++++++++++++++++++++++++++++-- src/routes.js | 79 ++++++++++++++-- src/streamMixer.js | 11 ++- 5 files changed, 482 insertions(+), 67 deletions(-) diff --git a/public/app.js b/public/app.js index 48f6c24..c939b9f 100644 --- a/public/app.js +++ b/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) => ``) + .join(''); + return `
${buttons}
`; + } + + 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) => `${escapeHtml(t)}`).join(''); return ` -
- + ${variantsButtons(group)}
- ${inSetView ? `` : ``} + ${inSetView + ? `` + : ``}
`; } 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) => ` -
+
- ${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,