sounds: upload, convert, play, rename, delete with references

/api/sounds (admin) takes the raw file, converts it with ffmpeg in one
pass to <id>.wav (8 kHz) and <id>.g722 (HD for G.722 calls) in SOUNDS_DIR,
measures it with ffprobe, and stores it as custom/<id>. Delete answers 409
with what still plays it (IVR greeting/invalid sound, announcements, the
recording notice). Sounds & music page: drop or pick a file (name
suggested from the file name), play, rename, delete. MOH classes stay in
M8. The web client and greetings share one raw-upload helper; the
api test audio helpers moved to test/audio.ts.

e2e m6-t1: drag-and-drop upload in the browser, Asterisk plays
custom/<id>, rename, delete refused while in use, then deleted.

Task: M6-T1
This commit is contained in:
iamdoubz committed 2026-10-18 10:13:58 -05:00
1 parent 609965bba5
commit 17db68a8c5
19 files changed
+752 -60

No files matched your search

+35 -15
View File
@@ -17,6 +17,7 @@ import type {
RouteTestResult,
SettingsDto,
SipCredentials,
SoundDto,
TrunkCreate,
TrunkDto,
TrunkTestResult,
@@ -59,6 +60,25 @@ async function api<T>(method: string, url: string, body?: unknown): Promise<T> {
return data as T;
}
/** Audio uploads: the file itself is the body (no multipart), with its own Content-Type. */
async function upload<T>(method: string, url: string, body: Blob): Promise<T> {
const res = await fetch(url, {
method,
credentials: 'same-origin',
headers: {
'X-Requested-With': 'selfphoneng',
'Content-Type': body.type || 'application/octet-stream',
},
body,
});
const data = await res.json().catch(() => null);
if (!res.ok) {
const e = data?.error ?? {};
throw new ApiError(res.status, e.code ?? 'internal', e.message ?? res.statusText, e.details);
}
return data as T;
}
export const authApi = {
me: () => api<{ user: AuthUser }>('GET', '/api/auth/me').then((r) => r.user),
login: (email: string, password: string) =>
@@ -157,20 +177,20 @@ export const meApi = {
greetings: () => api<Record<GreetingKind, boolean>>('GET', '/api/me/greetings'),
greetingUrl: (kind: GreetingKind) => `/api/me/greetings/${kind}/audio`,
/** The raw recording or file is the body; the server converts it with ffmpeg. */
putGreeting: async (kind: GreetingKind, audio: Blob) => {
const res = await fetch(`/api/me/greetings/${kind}`, {
method: 'PUT',
credentials: 'same-origin',
headers: {
'X-Requested-With': 'selfphoneng',
'Content-Type': audio.type || 'application/octet-stream',
},
body: audio,
});
if (!res.ok) {
const e = (await res.json().catch(() => null))?.error ?? {};
throw new ApiError(res.status, e.code ?? 'internal', e.message ?? res.statusText, e.details);
}
},
putGreeting: (kind: GreetingKind, audio: Blob) =>
upload<{ ok: true }>('PUT', `/api/me/greetings/${kind}`, audio),
deleteGreeting: (kind: GreetingKind) => api<{ ok: true }>('DELETE', `/api/me/greetings/${kind}`),
};
export const soundsApi = {
list: () => api<ListResponse<SoundDto>>('GET', '/api/sounds').then((r) => r.items),
audioUrl: (id: string) => `/api/sounds/${id}/audio`,
upload: (name: string, file: File) =>
upload<SoundDto>(
'POST',
`/api/sounds?${new URLSearchParams({ name, filename: file.name })}`,
file,
),
rename: (id: string, name: string) => api<SoundDto>('PATCH', `/api/sounds/${id}`, { name }),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/sounds/${id}`),
};
+3
View File
@@ -14,6 +14,7 @@ import { TrunksPage } from './pages/admin/TrunksPage.tsx';
import { NumbersPage } from './pages/admin/NumbersPage.tsx';
import { OutboundPage } from './pages/admin/OutboundPage.tsx';
import { SettingsPage } from './pages/admin/SettingsPage.tsx';
import { SoundsPage } from './pages/admin/SoundsPage.tsx';
import { MePage } from './pages/user/MePage.tsx';
import { SetupPage } from './pages/SetupPage.tsx';
import './index.css';
@@ -41,6 +42,8 @@ const router = createBrowserRouter([
<NumbersPage />
) : p.path === 'trunks' ? (
<TrunksPage />
) : p.path === 'sounds' ? (
<SoundsPage />
) : (
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
),
+1 -1
View File
@@ -21,7 +21,7 @@ export const ADMIN_PAGES = [
{ path: 'hours', label: 'Office hours', milestone: 'M6' },
{ path: 'conferences', label: 'Conferences', milestone: 'M8' },
{ path: 'paging', label: 'Paging', milestone: 'M8' },
{ path: 'sounds', label: 'Sounds & music', milestone: 'M6' },
{ path: 'sounds', label: 'Sounds & music', milestone: null },
{ path: 'devices', label: 'Phones', milestone: 'M10' },
{ path: 'contacts', label: 'Contacts', milestone: 'M10' },
{ path: 'calls', label: 'Call history', milestone: 'M7' },
+218
View File
@@ -0,0 +1,218 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sounds & music (05 §2): recorded prompts for digital receptionists and announcements.
// Upload by picking or dropping a file; play, rename, delete. Music on hold arrives with M8.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { SOUND_MAX_BYTES, type SoundDto } from '@selfphoneng/shared';
import { soundsApi } from '../../api/client.ts';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
const secs = (ms: number) =>
`${Math.floor(ms / 60000)}:${String(Math.round(ms / 1000) % 60).padStart(2, '0')}`;
const stem = (filename: string) =>
filename
.replace(/\.[^.]+$/, '')
.replace(/[_-]+/g, ' ')
.trim();
export function SoundsPage() {
const qc = useQueryClient();
const sounds = useQuery({ queryKey: ['sounds'], queryFn: soundsApi.list });
const refresh = () => qc.invalidateQueries({ queryKey: ['sounds'] });
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState('');
const [dragging, setDragging] = useState(false);
const [renaming, setRenaming] = useState<SoundDto | null>(null);
const [deleting, setDeleting] = useState<SoundDto | null>(null);
const add = useMutation({
mutationFn: () => soundsApi.upload(name.trim(), file!),
onSuccess: async () => {
setFile(null);
setName('');
await refresh();
},
});
const remove = useMutation({
mutationFn: (id: string) => soundsApi.remove(id),
onSuccess: async () => {
setDeleting(null);
await refresh();
},
});
function pick(f: File | undefined) {
if (!f) return;
add.reset();
setFile(f);
if (!name.trim()) setName(stem(f.name));
}
const tooBig = file !== null && file.size > SOUND_MAX_BYTES;
return (
<section className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Sounds &amp; music</h1>
<form
aria-label="Upload a sound"
className={`flex flex-col gap-3 rounded-lg border-2 border-dashed p-4 ${dragging ? 'border-sky-500 bg-sky-50 dark:bg-sky-950' : 'border-zinc-300 dark:border-zinc-700'}`}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
pick(e.dataTransfer.files[0]);
}}
onSubmit={(e) => {
e.preventDefault();
if (file && name.trim() && !tooBig) add.mutate();
}}
>
<p className="text-sm">
Drop an audio file here, or pick one. Any common format works (mp3, wav, m4a, ogg, flac);
it's converted for phones and evened out in volume. Up to 20 MB.
</p>
<div className="flex flex-wrap items-end gap-3">
<Field label="File">
{(p) => (
<input
{...p}
type="file"
accept="audio/*"
className="text-sm"
onChange={(e) => {
pick(e.target.files?.[0]);
e.target.value = '';
}}
/>
)}
</Field>
<Field label="Name">
{(p) => (
<input
{...p}
className={inputClass}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Main greeting"
/>
)}
</Field>
<Button
type="submit"
variant="primary"
disabled={!file || !name.trim() || tooBig || add.isPending}
>
{add.isPending ? 'Converting…' : 'Upload'}
</Button>
</div>
{file && <p className="text-sm text-zinc-500">Selected: {file.name}</p>}
{tooBig && <Alert>That file is larger than 20 MB.</Alert>}
{add.error && <Alert>{add.error.message}</Alert>}
</form>
{sounds.isError && <Alert>{sounds.error.message}</Alert>}
{sounds.data?.length === 0 && (
<p className="text-sm text-zinc-500">
No sounds yet. Upload greetings for your digital receptionist and announcements.
</p>
)}
{sounds.data && sounds.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{sounds.data.map((s) => (
<li
key={s.id}
className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2 text-sm"
>
<span className="min-w-0 flex-1">
<span className="font-medium">{s.name}</span>{' '}
<span className="text-zinc-500">{secs(s.durationMs)}</span>
</span>
<audio
controls
preload="none"
src={soundsApi.audioUrl(s.id)}
aria-label={`Play ${s.name}`}
className="h-9 w-full sm:w-64"
/>
<Button onClick={() => setRenaming(s)}>Rename</Button>
<Button variant="ghost" onClick={() => setDeleting(s)}>
Delete
</Button>
</li>
))}
</ul>
)}
{renaming && (
<RenameDialog sound={renaming} onClose={() => setRenaming(null)} onDone={refresh} />
)}
<ConfirmDialog
open={deleting !== null}
title="Delete sound"
message={<p>Delete "{deleting?.name}"? This can't be undone.</p>}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => {
setDeleting(null);
remove.reset();
}}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
</section>
);
}
function RenameDialog({
sound,
onClose,
onDone,
}: {
sound: SoundDto;
onClose: () => void;
onDone: () => Promise<unknown>;
}) {
const [name, setName] = useState(sound.name);
const save = useMutation({
mutationFn: () => soundsApi.rename(sound.id, name.trim()),
onSuccess: async () => {
await onDone();
onClose();
},
});
return (
<Dialog
open
onClose={onClose}
title="Rename sound"
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={!name.trim() || save.isPending}
onClick={() => save.mutate()}
>
Save
</Button>
</>
}
>
<Field label="Name">
{(p) => (
<input
{...p}
className={inputClass}
value={name}
onChange={(e) => setName(e.target.value)}
/>
)}
</Field>
{save.error && <Alert>{save.error.message}</Alert>}
</Dialog>
);
}