feat(api): #286 — mount the whole /api surface at /api/v1
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.

Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.

Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).

Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.

Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.

fixes #286
refs #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 00:30:20 +02:00
co-authored by Claude Opus 4.8
parent 51b67dea06
commit ef2bd65c27
218 changed files with 2586 additions and 2285 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ export function uploadArtwork(file: File, target: ArtworkUploadTarget): Promise<
body.append('file', file);
body.append('target', target);
return request<ArtworkUpload>('/api/artwork/uploads', {
return request<ArtworkUpload>('/api/v1/artwork/uploads', {
body,
method: 'POST'
});
+15 -15
View File
@@ -48,7 +48,7 @@ describe('auth endpoints', () => {
vi.restoreAllMocks();
});
it('GET /api/auth/config', async () => {
it('GET /api/v1/auth/config', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ oidcEnabled: true, localLoginEnabled: true, setupRequired: false }));
@@ -56,10 +56,10 @@ describe('auth endpoints', () => {
const config = await getAuthConfig();
expect(config).toEqual({ oidcEnabled: true, localLoginEnabled: true, setupRequired: false });
expect(fetchMock).toHaveBeenCalledWith('/api/auth/config', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/config', expect.objectContaining({ method: 'GET' }));
});
it('GET /api/auth/session', async () => {
it('GET /api/v1/auth/session', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ authenticated: true, username: 'admin', method: 'local' }));
@@ -67,10 +67,10 @@ describe('auth endpoints', () => {
const session = await getAuthSession();
expect(session).toEqual({ authenticated: true, username: 'admin', method: 'local' });
expect(fetchMock).toHaveBeenCalledWith('/api/auth/session', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/session', expect.objectContaining({ method: 'GET' }));
});
it('GET /api/auth/session for an anonymous caller (username/method omitted)', async () => {
it('GET /api/v1/auth/session for an anonymous caller (username/method omitted)', async () => {
// The server serializes anonymous sessions as `{ "authenticated": false }` — username/method are
// dropped by Newtonsoft's global NullValueHandling.Ignore, so AuthSession must treat them as
// optional/undefined rather than present-but-null.
@@ -83,7 +83,7 @@ describe('auth endpoints', () => {
expect(session.method).toBeUndefined();
});
it('POST /api/auth/login with credentials in the body', async () => {
it('POST /api/v1/auth/login with credentials in the body', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ authenticated: true, username: 'admin', method: 'local' }));
@@ -91,7 +91,7 @@ describe('auth endpoints', () => {
await login('admin', 'hunter2');
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/login',
'/api/v1/auth/login',
expect.objectContaining({ body: JSON.stringify({ username: 'admin', password: 'hunter2' }), method: 'POST' })
);
});
@@ -107,7 +107,7 @@ describe('auth endpoints', () => {
unsubscribe();
});
it('POST /api/auth/setup with credentials in the body', async () => {
it('POST /api/v1/auth/setup with credentials in the body', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ authenticated: true, username: 'admin', method: 'local' }));
@@ -115,37 +115,37 @@ describe('auth endpoints', () => {
await setup('admin', 'hunter2');
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/setup',
'/api/v1/auth/setup',
expect.objectContaining({ body: JSON.stringify({ username: 'admin', password: 'hunter2' }), method: 'POST' })
);
});
it('POST /api/auth/logout', async () => {
it('POST /api/v1/auth/logout', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
await logout();
expect(fetchMock).toHaveBeenCalledWith('/api/auth/logout', expect.objectContaining({ method: 'POST' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/logout', expect.objectContaining({ method: 'POST' }));
});
it('POST /api/auth/password with both passwords, suppressing the 401 banner', async () => {
it('POST /api/v1/auth/password with both passwords, suppressing the 401 banner', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
await changePassword('old', 'new');
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/password',
'/api/v1/auth/password',
expect.objectContaining({ body: JSON.stringify({ currentPassword: 'old', newPassword: 'new' }), method: 'POST' })
);
});
it('GET /api/auth/machine-key', async () => {
it('GET /api/v1/auth/machine-key', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ apiKey: 'abc123' }));
const result = await getMachineKey();
expect(result).toEqual({ apiKey: 'abc123' });
expect(fetchMock).toHaveBeenCalledWith('/api/auth/machine-key', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/machine-key', expect.objectContaining({ method: 'GET' }));
});
});
+11 -11
View File
@@ -1,20 +1,20 @@
import { request } from './client';
// --- wire types -------------------------------------------------------------
// The auth surface (`/api/auth/*`) is deliberately excluded from the OpenAPI document (server-side
// The auth surface (`/api/v1/auth/*`) is deliberately excluded from the OpenAPI document (server-side
// [IgnoreApi]), so — unlike every other domain module (spa-conventions §4) — these DTOs are NOT in the
// generated types and are hand-written here. Keep the field names camelCase to match the server records
// exactly (the JSON the auth endpoints emit). This deviation is intentional; do not try to source these
// from `./generated/v1`.
/** `GET /api/auth/config` — PUBLIC; drives the boot gate. */
/** `GET /api/v1/auth/config` — PUBLIC; drives the boot gate. */
export interface AuthConfig {
oidcEnabled: boolean;
localLoginEnabled: boolean;
setupRequired: boolean;
}
/** `GET /api/auth/session` — anonymous callers get 200 with `authenticated: false` (never 401). */
/** `GET /api/v1/auth/session` — anonymous callers get 200 with `authenticated: false` (never 401). */
export interface AuthSession {
authenticated: boolean;
// Omitted (undefined) for anonymous callers — the server serializes `{ "authenticated": false }` and
@@ -24,7 +24,7 @@ export interface AuthSession {
}
/**
* `GET /api/auth/machine-key` — the server-generated machine API key.
* `GET /api/v1/auth/machine-key` — the server-generated machine API key.
* The wire field is `apiKey` (server record `MachineKeyResponse(string ApiKey)`), not `key`.
*/
export interface MachineKey {
@@ -78,16 +78,16 @@ export function notifyUnauthorized(): void {
// --- auth endpoints ---------------------------------------------------------
export function getAuthConfig(): Promise<AuthConfig> {
return request<AuthConfig>('/api/auth/config');
return request<AuthConfig>('/api/v1/auth/config');
}
export function getAuthSession(): Promise<AuthSession> {
return request<AuthSession>('/api/auth/session');
return request<AuthSession>('/api/v1/auth/session');
}
export function login(username: string, password: string): Promise<AuthSession> {
// A wrong-password 401 is an expected inline answer here — it must NOT trip the global 401 banner.
return request<AuthSession>('/api/auth/login', {
return request<AuthSession>('/api/v1/auth/login', {
body: { username, password },
method: 'POST',
suppressUnauthorizedSignal: true
@@ -95,19 +95,19 @@ export function login(username: string, password: string): Promise<AuthSession>
}
export function setup(username: string, password: string): Promise<AuthSession> {
return request<AuthSession>('/api/auth/setup', {
return request<AuthSession>('/api/v1/auth/setup', {
body: { username, password },
method: 'POST'
});
}
export function logout(): Promise<void> {
return request<void>('/api/auth/logout', { method: 'POST' });
return request<void>('/api/v1/auth/logout', { method: 'POST' });
}
export function changePassword(currentPassword: string, newPassword: string): Promise<void> {
// A wrong current-password 401 is shown inline, not via the global banner.
return request<void>('/api/auth/password', {
return request<void>('/api/v1/auth/password', {
body: { currentPassword, newPassword },
method: 'POST',
suppressUnauthorizedSignal: true
@@ -115,5 +115,5 @@ export function changePassword(currentPassword: string, newPassword: string): Pr
}
export function getMachineKey(): Promise<MachineKey> {
return request<MachineKey>('/api/auth/machine-key');
return request<MachineKey>('/api/v1/auth/machine-key');
}
+15 -15
View File
@@ -58,14 +58,14 @@ describe('blocks api client', () => {
it('getBlockGroups fetches all block groups', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([{ id: 1, name: 'Prime' }]));
await expect(getBlockGroups()).resolves.toHaveLength(1);
expect(fetchMock).toHaveBeenCalledWith('/api/blocks/groups', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/blocks/groups', expect.objectContaining({ method: 'GET' }));
});
it('createBlockGroup POSTs the name', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3, name: 'Prime' }, 201));
await createBlockGroup({ name: 'Prime' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/blocks/groups');
expect(url).toBe('/api/v1/blocks/groups');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body)).name).toBe('Prime');
});
@@ -73,26 +73,26 @@ describe('blocks api client', () => {
it('deleteBlockGroup DELETEs by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteBlockGroup(3)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/blocks/groups/3', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/blocks/groups/3', expect.objectContaining({ method: 'DELETE' }));
});
it('getBlocks fetches all blocks', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getBlocks();
expect(fetchMock).toHaveBeenCalledWith('/api/blocks', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/blocks', expect.objectContaining({ method: 'GET' }));
});
it('getBlock fetches a block by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4 }));
await getBlock(4);
expect(fetchMock).toHaveBeenCalledWith('/api/blocks/4', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/blocks/4', expect.objectContaining({ method: 'GET' }));
});
it('createBlock POSTs group id and name', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 8 }, 201));
await createBlock({ blockGroupId: 2, name: 'Morning' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/blocks');
expect(url).toBe('/api/v1/blocks');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toMatchObject({ blockGroupId: 2, name: 'Morning' });
});
@@ -100,20 +100,20 @@ describe('blocks api client', () => {
it('deleteBlock DELETEs by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await deleteBlock(4);
expect(fetchMock).toHaveBeenCalledWith('/api/blocks/4', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/blocks/4', expect.objectContaining({ method: 'DELETE' }));
});
it('getBlockItems fetches items for a block', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getBlockItems(4);
expect(fetchMock).toHaveBeenCalledWith('/api/blocks/4/items', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/blocks/4/items', expect.objectContaining({ method: 'GET' }));
});
it('replaceBlock PUTs the full block body', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, items: [] }));
await replaceBlock(4, sampleReplace);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/blocks/4');
expect(url).toBe('/api/v1/blocks/4');
expect(init).toMatchObject({ method: 'PUT' });
const body = JSON.parse(String(init?.body));
expect(body.minutes).toBe(60);
@@ -162,7 +162,7 @@ describe('blocks api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await previewBlock(4, sampleReplace);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/blocks/4/preview');
expect(url).toBe('/api/v1/blocks/4/preview');
expect(init).toMatchObject({ method: 'POST' });
});
@@ -170,7 +170,7 @@ describe('blocks api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 9 }, 201));
await copyBlock(4, { blockGroupId: 3, name: 'Morning Copy' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/blocks/4/copy');
expect(url).toBe('/api/v1/blocks/4/copy');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toMatchObject({ blockGroupId: 3, name: 'Morning Copy' });
});
@@ -182,10 +182,10 @@ describe('blocks api client', () => {
await searchTelevisionSeasons('season');
await searchSmartCollections('smart');
expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
'/api/search/collections?query=a%20b',
'/api/search/television-shows?query=show',
'/api/search/television-seasons?query=season',
'/api/search/smart-collections?query=smart'
'/api/v1/search/collections?query=a%20b',
'/api/v1/search/television-shows?query=show',
'/api/v1/search/television-seasons?query=season',
'/api/v1/search/smart-collections?query=smart'
]);
});
});
+18 -18
View File
@@ -16,42 +16,42 @@ export type SchedulingPickerOption = components['schemas']['SchedulingPickerOpti
// Block groups
export function getBlockGroups(): Promise<BlockGroup[]> {
return request<BlockGroup[]>('/api/blocks/groups');
return request<BlockGroup[]>('/api/v1/blocks/groups');
}
export function createBlockGroup(body: CreateBlockGroupRequest): Promise<BlockGroup> {
return request<BlockGroup>('/api/blocks/groups', { body, method: 'POST' });
return request<BlockGroup>('/api/v1/blocks/groups', { body, method: 'POST' });
}
export function deleteBlockGroup(id: number): Promise<void> {
return request<void>(`/api/blocks/groups/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/blocks/groups/${id}`, { method: 'DELETE' });
}
// Blocks
export function getBlocks(): Promise<Block[]> {
return request<Block[]>('/api/blocks');
return request<Block[]>('/api/v1/blocks');
}
export function getBlock(id: number): Promise<Block> {
return request<Block>(`/api/blocks/${id}`);
return request<Block>(`/api/v1/blocks/${id}`);
}
export function createBlock(body: CreateBlockRequest): Promise<Block> {
return request<Block>('/api/blocks', { body, method: 'POST' });
return request<Block>('/api/v1/blocks', { body, method: 'POST' });
}
export function deleteBlock(id: number): Promise<void> {
return request<void>(`/api/blocks/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/blocks/${id}`, { method: 'DELETE' });
}
export function getBlockItems(id: number): Promise<BlockItem[]> {
return request<BlockItem[]>(`/api/blocks/${id}/items`);
return request<BlockItem[]>(`/api/v1/blocks/${id}/items`);
}
/** Load block items together with the block's concurrency ETag (issue #253). */
export function getBlockItemsWithMeta(id: number): Promise<ResponseWithMeta<BlockItem[]>> {
return requestWithMeta<BlockItem[]>(`/api/blocks/${id}/items`);
return requestWithMeta<BlockItem[]>(`/api/v1/blocks/${id}/items`);
}
/**
@@ -63,7 +63,7 @@ export function replaceBlock(
body: ReplaceBlockRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<BlockWithItems>> {
return requestWithMeta<BlockWithItems>(`/api/blocks/${id}`, {
return requestWithMeta<BlockWithItems>(`/api/v1/blocks/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
@@ -71,11 +71,11 @@ export function replaceBlock(
}
export function previewBlock(id: number, body: ReplaceBlockRequest): Promise<BlockPreviewItem[]> {
return request<BlockPreviewItem[]>(`/api/blocks/${id}/preview`, { body, method: 'POST' });
return request<BlockPreviewItem[]>(`/api/v1/blocks/${id}/preview`, { body, method: 'POST' });
}
export function copyBlock(id: number, body: CopyBlockRequest): Promise<Block> {
return request<Block>(`/api/blocks/${id}/copy`, { body, method: 'POST' });
return request<Block>(`/api/v1/blocks/${id}/copy`, { body, method: 'POST' });
}
// Scheduling search pickers
@@ -85,27 +85,27 @@ function searchOptions(path: string, query: string): Promise<SchedulingPickerOpt
}
export function searchCollections(query: string): Promise<SchedulingPickerOption[]> {
return searchOptions('/api/search/collections', query);
return searchOptions('/api/v1/search/collections', query);
}
export function searchTelevisionShows(query: string): Promise<SchedulingPickerOption[]> {
return searchOptions('/api/search/television-shows', query);
return searchOptions('/api/v1/search/television-shows', query);
}
export function searchTelevisionSeasons(query: string): Promise<SchedulingPickerOption[]> {
return searchOptions('/api/search/television-seasons', query);
return searchOptions('/api/v1/search/television-seasons', query);
}
export function searchSmartCollections(query: string): Promise<SchedulingPickerOption[]> {
return searchOptions('/api/search/smart-collections', query);
return searchOptions('/api/v1/search/smart-collections', query);
}
export function searchArtists(query: string): Promise<SchedulingPickerOption[]> {
return searchOptions('/api/search/artists', query);
return searchOptions('/api/v1/search/artists', query);
}
export function searchMultiCollections(query: string): Promise<SchedulingPickerOption[]> {
return searchOptions('/api/search/multi-collections', query);
return searchOptions('/api/v1/search/multi-collections', query);
}
export function messageFromBlockError(error: unknown, fallback = 'Unable to load blocks'): string {
+3 -3
View File
@@ -5,12 +5,12 @@ export type ChannelTemplate = components['schemas']['ChannelTemplateResponseMode
export type CreateChannelTemplateRequest = components['schemas']['CreateChannelTemplateRequest'];
export function getChannelTemplates(): Promise<ChannelTemplate[]> {
return request<ChannelTemplate[]>('/api/channel-templates');
return request<ChannelTemplate[]>('/api/v1/channel-templates');
}
export async function getDefaultChannelTemplate(): Promise<ChannelTemplate | null> {
try {
return await request<ChannelTemplate>('/api/channel-templates/default');
return await request<ChannelTemplate>('/api/v1/channel-templates/default');
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return null;
@@ -21,7 +21,7 @@ export async function getDefaultChannelTemplate(): Promise<ChannelTemplate | nul
}
export function createChannelTemplate(body: CreateChannelTemplateRequest): Promise<ChannelTemplate> {
return request<ChannelTemplate>('/api/channel-templates', {
return request<ChannelTemplate>('/api/v1/channel-templates', {
body,
method: 'POST'
});
+8 -8
View File
@@ -58,7 +58,7 @@ describe('getChannelById', () => {
await expect(getChannelById(5)).resolves.toMatchObject({ id: 5, name: 'Cartoons' });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels/5');
expect(url).toBe('/api/v1/channels/5');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
@@ -93,7 +93,7 @@ describe('updateChannel', () => {
await expect(updateChannel(5, body)).resolves.toMatchObject({ name: 'Renamed' });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels/5');
expect(url).toBe('/api/v1/channels/5');
expect((init?.method ?? '').toUpperCase()).toBe('PUT');
expect(JSON.parse(init?.body as string)).toMatchObject({ name: 'Renamed', number: '5' });
});
@@ -105,7 +105,7 @@ describe('createChannel', () => {
vi.restoreAllMocks();
});
it('POSTs the request body to /api/channels and returns the created view model', async () => {
it('POSTs the request body to /api/v1/channels and returns the created view model', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleChannel), {
headers: { 'Content-Type': 'application/json' },
@@ -118,7 +118,7 @@ describe('createChannel', () => {
await expect(createChannel(body)).resolves.toMatchObject({ id: 5 });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels');
expect(url).toBe('/api/v1/channels');
expect((init?.method ?? '').toUpperCase()).toBe('POST');
});
});
@@ -129,7 +129,7 @@ describe('getMusicVideoCreditsTemplates', () => {
vi.restoreAllMocks();
});
it('GETs /api/channels/music-video-credits-templates', async () => {
it('GETs /api/v1/channels/music-video-credits-templates', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(['default']), {
headers: { 'Content-Type': 'application/json' },
@@ -140,7 +140,7 @@ describe('getMusicVideoCreditsTemplates', () => {
await expect(getMusicVideoCreditsTemplates()).resolves.toEqual(['default']);
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels/music-video-credits-templates');
expect(url).toBe('/api/v1/channels/music-video-credits-templates');
});
});
@@ -150,7 +150,7 @@ describe('getChannelStreamSelectors', () => {
vi.restoreAllMocks();
});
it('GETs /api/channels/stream-selectors', async () => {
it('GETs /api/v1/channels/stream-selectors', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(['selector.py']), {
headers: { 'Content-Type': 'application/json' },
@@ -161,6 +161,6 @@ describe('getChannelStreamSelectors', () => {
await expect(getChannelStreamSelectors()).resolves.toEqual(['selector.py']);
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels/stream-selectors');
expect(url).toBe('/api/v1/channels/stream-selectors');
});
});
+12 -12
View File
@@ -29,37 +29,37 @@ type ChannelsScreenState =
| { data: null; error: null; status: 'loading' };
export function getChannels(): Promise<ChannelSummary[]> {
return request<ChannelSummary[]>('/api/channels');
return request<ChannelSummary[]>('/api/v1/channels');
}
export function getChannelStates(): Promise<ChannelState[]> {
return request<ChannelState[]>('/api/channels/state');
return request<ChannelState[]>('/api/v1/channels/state');
}
export function getChannelById(channelId: number): Promise<Channel> {
return request<Channel>(`/api/channels/${channelId}`);
return request<Channel>(`/api/v1/channels/${channelId}`);
}
export function updateChannel(channelId: number, body: UpdateChannelRequest): Promise<Channel> {
return request<Channel>(`/api/channels/${channelId}`, {
return request<Channel>(`/api/v1/channels/${channelId}`, {
body,
method: 'PUT'
});
}
export function createChannel(body: CreateChannelRequest): Promise<Channel> {
return request<Channel>('/api/channels', {
return request<Channel>('/api/v1/channels', {
body,
method: 'POST'
});
}
export function getMusicVideoCreditsTemplates(): Promise<string[]> {
return request<string[]>('/api/channels/music-video-credits-templates');
return request<string[]>('/api/v1/channels/music-video-credits-templates');
}
export function getChannelStreamSelectors(): Promise<string[]> {
return request<string[]>('/api/channels/stream-selectors');
return request<string[]>('/api/v1/channels/stream-selectors');
}
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
@@ -72,28 +72,28 @@ export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
}
export function bulkRenumberChannels(body: BulkRenumberChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/renumber', {
return request<void>('/api/v1/channels/bulk/renumber', {
body,
method: 'POST'
});
}
export function bulkMoveChannelsToGroup(body: BulkMoveChannelsToGroupRequest): Promise<void> {
return request<void>('/api/channels/bulk/group', {
return request<void>('/api/v1/channels/bulk/group', {
body,
method: 'POST'
});
}
export function bulkDeleteChannels(body: BulkDeleteChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/delete', {
return request<void>('/api/v1/channels/bulk/delete', {
body,
method: 'POST'
});
}
export function deleteChannel(channelId: number): Promise<void> {
return request<void>(`/api/channels/${channelId}`, {
return request<void>(`/api/v1/channels/${channelId}`, {
method: 'DELETE'
});
}
@@ -101,7 +101,7 @@ export function deleteChannel(channelId: number): Promise<void> {
export function createChannelFromLineup(
body: CreateChannelFromLineupRequest
): Promise<CreateChannelFromLineupResponseModel> {
return request<CreateChannelFromLineupResponseModel>('/api/channels/from-lineup', {
return request<CreateChannelFromLineupResponseModel>('/api/v1/channels/from-lineup', {
body,
method: 'POST'
});
+16 -16
View File
@@ -19,10 +19,10 @@ describe('API request client', () => {
async (method) => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
await request('/api/channels/1', { method });
await request('/api/v1/channels/1', { method });
expect(fetchMock).toHaveBeenCalledWith(
'/api/channels/1',
'/api/v1/channels/1',
expect.objectContaining({
headers: expect.objectContaining({ 'X-Csrf': '1' }),
method
@@ -39,10 +39,10 @@ describe('API request client', () => {
})
);
await request('/api/channels');
await request('/api/v1/channels');
expect(fetchMock).toHaveBeenCalledWith(
'/api/channels',
'/api/v1/channels',
expect.objectContaining({
headers: expect.not.objectContaining({ 'X-Csrf': expect.anything() })
})
@@ -54,8 +54,8 @@ describe('API request client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
window.localStorage.setItem('ctv-api-key', 'legacy-secret');
await request('/api/channels');
await request('/api/channels/1', { method: 'DELETE' });
await request('/api/v1/channels');
await request('/api/v1/channels/1', { method: 'DELETE' });
for (const call of fetchMock.mock.calls) {
const headers = (call[1] as RequestInit | undefined)?.headers as Record<string, string> | undefined;
@@ -71,7 +71,7 @@ describe('API request client', () => {
})
);
await expect(request('/api/channels', { method: 'POST', body: { name: 'News' } })).rejects.toMatchObject({
await expect(request('/api/v1/channels', { method: 'POST', body: { name: 'News' } })).rejects.toMatchObject({
detail: 'Number exists',
message: 'Validation failed',
status: 422
@@ -81,7 +81,7 @@ describe('API request client', () => {
it('returns undefined for successful responses without a JSON body', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
await expect(request('/api/channels', { method: 'POST' })).resolves.toBeUndefined();
await expect(request('/api/v1/channels', { method: 'POST' })).resolves.toBeUndefined();
});
it('signals unauthorized subscribers on a 401 response', async () => {
@@ -95,7 +95,7 @@ describe('API request client', () => {
const onUnauthorized = vi.fn();
const unsubscribe = subscribeUnauthorized(onUnauthorized);
await expect(request('/api/channels')).rejects.toMatchObject({ status: 401 });
await expect(request('/api/v1/channels')).rejects.toMatchObject({ status: 401 });
expect(onUnauthorized).toHaveBeenCalledTimes(1);
unsubscribe();
@@ -113,7 +113,7 @@ describe('API request client', () => {
const unsubscribe = subscribeUnauthorized(onUnauthorized);
await expect(
request('/api/auth/login', { body: { username: 'x', password: 'y' }, method: 'POST', suppressUnauthorizedSignal: true })
request('/api/v1/auth/login', { body: { username: 'x', password: 'y' }, method: 'POST', suppressUnauthorizedSignal: true })
).rejects.toMatchObject({ status: 401 });
expect(onUnauthorized).not.toHaveBeenCalled();
@@ -131,7 +131,7 @@ describe('API request client', () => {
const onUnauthorized = vi.fn();
const unsubscribe = subscribeUnauthorized(onUnauthorized);
await expect(request('/api/channels')).rejects.toMatchObject({ status: 403 });
await expect(request('/api/v1/channels')).rejects.toMatchObject({ status: 403 });
expect(onUnauthorized).not.toHaveBeenCalled();
unsubscribe();
@@ -140,14 +140,14 @@ describe('API request client', () => {
it('does not add a duplicate JSON content type when callers provide lowercase content-type', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
await request('/api/channels', {
await request('/api/v1/channels', {
body: { name: 'News' },
headers: { 'content-type': 'application/merge-patch+json' },
method: 'PATCH'
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/channels',
'/api/v1/channels',
expect.objectContaining({
headers: expect.not.objectContaining({ 'Content-Type': 'application/json' })
})
@@ -162,7 +162,7 @@ describe('API request client', () => {
})
);
const result = await requestWithMeta<{ id: number }[]>('/api/blocks/1/items');
const result = await requestWithMeta<{ id: number }[]>('/api/v1/blocks/1/items');
expect(result.etag).toBe('"7"');
expect(result.data).toEqual([{ id: 1 }]);
@@ -176,7 +176,7 @@ describe('API request client', () => {
})
);
const result = await requestWithMeta('/api/blocks/1/items');
const result = await requestWithMeta('/api/v1/blocks/1/items');
expect(result.etag).toBeNull();
});
@@ -184,7 +184,7 @@ describe('API request client', () => {
it('requestWithMeta surfaces the ETag on a 204 (no body) response', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { headers: { ETag: '"3"' }, status: 204 }));
const result = await requestWithMeta('/api/blocks/1', { method: 'PUT' });
const result = await requestWithMeta('/api/v1/blocks/1', { method: 'PUT' });
expect(result.data).toBeUndefined();
expect(result.etag).toBe('"3"');
+13 -13
View File
@@ -61,7 +61,7 @@ describe('collections api client', () => {
.mockResolvedValue(jsonResponse([{ id: 1, name: 'Movies', useCustomPlaybackOrder: false }]));
await expect(getCollections()).resolves.toHaveLength(1);
expect(fetchMock).toHaveBeenCalledWith('/api/collections', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/collections', expect.objectContaining({ method: 'GET' }));
});
it('createCollection POSTs the name and returns the created collection', async () => {
@@ -72,7 +72,7 @@ describe('collections api client', () => {
await expect(createCollection({ name: 'New' })).resolves.toMatchObject({ id: 7 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/collections');
expect(url).toBe('/api/v1/collections');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toEqual({ name: 'New' });
});
@@ -85,7 +85,7 @@ describe('collections api client', () => {
await updateCollection(3, { name: 'Renamed', useCustomPlaybackOrder: true });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/collections/3');
expect(url).toBe('/api/v1/collections/3');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toEqual({ name: 'Renamed', useCustomPlaybackOrder: true });
});
@@ -94,7 +94,7 @@ describe('collections api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteCollection(9)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/collections/9', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/collections/9', expect.objectContaining({ method: 'DELETE' }));
});
it('addItemsToCollection POSTs the bucketed request', async () => {
@@ -104,7 +104,7 @@ describe('collections api client', () => {
await addItemsToCollection(5, body);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/collections/5/items');
expect(url).toBe('/api/v1/collections/5/items');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body)).movieIds).toEqual([11, 12]);
});
@@ -113,7 +113,7 @@ describe('collections api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await removeItemFromCollection(5, 42);
expect(fetchMock).toHaveBeenCalledWith('/api/collections/5/items/42', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/collections/5/items/42', expect.objectContaining({ method: 'DELETE' }));
});
it('rethrows API errors (e.g. 422 on delete)', async () => {
@@ -129,19 +129,19 @@ describe('collections api client', () => {
const url = input.toString();
const method = (init?.method ?? 'GET').toUpperCase();
if (url === '/api/smart-collections' && method === 'GET') {
if (url === '/api/v1/smart-collections' && method === 'GET') {
return Promise.resolve(jsonResponse([{ id: 1, name: 'Action', query: 'genre:action' }]));
}
if (url === '/api/smart-collections' && method === 'POST') {
if (url === '/api/v1/smart-collections' && method === 'POST') {
return Promise.resolve(jsonResponse({ id: 2, name: 'Sci-Fi', query: 'genre:scifi' }, 201));
}
if (url === '/api/smart-collections/2' && method === 'PUT') {
if (url === '/api/v1/smart-collections/2' && method === 'PUT') {
return Promise.resolve(jsonResponse({ id: 2, name: 'Sci-Fi', query: 'genre:"science fiction"' }));
}
if (url === '/api/smart-collections/2' && method === 'DELETE') {
if (url === '/api/v1/smart-collections/2' && method === 'DELETE') {
return Promise.resolve(noContent());
}
@@ -167,7 +167,7 @@ describe('collections api client', () => {
const result = await getCollectionItems(7, 2, 50);
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/collections/7/items');
expect(url.pathname).toBe('/api/v1/collections/7/items');
expect(url.searchParams.get('pageNum')).toBe('2');
expect(url.searchParams.get('pageSize')).toBe('50');
expect(result.totalCount).toBe(1);
@@ -181,7 +181,7 @@ describe('collections api client', () => {
await getCollectionItems(9);
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/collections/9/items');
expect(url.pathname).toBe('/api/v1/collections/9/items');
expect(url.searchParams.get('pageNum')).toBe('0');
expect(url.searchParams.get('pageSize')).toBe('100');
});
@@ -192,7 +192,7 @@ describe('collections api client', () => {
await updateCollectionCustomOrder(7, [30, 10, 20]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/collections/7/custom-order');
expect(url).toBe('/api/v1/collections/7/custom-order');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toEqual({ mediaItemIds: [30, 10, 20] });
});
+16 -16
View File
@@ -14,35 +14,35 @@ export type UpdateSmartCollectionRequest = components['schemas']['UpdateSmartCol
/* ---------- manual collections ---------- */
export function getCollections(): Promise<MediaCollection[]> {
return request<MediaCollection[]>('/api/collections');
return request<MediaCollection[]>('/api/v1/collections');
}
export function getCollection(id: number): Promise<MediaCollection> {
return request<MediaCollection>(`/api/collections/${id}`);
return request<MediaCollection>(`/api/v1/collections/${id}`);
}
export function createCollection(body: CreateCollectionRequest): Promise<MediaCollection> {
return request<MediaCollection>('/api/collections', { body, method: 'POST' });
return request<MediaCollection>('/api/v1/collections', { body, method: 'POST' });
}
export function updateCollection(id: number, body: UpdateCollectionRequest): Promise<MediaCollection> {
return request<MediaCollection>(`/api/collections/${id}`, { body, method: 'PUT' });
return request<MediaCollection>(`/api/v1/collections/${id}`, { body, method: 'PUT' });
}
export function deleteCollection(id: number): Promise<void> {
return request<void>(`/api/collections/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/collections/${id}`, { method: 'DELETE' });
}
export function addItemsToCollection(id: number, body: AddItemsToCollectionRequest): Promise<void> {
return request<void>(`/api/collections/${id}/items`, { body, method: 'POST' });
return request<void>(`/api/v1/collections/${id}/items`, { body, method: 'POST' });
}
export function removeItemFromCollection(id: number, mediaItemId: number): Promise<void> {
return request<void>(`/api/collections/${id}/items/${mediaItemId}`, { method: 'DELETE' });
return request<void>(`/api/v1/collections/${id}/items/${mediaItemId}`, { method: 'DELETE' });
}
// Lists a manual collection's full contents (all media kinds), paged. Backed by
// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape. When the
// GET /api/v1/collections/{id}/items (#155), which reuses the library-browse item shape. When the
// collection's useCustomPlaybackOrder is true, items come back ordered by CustomIndex (nulls
// last, then title); otherwise title order.
export function getCollectionItems(
@@ -54,7 +54,7 @@ export function getCollectionItems(
pageNum: String(pageNum),
pageSize: String(pageSize)
});
return request<PagedLibraryBrowseItems>(`/api/collections/${id}/items?${params.toString()}`);
return request<PagedLibraryBrowseItems>(`/api/v1/collections/${id}/items?${params.toString()}`);
}
/** Load a page of collection items together with the collection's concurrency ETag (issue #253). */
@@ -67,7 +67,7 @@ export function getCollectionItemsWithMeta(
pageNum: String(pageNum),
pageSize: String(pageSize)
});
return requestWithMeta<PagedLibraryBrowseItems>(`/api/collections/${id}/items?${params.toString()}`);
return requestWithMeta<PagedLibraryBrowseItems>(`/api/v1/collections/${id}/items?${params.toString()}`);
}
// Replaces a manual collection's custom order wholesale: the CustomIndex of each media item is
@@ -82,7 +82,7 @@ export function updateCollectionCustomOrder(
ifMatch?: string | null
): Promise<ResponseWithMeta<void>> {
const body: UpdateCollectionCustomOrderRequest = { mediaItemIds };
return requestWithMeta<void>(`/api/collections/${id}/custom-order`, {
return requestWithMeta<void>(`/api/v1/collections/${id}/custom-order`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
@@ -92,23 +92,23 @@ export function updateCollectionCustomOrder(
/* ---------- smart collections ---------- */
export function getSmartCollections(): Promise<SmartCollection[]> {
return request<SmartCollection[]>('/api/smart-collections');
return request<SmartCollection[]>('/api/v1/smart-collections');
}
export function getSmartCollection(id: number): Promise<SmartCollection> {
return request<SmartCollection>(`/api/smart-collections/${id}`);
return request<SmartCollection>(`/api/v1/smart-collections/${id}`);
}
export function createSmartCollection(body: CreateSmartCollectionRequest): Promise<SmartCollection> {
return request<SmartCollection>('/api/smart-collections', { body, method: 'POST' });
return request<SmartCollection>('/api/v1/smart-collections', { body, method: 'POST' });
}
export function updateSmartCollection(id: number, body: UpdateSmartCollectionRequest): Promise<SmartCollection> {
return request<SmartCollection>(`/api/smart-collections/${id}`, { body, method: 'PUT' });
return request<SmartCollection>(`/api/v1/smart-collections/${id}`, { body, method: 'PUT' });
}
export function deleteSmartCollection(id: number): Promise<void> {
return request<void>(`/api/smart-collections/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/smart-collections/${id}`, { method: 'DELETE' });
}
/* ---------- add-items bucket mapping ---------- */
+6 -6
View File
@@ -38,21 +38,21 @@ type DashboardHealthState =
export async function getDashboardData(): Promise<DashboardData> {
const [channels, channelStates, mediaSources, playouts] = await Promise.all([
request<DashboardChannel[]>('/api/channels'),
request<DashboardChannelState[]>('/api/channels/state'),
request<DashboardMediaSource[]>('/api/media-sources'),
request<DashboardPlayouts>('/api/playouts')
request<DashboardChannel[]>('/api/v1/channels'),
request<DashboardChannelState[]>('/api/v1/channels/state'),
request<DashboardMediaSource[]>('/api/v1/media-sources'),
request<DashboardPlayouts>('/api/v1/playouts')
]);
return { channels, channelStates, mediaSources, playouts };
}
export function getDashboardHealth(): Promise<DashboardHealthCheck[]> {
return request<DashboardHealthCheck[]>('/api/health');
return request<DashboardHealthCheck[]>('/api/v1/health');
}
export function getDashboardVersion(): Promise<DashboardVersion> {
return request<DashboardVersion>('/api/version');
return request<DashboardVersion>('/api/v1/version');
}
export function useDashboardQuery(): DashboardQueryState {
+9 -9
View File
@@ -37,7 +37,7 @@ describe('decoTemplates api client', () => {
.mockResolvedValue(jsonResponse([{ id: 1, name: 'Weekday', decoTemplateCount: 2 }]));
await expect(getDecoTemplateGroups()).resolves.toHaveLength(1);
expect(fetchMock).toHaveBeenCalledWith(
'/api/deco-templates/groups',
'/api/v1/deco-templates/groups',
expect.objectContaining({ method: 'GET' })
);
});
@@ -48,7 +48,7 @@ describe('decoTemplates api client', () => {
.mockResolvedValue(jsonResponse({ id: 3, name: 'Weekday', decoTemplateCount: 0 }, 201));
await createDecoTemplateGroup({ name: 'Weekday' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/deco-templates/groups');
expect(url).toBe('/api/v1/deco-templates/groups');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body)).name).toBe('Weekday');
});
@@ -57,7 +57,7 @@ describe('decoTemplates api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteDecoTemplateGroup(3)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith(
'/api/deco-templates/groups/3',
'/api/v1/deco-templates/groups/3',
expect.objectContaining({ method: 'DELETE' })
);
});
@@ -65,20 +65,20 @@ describe('decoTemplates api client', () => {
it('getDecoTemplates fetches all deco templates', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getDecoTemplates();
expect(fetchMock).toHaveBeenCalledWith('/api/deco-templates', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/deco-templates', expect.objectContaining({ method: 'GET' }));
});
it('getDecoTemplate fetches a deco template by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4 }));
await getDecoTemplate(4);
expect(fetchMock).toHaveBeenCalledWith('/api/deco-templates/4', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/deco-templates/4', expect.objectContaining({ method: 'GET' }));
});
it('createDecoTemplate POSTs group id and name', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 8 }, 201));
await createDecoTemplate({ decoTemplateGroupId: 2, name: 'Weekdays' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/deco-templates');
expect(url).toBe('/api/v1/deco-templates');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toMatchObject({ decoTemplateGroupId: 2, name: 'Weekdays' });
});
@@ -86,14 +86,14 @@ describe('decoTemplates api client', () => {
it('deleteDecoTemplate DELETEs by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await deleteDecoTemplate(4);
expect(fetchMock).toHaveBeenCalledWith('/api/deco-templates/4', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/deco-templates/4', expect.objectContaining({ method: 'DELETE' }));
});
it('getDecoTemplateItems fetches items for a deco template', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getDecoTemplateItems(4);
expect(fetchMock).toHaveBeenCalledWith(
'/api/deco-templates/4/items',
'/api/v1/deco-templates/4/items',
expect.objectContaining({ method: 'GET' })
);
});
@@ -102,7 +102,7 @@ describe('decoTemplates api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, items: [] }));
await replaceDecoTemplate(4, sampleReplace);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/deco-templates/4');
expect(url).toBe('/api/v1/deco-templates/4');
expect(init).toMatchObject({ method: 'PUT' });
const body = JSON.parse(String(init?.body));
expect(body.name).toBe('Weekdays');
+10 -10
View File
@@ -13,42 +13,42 @@ export type DecoTemplateItemRequest = components['schemas']['DecoTemplateItemReq
// Deco template groups
export function getDecoTemplateGroups(): Promise<DecoTemplateGroup[]> {
return request<DecoTemplateGroup[]>('/api/deco-templates/groups');
return request<DecoTemplateGroup[]>('/api/v1/deco-templates/groups');
}
export function createDecoTemplateGroup(body: CreateDecoTemplateGroupRequest): Promise<DecoTemplateGroup> {
return request<DecoTemplateGroup>('/api/deco-templates/groups', { body, method: 'POST' });
return request<DecoTemplateGroup>('/api/v1/deco-templates/groups', { body, method: 'POST' });
}
export function deleteDecoTemplateGroup(id: number): Promise<void> {
return request<void>(`/api/deco-templates/groups/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/deco-templates/groups/${id}`, { method: 'DELETE' });
}
// Deco templates
export function getDecoTemplates(): Promise<DecoTemplate[]> {
return request<DecoTemplate[]>('/api/deco-templates');
return request<DecoTemplate[]>('/api/v1/deco-templates');
}
export function getDecoTemplate(id: number): Promise<DecoTemplate> {
return request<DecoTemplate>(`/api/deco-templates/${id}`);
return request<DecoTemplate>(`/api/v1/deco-templates/${id}`);
}
export function createDecoTemplate(body: CreateDecoTemplateRequest): Promise<DecoTemplate> {
return request<DecoTemplate>('/api/deco-templates', { body, method: 'POST' });
return request<DecoTemplate>('/api/v1/deco-templates', { body, method: 'POST' });
}
export function deleteDecoTemplate(id: number): Promise<void> {
return request<void>(`/api/deco-templates/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/deco-templates/${id}`, { method: 'DELETE' });
}
export function getDecoTemplateItems(id: number): Promise<DecoTemplateItem[]> {
return request<DecoTemplateItem[]>(`/api/deco-templates/${id}/items`);
return request<DecoTemplateItem[]>(`/api/v1/deco-templates/${id}/items`);
}
/** Load deco template items together with the deco template's concurrency ETag (issue #253). */
export function getDecoTemplateItemsWithMeta(id: number): Promise<ResponseWithMeta<DecoTemplateItem[]>> {
return requestWithMeta<DecoTemplateItem[]>(`/api/deco-templates/${id}/items`);
return requestWithMeta<DecoTemplateItem[]>(`/api/v1/deco-templates/${id}/items`);
}
/**
@@ -60,7 +60,7 @@ export function replaceDecoTemplate(
body: ReplaceDecoTemplateRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<DecoTemplateWithItems>> {
return requestWithMeta<DecoTemplateWithItems>(`/api/deco-templates/${id}`, {
return requestWithMeta<DecoTemplateWithItems>(`/api/v1/deco-templates/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
+10 -10
View File
@@ -65,49 +65,49 @@ describe('decos api client', () => {
it('getDecoGroups fetches all deco groups', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([{ id: 1, name: 'Bumpers', decoCount: 0 }]));
await expect(getDecoGroups()).resolves.toHaveLength(1);
expect(fetchMock).toHaveBeenCalledWith('/api/decos/groups', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/decos/groups', expect.objectContaining({ method: 'GET' }));
});
it('createDecoGroup POSTs to the groups route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 5, name: 'Bumpers', decoCount: 0 }, 201));
await createDecoGroup({ name: 'Bumpers' });
expect(fetchMock).toHaveBeenCalledWith('/api/decos/groups', expect.objectContaining({ method: 'POST' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/decos/groups', expect.objectContaining({ method: 'POST' }));
});
it('deleteDecoGroup DELETEs the group route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await deleteDecoGroup(3);
expect(fetchMock).toHaveBeenCalledWith('/api/decos/groups/3', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/decos/groups/3', expect.objectContaining({ method: 'DELETE' }));
});
it('getDecos fetches the flat list', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getDecos();
expect(fetchMock).toHaveBeenCalledWith('/api/decos', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/decos', expect.objectContaining({ method: 'GET' }));
});
it('getDeco fetches a single deco', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4 }));
await getDeco(4);
expect(fetchMock).toHaveBeenCalledWith('/api/decos/4', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/decos/4', expect.objectContaining({ method: 'GET' }));
});
it('createDeco POSTs to the decos route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 8 }, 201));
await createDeco({ decoGroupId: 2, name: 'Movie Night' });
expect(fetchMock).toHaveBeenCalledWith('/api/decos', expect.objectContaining({ method: 'POST' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/decos', expect.objectContaining({ method: 'POST' }));
});
it('deleteDeco DELETEs the deco route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await deleteDeco(4);
expect(fetchMock).toHaveBeenCalledWith('/api/decos/4', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/decos/4', expect.objectContaining({ method: 'DELETE' }));
});
it('replaceDeco PUTs the full state', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4 }));
await replaceDeco(4, sampleReplace);
expect(fetchMock).toHaveBeenCalledWith('/api/decos/4', expect.objectContaining({ method: 'PUT' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/decos/4', expect.objectContaining({ method: 'PUT' }));
const init = fetchMock.mock.calls[0][1];
const sent = JSON.parse(String(init?.body));
expect(sent.breakContent[0].playlistId).toBe(9);
@@ -117,9 +117,9 @@ describe('decos api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse([])));
await searchArtists('bowie');
await searchMultiCollections('mix');
expect(fetchMock).toHaveBeenCalledWith('/api/search/artists?query=bowie', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/search/artists?query=bowie', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith(
'/api/search/multi-collections?query=mix',
'/api/v1/search/multi-collections?query=mix',
expect.objectContaining({ method: 'GET' })
);
});
+8 -8
View File
@@ -13,37 +13,37 @@ export type DecoBreakContentRequest = components['schemas']['DecoBreakContentReq
// Deco groups
export function getDecoGroups(): Promise<DecoGroup[]> {
return request<DecoGroup[]>('/api/decos/groups');
return request<DecoGroup[]>('/api/v1/decos/groups');
}
export function createDecoGroup(body: CreateDecoGroupRequest): Promise<DecoGroup> {
return request<DecoGroup>('/api/decos/groups', { body, method: 'POST' });
return request<DecoGroup>('/api/v1/decos/groups', { body, method: 'POST' });
}
export function deleteDecoGroup(id: number): Promise<void> {
return request<void>(`/api/decos/groups/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/decos/groups/${id}`, { method: 'DELETE' });
}
// Decos
export function getDecos(): Promise<DecoListItem[]> {
return request<DecoListItem[]>('/api/decos');
return request<DecoListItem[]>('/api/v1/decos');
}
export function getDeco(id: number): Promise<Deco> {
return request<Deco>(`/api/decos/${id}`);
return request<Deco>(`/api/v1/decos/${id}`);
}
export function createDeco(body: CreateDecoRequest): Promise<Deco> {
return request<Deco>('/api/decos', { body, method: 'POST' });
return request<Deco>('/api/v1/decos', { body, method: 'POST' });
}
export function deleteDeco(id: number): Promise<void> {
return request<void>(`/api/decos/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/decos/${id}`, { method: 'DELETE' });
}
export function replaceDeco(id: number, body: ReplaceDecoRequest): Promise<Deco> {
return request<Deco>(`/api/decos/${id}`, { body, method: 'PUT' });
return request<Deco>(`/api/v1/decos/${id}`, { body, method: 'PUT' });
}
export function messageFromDecoError(error: unknown, fallback = 'Unable to load decos'): string {
+5 -5
View File
@@ -62,7 +62,7 @@ describe('ffmpeg profiles api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, name: 'Default' }));
await expect(getFFmpegProfile(4)).resolves.toMatchObject({ id: 4 });
expect(fetchMock).toHaveBeenCalledWith('/api/ffmpeg/profiles/4', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/ffmpeg/profiles/4', expect.objectContaining({ method: 'GET' }));
});
it('createFFmpegProfile POSTs the request body', async () => {
@@ -71,7 +71,7 @@ describe('ffmpeg profiles api client', () => {
await expect(createFFmpegProfile(sampleRequest)).resolves.toMatchObject({ id: 7 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/ffmpeg/profiles');
expect(url).toBe('/api/v1/ffmpeg/profiles');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body)).resolutionId).toBe(1);
});
@@ -82,7 +82,7 @@ describe('ffmpeg profiles api client', () => {
await updateFFmpegProfile(3, sampleRequest);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/ffmpeg/profiles/3');
expect(url).toBe('/api/v1/ffmpeg/profiles/3');
expect(init).toMatchObject({ method: 'PUT' });
});
@@ -90,7 +90,7 @@ describe('ffmpeg profiles api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteFFmpegProfile(9)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/ffmpeg/profiles/9', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/ffmpeg/profiles/9', expect.objectContaining({ method: 'DELETE' }));
});
it('getHardwareAccelerationKinds fetches the lookup', async () => {
@@ -98,7 +98,7 @@ describe('ffmpeg profiles api client', () => {
await expect(getHardwareAccelerationKinds()).resolves.toEqual(['None', 'Nvenc']);
expect(fetchMock).toHaveBeenCalledWith(
'/api/ffmpeg/hardware-acceleration-kinds',
'/api/v1/ffmpeg/hardware-acceleration-kinds',
expect.objectContaining({ method: 'GET' })
);
});
+6 -6
View File
@@ -1,7 +1,7 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
// The list endpoint (GET /api/ffmpeg/profiles) is already provided by pickers.ts as
// The list endpoint (GET /api/v1/ffmpeg/profiles) is already provided by pickers.ts as
// getFFmpegProfiles() returning FFmpegProfile (= FFmpegFullProfileResponseModel). This
// module adds the by-id read, the mutations, and the hardware-acceleration lookup.
@@ -11,23 +11,23 @@ export type UpdateFFmpegProfileRequest = components['schemas']['UpdateFFmpegProf
export type HardwareAccelerationKind = components['schemas']['HardwareAccelerationKind'];
export function getFFmpegProfile(id: number): Promise<FFmpegProfileDetail> {
return request<FFmpegProfileDetail>(`/api/ffmpeg/profiles/${id}`);
return request<FFmpegProfileDetail>(`/api/v1/ffmpeg/profiles/${id}`);
}
export function createFFmpegProfile(body: CreateFFmpegProfileRequest): Promise<FFmpegProfileDetail> {
return request<FFmpegProfileDetail>('/api/ffmpeg/profiles', { body, method: 'POST' });
return request<FFmpegProfileDetail>('/api/v1/ffmpeg/profiles', { body, method: 'POST' });
}
export function updateFFmpegProfile(id: number, body: UpdateFFmpegProfileRequest): Promise<FFmpegProfileDetail> {
return request<FFmpegProfileDetail>(`/api/ffmpeg/profiles/${id}`, { body, method: 'PUT' });
return request<FFmpegProfileDetail>(`/api/v1/ffmpeg/profiles/${id}`, { body, method: 'PUT' });
}
export function deleteFFmpegProfile(id: number): Promise<void> {
return request<void>(`/api/ffmpeg/profiles/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/ffmpeg/profiles/${id}`, { method: 'DELETE' });
}
export function getHardwareAccelerationKinds(): Promise<HardwareAccelerationKind[]> {
return request<HardwareAccelerationKind[]>('/api/ffmpeg/hardware-acceleration-kinds');
return request<HardwareAccelerationKind[]>('/api/v1/ffmpeg/hardware-acceleration-kinds');
}
export function messageFromFFmpegProfileError(error: unknown, fallback = 'Unable to load FFmpeg profiles'): string {
+4 -4
View File
@@ -46,7 +46,7 @@ describe('filler presets api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, name: 'Intro' }));
await expect(getFillerPreset(4)).resolves.toMatchObject({ id: 4 });
expect(fetchMock).toHaveBeenCalledWith('/api/filler-presets/4', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/filler-presets/4', expect.objectContaining({ method: 'GET' }));
});
it('createFillerPreset POSTs the request body', async () => {
@@ -55,7 +55,7 @@ describe('filler presets api client', () => {
await expect(createFillerPreset(sampleRequest)).resolves.toMatchObject({ id: 7 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/filler-presets');
expect(url).toBe('/api/v1/filler-presets');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body)).fillerKind).toBe('PreRoll');
});
@@ -66,7 +66,7 @@ describe('filler presets api client', () => {
await updateFillerPreset(3, sampleRequest);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/filler-presets/3');
expect(url).toBe('/api/v1/filler-presets/3');
expect(init).toMatchObject({ method: 'PUT' });
});
@@ -74,6 +74,6 @@ describe('filler presets api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteFillerPreset(9)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/filler-presets/9', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/filler-presets/9', expect.objectContaining({ method: 'DELETE' }));
});
});
+5 -5
View File
@@ -1,7 +1,7 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
// The list endpoint (GET /api/filler-presets) is picker-grade (id + name) and is already
// The list endpoint (GET /api/v1/filler-presets) is picker-grade (id + name) and is already
// provided by pickers.ts as getFillerPresets(). This module adds the by-id read (full
// model) and the mutations.
@@ -10,19 +10,19 @@ export type CreateFillerPresetRequest = components['schemas']['CreateFillerPrese
export type UpdateFillerPresetRequest = components['schemas']['UpdateFillerPresetRequest'];
export function getFillerPreset(id: number): Promise<FillerPresetDetail> {
return request<FillerPresetDetail>(`/api/filler-presets/${id}`);
return request<FillerPresetDetail>(`/api/v1/filler-presets/${id}`);
}
export function createFillerPreset(body: CreateFillerPresetRequest): Promise<FillerPresetDetail> {
return request<FillerPresetDetail>('/api/filler-presets', { body, method: 'POST' });
return request<FillerPresetDetail>('/api/v1/filler-presets', { body, method: 'POST' });
}
export function updateFillerPreset(id: number, body: UpdateFillerPresetRequest): Promise<FillerPresetDetail> {
return request<FillerPresetDetail>(`/api/filler-presets/${id}`, { body, method: 'PUT' });
return request<FillerPresetDetail>(`/api/v1/filler-presets/${id}`, { body, method: 'PUT' });
}
export function deleteFillerPreset(id: number): Promise<void> {
return request<void>(`/api/filler-presets/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/filler-presets/${id}`, { method: 'DELETE' });
}
export function messageFromFillerPresetError(error: unknown, fallback = 'Unable to load filler presets'): string {
+1 -1
View File
@@ -43,7 +43,7 @@ export function getGuide(start: Date, end: Date): Promise<ChannelGuide> {
end: end.toISOString()
});
return request<ChannelGuide>(`/api/guide?${query.toString()}`);
return request<ChannelGuide>(`/api/v1/guide?${query.toString()}`);
}
export async function getGuideScreenData(start: Date, end: Date): Promise<GuideScreenData> {
+2 -2
View File
@@ -14,7 +14,7 @@ describe('image folder clients', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getImageFolders();
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/images/folders');
expect(url.pathname).toBe('/api/v1/images/folders');
expect(url.searchParams.has('parentId')).toBe(false);
});
@@ -30,7 +30,7 @@ describe('image folder clients', () => {
const result = await updateImageFolderDuration(7, 3);
const [input, init] = fetchMock.mock.calls[0];
expect(new URL(String(input), 'http://localhost').pathname).toBe('/api/images/folders/7/duration');
expect(new URL(String(input), 'http://localhost').pathname).toBe('/api/v1/images/folders/7/duration');
expect((init as RequestInit).method).toBe('PUT');
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ durationSeconds: 3 });
expect(result.durationSeconds).toBe(3);
+2 -2
View File
@@ -6,14 +6,14 @@ export type UpdateImageFolderDurationResponse = components['schemas']['UpdateIma
export function getImageFolders(parentId?: number): Promise<ImageFolder[]> {
const queryString = parentId != null ? `?parentId=${parentId}` : '';
return request<ImageFolder[]>(`/api/images/folders${queryString}`);
return request<ImageFolder[]>(`/api/v1/images/folders${queryString}`);
}
export function updateImageFolderDuration(
id: number,
durationSeconds: number | null
): Promise<UpdateImageFolderDurationResponse> {
return request<UpdateImageFolderDurationResponse>(`/api/images/folders/${id}/duration`, {
return request<UpdateImageFolderDurationResponse>(`/api/v1/images/folders/${id}/duration`, {
body: { durationSeconds },
method: 'PUT'
});
+2 -2
View File
@@ -11,7 +11,7 @@ describe('getLanguages', () => {
vi.restoreAllMocks();
});
it('GETs /api/languages and returns the list', async () => {
it('GETs /api/v1/languages and returns the list', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse([{ code: 'eng', englishName: 'English' }])
);
@@ -19,7 +19,7 @@ describe('getLanguages', () => {
await expect(getLanguages()).resolves.toEqual([{ code: 'eng', englishName: 'English' }]);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/languages');
expect(url).toBe('/api/v1/languages');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
+1 -1
View File
@@ -4,7 +4,7 @@ import type { components } from './generated/v1';
export type LanguageCode = components['schemas']['LanguageCodeResponseModel'];
export function getLanguages(): Promise<LanguageCode[]> {
return request<LanguageCode[]>('/api/languages');
return request<LanguageCode[]>('/api/v1/languages');
}
export function messageFromLanguagesError(error: unknown, fallback = 'Unable to load languages'): string {
+8 -8
View File
@@ -35,11 +35,11 @@ function mockCollectionsBackend(opts: { deferPost?: boolean; postStatus?: number
const url = String(input);
const method = (init?.method ?? 'GET').toUpperCase();
if (url.includes('/api/media-sources/collections-scan-status')) {
if (url.includes('/api/v1/media-sources/collections-scan-status')) {
return json([...active].map((family) => ({ family })));
}
const match = url.match(/\/api\/media-sources\/(\w+)\/\d+\/scan-collections/);
const match = url.match(/\/api\/v1\/media-sources\/(\w+)\/\d+\/scan-collections/);
if (match && method === 'POST') {
await postGate;
const status = opts.postStatus ?? 202;
@@ -70,33 +70,33 @@ describe('libraries api client', () => {
it('scanLibrary POSTs to the library scan endpoint', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanLibrary(4);
expect(fetchMock).toHaveBeenCalledWith('/api/libraries/4/scan', expect.objectContaining({ method: 'POST' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/libraries/4/scan', expect.objectContaining({ method: 'POST' }));
});
it('scanLibrary appends ?deep=true for a deep scan', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanLibrary(4, true);
expect(fetchMock).toHaveBeenCalledWith('/api/libraries/4/scan?deep=true', expect.objectContaining({ method: 'POST' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/libraries/4/scan?deep=true', expect.objectContaining({ method: 'POST' }));
});
it('scanCollections POSTs to the media-source scan-collections endpoint', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanCollections('jellyfin', 7);
expect(fetchMock).toHaveBeenCalledWith('/api/media-sources/jellyfin/7/scan-collections', expect.objectContaining({ method: 'POST' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/media-sources/jellyfin/7/scan-collections', expect.objectContaining({ method: 'POST' }));
});
it('scanCollections appends ?deep=true for a deep scan', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanCollections('plex', 3, true);
const { url } = lastCall(fetchMock);
expect(url).toBe('/api/media-sources/plex/3/scan-collections?deep=true');
expect(url).toBe('/api/v1/media-sources/plex/3/scan-collections?deep=true');
});
it('scanShow POSTs the show id and deepScan flag', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanShow(4, { deepScan: true, showId: 42 });
const { init, url } = lastCall(fetchMock);
expect(url).toBe('/api/libraries/4/scan-show');
expect(url).toBe('/api/v1/libraries/4/scan-show');
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: true, showId: 42 });
});
@@ -111,7 +111,7 @@ describe('libraries api client', () => {
it('getCollectionsScanStatus GETs the collections scan-status endpoint', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(json([{ family: 'plex' }]));
const result = await getCollectionsScanStatus();
expect(fetchMock).toHaveBeenCalledWith('/api/media-sources/collections-scan-status', expect.anything());
expect(fetchMock).toHaveBeenCalledWith('/api/v1/media-sources/collections-scan-status', expect.anything());
expect(result).toEqual([{ family: 'plex' }]);
});
});
+7 -7
View File
@@ -30,7 +30,7 @@ type LibrariesScreenState =
| { data: null; error: null; status: 'loading' };
export function getMediaSources(): Promise<MediaSource[]> {
return request<MediaSource[]>('/api/media-sources');
return request<MediaSource[]>('/api/v1/media-sources');
}
export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
@@ -38,7 +38,7 @@ export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
// never multiplies by 100 (known backend wart, tracked in the handoff backlog).
// Normalize to a 0-100 percentage once, here at the API boundary, so every
// consumer (ProgressBar, percent labels) works in ordinary percentage terms.
return request<LibraryScanStatus[]>('/api/libraries/scan-status').then((scanStatuses) =>
return request<LibraryScanStatus[]>('/api/v1/libraries/scan-status').then((scanStatuses) =>
scanStatuses.map((scanStatus) => ({ ...scanStatus, percent: scanStatus.percent * 100 }))
);
}
@@ -48,7 +48,7 @@ export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
// 202 when queued, 404 (missing), 409 (already scanning), 422 (sync disabled) — see §3b.
export function scanLibrary(libraryId: number, deep = false): Promise<void> {
const query = deep ? '?deep=true' : '';
return request<void>(`/api/libraries/${libraryId}/scan${query}`, { method: 'POST' });
return request<void>(`/api/v1/libraries/${libraryId}/scan${query}`, { method: 'POST' });
}
// Media-source families that support an external-collections scan (#235 F9). Matches the three
@@ -60,7 +60,7 @@ export type CollectionsScanSource = 'emby' | 'jellyfin' | 'plex';
// when queued, 404 (source missing), 409 (a collections scan is already running for that source).
export function scanCollections(source: CollectionsScanSource, sourceId: number, deep = false): Promise<void> {
const query = deep ? '?deep=true' : '';
return request<void>(`/api/media-sources/${source}/${sourceId}/scan-collections${query}`, { method: 'POST' });
return request<void>(`/api/v1/media-sources/${source}/${sourceId}/scan-collections${query}`, { method: 'POST' });
}
export interface ScanShowParams {
@@ -74,7 +74,7 @@ export interface ScanShowParams {
// error bodies are ProblemDetails; #235 normalized the old conflated 400). Body keys are `showId`
// and `deepScan` (see LibrariesController.ScanShowRequest).
export function scanShow(libraryId: number, params: ScanShowParams): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan-show`, {
return request<void>(`/api/v1/libraries/${libraryId}/scan-show`, {
body: { deepScan: params.deepScan ?? false, showId: params.showId },
method: 'POST'
});
@@ -407,9 +407,9 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
// Fetch the media-source families whose external-collections scan is currently running. Collections
// locks are family-global, so this returns at most one entry per family and only for active ones -
// the authoritative counterpart to /api/libraries/scan-status for collections (#271, #91b F9).
// the authoritative counterpart to /api/v1/libraries/scan-status for collections (#271, #91b F9).
export function getCollectionsScanStatus(): Promise<CollectionsScanStatus[]> {
return request<CollectionsScanStatus[]>('/api/media-sources/collections-scan-status');
return request<CollectionsScanStatus[]>('/api/v1/media-sources/collections-scan-status');
}
export interface CollectionsScanState {
+1 -1
View File
@@ -29,7 +29,7 @@ describe('getLibraryBrowseItems', () => {
});
const url = browseUrl(fetchMock);
expect(url.pathname).toBe('/api/library/browse');
expect(url.pathname).toBe('/api/v1/library/browse');
expect(url.searchParams.get('query')).toBe('star');
expect(url.searchParams.get('libraryId')).toBe('5');
expect(url.searchParams.get('mediaType')).toBe('TelevisionShow');
+1 -1
View File
@@ -44,7 +44,7 @@ export function getLibraryBrowseItems(params: GetLibraryBrowseItemsParams = {}):
const queryString = searchParams.toString();
return request<PagedLibraryBrowseItems>(`/api/library/browse${queryString ? `?${queryString}` : ''}`);
return request<PagedLibraryBrowseItems>(`/api/v1/library/browse${queryString ? `?${queryString}` : ''}`);
}
export function messageFromLibraryBrowseError(error: unknown, fallback = 'Unable to load library items'): string {
+4 -4
View File
@@ -15,7 +15,7 @@ describe('getLogs', () => {
vi.restoreAllMocks();
});
it('GETs /api/logs with no query string when called without params', async () => {
it('GETs /api/v1/logs with no query string when called without params', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(samplePage), {
headers: { 'Content-Type': 'application/json' },
@@ -26,7 +26,7 @@ describe('getLogs', () => {
await expect(getLogs()).resolves.toMatchObject({ totalCount: 2 });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/logs');
expect(url).toBe('/api/v1/logs');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
@@ -41,7 +41,7 @@ describe('getLogs', () => {
await getLogs({ filter: 'boom', pageNum: 2, pageSize: 50 });
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/logs?filter=boom&pageNum=2&pageSize=50');
expect(url).toBe('/api/v1/logs?filter=boom&pageNum=2&pageSize=50');
});
it('builds the query string from sortField and sortDirection', async () => {
@@ -55,7 +55,7 @@ describe('getLogs', () => {
await getLogs({ sortDirection: 'asc', sortField: 'level' });
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/logs?sortField=level&sortDirection=asc');
expect(url).toBe('/api/v1/logs?sortField=level&sortDirection=asc');
});
it('rejects with the ApiError status on failure', async () => {
+1 -1
View File
@@ -40,7 +40,7 @@ export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
const queryString = searchParams.toString();
return request<PagedLogEntries>(`/api/logs${queryString ? `?${queryString}` : ''}`);
return request<PagedLogEntries>(`/api/v1/logs${queryString ? `?${queryString}` : ''}`);
}
export function messageFromLogsError(error: unknown, fallback = 'Unable to load logs'): string {
+2 -2
View File
@@ -7,13 +7,13 @@ describe('emptyTrash', () => {
vi.restoreAllMocks();
});
it('POSTs /api/maintenance/empty_trash', async () => {
it('POSTs /api/v1/maintenance/empty_trash', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
await emptyTrash();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/maintenance/empty_trash');
expect(url).toBe('/api/v1/maintenance/empty_trash');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { ApiError, request } from './client';
export function emptyTrash(): Promise<void> {
return request<void>('/api/maintenance/empty_trash', { method: 'POST' });
return request<void>('/api/v1/maintenance/empty_trash', { method: 'POST' });
}
export function messageFromMaintenanceError(error: unknown, fallback = 'Maintenance action failed'): string {
+5 -5
View File
@@ -16,11 +16,11 @@ describe('media detail clients', () => {
it('requests the right detail routes', async () => {
const cases: Array<[() => Promise<unknown>, string]> = [
[() => getMovie(5), '/api/movies/5'],
[() => getShow(6), '/api/shows/6'],
[() => getSeason(7), '/api/seasons/7'],
[() => getArtist(8), '/api/artists/8'],
[() => getMediaItemInfo(9), '/api/media-items/9/info']
[() => getMovie(5), '/api/v1/movies/5'],
[() => getShow(6), '/api/v1/shows/6'],
[() => getSeason(7), '/api/v1/seasons/7'],
[() => getArtist(8), '/api/v1/artists/8'],
[() => getMediaItemInfo(9), '/api/v1/media-items/9/info']
];
for (const [call, path] of cases) {
+5 -5
View File
@@ -11,23 +11,23 @@ export type MediaItemInfoStream = components['schemas']['MediaItemInfoStreamResp
export type MediaItemInfoChapter = components['schemas']['MediaItemInfoChapterResponseModel'];
export function getMovie(id: number): Promise<MovieDetail> {
return request<MovieDetail>(`/api/movies/${id}`);
return request<MovieDetail>(`/api/v1/movies/${id}`);
}
export function getShow(id: number): Promise<ShowDetail> {
return request<ShowDetail>(`/api/shows/${id}`);
return request<ShowDetail>(`/api/v1/shows/${id}`);
}
export function getSeason(id: number): Promise<SeasonDetail> {
return request<SeasonDetail>(`/api/seasons/${id}`);
return request<SeasonDetail>(`/api/v1/seasons/${id}`);
}
export function getArtist(id: number): Promise<ArtistDetail> {
return request<ArtistDetail>(`/api/artists/${id}`);
return request<ArtistDetail>(`/api/v1/artists/${id}`);
}
export function getMediaItemInfo(id: number): Promise<MediaItemInfo> {
return request<MediaItemInfo>(`/api/media-items/${id}/info`);
return request<MediaItemInfo>(`/api/v1/media-items/${id}/info`);
}
export function messageFromMediaDetailError(error: unknown, fallback = 'Unable to load media details'): string {
+2 -2
View File
@@ -7,13 +7,13 @@ describe('deleteMediaItems', () => {
vi.restoreAllMocks();
});
it('DELETEs /api/media-items with the ids in the body', async () => {
it('DELETEs /api/v1/media-items with the ids in the body', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
await deleteMediaItems([1, 2, 3]);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/media-items');
expect(url).toBe('/api/v1/media-items');
expect((init?.method ?? 'GET').toUpperCase()).toBe('DELETE');
expect(JSON.parse(init?.body as string)).toEqual({ ids: [1, 2, 3] });
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { ApiError, request } from './client';
export function deleteMediaItems(ids: number[]): Promise<void> {
return request<void>('/api/media-items', { body: { ids }, method: 'DELETE' });
return request<void>('/api/v1/media-items', { body: { ids }, method: 'DELETE' });
}
export function messageFromMediaItemsError(error: unknown, fallback = 'Unable to delete media items'): string {
+22 -22
View File
@@ -46,108 +46,108 @@ describe('mediaSources API client', () => {
vi.restoreAllMocks();
});
it('local library reads and mutations hit /api/libraries/local with the right verbs', async () => {
it('local library reads and mutations hit /api/v1/libraries/local with the right verbs', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse({})));
await getLocalLibraries();
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/libraries/local', method: 'GET' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/libraries/local', method: 'GET' });
await getLocalLibrary(7);
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/libraries/local/7', method: 'GET' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/libraries/local/7', method: 'GET' });
await createLocalLibrary({ name: 'Movies', mediaKind: 'Movies', paths: ['/media/movies'] });
expect(lastCall(fetchSpy)).toMatchObject({
url: '/api/libraries/local',
url: '/api/v1/libraries/local',
method: 'POST',
body: { name: 'Movies', mediaKind: 'Movies', paths: ['/media/movies'] }
});
await updateLocalLibrary(7, { name: 'Films', paths: [{ id: 1, path: '/media/movies' }] });
expect(lastCall(fetchSpy)).toMatchObject({
url: '/api/libraries/local/7',
url: '/api/v1/libraries/local/7',
method: 'PUT',
body: { name: 'Films', paths: [{ id: 1, path: '/media/movies' }] }
});
await deleteLocalLibrary(7);
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/libraries/local/7', method: 'DELETE' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/libraries/local/7', method: 'DELETE' });
await moveLocalLibraryPath(42, { targetLibraryId: 9 });
expect(lastCall(fetchSpy)).toMatchObject({
url: '/api/libraries/local/paths/42/move',
url: '/api/v1/libraries/local/paths/42/move',
method: 'POST',
body: { targetLibraryId: 9 }
});
await checkLocalPathExists({ path: '/media/movies' });
expect(lastCall(fetchSpy)).toMatchObject({
url: '/api/libraries/local/path-exists',
url: '/api/v1/libraries/local/path-exists',
method: 'POST',
body: { path: '/media/movies' }
});
});
it('Plex helpers map to /api/media-sources/plex', async () => {
it('Plex helpers map to /api/v1/media-sources/plex', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse({})));
await getPlexState();
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/plex', method: 'GET' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/media-sources/plex', method: 'GET' });
await startPlexPinFlow();
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/plex/pin-flow', method: 'POST' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/media-sources/plex/pin-flow', method: 'POST' });
await signOutOfPlex();
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/plex/sign-out', method: 'POST' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/media-sources/plex/sign-out', method: 'POST' });
});
it('the shared remote surface varies only by the family segment', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse({})));
await getRemoteState('jellyfin');
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/jellyfin', method: 'GET' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/media-sources/jellyfin', method: 'GET' });
await getRemoteState('emby');
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/emby', method: 'GET' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/media-sources/emby', method: 'GET' });
await getRemoteLibraries('plex', 3);
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/plex/3/libraries', method: 'GET' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/media-sources/plex/3/libraries', method: 'GET' });
await replaceRemoteLibraryPreferences('emby', 3, { libraries: [{ id: 5, shouldSyncItems: true }] });
expect(lastCall(fetchSpy)).toMatchObject({
url: '/api/media-sources/emby/3/libraries',
url: '/api/v1/media-sources/emby/3/libraries',
method: 'PUT',
body: { libraries: [{ id: 5, shouldSyncItems: true }] }
});
await getPathReplacements('jellyfin', 3);
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/jellyfin/3/path-replacements', method: 'GET' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/media-sources/jellyfin/3/path-replacements', method: 'GET' });
await replacePathReplacements('plex', 3, { items: [{ id: 0, remotePath: '/r', localPath: '/l' }] });
expect(lastCall(fetchSpy)).toMatchObject({
url: '/api/media-sources/plex/3/path-replacements',
url: '/api/v1/media-sources/plex/3/path-replacements',
method: 'PUT',
body: { items: [{ id: 0, remotePath: '/r', localPath: '/l' }] }
});
await refreshRemoteLibraries('emby', 3);
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/emby/3/refresh-libraries', method: 'POST' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/media-sources/emby/3/refresh-libraries', method: 'POST' });
});
it('connection endpoints (Jellyfin/Emby) round-trip address + apiKey', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse({})));
await getRemoteConnection('jellyfin');
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/jellyfin/connection', method: 'GET' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/media-sources/jellyfin/connection', method: 'GET' });
await saveRemoteConnection('emby', { address: 'http://emby:8096', apiKey: '' });
expect(lastCall(fetchSpy)).toMatchObject({
url: '/api/media-sources/emby/connection',
url: '/api/v1/media-sources/emby/connection',
method: 'PUT',
body: { address: 'http://emby:8096', apiKey: '' }
});
await disconnectRemote('jellyfin');
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/jellyfin/disconnect', method: 'POST' });
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/v1/media-sources/jellyfin/disconnect', method: 'POST' });
});
it('204 responses resolve to undefined', async () => {
+21 -21
View File
@@ -28,74 +28,74 @@ export type SaveRemoteConnectionRequest = components['schemas']['SaveRemoteConne
export type { RemoteFamily } from '../mediaSources/familyMeta';
// --- Local libraries (/api/libraries/local) ---
// --- Local libraries (/api/v1/libraries/local) ---
export function getLocalLibraries(): Promise<LocalLibrary[]> {
return request<LocalLibrary[]>('/api/libraries/local');
return request<LocalLibrary[]>('/api/v1/libraries/local');
}
export function getLocalLibrary(id: number): Promise<LocalLibraryDetail> {
return request<LocalLibraryDetail>(`/api/libraries/local/${id}`);
return request<LocalLibraryDetail>(`/api/v1/libraries/local/${id}`);
}
export function createLocalLibrary(body: CreateLocalLibraryRequest): Promise<LocalLibrary> {
return request<LocalLibrary>('/api/libraries/local', { body, method: 'POST' });
return request<LocalLibrary>('/api/v1/libraries/local', { body, method: 'POST' });
}
export function updateLocalLibrary(id: number, body: UpdateLocalLibraryRequest): Promise<LocalLibraryDetail> {
return request<LocalLibraryDetail>(`/api/libraries/local/${id}`, { body, method: 'PUT' });
return request<LocalLibraryDetail>(`/api/v1/libraries/local/${id}`, { body, method: 'PUT' });
}
export function deleteLocalLibrary(id: number): Promise<void> {
return request<void>(`/api/libraries/local/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/libraries/local/${id}`, { method: 'DELETE' });
}
export function moveLocalLibraryPath(pathId: number, body: MoveLocalLibraryPathRequest): Promise<void> {
return request<void>(`/api/libraries/local/paths/${pathId}/move`, { body, method: 'POST' });
return request<void>(`/api/v1/libraries/local/paths/${pathId}/move`, { body, method: 'POST' });
}
export function checkLocalPathExists(body: LocalPathCheckRequest): Promise<LocalPathCheckResult> {
return request<LocalPathCheckResult>('/api/libraries/local/path-exists', { body, method: 'POST' });
return request<LocalPathCheckResult>('/api/v1/libraries/local/path-exists', { body, method: 'POST' });
}
// --- Plex-specific convenience over the shared remote surface (/api/media-sources/plex) ---
// --- Plex-specific convenience over the shared remote surface (/api/v1/media-sources/plex) ---
export function getPlexState(): Promise<RemoteMediaSourceState> {
return getRemoteState('plex');
}
export function startPlexPinFlow(): Promise<PlexPinFlow> {
return request<PlexPinFlow>('/api/media-sources/plex/pin-flow', { method: 'POST' });
return request<PlexPinFlow>('/api/v1/media-sources/plex/pin-flow', { method: 'POST' });
}
export function signOutOfPlex(): Promise<void> {
return request<void>('/api/media-sources/plex/sign-out', { method: 'POST' });
return request<void>('/api/v1/media-sources/plex/sign-out', { method: 'POST' });
}
// --- Shared remote surface (/api/media-sources/{family}); family is the only URL variance ---
// --- Shared remote surface (/api/v1/media-sources/{family}); family is the only URL variance ---
export function getRemoteState(family: RemoteFamily): Promise<RemoteMediaSourceState> {
return request<RemoteMediaSourceState>(`/api/media-sources/${family}`);
return request<RemoteMediaSourceState>(`/api/v1/media-sources/${family}`);
}
// Connection endpoints exist only for the api-key families (Jellyfin / Emby); Plex uses the pin flow.
export function getRemoteConnection(family: 'jellyfin' | 'emby'): Promise<RemoteConnection> {
return request<RemoteConnection>(`/api/media-sources/${family}/connection`);
return request<RemoteConnection>(`/api/v1/media-sources/${family}/connection`);
}
export function saveRemoteConnection(
family: 'jellyfin' | 'emby',
body: SaveRemoteConnectionRequest
): Promise<RemoteConnection> {
return request<RemoteConnection>(`/api/media-sources/${family}/connection`, { body, method: 'PUT' });
return request<RemoteConnection>(`/api/v1/media-sources/${family}/connection`, { body, method: 'PUT' });
}
export function disconnectRemote(family: 'jellyfin' | 'emby'): Promise<void> {
return request<void>(`/api/media-sources/${family}/disconnect`, { method: 'POST' });
return request<void>(`/api/v1/media-sources/${family}/disconnect`, { method: 'POST' });
}
export function getRemoteLibraries(family: RemoteFamily, sourceId: number): Promise<RemoteLibrary[]> {
return request<RemoteLibrary[]>(`/api/media-sources/${family}/${sourceId}/libraries`);
return request<RemoteLibrary[]>(`/api/v1/media-sources/${family}/${sourceId}/libraries`);
}
export function replaceRemoteLibraryPreferences(
@@ -103,11 +103,11 @@ export function replaceRemoteLibraryPreferences(
sourceId: number,
body: ReplaceRemoteLibraryPreferencesRequest
): Promise<RemoteLibrary[]> {
return request<RemoteLibrary[]>(`/api/media-sources/${family}/${sourceId}/libraries`, { body, method: 'PUT' });
return request<RemoteLibrary[]>(`/api/v1/media-sources/${family}/${sourceId}/libraries`, { body, method: 'PUT' });
}
export function getPathReplacements(family: RemoteFamily, sourceId: number): Promise<PathReplacement[]> {
return request<PathReplacement[]>(`/api/media-sources/${family}/${sourceId}/path-replacements`);
return request<PathReplacement[]>(`/api/v1/media-sources/${family}/${sourceId}/path-replacements`);
}
export function replacePathReplacements(
@@ -115,14 +115,14 @@ export function replacePathReplacements(
sourceId: number,
body: ReplacePathReplacementsRequest
): Promise<PathReplacement[]> {
return request<PathReplacement[]>(`/api/media-sources/${family}/${sourceId}/path-replacements`, {
return request<PathReplacement[]>(`/api/v1/media-sources/${family}/${sourceId}/path-replacements`, {
body,
method: 'PUT'
});
}
export function refreshRemoteLibraries(family: RemoteFamily, sourceId: number): Promise<void> {
return request<void>(`/api/media-sources/${family}/${sourceId}/refresh-libraries`, { method: 'POST' });
return request<void>(`/api/v1/media-sources/${family}/${sourceId}/refresh-libraries`, { method: 'POST' });
}
export function messageFromMediaSourcesError(error: unknown, fallback = 'Unable to load media sources'): string {
+6 -6
View File
@@ -32,7 +32,7 @@ describe('multiCollections api client', () => {
await getMultiCollections({ pageNum: 2, pageSize: 25, query: 'kids' });
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/multi-collections');
expect(url.pathname).toBe('/api/v1/multi-collections');
expect(url.searchParams.get('pageNum')).toBe('2');
expect(url.searchParams.get('pageSize')).toBe('25');
expect(url.searchParams.get('query')).toBe('kids');
@@ -43,7 +43,7 @@ describe('multiCollections api client', () => {
await getMultiCollections();
expect(fetchMock).toHaveBeenCalledWith('/api/multi-collections', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/multi-collections', expect.objectContaining({ method: 'GET' }));
});
it('getMultiCollection fetches a single multi-collection by id', async () => {
@@ -52,7 +52,7 @@ describe('multiCollections api client', () => {
.mockResolvedValue(jsonResponse({ id: 4, items: [], name: 'Bundle' }));
await expect(getMultiCollection(4)).resolves.toMatchObject({ id: 4 });
expect(fetchMock).toHaveBeenCalledWith('/api/multi-collections/4', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/multi-collections/4', expect.objectContaining({ method: 'GET' }));
});
it('createMultiCollection POSTs the name and items', async () => {
@@ -65,7 +65,7 @@ describe('multiCollections api client', () => {
await expect(createMultiCollection(body)).resolves.toMatchObject({ id: 9 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/multi-collections');
expect(url).toBe('/api/v1/multi-collections');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toEqual(body);
});
@@ -80,7 +80,7 @@ describe('multiCollections api client', () => {
await updateMultiCollection(5, body);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/multi-collections/5');
expect(url).toBe('/api/v1/multi-collections/5');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toEqual(body);
});
@@ -89,7 +89,7 @@ describe('multiCollections api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteMultiCollection(6)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/multi-collections/6', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/multi-collections/6', expect.objectContaining({ method: 'DELETE' }));
});
it('messageFromMultiCollectionError narrows ApiError, Error, and unknown', () => {
+6 -6
View File
@@ -31,20 +31,20 @@ export function getMultiCollections(params: GetMultiCollectionsParams = {}): Pro
const queryString = searchParams.toString();
return request<PagedMultiCollections>(`/api/multi-collections${queryString ? `?${queryString}` : ''}`);
return request<PagedMultiCollections>(`/api/v1/multi-collections${queryString ? `?${queryString}` : ''}`);
}
export function getMultiCollection(id: number): Promise<MultiCollection> {
return request<MultiCollection>(`/api/multi-collections/${id}`);
return request<MultiCollection>(`/api/v1/multi-collections/${id}`);
}
/** Load a single multi-collection together with its concurrency ETag (issue #253). */
export function getMultiCollectionWithMeta(id: number): Promise<ResponseWithMeta<MultiCollection>> {
return requestWithMeta<MultiCollection>(`/api/multi-collections/${id}`);
return requestWithMeta<MultiCollection>(`/api/v1/multi-collections/${id}`);
}
export function createMultiCollection(body: CreateMultiCollectionRequest): Promise<MultiCollection> {
return request<MultiCollection>('/api/multi-collections', { body, method: 'POST' });
return request<MultiCollection>('/api/v1/multi-collections', { body, method: 'POST' });
}
/**
@@ -56,7 +56,7 @@ export function updateMultiCollection(
body: UpdateMultiCollectionRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<MultiCollection>> {
return requestWithMeta<MultiCollection>(`/api/multi-collections/${id}`, {
return requestWithMeta<MultiCollection>(`/api/v1/multi-collections/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
@@ -64,7 +64,7 @@ export function updateMultiCollection(
}
export function deleteMultiCollection(id: number): Promise<void> {
return request<void>(`/api/multi-collections/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/multi-collections/${id}`, { method: 'DELETE' });
}
export function messageFromMultiCollectionError(
+3 -3
View File
@@ -11,12 +11,12 @@ describe('getGraphicsElements', () => {
vi.restoreAllMocks();
});
it('GETs /api/graphics-elements without refresh by default', async () => {
it('GETs /api/v1/graphics-elements without refresh by default', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getGraphicsElements();
expect(fetchSpy.mock.calls[0][0]).toBe('/api/graphics-elements');
expect(fetchSpy.mock.calls[0][0]).toBe('/api/v1/graphics-elements');
});
it('passes refresh=true to re-sync on-disk definitions first', async () => {
@@ -24,6 +24,6 @@ describe('getGraphicsElements', () => {
await getGraphicsElements(true);
expect(fetchSpy.mock.calls[0][0]).toBe('/api/graphics-elements?refresh=true');
expect(fetchSpy.mock.calls[0][0]).toBe('/api/v1/graphics-elements?refresh=true');
});
});
+4 -4
View File
@@ -7,22 +7,22 @@ export type GraphicsElement = components['schemas']['GraphicsElementResponseMode
export type FFmpegProfile = components['schemas']['FFmpegFullProfileResponseModel'];
export function getFillerPresets(): Promise<FillerPreset[]> {
return request<FillerPreset[]>('/api/filler-presets').then(sortByName);
return request<FillerPreset[]>('/api/v1/filler-presets').then(sortByName);
}
export function getWatermarks(): Promise<Watermark[]> {
return request<Watermark[]>('/api/watermarks').then(sortByName);
return request<Watermark[]>('/api/v1/watermarks').then(sortByName);
}
// Pass refresh=true to first re-sync the on-disk graphics element definitions into the database
// (matching legacy Blazor's RefreshGraphicsElements-before-list) so newly added files appear.
export function getGraphicsElements(refresh = false): Promise<GraphicsElement[]> {
const url = refresh ? '/api/graphics-elements?refresh=true' : '/api/graphics-elements';
const url = refresh ? '/api/v1/graphics-elements?refresh=true' : '/api/v1/graphics-elements';
return request<GraphicsElement[]>(url).then(sortByName);
}
export function getFFmpegProfiles(): Promise<FFmpegProfile[]> {
return request<FFmpegProfile[]>('/api/ffmpeg/profiles').then(sortByName);
return request<FFmpegProfile[]>('/api/v1/ffmpeg/profiles').then(sortByName);
}
function sortByName<T extends { name: null | string }>(items: T[]): T[] {
+12 -12
View File
@@ -54,14 +54,14 @@ describe('playlists api client', () => {
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse([{ id: 1, isSystem: false, name: 'Idents', playlistCount: 2 }]));
await expect(getPlaylistGroups()).resolves.toHaveLength(1);
expect(fetchMock).toHaveBeenCalledWith('/api/playlists/groups', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/playlists/groups', expect.objectContaining({ method: 'GET' }));
});
it('createPlaylistGroup POSTs the name', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 5, isSystem: false, name: 'New', playlistCount: 0 }, 201));
await createPlaylistGroup({ name: 'New' });
const { init, url } = lastCall(fetchMock);
expect(url).toBe('/api/playlists/groups');
expect(url).toBe('/api/v1/playlists/groups');
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({ name: 'New' });
});
@@ -70,7 +70,7 @@ describe('playlists api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 5, isSystem: false, name: 'Renamed', playlistCount: 0 }));
await updatePlaylistGroup(5, { name: 'Renamed' });
const { init, url } = lastCall(fetchMock);
expect(url).toBe('/api/playlists/groups/5');
expect(url).toBe('/api/v1/playlists/groups/5');
expect(init?.method).toBe('PUT');
expect(JSON.parse(String(init?.body))).toEqual({ name: 'Renamed' });
});
@@ -78,32 +78,32 @@ describe('playlists api client', () => {
it('deletePlaylistGroup DELETEs the group', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await deletePlaylistGroup(9);
expect(fetchMock).toHaveBeenCalledWith('/api/playlists/groups/9', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/playlists/groups/9', expect.objectContaining({ method: 'DELETE' }));
});
it('getPlaylists filters by playlist group id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getPlaylists(3);
expect(fetchMock).toHaveBeenCalledWith('/api/playlists?playlistGroupId=3', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/playlists?playlistGroupId=3', expect.objectContaining({ method: 'GET' }));
});
it('getPlaylistById fetches a single playlist', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, isSystem: false, name: 'Bumps', playlistGroupId: 3 }));
await expect(getPlaylistById(4)).resolves.toMatchObject({ id: 4 });
expect(fetchMock).toHaveBeenCalledWith('/api/playlists/4', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/playlists/4', expect.objectContaining({ method: 'GET' }));
});
it('getPlaylistItems fetches the items of a playlist', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getPlaylistItems(4);
expect(fetchMock).toHaveBeenCalledWith('/api/playlists/4/items', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/playlists/4/items', expect.objectContaining({ method: 'GET' }));
});
it('createPlaylist POSTs the group id and name', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 8, isSystem: false, name: 'Bumps', playlistGroupId: 3 }, 201));
await createPlaylist({ name: 'Bumps', playlistGroupId: 3 });
const { init, url } = lastCall(fetchMock);
expect(url).toBe('/api/playlists');
expect(url).toBe('/api/v1/playlists');
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({ name: 'Bumps', playlistGroupId: 3 });
});
@@ -112,7 +112,7 @@ describe('playlists api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await updatePlaylist(4, { items: [sampleItem], name: 'Bumps' });
const { init, url } = lastCall(fetchMock);
expect(url).toBe('/api/playlists/4');
expect(url).toBe('/api/v1/playlists/4');
expect(init?.method).toBe('PUT');
expect(JSON.parse(String(init?.body))).toEqual({ items: [sampleItem], name: 'Bumps' });
});
@@ -120,14 +120,14 @@ describe('playlists api client', () => {
it('deletePlaylist DELETEs the playlist', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await deletePlaylist(4);
expect(fetchMock).toHaveBeenCalledWith('/api/playlists/4', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/playlists/4', expect.objectContaining({ method: 'DELETE' }));
});
it('previewPlaylist POSTs the draft to the preview endpoint', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await previewPlaylist({ items: [sampleItem], name: 'Draft' });
const { init, url } = lastCall(fetchMock);
expect(url).toBe('/api/playlists/preview');
expect(url).toBe('/api/v1/playlists/preview');
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({ items: [sampleItem], name: 'Draft' });
});
@@ -148,7 +148,7 @@ describe('playlists api client', () => {
};
await addItemsToPlaylist(6, body);
const { init, url } = lastCall(fetchMock);
expect(url).toBe('/api/playlists/6/items');
expect(url).toBe('/api/v1/playlists/6/items');
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual(body);
});
+13 -13
View File
@@ -14,40 +14,40 @@ export type CreatePlaylistRequest = components['schemas']['CreatePlaylistRequest
export type ReplacePlaylistRequest = components['schemas']['ReplacePlaylistRequest'];
export function getPlaylistGroups(): Promise<PlaylistGroup[]> {
return request<PlaylistGroup[]>('/api/playlists/groups');
return request<PlaylistGroup[]>('/api/v1/playlists/groups');
}
export function createPlaylistGroup(body: CreatePlaylistGroupRequest): Promise<PlaylistGroup> {
return request<PlaylistGroup>('/api/playlists/groups', { body, method: 'POST' });
return request<PlaylistGroup>('/api/v1/playlists/groups', { body, method: 'POST' });
}
export function updatePlaylistGroup(id: number, body: UpdatePlaylistGroupRequest): Promise<PlaylistGroup> {
return request<PlaylistGroup>(`/api/playlists/groups/${id}`, { body, method: 'PUT' });
return request<PlaylistGroup>(`/api/v1/playlists/groups/${id}`, { body, method: 'PUT' });
}
export function deletePlaylistGroup(id: number): Promise<void> {
return request<void>(`/api/playlists/groups/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/playlists/groups/${id}`, { method: 'DELETE' });
}
export function getPlaylists(playlistGroupId: number): Promise<Playlist[]> {
return request<Playlist[]>(`/api/playlists?playlistGroupId=${playlistGroupId}`);
return request<Playlist[]>(`/api/v1/playlists?playlistGroupId=${playlistGroupId}`);
}
export function getPlaylistById(id: number): Promise<Playlist> {
return request<Playlist>(`/api/playlists/${id}`);
return request<Playlist>(`/api/v1/playlists/${id}`);
}
export function getPlaylistItems(id: number): Promise<PlaylistItem[]> {
return request<PlaylistItem[]>(`/api/playlists/${id}/items`);
return request<PlaylistItem[]>(`/api/v1/playlists/${id}/items`);
}
/** Load playlist items together with the playlist's concurrency ETag (issue #253). */
export function getPlaylistItemsWithMeta(id: number): Promise<ResponseWithMeta<PlaylistItem[]>> {
return requestWithMeta<PlaylistItem[]>(`/api/playlists/${id}/items`);
return requestWithMeta<PlaylistItem[]>(`/api/v1/playlists/${id}/items`);
}
export function createPlaylist(body: CreatePlaylistRequest): Promise<Playlist> {
return request<Playlist>('/api/playlists', { body, method: 'POST' });
return request<Playlist>('/api/v1/playlists', { body, method: 'POST' });
}
// PUT = rename + replace the full item list; returns the persisted (re-indexed) items. Pass the
@@ -58,7 +58,7 @@ export function updatePlaylist(
body: ReplacePlaylistRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<PlaylistItem[]>> {
return requestWithMeta<PlaylistItem[]>(`/api/playlists/${id}`, {
return requestWithMeta<PlaylistItem[]>(`/api/v1/playlists/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
@@ -66,11 +66,11 @@ export function updatePlaylist(
}
export function deletePlaylist(id: number): Promise<void> {
return request<void>(`/api/playlists/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/playlists/${id}`, { method: 'DELETE' });
}
export function previewPlaylist(body: ReplacePlaylistRequest): Promise<PlaylistPreviewItem[]> {
return request<PlaylistPreviewItem[]>('/api/playlists/preview', { body, method: 'POST' });
return request<PlaylistPreviewItem[]>('/api/v1/playlists/preview', { body, method: 'POST' });
}
// Adds media items (bucketed by kind) to an existing playlist. The request body is the same
@@ -79,7 +79,7 @@ export function previewPlaylist(body: ReplacePlaylistRequest): Promise<PlaylistP
// for this endpoint's body, but the collection-shaped request is structurally identical, so
// callers can pipe toAddItemsRequest / toAddItemsRequestFromSearch output straight in.
export function addItemsToPlaylist(playlistId: number, body: AddItemsToPlaylistRequest): Promise<void> {
return request<void>(`/api/playlists/${playlistId}/items`, { body, method: 'POST' });
return request<void>(`/api/v1/playlists/${playlistId}/items`, { body, method: 'POST' });
}
export function messageFromPlaylistError(error: unknown, fallback = 'Unable to load playlists'): string {
+6 -6
View File
@@ -16,13 +16,13 @@ describe('getPlayoutBlocks', () => {
vi.restoreAllMocks();
});
it('GETs /api/playouts/{id}/blocks', async () => {
it('GETs /api/v1/playouts/{id}/blocks', async () => {
const fetchSpy = mockJson([{ id: 1, name: 'Morning' }]);
await expect(getPlayoutBlocks(7)).resolves.toHaveLength(1);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/playouts/7/blocks');
expect(url).toBe('/api/v1/playouts/7/blocks');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
});
@@ -39,7 +39,7 @@ describe('getPlayoutBlockHistory', () => {
await getPlayoutBlockHistory(7, 3);
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/playouts/7/blocks/3/history');
expect(url).toBe('/api/v1/playouts/7/blocks/3/history');
});
it('builds the query string from pageNum and pageSize', async () => {
@@ -48,7 +48,7 @@ describe('getPlayoutBlockHistory', () => {
await getPlayoutBlockHistory(7, 3, { pageNum: 2, pageSize: 25 });
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/playouts/7/blocks/3/history?pageNum=2&pageSize=25');
expect(url).toBe('/api/v1/playouts/7/blocks/3/history?pageNum=2&pageSize=25');
});
});
@@ -58,7 +58,7 @@ describe('getPlayoutHistoryDetails', () => {
vi.restoreAllMocks();
});
it('GETs /api/playouts/history/{id}', async () => {
it('GETs /api/v1/playouts/history/{id}', async () => {
const fetchSpy = mockJson({
playbackOrder: 'Shuffle',
collectionType: 'Collection',
@@ -70,7 +70,7 @@ describe('getPlayoutHistoryDetails', () => {
await expect(getPlayoutHistoryDetails(42)).resolves.toMatchObject({ name: 'Cartoons' });
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/playouts/history/42');
expect(url).toBe('/api/v1/playouts/history/42');
});
it('rejects with the ApiError status on failure', async () => {
+3 -3
View File
@@ -12,7 +12,7 @@ export interface GetBlockHistoryParams {
}
export function getPlayoutBlocks(playoutId: number): Promise<PlayoutBlock[]> {
return request<PlayoutBlock[]>(`/api/playouts/${playoutId}/blocks`);
return request<PlayoutBlock[]>(`/api/v1/playouts/${playoutId}/blocks`);
}
export function getPlayoutBlockHistory(
@@ -33,12 +33,12 @@ export function getPlayoutBlockHistory(
const queryString = searchParams.toString();
return request<PagedPlayoutHistory>(
`/api/playouts/${playoutId}/blocks/${blockId}/history${queryString ? `?${queryString}` : ''}`
`/api/v1/playouts/${playoutId}/blocks/${blockId}/history${queryString ? `?${queryString}` : ''}`
);
}
export function getPlayoutHistoryDetails(historyId: number): Promise<PlayoutHistoryDetails> {
return request<PlayoutHistoryDetails>(`/api/playouts/history/${historyId}`);
return request<PlayoutHistoryDetails>(`/api/v1/playouts/history/${historyId}`);
}
export function messageFromPlayoutHistoryError(
+12 -12
View File
@@ -45,7 +45,7 @@ describe('playouts api client', () => {
await expect(createPlayout(body)).resolves.toMatchObject({ id: 7 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts');
expect(url).toBe('/api/v1/playouts');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toMatchObject({
channelId: 1,
@@ -80,7 +80,7 @@ describe('playouts api client', () => {
await updatePlayoutDetails(3, { dailyRebuildTime: '04:00:00', scheduleFile: null });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/3');
expect(url).toBe('/api/v1/playouts/3');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toMatchObject({ dailyRebuildTime: '04:00:00' });
});
@@ -100,7 +100,7 @@ describe('playouts api client', () => {
await updatePlayoutDefaultDeco(3, 7);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/3/deco');
expect(url).toBe('/api/v1/playouts/3/deco');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toMatchObject({ decoId: 7 });
});
@@ -111,7 +111,7 @@ describe('playouts api client', () => {
await expect(deletePlayout(4)).resolves.toBeUndefined();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/4');
expect(url).toBe('/api/v1/playouts/4');
expect(init).toMatchObject({ method: 'DELETE' });
});
@@ -121,7 +121,7 @@ describe('playouts api client', () => {
await resetChannelPlayout(20);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/channels/20/playout/reset');
expect(url).toBe('/api/v1/channels/20/playout/reset');
expect(init).toMatchObject({ method: 'POST' });
});
@@ -131,7 +131,7 @@ describe('playouts api client', () => {
await erasePlayoutItems(6);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/6/erase-items');
expect(url).toBe('/api/v1/playouts/6/erase-items');
expect(init).toMatchObject({ method: 'POST' });
});
@@ -141,7 +141,7 @@ describe('playouts api client', () => {
await erasePlayoutItemsAndHistory(6);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/6/erase-items-and-history');
expect(url).toBe('/api/v1/playouts/6/erase-items-and-history');
expect(init).toMatchObject({ method: 'POST' });
});
@@ -151,7 +151,7 @@ describe('playouts api client', () => {
await expect(getPlayoutItemSchedulingContext(42)).resolves.toMatchObject({ context: '{ "x": 1 }' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/items/42/scheduling-context');
expect(url).toBe('/api/v1/playouts/items/42/scheduling-context');
expect(init?.method ?? 'GET').toBe('GET');
});
@@ -161,7 +161,7 @@ describe('playouts api client', () => {
await getAlternateSchedules(5);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/5/alternate-schedules');
expect(url).toBe('/api/v1/playouts/5/alternate-schedules');
expect(init?.method ?? 'GET').toBe('GET');
});
@@ -188,7 +188,7 @@ describe('playouts api client', () => {
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/5/alternate-schedules');
expect(url).toBe('/api/v1/playouts/5/alternate-schedules');
expect(init).toMatchObject({ method: 'PUT' });
const body = JSON.parse(String(init?.body));
expect(body.items[0].daysOfWeek).toEqual(['Monday', 'Tuesday']);
@@ -201,7 +201,7 @@ describe('playouts api client', () => {
await getPlayoutTemplates(5);
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/5/templates');
expect(url).toBe('/api/v1/playouts/5/templates');
});
it('replacePlayoutTemplates PUTs template items', async () => {
@@ -228,7 +228,7 @@ describe('playouts api client', () => {
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/5/templates');
expect(url).toBe('/api/v1/playouts/5/templates');
expect(init).toMatchObject({ method: 'PUT' });
const body = JSON.parse(String(init?.body));
expect(body.items[0].templateId).toBe(3);
+21 -21
View File
@@ -86,23 +86,23 @@ type PlayoutsScreenState =
| { data: null; error: null; status: 'loading' };
export function getPlayouts(): Promise<PlayoutsPage> {
return request<PlayoutsPage>('/api/playouts');
return request<PlayoutsPage>('/api/v1/playouts');
}
export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
return request<PlayoutDetail>(`/api/v1/playouts/${playoutId}`);
}
export function getPlayoutItems(playoutId: number, showFiller = false): Promise<PlayoutItemsPage> {
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
return request<PlayoutItemsPage>(`/api/v1/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
}
export function getPlayoutWarningsCount(): Promise<number> {
return request<number>('/api/playouts/warnings/count');
return request<number>('/api/v1/playouts/warnings/count');
}
export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
return request<PlayoutChannelState[]>('/api/channels/state');
return request<PlayoutChannelState[]>('/api/v1/channels/state');
}
export type ResetAllPlayoutsResult = components['schemas']['ResetAllPlayoutsResponseModel'];
@@ -111,62 +111,62 @@ export type ResetAllPlayoutsResult = components['schemas']['ResetAllPlayoutsResp
// queued vs skipped (locked, or an unsupported ExternalJson/None schedule kind) — #235 replaced the
// old silent skip. The caller may surface `skipped*` to explain why some playouts didn't reset.
export function resetAllPlayouts(): Promise<ResetAllPlayoutsResult> {
return request<ResetAllPlayoutsResult>('/api/playouts/reset-all', { method: 'POST' });
return request<ResetAllPlayoutsResult>('/api/v1/playouts/reset-all', { method: 'POST' });
}
export function deletePlayout(playoutId: number): Promise<void> {
return request<void>(`/api/playouts/${playoutId}`, { method: 'DELETE' });
return request<void>(`/api/v1/playouts/${playoutId}`, { method: 'DELETE' });
}
// Resets the given channel's playout. The server picks the correct default mode per schedule kind
// (Classic → Refresh, others → Reset), matching the Blazor "Reset Playout" action — so no mode is sent.
// Keyed on the immutable channel id (not the user-mutable number), matching the /api/channels/{id} contract.
// Keyed on the immutable channel id (not the user-mutable number), matching the /api/v1/channels/{id} contract.
export function resetChannelPlayout(channelId: number): Promise<void> {
return request<void>(`/api/channels/${channelId}/playout/reset`, { method: 'POST' });
return request<void>(`/api/v1/channels/${channelId}/playout/reset`, { method: 'POST' });
}
export function erasePlayoutItems(playoutId: number): Promise<void> {
return request<void>(`/api/playouts/${playoutId}/erase-items`, { method: 'POST' });
return request<void>(`/api/v1/playouts/${playoutId}/erase-items`, { method: 'POST' });
}
export function erasePlayoutItemsAndHistory(playoutId: number): Promise<void> {
return request<void>(`/api/playouts/${playoutId}/erase-items-and-history`, { method: 'POST' });
return request<void>(`/api/v1/playouts/${playoutId}/erase-items-and-history`, { method: 'POST' });
}
export function getPlayoutItemSchedulingContext(itemId: number): Promise<PlayoutItemSchedulingContext> {
return request<PlayoutItemSchedulingContext>(`/api/playouts/items/${itemId}/scheduling-context`);
return request<PlayoutItemSchedulingContext>(`/api/v1/playouts/items/${itemId}/scheduling-context`);
}
export function createPlayout(body: CreatePlayoutRequest): Promise<PlayoutDetail> {
return request<PlayoutDetail>('/api/playouts', {
return request<PlayoutDetail>('/api/v1/playouts', {
body,
method: 'POST'
});
}
export function updatePlayoutDetails(playoutId: number, body: UpdatePlayoutDetailsRequest): Promise<PlayoutDetail> {
return request<PlayoutDetail>(`/api/playouts/${playoutId}`, {
return request<PlayoutDetail>(`/api/v1/playouts/${playoutId}`, {
body,
method: 'PUT'
});
}
export function updatePlayoutDefaultDeco(playoutId: number, decoId: number | null): Promise<PlayoutDetail> {
return request<PlayoutDetail>(`/api/playouts/${playoutId}/deco`, {
return request<PlayoutDetail>(`/api/v1/playouts/${playoutId}/deco`, {
body: { decoId },
method: 'PUT'
});
}
export function getAlternateSchedules(playoutId: number): Promise<PlayoutAlternateSchedule[]> {
return request<PlayoutAlternateSchedule[]>(`/api/playouts/${playoutId}/alternate-schedules`);
return request<PlayoutAlternateSchedule[]>(`/api/v1/playouts/${playoutId}/alternate-schedules`);
}
/** Load alternate schedules together with the playout's concurrency ETag (issue #253). */
export function getAlternateSchedulesWithMeta(
playoutId: number
): Promise<ResponseWithMeta<PlayoutAlternateSchedule[]>> {
return requestWithMeta<PlayoutAlternateSchedule[]>(`/api/playouts/${playoutId}/alternate-schedules`);
return requestWithMeta<PlayoutAlternateSchedule[]>(`/api/v1/playouts/${playoutId}/alternate-schedules`);
}
/**
@@ -178,7 +178,7 @@ export function replaceAlternateSchedules(
body: ReplacePlayoutAlternateSchedulesRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<PlayoutAlternateSchedule[]>> {
return requestWithMeta<PlayoutAlternateSchedule[]>(`/api/playouts/${playoutId}/alternate-schedules`, {
return requestWithMeta<PlayoutAlternateSchedule[]>(`/api/v1/playouts/${playoutId}/alternate-schedules`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
@@ -186,12 +186,12 @@ export function replaceAlternateSchedules(
}
export function getPlayoutTemplates(playoutId: number): Promise<PlayoutTemplate[]> {
return request<PlayoutTemplate[]>(`/api/playouts/${playoutId}/templates`);
return request<PlayoutTemplate[]>(`/api/v1/playouts/${playoutId}/templates`);
}
/** Load playout templates together with the playout's concurrency ETag (issue #253). */
export function getPlayoutTemplatesWithMeta(playoutId: number): Promise<ResponseWithMeta<PlayoutTemplate[]>> {
return requestWithMeta<PlayoutTemplate[]>(`/api/playouts/${playoutId}/templates`);
return requestWithMeta<PlayoutTemplate[]>(`/api/v1/playouts/${playoutId}/templates`);
}
/**
@@ -203,7 +203,7 @@ export function replacePlayoutTemplates(
body: ReplacePlayoutTemplatesRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<PlayoutTemplate[]>> {
return requestWithMeta<PlayoutTemplate[]>(`/api/playouts/${playoutId}/templates`, {
return requestWithMeta<PlayoutTemplate[]>(`/api/v1/playouts/${playoutId}/templates`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
+6 -6
View File
@@ -32,7 +32,7 @@ describe('rerunCollections api client', () => {
await getRerunCollections({ pageNum: 1, pageSize: 50, query: 'noir' });
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/rerun-collections');
expect(url.pathname).toBe('/api/v1/rerun-collections');
expect(url.searchParams.get('pageNum')).toBe('1');
expect(url.searchParams.get('pageSize')).toBe('50');
expect(url.searchParams.get('query')).toBe('noir');
@@ -43,7 +43,7 @@ describe('rerunCollections api client', () => {
await getRerunCollections();
expect(fetchMock).toHaveBeenCalledWith('/api/rerun-collections', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/rerun-collections', expect.objectContaining({ method: 'GET' }));
});
it('getRerunCollection fetches a single rerun collection by id', async () => {
@@ -60,7 +60,7 @@ describe('rerunCollections api client', () => {
);
await expect(getRerunCollection(3)).resolves.toMatchObject({ id: 3, selectedName: 'Favorites' });
expect(fetchMock).toHaveBeenCalledWith('/api/rerun-collections/3', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/rerun-collections/3', expect.objectContaining({ method: 'GET' }));
});
it('createRerunCollection POSTs the full tagged-union body', async () => {
@@ -76,7 +76,7 @@ describe('rerunCollections api client', () => {
await expect(createRerunCollection(body)).resolves.toMatchObject({ id: 12 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/rerun-collections');
expect(url).toBe('/api/v1/rerun-collections');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toEqual(body);
});
@@ -94,7 +94,7 @@ describe('rerunCollections api client', () => {
await updateRerunCollection(5, body);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/rerun-collections/5');
expect(url).toBe('/api/v1/rerun-collections/5');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toEqual(body);
});
@@ -103,7 +103,7 @@ describe('rerunCollections api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteRerunCollection(7)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/rerun-collections/7', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/rerun-collections/7', expect.objectContaining({ method: 'DELETE' }));
});
it('messageFromRerunCollectionError narrows ApiError, Error, and unknown', () => {
+6 -6
View File
@@ -29,20 +29,20 @@ export function getRerunCollections(params: GetRerunCollectionsParams = {}): Pro
const queryString = searchParams.toString();
return request<PagedRerunCollections>(`/api/rerun-collections${queryString ? `?${queryString}` : ''}`);
return request<PagedRerunCollections>(`/api/v1/rerun-collections${queryString ? `?${queryString}` : ''}`);
}
export function getRerunCollection(id: number): Promise<RerunCollection> {
return request<RerunCollection>(`/api/rerun-collections/${id}`);
return request<RerunCollection>(`/api/v1/rerun-collections/${id}`);
}
/** Load a single rerun collection together with its concurrency ETag (issue #253). */
export function getRerunCollectionWithMeta(id: number): Promise<ResponseWithMeta<RerunCollection>> {
return requestWithMeta<RerunCollection>(`/api/rerun-collections/${id}`);
return requestWithMeta<RerunCollection>(`/api/v1/rerun-collections/${id}`);
}
export function createRerunCollection(body: CreateRerunCollectionRequest): Promise<RerunCollection> {
return request<RerunCollection>('/api/rerun-collections', { body, method: 'POST' });
return request<RerunCollection>('/api/v1/rerun-collections', { body, method: 'POST' });
}
/**
@@ -54,7 +54,7 @@ export function updateRerunCollection(
body: UpdateRerunCollectionRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<RerunCollection>> {
return requestWithMeta<RerunCollection>(`/api/rerun-collections/${id}`, {
return requestWithMeta<RerunCollection>(`/api/v1/rerun-collections/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
@@ -62,7 +62,7 @@ export function updateRerunCollection(
}
export function deleteRerunCollection(id: number): Promise<void> {
return request<void>(`/api/rerun-collections/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/rerun-collections/${id}`, { method: 'DELETE' });
}
export function messageFromRerunCollectionError(
+11 -11
View File
@@ -21,40 +21,40 @@ export type FillerKind = components['schemas']['FillerKind'];
// ---- Schedule CRUD -------------------------------------------------------
export function getSchedules(): Promise<ProgramSchedule[]> {
return request<ProgramSchedule[]>('/api/schedules');
return request<ProgramSchedule[]>('/api/v1/schedules');
}
export function getScheduleById(id: number): Promise<ProgramSchedule> {
return request<ProgramSchedule>(`/api/schedules/${id}`);
return request<ProgramSchedule>(`/api/v1/schedules/${id}`);
}
export function createSchedule(body: CreateScheduleRequest): Promise<ProgramSchedule> {
return request<ProgramSchedule>('/api/schedules', { body, method: 'POST' });
return request<ProgramSchedule>('/api/v1/schedules', { body, method: 'POST' });
}
export function updateSchedule(id: number, body: UpdateScheduleRequest): Promise<ProgramSchedule> {
return request<ProgramSchedule>(`/api/schedules/${id}`, { body, method: 'PUT' });
return request<ProgramSchedule>(`/api/v1/schedules/${id}`, { body, method: 'PUT' });
}
export function deleteSchedule(id: number): Promise<void> {
return request<void>(`/api/schedules/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/schedules/${id}`, { method: 'DELETE' });
}
// ---- Schedule items ------------------------------------------------------
export function getScheduleItems(scheduleId: number): Promise<ScheduleItemsResponse> {
return request<ScheduleItemsResponse>(`/api/schedules/${scheduleId}/items`);
return request<ScheduleItemsResponse>(`/api/v1/schedules/${scheduleId}/items`);
}
/** Load schedule items together with the schedule's concurrency ETag (issue #253). */
export function getScheduleItemsWithMeta(
scheduleId: number
): Promise<ResponseWithMeta<ScheduleItemsResponse>> {
return requestWithMeta<ScheduleItemsResponse>(`/api/schedules/${scheduleId}/items`);
return requestWithMeta<ScheduleItemsResponse>(`/api/v1/schedules/${scheduleId}/items`);
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ScheduleItem> {
return request<ScheduleItem>(`/api/schedules/${scheduleId}/items`, { body, method: 'POST' });
return request<ScheduleItem>(`/api/v1/schedules/${scheduleId}/items`, { body, method: 'POST' });
}
// Id-based reconcile (#259): each item's `id` (round-tripped from the GET response by the editor;
@@ -70,7 +70,7 @@ export function replaceScheduleItems(
body: ReplaceScheduleItemsRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<ScheduleItem[]>> {
return requestWithMeta<ScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
return requestWithMeta<ScheduleItem[]>(`/api/v1/schedules/${scheduleId}/items`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
@@ -78,14 +78,14 @@ export function replaceScheduleItems(
}
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, { method: 'DELETE' });
return request<void>(`/api/v1/schedules/${scheduleId}/items/${itemId}`, { method: 'DELETE' });
}
// ---- Pickers -------------------------------------------------------------
// getLanguages lives in ./languages (imported above); re-exported implicitly via api/index.ts.
export function getFillerPresetsByKind(fillerKind: FillerKind): Promise<FillerPreset[]> {
return request<FillerPreset[]>(`/api/filler-presets?fillerKind=${encodeURIComponent(fillerKind)}`);
return request<FillerPreset[]>(`/api/v1/filler-presets?fillerKind=${encodeURIComponent(fillerKind)}`);
}
// The search-backed source pickers (searchTelevisionShows/…/searchMultiCollections) already live in
+5 -5
View File
@@ -21,7 +21,7 @@ describe('getSearchResults', () => {
vi.restoreAllMocks();
});
it('GETs /api/search with the query', async () => {
it('GETs /api/v1/search with the query', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResults), {
headers: { 'Content-Type': 'application/json' },
@@ -32,7 +32,7 @@ describe('getSearchResults', () => {
await getSearchResults({ query: 'star' });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/search?query=star');
expect(url).toBe('/api/v1/search?query=star');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
@@ -47,7 +47,7 @@ describe('getSearchResults', () => {
await getSearchResults({ query: 'star wars', pageSize: 50 });
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/search?query=star+wars&pageSize=50');
expect(url).toBe('/api/v1/search?query=star+wars&pageSize=50');
});
it('rejects with the ApiError status on failure', async () => {
@@ -68,7 +68,7 @@ describe('getSearchAllItems', () => {
vi.restoreAllMocks();
});
it('GETs /api/search/all-items with the query', async () => {
it('GETs /api/v1/search/all-items with the query', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ movieIds: [1], showIds: [2] }), {
headers: { 'Content-Type': 'application/json' },
@@ -79,7 +79,7 @@ describe('getSearchAllItems', () => {
await getSearchAllItems('star wars');
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/search/all-items?query=star+wars');
expect(url).toBe('/api/v1/search/all-items?query=star+wars');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
});
+2 -2
View File
@@ -23,7 +23,7 @@ export function getSearchResults(params: GetSearchResultsParams): Promise<Search
searchParams.set('pageSize', String(params.pageSize));
}
return request<SearchResults>(`/api/search?${searchParams.toString()}`);
return request<SearchResults>(`/api/v1/search?${searchParams.toString()}`);
}
// Resolves a search query to the full set of matching media-item ids, bucketed by kind. Backs the
@@ -31,7 +31,7 @@ export function getSearchResults(params: GetSearchResultsParams): Promise<Search
export function getSearchAllItems(query: string): Promise<SearchAllItemIds> {
const searchParams = new URLSearchParams();
searchParams.set('query', query);
return request<SearchAllItemIds>(`/api/search/all-items?${searchParams.toString()}`);
return request<SearchAllItemIds>(`/api/v1/search/all-items?${searchParams.toString()}`);
}
// Normalizes a SearchAllItemIds result (nullable arrays) into a full AddItemsToCollectionRequest
+22 -22
View File
@@ -38,23 +38,23 @@ describe('settings API module', () => {
vi.restoreAllMocks();
});
it('fetches ffmpeg settings from GET /api/settings/ffmpeg', async () => {
it('fetches ffmpeg settings from GET /api/v1/settings/ffmpeg', async () => {
const settings = { fFmpegPath: '/usr/bin/ffmpeg' };
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getFfmpegSettings()).resolves.toMatchObject(settings);
const [path] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/ffmpeg');
expect(path.toString()).toBe('/api/v1/settings/ffmpeg');
});
it('PUTs ffmpeg settings to /api/settings/ffmpeg', async () => {
it('PUTs ffmpeg settings to /api/v1/settings/ffmpeg', async () => {
const body = { fFmpegPath: '/usr/bin/ffmpeg', hlsDirectOutputFormat: 'MpegTs' as const };
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(body));
await updateFfmpegSettings(body as never);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/ffmpeg');
expect(path.toString()).toBe('/api/v1/settings/ffmpeg');
expect(init?.method).toBe('PUT');
expect(JSON.parse(init?.body as string)).toMatchObject({ fFmpegPath: '/usr/bin/ffmpeg' });
});
@@ -64,11 +64,11 @@ describe('settings API module', () => {
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getPlayoutSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/playout');
expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/playout');
await updatePlayoutSettings(settings);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/playout');
expect(path.toString()).toBe('/api/v1/settings/playout');
expect(init?.method).toBe('PUT');
});
@@ -77,11 +77,11 @@ describe('settings API module', () => {
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getXmltvSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/xmltv');
expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/xmltv');
await updateXmltvSettings(settings);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/xmltv');
expect(path.toString()).toBe('/api/v1/settings/xmltv');
expect(init?.method).toBe('PUT');
});
@@ -90,11 +90,11 @@ describe('settings API module', () => {
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getScannerSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/scanner');
expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/scanner');
await updateScannerSettings(settings);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/scanner');
expect(path.toString()).toBe('/api/v1/settings/scanner');
expect(init?.method).toBe('PUT');
});
@@ -110,11 +110,11 @@ describe('settings API module', () => {
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getLoggingSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/logging');
expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/logging');
await updateLoggingSettings(settings);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/logging');
expect(path.toString()).toBe('/api/v1/settings/logging');
expect(init?.method).toBe('PUT');
});
@@ -123,11 +123,11 @@ describe('settings API module', () => {
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getUiSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/ui');
expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/ui');
await updateUiSettings(settings);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/ui');
expect(path.toString()).toBe('/api/v1/settings/ui');
expect(init?.method).toBe('PUT');
});
@@ -136,41 +136,41 @@ describe('settings API module', () => {
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getHdhrSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/hdhr');
expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/hdhr');
await updateHdhrSettings({ tunerCount: 2 });
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/hdhr');
expect(path.toString()).toBe('/api/v1/settings/hdhr');
expect(init?.method).toBe('PUT');
expect(JSON.parse(init?.body as string)).toMatchObject({ tunerCount: 2 });
});
it('fetches resolutions from GET /api/settings/resolutions', async () => {
it('fetches resolutions from GET /api/v1/settings/resolutions', async () => {
const resolutions = [{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 }];
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(resolutions));
await expect(getResolutions()).resolves.toMatchObject(resolutions);
expect(lastFetchCall()[0].toString()).toBe('/api/settings/resolutions');
expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/resolutions');
});
it('creates a custom resolution via POST /api/settings/resolutions', async () => {
it('creates a custom resolution via POST /api/v1/settings/resolutions', async () => {
const created = { height: 1080, id: 3, isCustom: true, name: '2560x1080', width: 2560 };
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(created, 201));
await expect(createResolution({ height: 1080, width: 2560 })).resolves.toMatchObject(created);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/resolutions');
expect(path.toString()).toBe('/api/v1/settings/resolutions');
expect(init?.method).toBe('POST');
expect(JSON.parse(init?.body as string)).toMatchObject({ height: 1080, width: 2560 });
});
it('deletes a custom resolution via DELETE /api/settings/resolutions/{id}', async () => {
it('deletes a custom resolution via DELETE /api/v1/settings/resolutions/{id}', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
await deleteResolution(3);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/settings/resolutions/3');
expect(path.toString()).toBe('/api/v1/settings/resolutions/3');
expect(init?.method).toBe('DELETE');
});
+17 -17
View File
@@ -28,71 +28,71 @@ export type OutputFormatKind = components['schemas']['OutputFormatKind'];
export type HealthCheck = components['schemas']['HealthCheckResponseModel'];
export function getFfmpegSettings(): Promise<FfmpegSettings> {
return request<FfmpegSettings>('/api/settings/ffmpeg');
return request<FfmpegSettings>('/api/v1/settings/ffmpeg');
}
export function updateFfmpegSettings(body: UpdateFfmpegSettingsRequest): Promise<FfmpegSettings> {
return request<FfmpegSettings>('/api/settings/ffmpeg', { body, method: 'PUT' });
return request<FfmpegSettings>('/api/v1/settings/ffmpeg', { body, method: 'PUT' });
}
export function getPlayoutSettings(): Promise<PlayoutSettings> {
return request<PlayoutSettings>('/api/settings/playout');
return request<PlayoutSettings>('/api/v1/settings/playout');
}
export function updatePlayoutSettings(body: UpdatePlayoutSettingsRequest): Promise<PlayoutSettings> {
return request<PlayoutSettings>('/api/settings/playout', { body, method: 'PUT' });
return request<PlayoutSettings>('/api/v1/settings/playout', { body, method: 'PUT' });
}
export function getXmltvSettings(): Promise<XmltvSettings> {
return request<XmltvSettings>('/api/settings/xmltv');
return request<XmltvSettings>('/api/v1/settings/xmltv');
}
export function updateXmltvSettings(body: UpdateXmltvSettingsRequest): Promise<XmltvSettings> {
return request<XmltvSettings>('/api/settings/xmltv', { body, method: 'PUT' });
return request<XmltvSettings>('/api/v1/settings/xmltv', { body, method: 'PUT' });
}
export function getScannerSettings(): Promise<ScannerSettings> {
return request<ScannerSettings>('/api/settings/scanner');
return request<ScannerSettings>('/api/v1/settings/scanner');
}
export function updateScannerSettings(body: UpdateScannerSettingsRequest): Promise<ScannerSettings> {
return request<ScannerSettings>('/api/settings/scanner', { body, method: 'PUT' });
return request<ScannerSettings>('/api/v1/settings/scanner', { body, method: 'PUT' });
}
export function getLoggingSettings(): Promise<LoggingSettings> {
return request<LoggingSettings>('/api/settings/logging');
return request<LoggingSettings>('/api/v1/settings/logging');
}
export function updateLoggingSettings(body: UpdateLoggingSettingsRequest): Promise<LoggingSettings> {
return request<LoggingSettings>('/api/settings/logging', { body, method: 'PUT' });
return request<LoggingSettings>('/api/v1/settings/logging', { body, method: 'PUT' });
}
export function getUiSettings(): Promise<UiSettings> {
return request<UiSettings>('/api/settings/ui');
return request<UiSettings>('/api/v1/settings/ui');
}
export function updateUiSettings(body: UpdateUiSettingsRequest): Promise<UiSettings> {
return request<UiSettings>('/api/settings/ui', { body, method: 'PUT' });
return request<UiSettings>('/api/v1/settings/ui', { body, method: 'PUT' });
}
export function getHdhrSettings(): Promise<HdhrSettings> {
return request<HdhrSettings>('/api/settings/hdhr');
return request<HdhrSettings>('/api/v1/settings/hdhr');
}
export function updateHdhrSettings(body: UpdateHdhrSettingsRequest): Promise<HdhrSettings> {
return request<HdhrSettings>('/api/settings/hdhr', { body, method: 'PUT' });
return request<HdhrSettings>('/api/v1/settings/hdhr', { body, method: 'PUT' });
}
export function getResolutions(): Promise<Resolution[]> {
return request<Resolution[]>('/api/settings/resolutions');
return request<Resolution[]>('/api/v1/settings/resolutions');
}
export function createResolution(body: CreateResolutionRequest): Promise<Resolution> {
return request<Resolution>('/api/settings/resolutions', { body, method: 'POST' });
return request<Resolution>('/api/v1/settings/resolutions', { body, method: 'POST' });
}
export function deleteResolution(id: number): Promise<void> {
return request<void>(`/api/settings/resolutions/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/settings/resolutions/${id}`, { method: 'DELETE' });
}
export interface SettingsScreenData {
+10 -10
View File
@@ -35,14 +35,14 @@ describe('templates api client', () => {
it('getTemplateGroups fetches all template groups', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([{ id: 1, name: 'Prime', templateCount: 2 }]));
await expect(getTemplateGroups()).resolves.toHaveLength(1);
expect(fetchMock).toHaveBeenCalledWith('/api/templates/groups', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/templates/groups', expect.objectContaining({ method: 'GET' }));
});
it('createTemplateGroup POSTs the name', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3, name: 'Prime', templateCount: 0 }, 201));
await createTemplateGroup({ name: 'Prime' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/templates/groups');
expect(url).toBe('/api/v1/templates/groups');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body)).name).toBe('Prime');
});
@@ -50,26 +50,26 @@ describe('templates api client', () => {
it('deleteTemplateGroup DELETEs by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteTemplateGroup(3)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/templates/groups/3', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/templates/groups/3', expect.objectContaining({ method: 'DELETE' }));
});
it('getTemplates fetches all templates', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getTemplates();
expect(fetchMock).toHaveBeenCalledWith('/api/templates', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/templates', expect.objectContaining({ method: 'GET' }));
});
it('getTemplate fetches a template by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4 }));
await getTemplate(4);
expect(fetchMock).toHaveBeenCalledWith('/api/templates/4', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/templates/4', expect.objectContaining({ method: 'GET' }));
});
it('createTemplate POSTs group id and name', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 8 }, 201));
await createTemplate({ templateGroupId: 2, name: 'Weekdays' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/templates');
expect(url).toBe('/api/v1/templates');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toMatchObject({ templateGroupId: 2, name: 'Weekdays' });
});
@@ -77,20 +77,20 @@ describe('templates api client', () => {
it('deleteTemplate DELETEs by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await deleteTemplate(4);
expect(fetchMock).toHaveBeenCalledWith('/api/templates/4', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/templates/4', expect.objectContaining({ method: 'DELETE' }));
});
it('getTemplateItems fetches items for a template', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getTemplateItems(4);
expect(fetchMock).toHaveBeenCalledWith('/api/templates/4/items', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/templates/4/items', expect.objectContaining({ method: 'GET' }));
});
it('replaceTemplate PUTs the full template body', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, items: [] }));
await replaceTemplate(4, sampleReplace);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/templates/4');
expect(url).toBe('/api/v1/templates/4');
expect(init).toMatchObject({ method: 'PUT' });
const body = JSON.parse(String(init?.body));
expect(body.name).toBe('Weekdays');
@@ -102,7 +102,7 @@ describe('templates api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 9 }, 201));
await copyTemplate(4, { templateGroupId: 3, name: 'Weekdays Copy' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/templates/4/copy');
expect(url).toBe('/api/v1/templates/4/copy');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toMatchObject({ templateGroupId: 3, name: 'Weekdays Copy' });
});
+11 -11
View File
@@ -14,42 +14,42 @@ export type CopyTemplateRequest = components['schemas']['CopyTemplateRequest'];
// Template groups
export function getTemplateGroups(): Promise<TemplateGroup[]> {
return request<TemplateGroup[]>('/api/templates/groups');
return request<TemplateGroup[]>('/api/v1/templates/groups');
}
export function createTemplateGroup(body: CreateTemplateGroupRequest): Promise<TemplateGroup> {
return request<TemplateGroup>('/api/templates/groups', { body, method: 'POST' });
return request<TemplateGroup>('/api/v1/templates/groups', { body, method: 'POST' });
}
export function deleteTemplateGroup(id: number): Promise<void> {
return request<void>(`/api/templates/groups/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/templates/groups/${id}`, { method: 'DELETE' });
}
// Templates
export function getTemplates(): Promise<Template[]> {
return request<Template[]>('/api/templates');
return request<Template[]>('/api/v1/templates');
}
export function getTemplate(id: number): Promise<Template> {
return request<Template>(`/api/templates/${id}`);
return request<Template>(`/api/v1/templates/${id}`);
}
export function createTemplate(body: CreateTemplateRequest): Promise<Template> {
return request<Template>('/api/templates', { body, method: 'POST' });
return request<Template>('/api/v1/templates', { body, method: 'POST' });
}
export function deleteTemplate(id: number): Promise<void> {
return request<void>(`/api/templates/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/templates/${id}`, { method: 'DELETE' });
}
export function getTemplateItems(id: number): Promise<TemplateItem[]> {
return request<TemplateItem[]>(`/api/templates/${id}/items`);
return request<TemplateItem[]>(`/api/v1/templates/${id}/items`);
}
/** Load template items together with the template's concurrency ETag (issue #253). */
export function getTemplateItemsWithMeta(id: number): Promise<ResponseWithMeta<TemplateItem[]>> {
return requestWithMeta<TemplateItem[]>(`/api/templates/${id}/items`);
return requestWithMeta<TemplateItem[]>(`/api/v1/templates/${id}/items`);
}
/**
@@ -61,7 +61,7 @@ export function replaceTemplate(
body: ReplaceTemplateRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<TemplateWithItems>> {
return requestWithMeta<TemplateWithItems>(`/api/templates/${id}`, {
return requestWithMeta<TemplateWithItems>(`/api/v1/templates/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
@@ -69,7 +69,7 @@ export function replaceTemplate(
}
export function copyTemplate(id: number, body: CopyTemplateRequest): Promise<Template> {
return request<Template>(`/api/templates/${id}/copy`, { body, method: 'POST' });
return request<Template>(`/api/v1/templates/${id}/copy`, { body, method: 'POST' });
}
export function messageFromTemplateError(error: unknown, fallback = 'Unable to load templates'): string {
+8 -8
View File
@@ -33,7 +33,7 @@ describe('trakt api client', () => {
await getTraktLists();
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/trakt/lists', expect.objectContaining({ method: 'GET' }));
});
it('getTraktLists forwards pageNum/pageSize as query params', async () => {
@@ -44,7 +44,7 @@ describe('trakt api client', () => {
await getTraktLists({ pageNum: 2, pageSize: 25 });
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/trakt/lists');
expect(url.pathname).toBe('/api/v1/trakt/lists');
expect(url.searchParams.get('pageNum')).toBe('2');
expect(url.searchParams.get('pageSize')).toBe('25');
});
@@ -64,7 +64,7 @@ describe('trakt api client', () => {
);
await expect(getTraktListById(1)).resolves.toMatchObject({ id: 1, slug: 'my-list' });
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists/1', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/trakt/lists/1', expect.objectContaining({ method: 'GET' }));
});
it('addTraktList POSTs the url and resolves on 202', async () => {
@@ -73,7 +73,7 @@ describe('trakt api client', () => {
await expect(addTraktList('https://trakt.tv/users/someuser/lists/some-list')).resolves.toBeUndefined();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/trakt/lists');
expect(url).toBe('/api/v1/trakt/lists');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toEqual({ url: 'https://trakt.tv/users/someuser/lists/some-list' });
});
@@ -88,14 +88,14 @@ describe('trakt api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(accepted());
await expect(matchTraktList(5)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists/5/match', expect.objectContaining({ method: 'POST' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/trakt/lists/5/match', expect.objectContaining({ method: 'POST' }));
});
it('deleteTraktList issues a DELETE and resolves on 202', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(accepted());
await expect(deleteTraktList(9)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists/9', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/trakt/lists/9', expect.objectContaining({ method: 'DELETE' }));
});
it('updateTraktList PUTs autoRefresh/generatePlaylist', async () => {
@@ -115,7 +115,7 @@ describe('trakt api client', () => {
await updateTraktList(3, { autoRefresh: true, generatePlaylist: true });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/trakt/lists/3');
expect(url).toBe('/api/v1/trakt/lists/3');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toEqual({ autoRefresh: true, generatePlaylist: true });
});
@@ -124,6 +124,6 @@ describe('trakt api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ busy: true }));
await expect(getTraktStatus()).resolves.toEqual({ busy: true });
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/status', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/trakt/status', expect.objectContaining({ method: 'GET' }));
});
});
+7 -7
View File
@@ -25,36 +25,36 @@ export function getTraktLists(params: GetTraktListsParams = {}): Promise<PagedTr
const queryString = searchParams.toString();
return request<PagedTraktLists>(`/api/trakt/lists${queryString ? `?${queryString}` : ''}`);
return request<PagedTraktLists>(`/api/v1/trakt/lists${queryString ? `?${queryString}` : ''}`);
}
export function getTraktListById(id: number): Promise<TraktList> {
return request<TraktList>(`/api/trakt/lists/${id}`);
return request<TraktList>(`/api/v1/trakt/lists/${id}`);
}
// 202 Accepted: the server dispatches to the same background worker channel the classic
// UI's "Add Trakt List" dialog uses. Fetch/save/match all happen asynchronously — poll
// getTraktStatus() and reload the list once it goes idle.
export function addTraktList(url: string): Promise<void> {
return request<void>('/api/trakt/lists', { body: { url } satisfies AddTraktListRequest, method: 'POST' });
return request<void>('/api/v1/trakt/lists', { body: { url } satisfies AddTraktListRequest, method: 'POST' });
}
// 202 Accepted; see addTraktList for the async/poll pattern.
export function matchTraktList(id: number): Promise<void> {
return request<void>(`/api/trakt/lists/${id}/match`, { method: 'POST' });
return request<void>(`/api/v1/trakt/lists/${id}/match`, { method: 'POST' });
}
// 202 Accepted; see addTraktList for the async/poll pattern.
export function deleteTraktList(id: number): Promise<void> {
return request<void>(`/api/trakt/lists/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/trakt/lists/${id}`, { method: 'DELETE' });
}
export function updateTraktList(id: number, body: UpdateTraktListRequest): Promise<TraktList> {
return request<TraktList>(`/api/trakt/lists/${id}`, { body, method: 'PUT' });
return request<TraktList>(`/api/v1/trakt/lists/${id}`, { body, method: 'PUT' });
}
export function getTraktStatus(): Promise<TraktStatus> {
return request<TraktStatus>('/api/trakt/status');
return request<TraktStatus>('/api/v1/trakt/status');
}
export function messageFromTraktError(error: unknown, fallback = 'Unable to load Trakt lists'): string {
+11 -11
View File
@@ -39,7 +39,7 @@ describe('getTroubleshootingInfo', () => {
vi.restoreAllMocks();
});
it('GETs /api/troubleshoot/info and returns the info payload', async () => {
it('GETs /api/v1/troubleshoot/info and returns the info payload', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleInfo), {
headers: { 'Content-Type': 'application/json' },
@@ -50,7 +50,7 @@ describe('getTroubleshootingInfo', () => {
await expect(getTroubleshootingInfo()).resolves.toMatchObject({ nvidiaCapabilities: 'nvidia output' });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/troubleshoot/info');
expect(url).toBe('/api/v1/troubleshoot/info');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
@@ -74,7 +74,7 @@ describe('validateSequentialSchedule', () => {
vi.restoreAllMocks();
});
it('POSTs /api/troubleshoot/validate-schedule with the yaml and isImport body', async () => {
it('POSTs /api/v1/troubleshoot/validate-schedule with the yaml and isImport body', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResult), {
headers: { 'Content-Type': 'application/json' },
@@ -85,7 +85,7 @@ describe('validateSequentialSchedule', () => {
await expect(validateSequentialSchedule('content: []', true)).resolves.toMatchObject({ isValid: true });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/troubleshoot/validate-schedule');
expect(url).toBe('/api/v1/troubleshoot/validate-schedule');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({ yaml: 'content: []', isImport: true });
});
@@ -112,7 +112,7 @@ describe('troubleshooting playback wrappers', () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(['a', 'b']));
await expect(getTroubleshootingStreamSelectors()).resolves.toEqual(['a', 'b']);
expect(fetchSpy.mock.calls[0][0]).toBe('/api/troubleshoot/playback/stream-selectors');
expect(fetchSpy.mock.calls[0][0]).toBe('/api/v1/troubleshoot/playback/stream-selectors');
});
it('GETs the subtitle list for a media item id', async () => {
@@ -121,7 +121,7 @@ describe('troubleshooting playback wrappers', () => {
.mockResolvedValue(jsonResponse([{ id: 3, language: 'eng', title: 'English', codec: 'subrip' }]));
await expect(getTroubleshootingSubtitles(42)).resolves.toMatchObject([{ id: 3 }]);
expect(fetchSpy.mock.calls[0][0]).toBe('/api/troubleshoot/playback/subtitles/42');
expect(fetchSpy.mock.calls[0][0]).toBe('/api/v1/troubleshoot/playback/subtitles/42');
});
it('GETs the playback status', async () => {
@@ -130,7 +130,7 @@ describe('troubleshooting playback wrappers', () => {
.mockResolvedValue(jsonResponse({ state: 'completed', exitCode: 0, speed: 1.2, logs: 'ok' }));
await expect(getTroubleshootingPlaybackStatus()).resolves.toMatchObject({ state: 'completed', speed: 1.2 });
expect(fetchSpy.mock.calls[0][0]).toBe('/api/troubleshoot/playback/status');
expect(fetchSpy.mock.calls[0][0]).toBe('/api/v1/troubleshoot/playback/status');
});
});
@@ -139,7 +139,7 @@ describe('startTroubleshootingPlayback', () => {
vi.restoreAllMocks();
});
it('POSTs /api/troubleshoot/playback/start with the mapped body and a CSRF header, returning the url', async () => {
it('POSTs /api/v1/troubleshoot/playback/start with the mapped body and a CSRF header, returning the url', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse({ url: '/iptv/session/.troubleshooting/live.m3u8' })
);
@@ -164,7 +164,7 @@ describe('startTroubleshootingPlayback', () => {
});
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/troubleshoot/playback/start');
expect(url).toBe('/api/v1/troubleshoot/playback/start');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(headerValue(init, 'X-Csrf')).toBe('1');
expect(JSON.parse(String(init?.body))).toEqual(body);
@@ -233,7 +233,7 @@ describe('troubleshooting download helpers', () => {
await downloadTroubleshootingArchive();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/troubleshoot/playback/archive');
expect(url).toBe('/api/v1/troubleshoot/playback/archive');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(headerValue(init, 'X-Csrf')).toBe('1');
expect(createObjectURL).toHaveBeenCalledTimes(1);
@@ -248,7 +248,7 @@ describe('troubleshooting download helpers', () => {
await downloadTroubleshootingMediaSample(42);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/troubleshoot/playback/sample/42');
expect(url).toBe('/api/v1/troubleshoot/playback/sample/42');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(headerValue(init, 'X-Csrf')).toBe('1');
});
+8 -8
View File
@@ -10,25 +10,25 @@ export type StartTroubleshootingPlaybackBody = components['schemas']['StartTroub
export type TroubleshootingPlaybackStarted = components['schemas']['TroubleshootingPlaybackStartedResponseModel'];
export function getTroubleshootingInfo(): Promise<TroubleshootingInfo> {
return request<TroubleshootingInfo>('/api/troubleshoot/info');
return request<TroubleshootingInfo>('/api/v1/troubleshoot/info');
}
// The stream selectors available for a troubleshooting playback (channel "smart" selectors).
export function getTroubleshootingStreamSelectors(): Promise<string[]> {
return request<string[]>('/api/troubleshoot/playback/stream-selectors');
return request<string[]>('/api/v1/troubleshoot/playback/stream-selectors');
}
// Subtitle streams that can be burned in for a media item. Each item's `id` is the value to pass
// back as the playback.m3u8 endpoint's `subtitleId` query param.
export function getTroubleshootingSubtitles(mediaItemId: number): Promise<TroubleshootingSubtitle[]> {
return request<TroubleshootingSubtitle[]>(`/api/troubleshoot/playback/subtitles/${mediaItemId}`);
return request<TroubleshootingSubtitle[]>(`/api/v1/troubleshoot/playback/subtitles/${mediaItemId}`);
}
// Status of the current or last troubleshooting playback session. `state` is idle|running|
// completed|failed; exitCode/speed are null until completed/failed; logs is the log tail (null
// until written).
export function getTroubleshootingPlaybackStatus(): Promise<TroubleshootingPlaybackStatus> {
return request<TroubleshootingPlaybackStatus>('/api/troubleshoot/playback/status');
return request<TroubleshootingPlaybackStatus>('/api/v1/troubleshoot/playback/status');
}
// Start a troubleshooting playback session (#295 — was a GET on `playback.m3u8` that started the
@@ -38,7 +38,7 @@ export function getTroubleshootingPlaybackStatus(): Promise<TroubleshootingPlayb
export function startTroubleshootingPlayback(
body: StartTroubleshootingPlaybackBody
): Promise<TroubleshootingPlaybackStarted> {
return request<TroubleshootingPlaybackStarted>('/api/troubleshoot/playback/start', {
return request<TroubleshootingPlaybackStarted>('/api/v1/troubleshoot/playback/start', {
body,
method: 'POST'
});
@@ -49,11 +49,11 @@ export function startTroubleshootingPlayback(
// #295), read the blob, and trigger a browser download. Same-origin fetch can read Content-Disposition
// so we use the server-provided filename when present.
export function downloadTroubleshootingArchive(): Promise<void> {
return downloadViaPost('/api/troubleshoot/playback/archive', 'ersatztv-troubleshooting.zip');
return downloadViaPost('/api/v1/troubleshoot/playback/archive', 'ersatztv-troubleshooting.zip');
}
export function downloadTroubleshootingMediaSample(id: number): Promise<void> {
return downloadViaPost(`/api/troubleshoot/playback/sample/${id}`, 'ersatztv-media-sample.zip');
return downloadViaPost(`/api/v1/troubleshoot/playback/sample/${id}`, 'ersatztv-media-sample.zip');
}
async function downloadViaPost(path: string, fallbackName: string): Promise<void> {
@@ -120,7 +120,7 @@ function filenameFromContentDisposition(header: null | string, fallback: string)
}
export function validateSequentialSchedule(yaml: string, isImport: boolean): Promise<ValidateScheduleResult> {
return request<ValidateScheduleResult>('/api/troubleshoot/validate-schedule', {
return request<ValidateScheduleResult>('/api/v1/troubleshoot/validate-schedule', {
body: { yaml, isImport },
method: 'POST'
});
+4 -4
View File
@@ -48,7 +48,7 @@ describe('watermarks api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, name: 'Corner Logo' }));
await expect(getWatermark(4)).resolves.toMatchObject({ id: 4 });
expect(fetchMock).toHaveBeenCalledWith('/api/watermarks/4', expect.objectContaining({ method: 'GET' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/watermarks/4', expect.objectContaining({ method: 'GET' }));
});
it('createWatermark POSTs the request body', async () => {
@@ -57,7 +57,7 @@ describe('watermarks api client', () => {
await expect(createWatermark(sampleRequest)).resolves.toMatchObject({ id: 7 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/watermarks');
expect(url).toBe('/api/v1/watermarks');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body)).mode).toBe('Permanent');
});
@@ -68,7 +68,7 @@ describe('watermarks api client', () => {
await updateWatermark(3, sampleRequest);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/watermarks/3');
expect(url).toBe('/api/v1/watermarks/3');
expect(init).toMatchObject({ method: 'PUT' });
});
@@ -76,7 +76,7 @@ describe('watermarks api client', () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteWatermark(9)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/watermarks/9', expect.objectContaining({ method: 'DELETE' }));
expect(fetchMock).toHaveBeenCalledWith('/api/v1/watermarks/9', expect.objectContaining({ method: 'DELETE' }));
});
it('watermarkImageUrl builds a path-only URL (no reflected ?contentType=)', () => {
+5 -5
View File
@@ -1,7 +1,7 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
// The list endpoint (GET /api/watermarks) is picker-grade (id + name) and is already
// The list endpoint (GET /api/v1/watermarks) is picker-grade (id + name) and is already
// provided by pickers.ts as getWatermarks(). This module adds the by-id read (full model)
// and the mutations. Image upload is handled by artwork.ts (uploadArtwork(file,'watermark')).
@@ -10,19 +10,19 @@ export type CreateWatermarkRequest = components['schemas']['CreateWatermarkReque
export type UpdateWatermarkRequest = components['schemas']['UpdateWatermarkRequest'];
export function getWatermark(id: number): Promise<WatermarkDetail> {
return request<WatermarkDetail>(`/api/watermarks/${id}`);
return request<WatermarkDetail>(`/api/v1/watermarks/${id}`);
}
export function createWatermark(body: CreateWatermarkRequest): Promise<WatermarkDetail> {
return request<WatermarkDetail>('/api/watermarks', { body, method: 'POST' });
return request<WatermarkDetail>('/api/v1/watermarks', { body, method: 'POST' });
}
export function updateWatermark(id: number, body: UpdateWatermarkRequest): Promise<WatermarkDetail> {
return request<WatermarkDetail>(`/api/watermarks/${id}`, { body, method: 'PUT' });
return request<WatermarkDetail>(`/api/v1/watermarks/${id}`, { body, method: 'PUT' });
}
export function deleteWatermark(id: number): Promise<void> {
return request<void>(`/api/watermarks/${id}`, { method: 'DELETE' });
return request<void>(`/api/v1/watermarks/${id}`, { method: 'DELETE' });
}
// Preview URL for a custom watermark image. The serve route sniffs the content type from the