release / go (push) Failing after 1m18s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Failing after 4m13s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m47s
release / Build signed APK (releases and dev) (push) Canceled after 4m21s
The first step of loudness normalization: the server measures each track with ffmpeg's EBU R128 filter (true peak, mono as dual mono) and stores the integrated loudness, true peak and loudness range in track_loudness (migration 0065). It also keeps a histogram of the 400 ms gating blocks at 0.1 LU, so album loudness can be computed exactly later with no second decode (#4996). The histogram reproduces ffmpeg's own figure (-10.68 against -10.7 on the captured fixture), and the analyzer logs a warning if the two ever drift. - A background worker, cloned from the fingerprint backfill, measures every track, new ones included. Measuring inline in the scan was dropped: the analysis decodes the whole file, and a large import could pass the scan's one-hour stuck threshold. The scan only deletes a changed file's measurement; the worker ticks every 10 minutes. - Timeouts, the cancel/missing-binary split and settled verdicts follow the fingerprint runner. Silence and undecodable files are stored as verdicts; stalls are retried. The deadline scales with track length. - loudness_settings (enabled, files at once) and an admin card with the coverage gauge, under GET/PUT /api/admin/library/loudness-settings and GET /api/admin/library/loudness. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
import type { LoudnessCoverage, LoudnessSettings } from '$lib/api/admin';
|
|
|
|
vi.mock('$lib/api/admin', () => ({
|
|
getLoudnessSettings: vi.fn(),
|
|
updateLoudnessSettings: vi.fn(),
|
|
getLoudnessCoverage: vi.fn()
|
|
}));
|
|
|
|
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
|
|
|
import LoudnessSettingsCard from './LoudnessSettingsCard.svelte';
|
|
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin';
|
|
import { pushToast } from '$lib/stores/toast.svelte';
|
|
|
|
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2 };
|
|
const coverage: LoudnessCoverage = {
|
|
total: 1200,
|
|
measured: 900,
|
|
silent: 2,
|
|
unreadable: 3,
|
|
pending: 295,
|
|
enabled: true
|
|
};
|
|
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
async function renderCard(
|
|
over: Partial<LoudnessSettings> = {},
|
|
cov: Partial<LoudnessCoverage> = {}
|
|
) {
|
|
vi.mocked(getLoudnessSettings).mockResolvedValue({ ...base, ...over });
|
|
vi.mocked(getLoudnessCoverage).mockResolvedValue({ ...coverage, ...cov });
|
|
const r = render(LoudnessSettingsCard);
|
|
await screen.findByRole('spinbutton', { name: /files analyzed at once/i });
|
|
return r;
|
|
}
|
|
|
|
const saveButton = () => screen.getByRole('button', { name: /save/i });
|
|
const concurrency = () => screen.getByRole('spinbutton', { name: /files analyzed at once/i });
|
|
|
|
describe('LoudnessSettingsCard', () => {
|
|
test('shows how far the analysis has got', async () => {
|
|
await renderCard();
|
|
const gauge = await screen.findByTestId('loudness-coverage');
|
|
expect(gauge.textContent).toMatch(/900 of 1,200 tracks measured/);
|
|
expect(gauge.textContent).toMatch(/295 pending/);
|
|
expect(gauge.textContent).toMatch(/2 silent/);
|
|
expect(gauge.textContent).toMatch(/3 unreadable/);
|
|
expect(gauge.textContent).not.toMatch(/paused/);
|
|
});
|
|
|
|
// With analysis off, pending never shrinks; the gauge must not read as progress.
|
|
test('says the work is paused when analysis is off', async () => {
|
|
await renderCard({ enabled: false }, { enabled: false });
|
|
const gauge = await screen.findByTestId('loudness-coverage');
|
|
expect(gauge.textContent).toMatch(/paused while analysis is off/);
|
|
});
|
|
|
|
test('save is disabled until something changes, then saves the form', async () => {
|
|
vi.mocked(updateLoudnessSettings).mockResolvedValue({ ...base, backfill_concurrency: 4 });
|
|
await renderCard();
|
|
expect(saveButton()).toHaveProperty('disabled', true);
|
|
|
|
await fireEvent.input(concurrency(), { target: { value: '4' } });
|
|
await waitFor(() => expect(saveButton()).toHaveProperty('disabled', false));
|
|
await fireEvent.click(saveButton());
|
|
|
|
await waitFor(() =>
|
|
expect(updateLoudnessSettings).toHaveBeenCalledWith({ enabled: true, backfill_concurrency: 4 })
|
|
);
|
|
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Loudness analysis settings saved.'));
|
|
// The gauge is read again after a save: switching analysis on or off changes it.
|
|
expect(getLoudnessCoverage).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('an out-of-range value is named and cannot be saved', async () => {
|
|
await renderCard();
|
|
await fireEvent.input(concurrency(), { target: { value: '9' } });
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId('settings-problems').textContent).toMatch(/1 to 8/)
|
|
);
|
|
expect(saveButton()).toHaveProperty('disabled', true);
|
|
});
|
|
|
|
test('a failed load offers a retry', async () => {
|
|
vi.mocked(getLoudnessSettings).mockRejectedValue(new Error('boom'));
|
|
vi.mocked(getLoudnessCoverage).mockResolvedValue(coverage);
|
|
render(LoudnessSettingsCard);
|
|
await screen.findByText(/couldn't load loudness analysis settings/i);
|
|
expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy();
|
|
});
|
|
});
|