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:
iamdoubz committed 2026-10-21 13:27:02 -05:00
1 parent aa6681da03
commit 175d5a2c73
17 files changed
+4432 -27

No files matched your search

@@ -0,0 +1,7 @@
ALTER TABLE "moh_classes" ADD COLUMN "shuffle" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "sound_files" ADD COLUMN "position" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
-- Existing music on hold keeps the order it was uploaded in (owner, 2026-10-21).
UPDATE "sound_files" s SET "position" = o.n FROM (
SELECT "id", row_number() OVER (PARTITION BY "moh_class_id" ORDER BY "created_at", "id") - 1 AS n
FROM "sound_files" WHERE "moh_class_id" IS NOT NULL
) o WHERE s."id" = o."id";
File diff suppressed because it is too large. Load diff
@@ -36,6 +36,13 @@
"when": 1792512298128,
"tag": "0004_system_events",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1792606712441,
"tag": "0005_moh_order",
"breakpoints": true
}
]
}
+4
View File
@@ -334,6 +334,8 @@ export const queueCalls = pgTable(
export const mohClasses = pgTable('moh_classes', {
id: id(),
name: text().notNull().unique(),
/** Play the files in random order; otherwise in `sound_files.position` order (03 §8). */
shuffle: boolean().notNull().default(false),
...timestamps,
});
@@ -349,6 +351,8 @@ export const soundFiles = pgTable(
originalFilename: text(),
asteriskName: text().notNull().unique(),
durationMs: integer().notNull(),
/** Music on hold: where it plays in its class's list (0 first). Prompts don't use it. */
position: integer().notNull().default(0),
...timestamps,
},
(t) => [
@@ -1,10 +1,10 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[after-hours]
mode = files
directory = /var/lib/asterisk/moh/after-hours
mode = playlist
entry = /var/lib/asterisk/moh/after-hours/5a1f0c3e-0000-4000-8000-000000000003
sort = random
[support-jazz]
mode = files
directory = /var/lib/asterisk/moh/support-jazz
sort = random
mode = playlist
entry = /var/lib/asterisk/moh/support-jazz/5a1f0c3e-0000-4000-8000-000000000002
entry = /var/lib/asterisk/moh/support-jazz/5a1f0c3e-0000-4000-8000-000000000001
@@ -1,5 +1,11 @@
{
"mohClasses": ["support-jazz", "after-hours"],
"mohClasses": [
{
"name": "support-jazz",
"files": ["5a1f0c3e-0000-4000-8000-000000000002", "5a1f0c3e-0000-4000-8000-000000000001"]
},
{ "name": "after-hours", "shuffle": true, "files": ["5a1f0c3e-0000-4000-8000-000000000003"] }
],
"extensions": [
{
"id": "00000000-0000-4000-8000-000000000101",
+2 -2
View File
@@ -25,7 +25,7 @@ export function loadCase(name: string): Snapshot {
timeConditions?: Partial<Snapshot['timeConditions'][number]>[];
holidays?: Snapshot['holidays'];
announcements?: Snapshot['announcements'];
mohClasses?: string[];
mohClasses?: { name: string; shuffle?: boolean; files: string[] }[];
};
const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[])
@@ -117,7 +117,7 @@ export function loadCase(name: string): Snapshot {
id: c.id!,
number: c.number!,
})),
mohClasses: raw.mohClasses ?? [],
mohClasses: (raw.mohClasses ?? []).map((c) => ({ shuffle: false, ...c })),
pagingGroups: (raw.pagingGroups ?? []).map((g, i) => ({
name: `Paging ${i + 1}`,
duplex: false,
+14 -4
View File
@@ -200,7 +200,7 @@ export type Snapshot = {
* Music-on-hold classes that have music ('default' is built in and not here). An empty class
* would play silence; left out, Asterisk falls back to default (seen in M8-T5).
*/
mohClasses: string[];
mohClasses: SnapMohClass[];
/** Played before recording a call that a phone number records (Settings → Recording). */
recordingAnnouncement: string | null;
/** Call audio ports from the environment (RTP_PORT_START/END), not the database. */
@@ -209,6 +209,9 @@ export type Snapshot = {
export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 };
/** A music-on-hold class: its files (sound_files ids, <MOH_DIR>/<name>/<id>.wav) in play order. */
export type SnapMohClass = { name: string; shuffle: boolean; files: string[] };
export function todayIn(timezone: string, now = new Date()): string {
// en-CA formats as YYYY-MM-DD.
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
@@ -265,9 +268,16 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
const holidayRows = await db.select().from(holidays);
const annRows = await db.select().from(announcements);
const mohRows = await db
.selectDistinct({ name: mohClasses.name })
.select({ name: mohClasses.name, shuffle: mohClasses.shuffle, file: soundFiles.id })
.from(mohClasses)
.innerJoin(soundFiles, eq(soundFiles.mohClassId, mohClasses.id));
.innerJoin(soundFiles, eq(soundFiles.mohClassId, mohClasses.id))
.orderBy(asc(mohClasses.name), asc(soundFiles.position), asc(soundFiles.createdAt));
const moh = new Map<string, SnapMohClass>();
for (const r of mohRows) {
const c = moh.get(r.name) ?? { name: r.name, shuffle: r.shuffle, files: [] };
c.files.push(r.file);
moh.set(r.name, c);
}
const optionRows = await db.select().from(ivrOptions);
const soundName = new Map(
(await db.select({ id: soundFiles.id, name: soundFiles.asteriskName }).from(soundFiles)).map(
@@ -366,7 +376,7 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
maxMembers: c.maxMembers,
record: c.record,
})),
mohClasses: mohRows.map((m) => m.name),
mohClasses: [...moh.values()],
pagingGroups: pageRows.map((g) => ({
id: g.id,
number: g.number,
+11 -8
View File
@@ -1,18 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// musiconhold_selfphoneng.conf (03 §8): one class per moh_classes row, a folder Asterisk plays in
// random order. [default] lives in the static musiconhold.conf (01 §3.11).
// musiconhold_selfphoneng.conf (03 §8): one class per moh_classes row that has music, as a
// playlist in the order the admin set (owner, 2026-10-21), or shuffled. [default] lives in the
// static musiconhold.conf (01 §3.11).
import type { Snapshot } from './load.ts';
import * as s from './sanitize.ts';
export function renderMoh(snap: Snapshot): string {
return [...snap.mohClasses]
.sort()
.map((name) =>
.sort((a, b) => a.name.localeCompare(b.name))
.map((c) =>
[
`[${s.ident(name)}]`,
'mode = files',
`directory = /var/lib/asterisk/moh/${s.ident(name)}`,
'sort = random',
`[${s.ident(c.name)}]`,
'mode = playlist',
// No extension: Asterisk picks the format, as it does for files mode.
...c.files.map((id) => `entry = /var/lib/asterisk/moh/${s.ident(c.name)}/${s.ident(id)}`),
// Random picks each next file at random (res_musiconhold MOH_RANDOMIZE, any mode).
...(c.shuffle ? ['sort = random'] : []),
'',
].join('\n'),
)
+51 -2
View File
@@ -7,11 +7,12 @@
import { randomUUID } from 'node:crypto';
import { mkdir, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { and, asc, eq } from 'drizzle-orm';
import { and, asc, eq, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
MohClassCreateSchema,
MohClassUpdateSchema,
SOUND_MAX_BYTES,
SoundName,
type ListResponse,
@@ -38,10 +39,11 @@ async function toDto(db: Db, c: ClassRow): Promise<MohClassDto> {
.select()
.from(soundFiles)
.where(eq(soundFiles.mohClassId, c.id))
.orderBy(asc(soundFiles.name));
.orderBy(asc(soundFiles.position), asc(soundFiles.createdAt));
return {
id: c.id,
name: c.name,
shuffle: c.shuffle,
files: files.map((f) => ({
id: f.id,
name: f.name,
@@ -79,6 +81,14 @@ export async function mohRoutes(app: FastifyInstance) {
.catch((e: unknown) => app.log.warn({ err: e }, 'moh reload'));
}
async function nextPosition(classId: string) {
const [r] = await db
.select({ n: sql<number>`coalesce(max(${soundFiles.position}) + 1, 0)::int` })
.from(soundFiles)
.where(eq(soundFiles.mohClassId, classId));
return r?.n ?? 0;
}
async function getClass(id: string) {
const [c] = await db.select().from(mohClasses).where(eq(mohClasses.id, id));
if (!c) throw notFound('Music on hold not found');
@@ -172,6 +182,8 @@ export async function mohRoutes(app: FastifyInstance) {
source: 'upload',
originalFilename: filename ?? null,
asteriskName: `moh/${c.name}/${id}`,
// Plays last, after the ones already there.
position: await nextPosition(c.id),
durationMs: (await durationMs(file)) ?? 0,
});
await audit(db, {
@@ -187,6 +199,43 @@ export async function mohRoutes(app: FastifyInstance) {
return reply.code(201).send(await toDto(db, c));
});
/** Shuffle on or off, and the play order (every file of the class, first to last). */
app.patch('/api/moh-classes/:id', async (req) => {
const c = await getClass(IdParams.parse(req.params).id);
const { shuffle, order } = MohClassUpdateSchema.parse(req.body);
if (order) {
const ids = (
await db
.select({ id: soundFiles.id })
.from(soundFiles)
.where(eq(soundFiles.mohClassId, c.id))
).map((f) => f.id);
if (
order.length !== ids.length ||
new Set(order).size !== order.length ||
!order.every((id) => ids.includes(id))
)
throw badRequest('The order must list every file of this music on hold once');
}
await db.transaction(async (tx) => {
if (shuffle !== undefined)
await tx.update(mohClasses).set({ shuffle }).where(eq(mohClasses.id, c.id));
for (const [position, id] of (order ?? []).entries())
await tx.update(soundFiles).set({ position }).where(eq(soundFiles.id, id));
});
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'moh_class',
entityId: c.id,
summary: `Changed the play order of music on hold "${c.name}"`,
diff: { after: { shuffle, order } },
});
app.generator.requestApply('api-change');
return toDto(db, await getClass(c.id));
});
app.get('/api/moh-classes/:id/files/:fileId/audio', async (req, reply) => {
const { id, fileId } = FileParams.parse(req.params);
const c = await getClass(id);
+147
View File
@@ -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();
+43 -1
View File
@@ -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);