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,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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -65,6 +65,7 @@ import type {
|
||||
SoundDto,
|
||||
SoftphoneQr,
|
||||
MohClassDto,
|
||||
MohClassUpdate,
|
||||
TotpSetup,
|
||||
TotpStatus,
|
||||
VoipmsDidDto,
|
||||
@@ -292,6 +293,8 @@ export const mohApi = {
|
||||
list: () => api<ListResponse<MohClassDto>>('GET', '/api/moh-classes').then((r) => r.items),
|
||||
create: (name: string) => api<MohClassDto>('POST', '/api/moh-classes', { name }),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/moh-classes/${id}`),
|
||||
update: (id: string, body: MohClassUpdate) =>
|
||||
api<MohClassDto>('PATCH', `/api/moh-classes/${id}`, body),
|
||||
addFile: (id: string, name: string, file: File) =>
|
||||
upload<MohClassDto>(
|
||||
'POST',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type AnnouncementDto,
|
||||
type Destination,
|
||||
type MohClassDto,
|
||||
type MohClassUpdate,
|
||||
type SoundDto,
|
||||
} from '@selfphoneng/shared';
|
||||
import { announcementsApi, mohApi, soundsApi } from '../../api/client.ts';
|
||||
@@ -280,6 +281,16 @@ function MohClassCard({
|
||||
mutationFn: (fileId: string) => mohApi.removeFile(cls.id, fileId),
|
||||
onSuccess: onChanged,
|
||||
});
|
||||
const update = useMutation({
|
||||
mutationFn: (body: MohClassUpdate) => mohApi.update(cls.id, body),
|
||||
onSuccess: onChanged,
|
||||
});
|
||||
/** The play order with file i moved one place up (-1) or down (+1). */
|
||||
const move = (i: number, by: -1 | 1) => {
|
||||
const ids = cls.files.map((f) => f.id);
|
||||
[ids[i], ids[i + by]] = [ids[i + by]!, ids[i]!];
|
||||
update.mutate({ order: ids });
|
||||
};
|
||||
const tooBig = file !== null && file.size > SOUND_MAX_BYTES;
|
||||
|
||||
return (
|
||||
@@ -297,9 +308,48 @@ function MohClassCard({
|
||||
{cls.files.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">No music yet: callers hear the default music.</p>
|
||||
)}
|
||||
<ul className="flex flex-col gap-1">
|
||||
{cls.files.map((f) => (
|
||||
{cls.files.length > 1 && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cls.shuffle}
|
||||
disabled={update.isPending}
|
||||
onChange={(e) => update.mutate({ shuffle: e.target.checked })}
|
||||
/>
|
||||
Shuffle
|
||||
<span className="text-zinc-500">
|
||||
{cls.shuffle
|
||||
? '(plays in random order)'
|
||||
: '(each caller on hold starts at the top, in this order)'}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<ol aria-label={`Play order of ${cls.name}`} className="flex flex-col gap-1">
|
||||
{cls.files.map((f, i) => (
|
||||
<li key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
{cls.files.length > 1 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="w-5 text-right text-zinc-500 tabular-nums">{i + 1}.</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Move ${f.name} up`}
|
||||
title="Play earlier"
|
||||
disabled={i === 0 || cls.shuffle || update.isPending}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Move ${f.name} down`}
|
||||
title="Play later"
|
||||
disabled={i === cls.files.length - 1 || cls.shuffle || update.isPending}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
{f.name} <span className="text-zinc-500">{secs(f.durationMs)}</span>
|
||||
</span>
|
||||
@@ -319,7 +369,7 @@ function MohClassCard({
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ol>
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
@@ -348,6 +398,7 @@ function MohClassCard({
|
||||
{tooBig && <Alert>That file is larger than 20 MB.</Alert>}
|
||||
{add.error && <Alert>{add.error.message}</Alert>}
|
||||
{removeFile.error && <Alert>{removeFile.error.message}</Alert>}
|
||||
{update.error && <Alert>{update.error.message}</Alert>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user