music on hold: play order set in the UI, and shuffle
Owner: the first upload always played first. A class is now an Asterisk playlist in the order set on Sounds & music (move up/down), new uploads last, with a Shuffle option (sort = random). moh-test.ts plays both in a real Asterisk and reads the file order from its debug log.
This commit is contained in:
1 parent
aa6681da03
commit
175d5a2c73
17 files changed
+4432
-27
No files matched your search
@@ -0,0 +1,147 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Music on hold order (owner, 2026-10-21, 03 §8): a class plays its files in the order set in the
|
||||
// UI, and a shuffled one doesn't. Asterisk plays each class to an echo test while
|
||||
// res_musiconhold's debug log says which file it opens, in what order.
|
||||
// Usage: node --conditions=source apps/api/test/asterisk/moh-test.ts (ASTERISK_IMAGE)
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const AST = 'sp-test-moh';
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const docker = (...args: string[]) => {
|
||||
const r = spawnSync('docker', args, { encoding: 'utf8' });
|
||||
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
||||
};
|
||||
const cli = (cmd: string) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
|
||||
const log = (...a: unknown[]) => console.log('[moh-test]', ...a);
|
||||
|
||||
// The play order set in the UI: not the ids' sort order, so a sorted folder wouldn't pass.
|
||||
const ORDER = [
|
||||
'c0000000-0000-4000-8000-000000000003',
|
||||
'a0000000-0000-4000-8000-000000000001',
|
||||
'b0000000-0000-4000-8000-000000000002',
|
||||
];
|
||||
const SHUF = [
|
||||
'd0000000-0000-4000-8000-000000000004',
|
||||
'e0000000-0000-4000-8000-000000000005',
|
||||
'f0000000-0000-4000-8000-000000000006',
|
||||
];
|
||||
|
||||
function snapshot(): Snapshot {
|
||||
const settings = Object.fromEntries(
|
||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||
.filter((k) => k !== 'sms.webhookToken')
|
||||
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
|
||||
) as Snapshot['settings'];
|
||||
return {
|
||||
today: '2026-10-21',
|
||||
settings,
|
||||
dids: [],
|
||||
trunks: [],
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
announcements: [],
|
||||
recordingAnnouncement: null,
|
||||
extensions: [],
|
||||
mohClasses: [
|
||||
{ name: 'order-test', shuffle: false, files: ORDER },
|
||||
{ name: 'shuffle-test', shuffle: true, files: SHUF },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** The files `cls` opened, in order, from the debug log (ids only). */
|
||||
const opened = (debug: string, cls: string) =>
|
||||
[
|
||||
...debug.matchAll(
|
||||
new RegExp(`Opened file \\d+ '/var/lib/asterisk/moh/${cls}/([0-9a-f-]+)'`, 'g'),
|
||||
),
|
||||
].map((m) => m[1]!);
|
||||
|
||||
async function main() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sp-moh-'));
|
||||
const conf = join(dir, 'conf');
|
||||
const moh = join(dir, 'moh');
|
||||
mkdirSync(conf);
|
||||
for (const c of ['order-test', 'shuffle-test']) mkdirSync(join(moh, c), { recursive: true });
|
||||
const files = render(snapshot());
|
||||
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
|
||||
const problems: string[] = [];
|
||||
try {
|
||||
docker('rm', '-f', AST);
|
||||
const run = docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
AST,
|
||||
'-e',
|
||||
'AMI_SECRET=moh-test-secret',
|
||||
'-v',
|
||||
`${conf}:/etc/asterisk/selfphoneng`,
|
||||
'-v',
|
||||
`${moh}:/var/lib/asterisk/moh`,
|
||||
IMAGE,
|
||||
);
|
||||
if (!run.ok) throw new Error(`asterisk: ${run.out}`);
|
||||
for (
|
||||
let i = 0;
|
||||
i < 60 && !docker('exec', AST, 'asterisk', '-rx', 'core waitfullybooted').ok;
|
||||
i++
|
||||
)
|
||||
await sleep(500);
|
||||
// Short recordings (spoken digits) so each class goes through its list in a few seconds.
|
||||
const copies = [...ORDER, ...SHUF]
|
||||
.map(
|
||||
(id, i) =>
|
||||
`cp /var/lib/asterisk/sounds/en/digits/${i + 1}.wav /var/lib/asterisk/moh/${ORDER.includes(id) ? 'order-test' : 'shuffle-test'}/${id}.wav`,
|
||||
)
|
||||
.join(' && ');
|
||||
const cp = docker('exec', AST, 'sh', '-c', copies);
|
||||
if (!cp.ok) throw new Error(`copying sounds: ${cp.out}`);
|
||||
cli('moh reload');
|
||||
cli('logger add channel moh-debug.log debug');
|
||||
cli('core set debug 1 res_musiconhold.so');
|
||||
|
||||
for (const cls of ['order-test', 'shuffle-test']) {
|
||||
cli(`channel originate Local/*43@sp-featurecodes application MusicOnHold ${cls},6`);
|
||||
await sleep(7000);
|
||||
}
|
||||
const debug = docker('exec', AST, 'cat', '/var/log/asterisk/moh-debug.log').out;
|
||||
const inOrder = opened(debug, 'order-test');
|
||||
log(`order-test played: ${inOrder.map((id) => id[0]).join(' ')}`);
|
||||
const want = Array.from({ length: inOrder.length }, (_, i) => ORDER[i % ORDER.length]);
|
||||
if (inOrder.length < 4 || inOrder.join() !== want.join())
|
||||
problems.push(`order-test didn't play c a b c…: ${inOrder.join(', ') || 'nothing'}`);
|
||||
const shuffled = opened(debug, 'shuffle-test');
|
||||
log(`shuffle-test played: ${shuffled.map((id) => id[0]).join(' ')}`);
|
||||
const cyclic = Array.from({ length: shuffled.length }, (_, i) => SHUF[i % SHUF.length]);
|
||||
if (shuffled.length < 4 || shuffled.join() === cyclic.join())
|
||||
problems.push(`shuffle-test played in list order: ${shuffled.join(', ') || 'nothing'}`);
|
||||
} catch (e) {
|
||||
problems.push((e as Error).message);
|
||||
} finally {
|
||||
docker('rm', '-f', AST);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
if (problems.length) {
|
||||
console.log('FAIL music on hold');
|
||||
for (const p of problems) console.log(` ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ok music on hold (in the order set, and shuffled)');
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -65,7 +65,7 @@ describe.skipIf(!hasTestDb || !hasFfmpeg)('/api/moh-classes', () => {
|
||||
const wav = join(mohDir(), 'sales-jazz', `${fileId}.wav`);
|
||||
expect(readFileSync(wav).readUInt32LE(24)).toBe(8000);
|
||||
expect(await conf()).toContain(
|
||||
'[sales-jazz]\nmode = files\ndirectory = /var/lib/asterisk/moh/sales-jazz\nsort = random',
|
||||
`[sales-jazz]\nmode = playlist\nentry = /var/lib/asterisk/moh/sales-jazz/${fileId}\n`,
|
||||
);
|
||||
const audio = await req('GET', `/api/moh-classes/${classId}/files/${fileId}/audio`);
|
||||
expect(audio.statusCode).toBe(200);
|
||||
@@ -77,6 +77,48 @@ describe.skipIf(!hasTestDb || !hasFfmpeg)('/api/moh-classes', () => {
|
||||
expect(bad.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('plays in upload order until reordered; shuffle; the order must list every file', async () => {
|
||||
const add = async (name: string) =>
|
||||
(
|
||||
await req('POST', `/api/moh-classes/${classId}/files?name=${name}`, silenceWav(), {
|
||||
'content-type': 'audio/wav',
|
||||
})
|
||||
).json();
|
||||
await add('Second');
|
||||
const dto = await add('Third');
|
||||
const ids = dto.files.map((f: { id: string }) => f.id);
|
||||
expect(dto.files.map((f: { name: string }) => f.name)).toEqual(['Smooth', 'Second', 'Third']);
|
||||
const entries = async () =>
|
||||
[...(await conf()).matchAll(/^entry = .*\/([0-9a-f-]+)$/gm)].map((m) => m[1]);
|
||||
expect(await entries()).toEqual(ids);
|
||||
|
||||
const reordered = await req('PATCH', `/api/moh-classes/${classId}`, {
|
||||
order: [ids[2], ids[0], ids[1]],
|
||||
});
|
||||
expect(reordered.statusCode, reordered.body).toBe(200);
|
||||
expect(reordered.json().files.map((f: { name: string }) => f.name)).toEqual([
|
||||
'Third',
|
||||
'Smooth',
|
||||
'Second',
|
||||
]);
|
||||
expect(await entries()).toEqual([ids[2], ids[0], ids[1]]);
|
||||
expect(await conf()).not.toContain('sort = random');
|
||||
|
||||
const shuffled = await req('PATCH', `/api/moh-classes/${classId}`, { shuffle: true });
|
||||
expect(shuffled.json().shuffle).toBe(true);
|
||||
expect(await conf()).toContain('sort = random');
|
||||
for (const order of [
|
||||
[ids[0], ids[1]],
|
||||
[ids[0], ids[0], ids[1]],
|
||||
[...ids.slice(1), extId],
|
||||
])
|
||||
expect((await req('PATCH', `/api/moh-classes/${classId}`, { order })).statusCode).toBe(400);
|
||||
|
||||
await req('PATCH', `/api/moh-classes/${classId}`, { shuffle: false });
|
||||
for (const id of [ids[1], ids[2]])
|
||||
await req('DELETE', `/api/moh-classes/${classId}/files/${id}`);
|
||||
});
|
||||
|
||||
it('users and queues may pick only classes that exist; delete is refused while used', async () => {
|
||||
const pick = (mohClass: string) => req('PATCH', `/api/extensions/${extId}`, { mohClass });
|
||||
expect((await pick('nope')).statusCode).toBe(400);
|
||||
|
||||
Reference in new issue
Block a user