Merge remote-tracking branch 'origin/main' into feat/141-media-browse
# Conflicts: # ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs # web/src/App.tsx # web/src/screens/SettingsScreen.tsx
This commit is contained in:
+30
-1
@@ -33,6 +33,7 @@ import {
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
Library,
|
||||
Link2,
|
||||
ListVideo,
|
||||
MonitorPlay,
|
||||
Music,
|
||||
@@ -65,6 +66,7 @@ import { LogsScreen } from './screens/LogsScreen';
|
||||
import { MediaBrowseScreen } from './screens/MediaBrowseScreen';
|
||||
import { SearchScreen } from './screens/SearchScreen';
|
||||
import { SettingsScreen } from './screens/SettingsScreen';
|
||||
import { TraktListsScreen } from './screens/TraktListsScreen';
|
||||
import { TrashScreen } from './screens/TrashScreen';
|
||||
import { TroubleshootingScreen } from './screens/TroubleshootingScreen';
|
||||
import { WatermarksScreen } from './screens/WatermarksScreen';
|
||||
@@ -147,6 +149,7 @@ type ScreenId =
|
||||
| 'collections'
|
||||
| 'fillerPresets'
|
||||
| 'libraries'
|
||||
| 'traktLists'
|
||||
| 'ffmpegProfiles'
|
||||
| 'watermarks'
|
||||
| 'settings'
|
||||
@@ -321,6 +324,20 @@ const routes: ScreenRoute[] = [
|
||||
primaryAction: 'Scan',
|
||||
placeholder: 'Libraries workspace'
|
||||
},
|
||||
{
|
||||
// The editor lives at a sub-path (/app/trakt-lists/{id}); the screen owns parsing the
|
||||
// {id} suffix itself (see TraktListsScreen), same pattern as editChannel/settings.
|
||||
id: 'traktLists',
|
||||
path: '/app/trakt-lists',
|
||||
label: 'Trakt Lists',
|
||||
title: 'Trakt Lists',
|
||||
kicker: 'Media',
|
||||
description: 'Add, match, and manage Trakt list imports.',
|
||||
icon: <Link2 aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Trakt List',
|
||||
placeholder: 'Trakt lists workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'ffmpegProfiles',
|
||||
path: '/app/ffmpeg-profiles',
|
||||
@@ -391,7 +408,15 @@ const primaryNavIds: ScreenId[] = [
|
||||
'schedules',
|
||||
'playouts'
|
||||
];
|
||||
const mediaNavIds: ScreenId[] = ['media', 'search', 'trash', 'collections', 'fillerPresets', 'libraries'];
|
||||
const mediaNavIds: ScreenId[] = [
|
||||
'media',
|
||||
'search',
|
||||
'trash',
|
||||
'collections',
|
||||
'fillerPresets',
|
||||
'libraries',
|
||||
'traktLists'
|
||||
];
|
||||
const systemNavIds: ScreenId[] = [
|
||||
'settings',
|
||||
'logs',
|
||||
@@ -3034,6 +3059,10 @@ function ScreenContent({
|
||||
return <CollectionsScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'traktLists') {
|
||||
return <TraktListsScreen key={window.location.pathname} />;
|
||||
}
|
||||
|
||||
if (route.id === 'fillerPresets') {
|
||||
return <FillerPresetsScreen />;
|
||||
}
|
||||
|
||||
Vendored
+24
@@ -14,6 +14,9 @@ export interface components {
|
||||
"songIds": null | Array<number>;
|
||||
"imageIds": null | Array<number>;
|
||||
"remoteStreamIds": null | Array<number>;
|
||||
};
|
||||
"AddTraktListRequest": {
|
||||
"url": null | string;
|
||||
};
|
||||
"ArtworkContentTypeModel": {
|
||||
"path": null | string;
|
||||
@@ -598,6 +601,10 @@ export interface components {
|
||||
"PagedPlayoutsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": null | Array<components["schemas"]["PlayoutListItemResponseModel"]>;
|
||||
};
|
||||
"PagedTraktListsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": Array<components["schemas"]["TraktListResponseModel"]>;
|
||||
};
|
||||
"PlaybackOrder": "None" | "Chronological" | "Random" | "Shuffle" | "ShuffleInOrder" | "MultiEpisodeShuffle" | "SeasonEpisode" | "RandomRotation" | "Marathon";
|
||||
"PlaylistViewModel": {
|
||||
@@ -808,6 +815,19 @@ export interface components {
|
||||
"StartType": "Dynamic" | "Fixed";
|
||||
"StreamingMode": "TransportStream" | "HttpLiveStreamingDirect" | "HttpLiveStreamingSegmenter" | "TransportStreamHybrid";
|
||||
"TailMode": "None" | "Offline" | "Slate" | "Filler";
|
||||
"TraktListResponseModel": {
|
||||
"id": number;
|
||||
"traktId": number;
|
||||
"slug": string;
|
||||
"name": string;
|
||||
"itemCount": number;
|
||||
"matchCount": number;
|
||||
"autoRefresh": boolean;
|
||||
"generatePlaylist": boolean;
|
||||
};
|
||||
"TraktStatusResponseModel": {
|
||||
"busy": boolean;
|
||||
};
|
||||
"TroubleshootingInfoResponseModel": {
|
||||
"generalJson": string;
|
||||
"nvidiaCapabilities": null | string;
|
||||
@@ -975,6 +995,10 @@ export interface components {
|
||||
"UpdateSmartCollectionRequest": {
|
||||
"name": null | string;
|
||||
"query": null | string;
|
||||
};
|
||||
"UpdateTraktListRequest": {
|
||||
"autoRefresh": boolean;
|
||||
"generatePlaylist": boolean;
|
||||
};
|
||||
"UpdateUiSettingsRequest": {
|
||||
"isDarkMode": boolean;
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from './playouts';
|
||||
export * from './schedules';
|
||||
export * from './search';
|
||||
export * from './settings';
|
||||
export * from './trakt';
|
||||
export * from './troubleshoot';
|
||||
export * from './useChannelsQuery';
|
||||
export * from './watermarks';
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
addTraktList,
|
||||
deleteTraktList,
|
||||
getTraktListById,
|
||||
getTraktLists,
|
||||
getTraktStatus,
|
||||
matchTraktList,
|
||||
updateTraktList
|
||||
} from './trakt';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
function accepted(): Response {
|
||||
return new Response(null, { headers: { 'Content-Length': '0' }, status: 202 });
|
||||
}
|
||||
|
||||
describe('trakt api client', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('getTraktLists fetches the default page without query params', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
||||
|
||||
await getTraktLists();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists', expect.objectContaining({ method: 'GET' }));
|
||||
});
|
||||
|
||||
it('getTraktLists forwards pageNum/pageSize as query params', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
||||
|
||||
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.searchParams.get('pageNum')).toBe('2');
|
||||
expect(url.searchParams.get('pageSize')).toBe('25');
|
||||
});
|
||||
|
||||
it('getTraktListById fetches a single list by id', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
jsonResponse({
|
||||
autoRefresh: true,
|
||||
generatePlaylist: false,
|
||||
id: 1,
|
||||
itemCount: 10,
|
||||
matchCount: 8,
|
||||
name: 'My List',
|
||||
slug: 'my-list',
|
||||
traktId: 100
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getTraktListById(1)).resolves.toMatchObject({ id: 1, slug: 'my-list' });
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists/1', expect.objectContaining({ method: 'GET' }));
|
||||
});
|
||||
|
||||
it('addTraktList POSTs the url and resolves on 202', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(accepted());
|
||||
|
||||
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(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ url: 'https://trakt.tv/users/someuser/lists/some-list' });
|
||||
});
|
||||
|
||||
it('addTraktList rethrows a 422 for an invalid url', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ detail: 'Invalid Trakt list url', status: 422 }, 422));
|
||||
|
||||
await expect(addTraktList('not-a-url')).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it('matchTraktList POSTs to the match sub-route', async () => {
|
||||
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' }));
|
||||
});
|
||||
|
||||
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' }));
|
||||
});
|
||||
|
||||
it('updateTraktList PUTs autoRefresh/generatePlaylist', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
jsonResponse({
|
||||
autoRefresh: true,
|
||||
generatePlaylist: true,
|
||||
id: 3,
|
||||
itemCount: 10,
|
||||
matchCount: 8,
|
||||
name: 'My List',
|
||||
slug: 'my-list',
|
||||
traktId: 100
|
||||
})
|
||||
);
|
||||
|
||||
await updateTraktList(3, { autoRefresh: true, generatePlaylist: true });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/trakt/lists/3');
|
||||
expect(init).toMatchObject({ method: 'PUT' });
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ autoRefresh: true, generatePlaylist: true });
|
||||
});
|
||||
|
||||
it('getTraktStatus fetches the busy flag', async () => {
|
||||
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' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type TraktList = components['schemas']['TraktListResponseModel'];
|
||||
export type PagedTraktLists = components['schemas']['PagedTraktListsResponseModel'];
|
||||
export type TraktStatus = components['schemas']['TraktStatusResponseModel'];
|
||||
export type AddTraktListRequest = components['schemas']['AddTraktListRequest'];
|
||||
export type UpdateTraktListRequest = components['schemas']['UpdateTraktListRequest'];
|
||||
|
||||
export interface GetTraktListsParams {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function getTraktLists(params: GetTraktListsParams = {}): Promise<PagedTraktLists> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params.pageNum != null) {
|
||||
searchParams.set('pageNum', String(params.pageNum));
|
||||
}
|
||||
|
||||
if (params.pageSize != null) {
|
||||
searchParams.set('pageSize', String(params.pageSize));
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
return request<PagedTraktLists>(`/api/trakt/lists${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
export function getTraktListById(id: number): Promise<TraktList> {
|
||||
return request<TraktList>(`/api/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' });
|
||||
}
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
export function updateTraktList(id: number, body: UpdateTraktListRequest): Promise<TraktList> {
|
||||
return request<TraktList>(`/api/trakt/lists/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function getTraktStatus(): Promise<TraktStatus> {
|
||||
return request<TraktStatus>('/api/trakt/status');
|
||||
}
|
||||
|
||||
export function messageFromTraktError(error: unknown, fallback = 'Unable to load Trakt lists'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -861,7 +861,7 @@ function SystemPane({
|
||||
Open Dashboard
|
||||
</Button>
|
||||
</Row>
|
||||
<Row control={220} help="Trakt, blocks/decos/templates and playout editors still live here." label="Classic UI">
|
||||
<Row control={220} help="Blocks/decos/templates and playout editors still live here." label="Classic UI">
|
||||
<a className="ctv-button ctv-button-secondary ctv-button-sm" href="/system/health">
|
||||
<span>Open Classic UI</span>
|
||||
<ExternalLink aria-hidden="true" size={13} />
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ExternalLink, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Spinner, Switch } from '../components';
|
||||
import {
|
||||
addTraktList,
|
||||
deleteTraktList,
|
||||
getTraktListById,
|
||||
getTraktLists,
|
||||
getTraktStatus,
|
||||
matchTraktList,
|
||||
messageFromTraktError,
|
||||
updateTraktList,
|
||||
type TraktList
|
||||
} from '../api';
|
||||
|
||||
const TRAKT_BASE_PATH = '/app/trakt-lists';
|
||||
|
||||
// The SPA has no search screen yet (#161) — "view matched items" opens the classic Blazor
|
||||
// search page, same interim deep-link pattern used elsewhere for un-migrated screens.
|
||||
function classicSearchUrl(traktId: number): string {
|
||||
const params = new URLSearchParams({ query: `trakt_list:${traktId}` });
|
||||
return `/search?${params.toString()}`;
|
||||
}
|
||||
|
||||
function traktListIdFromPathname(pathname: string): number | null {
|
||||
const normalized = pathname.replace(/\/+$/, '');
|
||||
|
||||
if (!normalized.startsWith(`${TRAKT_BASE_PATH}/`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = Number(normalized.slice(TRAKT_BASE_PATH.length + 1).split('/')[0]);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
// The status endpoint is a poll-only substitute for the Blazor page's live
|
||||
// IEntityLocker.OnTraktChanged event (no push channel exists for the REST API). Poll while
|
||||
// busy; stop once idle; the caller re-fetches its own data on the busy -> idle transition.
|
||||
function useTraktBusyPoll(onIdleTransition: () => void) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const wasBusyRef = useRef(false);
|
||||
const timerRef = useRef<number | undefined>(undefined);
|
||||
const onIdleTransitionRef = useRef(onIdleTransition);
|
||||
// Indirection so the recursive setTimeout call always reaches the latest poll closure
|
||||
// without the callback needing to reference its own (not-yet-assigned) binding.
|
||||
const pollRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
onIdleTransitionRef.current = onIdleTransition;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
pollRef.current = () => {
|
||||
getTraktStatus()
|
||||
.then((status) => {
|
||||
setBusy(status.busy);
|
||||
|
||||
if (wasBusyRef.current && !status.busy) {
|
||||
onIdleTransitionRef.current();
|
||||
}
|
||||
|
||||
wasBusyRef.current = status.busy;
|
||||
|
||||
if (status.busy) {
|
||||
timerRef.current = window.setTimeout(() => pollRef.current(), 2500);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Give up silently; the next dispatched action restarts polling via markBusy().
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
pollRef.current();
|
||||
return () => window.clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
const markBusy = useCallback(() => {
|
||||
setBusy(true);
|
||||
wasBusyRef.current = true;
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => pollRef.current(), 2500);
|
||||
}, []);
|
||||
|
||||
return { busy, markBusy };
|
||||
}
|
||||
|
||||
/* ---------- add dialog ---------- */
|
||||
|
||||
function AddTraktListDialog({
|
||||
busy,
|
||||
error,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
open
|
||||
}: {
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (url: string) => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
const [url, setUrl] = useState('');
|
||||
const trimmed = url.trim();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy || trimmed.length === 0} loading={busy} onClick={() => onSubmit(trimmed)} variant="primary">
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onCancel}
|
||||
open={open}
|
||||
title="Add Trakt list"
|
||||
width={480}
|
||||
>
|
||||
<Input
|
||||
label="Trakt list URL"
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="https://trakt.tv/users/username/lists/list-name"
|
||||
value={url}
|
||||
/>
|
||||
<p className="ctv-collections-picker-note">
|
||||
Fetching, saving and matching happen in the background — this dialog closes right away and the list appears
|
||||
once the server finishes.
|
||||
</p>
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- sub-path editor ---------- */
|
||||
|
||||
function TraktListEditor({
|
||||
id,
|
||||
onBack,
|
||||
onBackgroundMatch,
|
||||
}: {
|
||||
id: number;
|
||||
onBack: () => void;
|
||||
onBackgroundMatch: () => void;
|
||||
}) {
|
||||
const [list, setList] = useState<TraktList | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [generatePlaylist, setGeneratePlaylist] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
getTraktListById(id)
|
||||
.then((fetched) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setList(fetched);
|
||||
setAutoRefresh(fetched.autoRefresh);
|
||||
setGeneratePlaylist(fetched.generatePlaylist);
|
||||
})
|
||||
.catch((fetchError: unknown) => {
|
||||
if (active) {
|
||||
setError(messageFromTraktError(fetchError, 'Unable to load Trakt list'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setSaved(false);
|
||||
|
||||
try {
|
||||
const updated = await updateTraktList(id, { autoRefresh, generatePlaylist });
|
||||
setList(updated);
|
||||
setSaved(true);
|
||||
if (generatePlaylist) {
|
||||
// saving with generatePlaylist enqueues a background match server-side
|
||||
onBackgroundMatch();
|
||||
}
|
||||
} catch (updateError) {
|
||||
setSaveError(messageFromTraktError(updateError, 'Unable to save Trakt list'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
<Button onClick={onBack} size="sm" startIcon={<ArrowLeft aria-hidden="true" size={14} />} variant="ghost">
|
||||
All Trakt lists
|
||||
</Button>
|
||||
<span className="ctv-collections-detail-title">{list?.name ?? `Trakt list ${id}`}</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<div className="ctv-collections-loading">
|
||||
<Spinner size={18} />
|
||||
<span>Loading Trakt list…</span>
|
||||
</div>
|
||||
) : list ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Input disabled label="Slug" value={list.slug} />
|
||||
<label className="ctv-collections-order-toggle" title="Automatically refresh this list's items">
|
||||
<Switch checked={autoRefresh} onChange={setAutoRefresh} size="sm" />
|
||||
<span>Auto refresh</span>
|
||||
</label>
|
||||
<label className="ctv-collections-order-toggle" title="Generate a system playlist from this list">
|
||||
<Switch checked={generatePlaylist} onChange={setGeneratePlaylist} size="sm" />
|
||||
<span>Generate playlist</span>
|
||||
</label>
|
||||
{saveError && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{saveError}
|
||||
</span>
|
||||
)}
|
||||
{saved && !saveError && <Badge tone="ok">Saved</Badge>}
|
||||
<div>
|
||||
<Button disabled={saving} loading={saving} onClick={() => void save()} variant="primary">
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- screen ---------- */
|
||||
|
||||
export function TraktListsScreen() {
|
||||
const editingId = traktListIdFromPathname(window.location.pathname);
|
||||
|
||||
const [lists, setLists] = useState<TraktList[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addBusy, setAddBusy] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<TraktList | null>(null);
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [rowError, setRowError] = useState<string | null>(null);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
// Fetch only; state updates happen in the async callbacks (never synchronously in the
|
||||
// effect body). `loading` starts true and flips false in `finally`, so the mount effect
|
||||
// shows the spinner; later quiet reloads (e.g. after a busy -> idle transition) never
|
||||
// touch `loading`, so the table stays visible instead of flashing back to a spinner.
|
||||
const load = useCallback(() => {
|
||||
getTraktLists({ pageSize: 100 })
|
||||
.then((paged) => {
|
||||
if (activeRef.current) {
|
||||
setLists(paged.page ?? []);
|
||||
setTotalCount(paged.totalCount ?? 0);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setError(messageFromTraktError(loadError));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
load();
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const { busy, markBusy } = useTraktBusyPoll(load);
|
||||
|
||||
if (editingId !== null) {
|
||||
return (
|
||||
<TraktListEditor
|
||||
id={editingId}
|
||||
onBack={() => navigateToPath(TRAKT_BASE_PATH)}
|
||||
onBackgroundMatch={markBusy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const submitAdd = async (url: string) => {
|
||||
setAddBusy(true);
|
||||
setAddError(null);
|
||||
|
||||
try {
|
||||
await addTraktList(url);
|
||||
markBusy();
|
||||
setAddOpen(false);
|
||||
} catch (submitError) {
|
||||
setAddError(messageFromTraktError(submitError, 'Unable to add Trakt list'));
|
||||
} finally {
|
||||
setAddBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const match = async (list: TraktList) => {
|
||||
setRowError(null);
|
||||
|
||||
try {
|
||||
await matchTraktList(list.id);
|
||||
markBusy();
|
||||
} catch (matchError) {
|
||||
setRowError(messageFromTraktError(matchError, 'Unable to match Trakt list items'));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteBusy(true);
|
||||
setDeleteError(null);
|
||||
|
||||
try {
|
||||
await deleteTraktList(deleteTarget.id);
|
||||
markBusy();
|
||||
setDeleteTarget(null);
|
||||
} catch (removeError) {
|
||||
setDeleteError(messageFromTraktError(removeError, 'Unable to delete Trakt list'));
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
<span className="ctv-collections-detail-title">Trakt Lists</span>
|
||||
{busy && (
|
||||
<Badge tone="accent">
|
||||
<Spinner size={12} /> Busy
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setAddError(null);
|
||||
setAddOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
startIcon={<Plus aria-hidden="true" size={14} />}
|
||||
>
|
||||
Add Trakt list
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{error}</span>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button onClick={refresh} size="sm" variant="secondary">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rowError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{rowError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card padded={false}>
|
||||
{loading ? (
|
||||
<div className="ctv-collections-loading" role="status">
|
||||
<Spinner size={18} />
|
||||
<span>Loading Trakt lists…</span>
|
||||
</div>
|
||||
) : lists.length === 0 ? (
|
||||
<div className="ctv-collections-empty">No Trakt lists yet.</div>
|
||||
) : (
|
||||
<div className="ctv-channels-table-frame">
|
||||
<div className="ctv-channels-table-scroll">
|
||||
<table aria-label="Trakt lists" className="ctv-channels-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Slug</th>
|
||||
<th>Name</th>
|
||||
<th>Match status</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lists.map((list) => (
|
||||
<tr key={list.id}>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{list.slug}</td>
|
||||
<td>{list.name}</td>
|
||||
<td>
|
||||
{list.matchCount} of {list.itemCount}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ alignItems: 'center', display: 'flex', gap: 4 }}>
|
||||
<IconButton
|
||||
disabled={busy}
|
||||
onClick={() => navigateToPath(`${TRAKT_BASE_PATH}/${list.id}`)}
|
||||
size="sm"
|
||||
title="Edit Trakt list properties"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={busy}
|
||||
onClick={() => void match(list)}
|
||||
size="sm"
|
||||
title="Match Trakt list items"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<a
|
||||
className="ctv-icon-button ctv-icon-button-sm"
|
||||
href={classicSearchUrl(list.traktId)}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title="View matched items (opens the Classic UI search page)"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" size={14} />
|
||||
</a>
|
||||
<IconButton
|
||||
disabled={busy}
|
||||
onClick={() => setDeleteTarget(list)}
|
||||
size="sm"
|
||||
title="Delete Trakt list"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="ctv-channels-footer">
|
||||
<span>
|
||||
{totalCount} list{totalCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="ctv-settings-callout">
|
||||
<Search aria-hidden="true" size={14} />
|
||||
<span>
|
||||
"View matched items" opens the Classic UI search page — the SPA doesn't have a search screen yet.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AddTraktListDialog
|
||||
busy={addBusy}
|
||||
error={addError}
|
||||
key={`add-${addOpen}`}
|
||||
onCancel={() => setAddOpen(false)}
|
||||
onSubmit={(url) => void submitAdd(url)}
|
||||
open={addOpen}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
busy={deleteBusy}
|
||||
confirmLabel="Delete"
|
||||
message={
|
||||
deleteTarget ? (
|
||||
<>
|
||||
<span>{`Delete "${deleteTarget.name}"? This cannot be undone.`}</span>
|
||||
{deleteError && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{deleteError}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
onCancel={() => {
|
||||
setDeleteTarget(null);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
open={deleteTarget !== null}
|
||||
title="Delete Trakt list"
|
||||
tone="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user