diff --git a/docs/decisions.md b/docs/decisions.md index 209d2c2f2..defbf3990 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -433,3 +433,29 @@ semantics: these are exclusive in-progress flags; two holders is the failure mod Call-site fixes this model prescribes land separately: #232 (scan lifecycle — enqueue only after a successful lock, compensating unlock on enqueue failure, one release per acquisition in batches) and #234 (BuildPlayout/subtitles gate their `finally` unlock on the captured acquire result). + +## 2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1) + +First bounded extraction under the App.tsx modularization epic (#243): the Channels domain moved +verbatim out of `web/src/App.tsx` into `web/src/screens/ChannelsScreen.tsx` (zero-prop, self-sufficient, +mirroring the SchedulesScreen extraction), with its behavior tests moved to a colocated +`ChannelsScreen.test.tsx` that owns its own scoped fetch mock (spa-conventions §6). Pure structural +move: no API/route/CSS/visual change; `App.tsx` retains only the import + the `` +dispatch clause. + +**Decision — no `web/src/channels/` sibling helper directory** (unlike Schedules' `web/src/schedules/`). +Channels' pure logic (`stateByChannelId`, `sortedChannels`, `groupedChannels`, `progressFromChannelState`, +`formatChannelNumber`) totals ~30 lines with no independent business-rule layer comparable to Schedules' +`itemRules.ts` (~470 lines, separately unit-tested). Keeping it inside the single screen file matches the +Blocks/Decos/Templates precedent. Revisit only if a later #243 phase adds substantial pure Channels logic +worth isolating. + +**One cross-domain helper inlined, not shared:** the Dashboard-owned `progressFromNowPlaying` (still in +`App.tsx`, used by `OnAirCard`) was structurally reused by the Channels `progressFromChannelState`. Rather +than export it from App or create a shared module, its ~5-line start/finish/now percentage math was inlined +into the moved `progressFromChannelState` (behavior-identical — `ChannelState.nowPlaying` carries the same +`startUtc`/`finishUtc` shape), so `ChannelsScreen.tsx` has no import back into `App.tsx`. + +**#238 (TopBar `primaryAction` dead button) left untouched** — the `channels` route's inert `ctv:primary-action` +dispatch is #238's owned bug and out of scope for a behavior-preserving extraction; the shell/action redesign +is deferred to #247 (epic phase 4). diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index a847ad923..8e4ce5927 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -188,6 +188,16 @@ session — if a preference needs to be shared across devices or is really serve contain "System". Anchor (`^`/`$`) or use exact strings in `getByRole` name matchers whenever a new label could be a substring of (or share a substring with) an existing one — check `App.tsx`'s nav `label:` list for collisions before picking a new label. +- **Extracted-screen tests own their own fetch mock** (Schedules #207, Channels #244): when a + screen is pulled out of `App.tsx` into `web/src/screens/Screen.tsx`, its colocated + `Screen.test.tsx` builds a **self-contained** `vi.spyOn(window, 'fetch')` mock scoped to + that screen's own endpoints (plus local `jsonResponse`/fixture-factory helpers) and renders the + screen component **directly** — it must not import from `App.test.tsx` or reuse the monolithic + `mockDashboardApi()`. `App.test.tsx` keeps only a thin **nav-smoke** test for the extracted + screen (route to it, assert it mounted and hit its own endpoint) plus genuinely cross-cutting + shell concerns (route table, sub-path ownership §2, the unsaved-changes/popstate guard §8, the + TopBar action wiring). Detailed screen behavior lives in the screen's own test file. See + `ChannelsScreen.test.tsx` / `SchedulesScreen.test.tsx` for the shape. ## 7. Verification gate — run before every commit touching `web/` diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 55b763ef2..e519a56f2 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -422,7 +422,10 @@ describe('ChicoryTV SPA scaffold', () => { expect(window.fetch).not.toHaveBeenCalledWith('/api/schedules', expect.any(Object)); }); - it('renders the Channels screen from live channel and state APIs', async () => { + it('renders the extracted Channels screen from live channel APIs (#244)', async () => { + // Thin route/composition smoke test: the full Channels behavioral suite lives colocated in + // web/src/screens/ChannelsScreen.test.tsx. This only asserts the nav wires the extracted screen + // into the shell and it mounts + hits its own endpoint. mockDashboardApi({ channels: [ { @@ -436,38 +439,9 @@ describe('ChicoryTV SPA scaffold', () => { showInEpg: true, sortNumber: 5.1, streamingMode: 'HLS Direct' - }, - { - ffmpegProfile: 'MPEG-TS', - group: 'News', - id: 2, - isEnabled: false, - language: 'fr', - name: 'News 24', - number: '24', - showInEpg: false, - sortNumber: 24, - streamingMode: 'MPEG-TS' } ], - channelStates: [ - { - channelId: 1, - channelNumber: '5.1', - onAir: true, - nowPlaying: { - finishUtc: '2026-07-04T21:30:00Z', - startUtc: '2026-07-04T21:00:00Z', - title: 'Saturday Morning Cartoons' - } - }, - { - channelId: 2, - channelNumber: '24', - onAir: false, - nowPlaying: null - } - ] + channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }] }); render(); @@ -477,492 +451,10 @@ describe('ChicoryTV SPA scaffold', () => { expect(await screen.findByRole('heading', { name: 'Channels' })).toBeInTheDocument(); expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument(); expect(screen.getByText('Retro Cartoons')).toBeInTheDocument(); - expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0); - expect(screen.getByText('News 24')).toBeInTheDocument(); - expect(screen.getByText('Off air')).toBeInTheDocument(); - expect(screen.getByTitle('Disabled')).toHaveTextContent('D'); - expect(screen.getByTitle('Hidden from EPG')).toHaveTextContent('H'); - expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument(); - expect(screen.getByText('2 of 2 channels')).toBeInTheDocument(); + expect(window.location.pathname).toBe('/app/channels'); expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object)); }); - it('polls only channel state after the Channels screen initial load', async () => { - const intervalHandlers: Array<() => void> = []; - vi.spyOn(window, 'setInterval').mockImplementation((handler) => { - if (typeof handler === 'function') { - intervalHandlers.push(handler as () => void); - } - - return 1; - }); - vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined); - mockDashboardApi({ - channels: [ - { - ffmpegProfile: 'HLS Direct', - group: 'Kids', - id: 1, - isEnabled: true, - language: 'en', - name: 'Retro Cartoons', - number: '5.1', - showInEpg: true, - sortNumber: 5.1, - streamingMode: 'HLS Segmenter' - } - ], - channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Channels' })); - expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); - - const channelFetchesBeforePoll = fetchCount('/api/channels'); - const stateFetchesBeforePoll = fetchCount('/api/channels/state'); - - intervalHandlers.forEach((handler) => handler()); - - await waitFor(() => { - expect(fetchCount('/api/channels/state')).toBeGreaterThan(stateFetchesBeforePoll); - }); - expect(fetchCount('/api/channels')).toBe(channelFetchesBeforePoll); - }); - - it('keeps preview disabled because the list DTO lacks legacy preview support data', async () => { - mockDashboardApi({ - channels: [ - { - ffmpegProfile: 'HLS Direct', - group: 'Kids', - id: 1, - isEnabled: true, - language: 'en', - name: 'Retro Cartoons', - number: '5.1', - showInEpg: true, - sortNumber: 5.1, - streamingMode: 'HLS Segmenter' - } - ], - channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Channels' })); - expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); - - expect(screen.getByRole('button', { name: 'Preview unavailable for Retro Cartoons' })).toBeDisabled(); - }); - - it('filters the Channels screen by on-air and disabled rows', async () => { - mockDashboardApi({ - channels: [ - { - ffmpegProfile: 'HLS Direct', - group: 'Kids', - id: 1, - isEnabled: true, - language: 'en', - name: 'Retro Cartoons', - number: '5.1', - showInEpg: true, - sortNumber: 5.1, - streamingMode: 'HLS Direct' - }, - { - ffmpegProfile: 'MPEG-TS', - group: 'News', - id: 2, - isEnabled: false, - language: 'fr', - name: 'News 24', - number: '24', - showInEpg: false, - sortNumber: 24, - streamingMode: 'MPEG-TS' - } - ], - channelStates: [ - { channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }, - { channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null } - ] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Channels' })); - expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'On air 1' })); - expect(screen.getByText('Retro Cartoons')).toBeInTheDocument(); - expect(screen.queryByText('News 24')).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Disabled 1' })); - expect(screen.queryByText('Retro Cartoons')).not.toBeInTheDocument(); - expect(screen.getByText('News 24')).toBeInTheDocument(); - }); - - it('runs channel row delete and refetches the Channels screen', async () => { - mockDashboardApi({ - channels: [ - { - ffmpegProfile: 'HLS Direct', - group: 'Kids', - id: 1, - isEnabled: true, - language: 'en', - name: 'Retro Cartoons', - number: '5.1', - showInEpg: true, - sortNumber: 5.1, - streamingMode: 'HLS Direct' - } - ], - confirm: true - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Channels' })); - expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Delete Retro Cartoons' })); - - await screen.findByText('Retro Cartoons'); - expect(window.fetch).toHaveBeenCalledWith('/api/channels/1', expect.objectContaining({ method: 'DELETE' })); - expect(fetchCount('/api/channels')).toBeGreaterThan(2); - }); - - it('runs bulk move and delete actions with ProblemDetails error display', async () => { - mockDashboardApi({ - channels: [ - { - ffmpegProfile: 'HLS Direct', - group: 'Kids', - id: 1, - isEnabled: true, - language: 'en', - name: 'Retro Cartoons', - number: '5.1', - showInEpg: true, - sortNumber: 5.1, - streamingMode: 'HLS Direct' - } - ], - confirm: true, - mutationFailures: { - '/api/channels/bulk/delete': { - detail: 'Channel 1 cannot be deleted while active', - status: 422, - title: 'Validation failed' - } - }, - prompt: 'Movies' - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Channels' })); - expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); - fireEvent.click(screen.getByRole('button', { name: 'Move to group' })); - - expect(window.fetch).toHaveBeenCalledWith('/api/channels/bulk/group', expect.objectContaining({ - body: JSON.stringify({ channelIds: [1], group: 'Movies' }), - method: 'POST' - })); - - expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute( - 'aria-checked', - 'false' - ); - fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); - fireEvent.click(screen.getByRole('button', { name: 'Delete selected' })); - - expect(await screen.findByText('Channel 1 cannot be deleted while active')).toBeInTheDocument(); - }); - - it('runs bulk renumber and clears the selection afterward', async () => { - mockDashboardApi({ - channels: [ - { - ffmpegProfile: 'HLS Direct', - group: 'Kids', - id: 1, - isEnabled: true, - language: 'en', - name: 'Retro Cartoons', - number: '5.1', - showInEpg: true, - sortNumber: 5.1, - streamingMode: 'HLS Direct' - }, - { - ffmpegProfile: 'MPEG-TS', - group: 'News', - id: 2, - isEnabled: false, - language: 'fr', - name: 'News 24', - number: '24', - showInEpg: false, - sortNumber: 24, - streamingMode: 'MPEG-TS' - } - ], - prompt: '10' - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Channels' })); - expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); - fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' })); - fireEvent.click(screen.getByRole('button', { name: 'Renumber' })); - - expect(window.fetch).toHaveBeenCalledWith('/api/channels/bulk/renumber', expect.objectContaining({ - body: JSON.stringify({ channels: [{ id: 1, number: '10' }, { id: 2, number: '11' }] }), - method: 'POST' - })); - - expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute( - 'aria-checked', - 'false' - ); - expect(screen.getByRole('checkbox', { name: 'Select News 24' })).toHaveAttribute( - 'aria-checked', - 'false' - ); - }); - - it('creates a blank channel with Blazor-parity defaults and navigates to its editor', async () => { - mockDashboardApi({ - channels: [ - { - ffmpegProfile: 'HLS Direct', - group: 'Kids', - id: 1, - isEnabled: true, - language: 'en', - name: 'Retro Cartoons', - number: '5.1', - showInEpg: true, - sortNumber: 5.1, - streamingMode: 'HLS Direct' - }, - { - ffmpegProfile: 'MPEG-TS', - group: 'News', - id: 2, - isEnabled: true, - language: 'fr', - name: 'News 24', - number: '24', - showInEpg: false, - sortNumber: 24, - streamingMode: 'MPEG-TS' - } - ], - createChannelResponse: { id: 42 }, - ffmpegSettings: defaultFfmpegSettings({ defaultFFmpegProfileId: 7 }) - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Channels' })); - expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); - - await waitFor(() => - expect(window.fetch).toHaveBeenCalledWith('/api/channels', expect.objectContaining({ method: 'POST' })) - ); - - const body = requestBodyFor('/api/channels'); - expect(body).toMatchObject({ - categories: null, - fallbackFillerId: null, - ffmpegProfileId: 7, - group: 'ErsatzTV', - idleBehavior: 'StopOnDisconnect', - isEnabled: true, - logo: { path: null, contentType: null }, - mirrorSourceChannelId: null, - musicVideoCreditsMode: 'None', - musicVideoCreditsTemplate: null, - name: 'New Channel', - number: '25', - playoutMode: 'Continuous', - playoutOffset: null, - playoutSource: 'Generated', - preferredAudioLanguageCode: null, - preferredAudioTitle: null, - preferredSubtitleLanguageCode: null, - showInEpg: true, - slugSeconds: null, - songVideoMode: 'Default', - streamingMode: 'TransportStreamHybrid', - streamSelector: null, - streamSelectorMode: 'Default', - subtitleMode: 'None', - transcodeMode: 'OnDemand', - watermarkId: null - }); - - await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/42')); - }); - - it('surfaces a 422 error from bare-channel create without navigating', async () => { - mockDashboardApi({ - channels: [ - { - ffmpegProfile: 'HLS Direct', - group: 'Kids', - id: 1, - isEnabled: true, - language: 'en', - name: 'Retro Cartoons', - number: '5.1', - showInEpg: true, - sortNumber: 5.1, - streamingMode: 'HLS Direct' - } - ], - createChannelFailure: { detail: 'A channel with that number already exists', status: 422, title: 'Validation failed' } - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Channels' })); - expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); - - expect(await screen.findByText('A channel with that number already exists')).toBeInTheDocument(); - expect(window.location.pathname).toBe('/app/channels'); - }); - - it('bare-creates the first channel from an empty lineup (#212 regression)', async () => { - // Fresh install: GET /api/channels returns []. The empty state must still offer bare-create — the - // old early return rendered a dead-end card before the action bar, so no channel could be created. - mockDashboardApi({ - channels: [], - createChannelResponse: { id: 7 }, - ffmpegSettings: defaultFfmpegSettings({ defaultFFmpegProfileId: 3 }) - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Channels' })); - expect(await screen.findByText('No channels yet')).toBeInTheDocument(); - - // Both create affordances are reachable on an empty lineup (the empty-state "Add Channel" is in - // addition to the always-present TopBar one, so there are two). - expect(screen.getAllByRole('button', { name: 'Add Channel' }).length).toBeGreaterThanOrEqual(2); - fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); - - await waitFor(() => - expect(window.fetch).toHaveBeenCalledWith('/api/channels', expect.objectContaining({ method: 'POST' })) - ); - - const body = requestBodyFor('/api/channels'); - expect(body).toMatchObject({ - ffmpegProfileId: 3, - group: 'ErsatzTV', - name: 'New Channel', - // max(number)+1 degenerates to 1 on an empty lineup. - number: '1' - }); - - await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/7')); - }); - - it('surfaces a bare-create failure on an empty lineup (#242 review follow-up)', async () => { - // The fresh-install path #212 exists for: a new install may have no default ffmpeg profile, so - // bare-create can 4xx. The empty state must render that error, not swallow it behind a spinner - // that just re-enables. (Regression for the empty branch dropping mutationError.) - mockDashboardApi({ - channels: [], - createChannelFailure: { detail: 'No FFmpeg profile is configured', status: 422, title: 'Validation failed' }, - ffmpegSettings: defaultFfmpegSettings({ defaultFFmpegProfileId: 0 }) - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Channels' })); - expect(await screen.findByText('No channels yet')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); - - expect(await screen.findByText('No FFmpeg profile is configured')).toBeInTheDocument(); - // Still on the empty lineup — no navigation, and the create affordances remain reachable. - expect(window.location.pathname).toBe('/app/channels'); - expect(screen.getByRole('button', { name: 'New blank channel' })).toBeInTheDocument(); - }); - - it('clears the selection when the channel view filter changes', async () => { - mockDashboardApi({ - channels: [ - { - ffmpegProfile: 'HLS Direct', - group: 'Kids', - id: 1, - isEnabled: true, - language: 'en', - name: 'Retro Cartoons', - number: '5.1', - showInEpg: true, - sortNumber: 5.1, - streamingMode: 'HLS Direct' - }, - { - ffmpegProfile: 'MPEG-TS', - group: 'News', - id: 2, - isEnabled: true, - language: 'fr', - name: 'News 24', - number: '24', - showInEpg: true, - sortNumber: 24, - streamingMode: 'MPEG-TS' - } - ], - channelStates: [ - { channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }, - { channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null } - ] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Channels' })); - expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); - fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' })); - expect(screen.getByRole('button', { name: 'Delete selected' })).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Clear' })); - expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'On air 1' })); - expect(screen.queryByText('News 24')).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); - fireEvent.click(screen.getByRole('button', { name: 'Clear' })); - - expect(screen.queryByRole('button', { name: 'Delete selected' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument(); - }); - it('renders the extracted Schedules editor from live schedule APIs (#207)', async () => { mockDashboardApi({ scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons', collectionName: 'Saturday Cartoons' })], diff --git a/web/src/App.tsx b/web/src/App.tsx index 95a1fb178..bb4f2fae1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -26,9 +26,7 @@ import { FileImage, Eraser, Folder, - FolderInput, FolderTree, - Hash, HardDrive, Info, Layers, @@ -70,6 +68,7 @@ import { MediaBrowseScreen } from './screens/MediaBrowseScreen'; import { MultiCollectionsScreen } from './screens/MultiCollectionsScreen'; import { PlaylistsScreen } from './screens/PlaylistsScreen'; import { RerunCollectionsScreen } from './screens/RerunCollectionsScreen'; +import { ChannelsScreen } from './screens/ChannelsScreen'; import { SchedulesScreen } from './screens/SchedulesScreen'; import { canLeaveCurrentScreen } from './navigationGuard'; import { @@ -101,7 +100,6 @@ import { Badge, Button, Card, - Checkbox, ChannelLogo, Dialog, IconButton, @@ -117,20 +115,13 @@ import { } from './components'; import { useChannelsQuery, - useChannelsScreenQuery, - bulkDeleteChannels, - bulkMoveChannelsToGroup, - bulkRenumberChannels, createPlayout, deletePlayout, erasePlayoutItems, erasePlayoutItemsAndHistory, getPlayoutItemSchedulingContext, resetChannelPlayout, - createChannel, - deleteChannel, getDecos, - getFfmpegSettings, ApiError, messageFromError, getSchedules, @@ -146,10 +137,8 @@ import { useGuideScreenQuery, useLibrariesScreenQuery, type ChannelState, - type ChannelSummary, type ChannelGuideChannel, type ChannelGuideProgramme, - type CreateChannelRequest, type CreatePlayoutRequest, type DashboardChannel, type DecoListItem, @@ -1181,538 +1170,6 @@ function DashboardScreen({ ); } -type ChannelViewFilter = 'all' | 'onair' | 'disabled'; - -const streamingModeBadgeTones: Record = { - 'HLS Direct': 'neutral', - 'HLS Segmenter': 'accent', - 'MPEG-TS': 'accent', - 'MPEG-TS (Legacy)': 'neutral' -}; - -function stateByChannelId(states: ChannelState[]): Map { - return new Map(states.map((state) => [state.channelId, state])); -} - -function sortedChannels(channels: ChannelSummary[]): ChannelSummary[] { - return [...channels].sort((left, right) => left.sortNumber - right.sortNumber || left.number.localeCompare(right.number)); -} - -function groupedChannels(channels: ChannelSummary[]): Array<{ group: string; rows: ChannelSummary[] }> { - const groups: Array<{ group: string; rows: ChannelSummary[] }> = []; - const indexes = new Map(); - - for (const channel of channels) { - const group = channel.group || 'Ungrouped'; - const index = indexes.get(group); - - if (index == null) { - indexes.set(group, groups.length); - groups.push({ group, rows: [channel] }); - } else { - groups[index].rows.push(channel); - } - } - - return groups; -} - -function progressFromChannelState(state: ChannelState): number | null { - return state.nowPlaying ? progressFromNowPlaying(state.nowPlaying) : null; -} - -function Marker({ children, title }: { children: ReactNode; title: string }) { - return ( - - {children} - - ); -} - -function ChannelsLoadingState() { - return ( - -
- - Loading channels -
-
- ); -} - -function ChannelsErrorState({ error, refresh }: { error: string; refresh: () => void }) { - return ( - Channels unavailable} - subtitle="Live API request failed" - actions={} - > -
- API request failed - {error} -
-
- ); -} - -// Empty lineup (fresh install) must still let the user create their first channel — both the -// library-to-lineup ChannelBuilder flow ("Add Channel") and the bare-create flow ("New blank -// channel"). The bare-create handler is passed in from ChannelsScreen so the defaults (number = -// max+1, which degenerates to 1 on an empty lineup; group "ErsatzTV"; default ffmpeg profile) are -// identical to the action bar's. Regression for #212 (reopened): the old early return rendered a -// dead-end card before the action bar, so a fresh install could never create a channel. -// mutationError is rendered here too: a fresh install may have no default ffmpeg profile, so -// bare-create can 4xx — the non-empty screen shows that alert, and this branch must not swallow it -// (a silent spinner on the one screen that exists to bootstrap the first channel is the worst place -// to hide the failure). -function ChannelsEmptyState({ - creatingBlank, - mutationError, - onCreateBlank -}: { - creatingBlank: boolean; - mutationError: string | null; - onCreateBlank: () => void; -}) { - return ( - No channels} subtitle="No channels yet — create your first one."> -
-

No channels yet

- {mutationError && ( -
-
- )} -
- - -
-
-
- ); -} - -function ChannelsScreen() { - const query = useChannelsScreenQuery(); - const [filter, setFilter] = useState('all'); - const [selectedIds, setSelectedIds] = useState>(() => new Set()); - const [mutationError, setMutationError] = useState(null); - const [mutating, setMutating] = useState(false); - const [creatingBlank, setCreatingBlank] = useState(false); - - if (query.status === 'loading') { - return ; - } - - if (query.status === 'error') { - return ; - } - - const channels = sortedChannels(query.data.channels); - const statesById = stateByChannelId(query.data.channelStates); - const onAirCount = query.data.channelStates.filter((state) => state.onAir).length; - const disabledCount = channels.filter((channel) => !channel.isEnabled).length; - const visibleChannels = channels.filter((channel) => { - if (filter === 'onair') { - return statesById.get(channel.id)?.onAir === true; - } - - if (filter === 'disabled') { - return !channel.isEnabled; - } - - return true; - }); - const groups = groupedChannels(visibleChannels); - const visibleIds = visibleChannels.map((channel) => channel.id); - const selectedVisibleCount = visibleIds.filter((id) => selectedIds.has(id)).length; - const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length; - const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected; - const selectedChannels = visibleChannels.filter((channel) => selectedIds.has(channel.id)); - - const refreshAfterMutation = async (operation: () => Promise) => { - setMutationError(null); - setMutating(true); - - try { - await operation(); - setSelectedIds(new Set()); - query.refresh(); - } catch (error: unknown) { - setMutationError(messageFromError(error)); - } finally { - setMutating(false); - } - }; - - const toggleAll = () => { - setSelectedIds((current) => { - const next = new Set(current); - - if (allVisibleSelected) { - visibleIds.forEach((id) => next.delete(id)); - } else { - visibleIds.forEach((id) => next.add(id)); - } - - return next; - }); - }; - - const toggleOne = (channelId: number) => { - setSelectedIds((current) => { - const next = new Set(current); - - if (next.has(channelId)) { - next.delete(channelId); - } else { - next.add(channelId); - } - - return next; - }); - }; - - const moveSelectedToGroup = () => { - const group = window.prompt('Move selected channels to group', selectedChannels[0]?.group ?? ''); - - if (group == null) { - return; - } - - void refreshAfterMutation(() => bulkMoveChannelsToGroup({ - channelIds: selectedChannels.map((channel) => channel.id), - group - })); - }; - - const renumberSelected = () => { - const firstNumber = window.prompt('First channel number', selectedChannels[0]?.number ?? '1'); - - if (firstNumber == null) { - return; - } - - const start = Number.parseFloat(firstNumber); - - if (!Number.isFinite(start)) { - setMutationError('First channel number must be numeric'); - return; - } - - void refreshAfterMutation(() => bulkRenumberChannels({ - channels: selectedChannels.map((channel, index) => ({ - id: channel.id, - number: formatChannelNumber(start + index) - })) - })); - }; - - const deleteSelected = () => { - if (!window.confirm(`Delete ${selectedChannels.length} selected channel${selectedChannels.length === 1 ? '' : 's'}?`)) { - return; - } - - void refreshAfterMutation(() => bulkDeleteChannels({ channelIds: selectedChannels.map((channel) => channel.id) })); - }; - - const changeFilter = (nextFilter: ChannelViewFilter) => { - setFilter(nextFilter); - setSelectedIds(new Set()); - }; - - const deleteOne = (channel: ChannelSummary) => { - if (!window.confirm(`Delete ${channel.name}?`)) { - return; - } - - void refreshAfterMutation(() => deleteChannel(channel.id)); - }; - - // Blazor parity (ChannelEditor.razor "add" branch): a blank channel is created with these - // defaults, then the editor opens for the rest of the fields. Distinct from "Add Channel", - // which is the library-to-lineup ChannelBuilder flow (untouched here). - const createBlankChannel = async () => { - if (creatingBlank) { - return; - } - - setMutationError(null); - setCreatingBlank(true); - - try { - const ffmpegSettings = await getFfmpegSettings(); - const maxNumber = channels.reduce((max, channel) => { - const parsed = Number.parseInt(channel.number.split('.')[0] ?? '', 10); - return Number.isFinite(parsed) ? Math.max(max, parsed) : max; - }, 0); - - const body: CreateChannelRequest = { - categories: null, - fallbackFillerId: null, - ffmpegProfileId: ffmpegSettings.defaultFFmpegProfileId, - group: 'ErsatzTV', - idleBehavior: 'StopOnDisconnect', - isEnabled: true, - logo: { path: null, contentType: null }, - mirrorSourceChannelId: null, - musicVideoCreditsMode: 'None', - musicVideoCreditsTemplate: null, - name: 'New Channel', - number: String(maxNumber + 1), - playoutMode: 'Continuous', - playoutOffset: null, - playoutSource: 'Generated', - preferredAudioLanguageCode: null, - preferredAudioTitle: null, - preferredSubtitleLanguageCode: null, - showInEpg: true, - slugSeconds: null, - songVideoMode: 'Default', - streamingMode: 'TransportStreamHybrid', - streamSelector: null, - streamSelectorMode: 'Default', - subtitleMode: 'None', - transcodeMode: 'OnDemand', - watermarkId: null - }; - - const created = await createChannel(body); - navigateToPath(`/app/edit-channel/${created.id}`); - } catch (error: unknown) { - setMutationError(messageFromError(error)); - } finally { - setCreatingBlank(false); - } - }; - - if (channels.length === 0) { - return ( - void createBlankChannel()} - /> - ); - } - - return ( -
-
- {selectedChannels.length > 0 ? ( - <> - {selectedChannels.length} selected - - - - - - - - ) : ( - <> -
- - - -
- - {onAirCount} on air - - - - )} -
- - {mutationError && ( -
-
- )} - -
-
- - - - - - - - - - - - - {groups.map(({ group, rows }) => ( - - ))} - -
No.ChannelNow playingStreamingFFmpeg -
-
-
- {visibleChannels.length} of {channels.length} channels - {groups.length} groups -
-
-
- ); -} - -function ChannelGroupRows({ - channels, - group, - mutating, - onDelete, - onToggle, - selectedIds, - statesById -}: { - channels: ChannelSummary[]; - group: string; - mutating: boolean; - onDelete: (channel: ChannelSummary) => void; - onToggle: (channelId: number) => void; - selectedIds: Set; - statesById: Map; -}) { - return ( - <> - - - - - - {channels.map((channel) => ( - - ))} - - ); -} - -function formatChannelNumber(value: number): string { - return Number.isInteger(value) ? `${value}` : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); -} - -function ChannelTableRow({ - channel, - mutating, - onDelete, - onToggle, - selected, - state -}: { - channel: ChannelSummary; - mutating: boolean; - onDelete: (channel: ChannelSummary) => void; - onToggle: (channelId: number) => void; - selected: boolean; - state: ChannelState | null; -}) { - const live = state?.onAir === true; - const dim = !channel.isEnabled; - const progress = state ? progressFromChannelState(state) : null; - const streamingTone = streamingModeBadgeTones[channel.streamingMode] ?? 'neutral'; - - return ( - - - onToggle(channel.id)} /> - - - {live && }{channel.number} - - -
- -
- {channel.name} - - {!channel.isEnabled && D} - {!channel.showInEpg && H} - - {channel.language || 'Language unset'} -
-
- - - {state?.nowPlaying ? ( -
- {state.nowPlaying.title} - -
- ) : ( - {channel.isEnabled ? 'Idle' : 'Off air'} - )} - - {channel.streamingMode} - {channel.ffmpegProfile || 'Unassigned'} - -
- - - navigateToPath(`/app/edit-channel/${channel.id}`)} size="sm" title={`Edit ${channel.name}`}> - - navigateToPath(`/app/troubleshooting/playback?channel=${channel.id}`)} - size="sm" - title={`Troubleshoot ${channel.name}`} - > - - onDelete(channel)} size="sm" title={`Delete ${channel.name}`}> - -
- - - ); -} - function formatScheduleEnum(value: string): string { return value.replace(/([a-z])([A-Z])/g, '$1 $2'); } diff --git a/web/src/screens/ChannelsScreen.test.tsx b/web/src/screens/ChannelsScreen.test.tsx new file mode 100644 index 000000000..bcd12d9ba --- /dev/null +++ b/web/src/screens/ChannelsScreen.test.tsx @@ -0,0 +1,524 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChannelsScreen } from './ChannelsScreen'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); +} + +// ChannelResponseModel row (runtime/Newtonsoft casing — see spa-conventions §4). +function channelRow(overrides: Record = {}): Record { + return { + ffmpegProfile: 'HLS Direct', + group: 'Kids', + id: 1, + isEnabled: true, + language: 'en', + name: 'Retro Cartoons', + number: '5.1', + showInEpg: true, + sortNumber: 5.1, + streamingMode: 'HLS Direct', + ...overrides + }; +} + +// ChannelStateResponseModel row. +function stateRow(overrides: Record = {}): Record { + return { + channelId: 1, + channelNumber: '5.1', + nowPlaying: null, + onAir: false, + ...overrides + }; +} + +function ffmpegSettingsFixture(overrides: Record = {}): Record { + return { + defaultFFmpegProfileId: 1, + ...overrides + }; +} + +interface MockOptions { + channels?: unknown[]; + channelStates?: unknown[]; + createChannelResponse?: { id: number } | null; + createChannelFailure?: { detail?: string; status?: number; title?: string } | null; + ffmpegSettings?: Record; + // path -> ProblemDetails body returned with a 422 for a bulk mutation. + mutationFailures?: Record; +} + +interface MockHandle { + fetchSpy: ReturnType; + requests: Array<{ url: string; method: string; body: unknown }>; +} + +function mockApi(options: MockOptions = {}): MockHandle { + const requests: Array<{ url: string; method: string; body: unknown }> = []; + const channels = options.channels ?? [channelRow()]; + const channelStates = options.channelStates ?? []; + const ffmpegSettings = options.ffmpegSettings ?? ffmpegSettingsFixture(); + const mutationFailures = options.mutationFailures ?? {}; + + const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? 'GET').toUpperCase(); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + requests.push({ url, method, body }); + + if (url === '/api/channels' && method === 'POST') { + if (options.createChannelFailure) { + return Promise.resolve(jsonResponse(options.createChannelFailure, options.createChannelFailure.status ?? 422)); + } + return Promise.resolve(jsonResponse(options.createChannelResponse ?? { id: 99 }, 201)); + } + if (url === '/api/channels' && method === 'GET') return Promise.resolve(jsonResponse(channels)); + if (url === '/api/channels/state' && method === 'GET') return Promise.resolve(jsonResponse(channelStates)); + if (url === '/api/settings/ffmpeg' && method === 'GET') return Promise.resolve(jsonResponse(ffmpegSettings)); + + if ( + url === '/api/channels/bulk/renumber' || + url === '/api/channels/bulk/group' || + url === '/api/channels/bulk/delete' + ) { + if (url in mutationFailures) { + return Promise.resolve(jsonResponse(mutationFailures[url], mutationFailures[url].status ?? 422)); + } + return Promise.resolve(new Response(null, { status: 204 })); + } + + if (/^\/api\/channels\/\d+$/.test(url) && method === 'DELETE') { + if (url in mutationFailures) { + return Promise.resolve(jsonResponse(mutationFailures[url], mutationFailures[url].status ?? 422)); + } + return Promise.resolve(new Response(null, { status: 204 })); + } + + return Promise.resolve(jsonResponse(null, 404)); + }); + + return { fetchSpy, requests }; +} + +function fetchCount(handle: MockHandle, path: string): number { + return handle.requests.filter((r) => r.url === path).length; +} + +function requestBodyFor(handle: MockHandle, path: string): Record { + const call = handle.requests.find((r) => r.url === path && r.body != null); + if (!call) { + throw new Error(`no request captured for ${path}`); + } + return call.body as Record; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + window.history.replaceState(null, '', '/app/channels'); +}); + +beforeEach(() => { + window.history.replaceState(null, '', '/app/channels'); + vi.spyOn(window, 'confirm').mockReturnValue(true); + vi.spyOn(window, 'prompt').mockReturnValue(null); +}); + +describe('ChannelsScreen — load + render', () => { + it('renders channels from live channel and state APIs', async () => { + const handle = mockApi({ + channels: [ + channelRow({ id: 1, group: 'Kids', name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1, streamingMode: 'HLS Direct' }), + channelRow({ + ffmpegProfile: 'MPEG-TS', + group: 'News', + id: 2, + isEnabled: false, + language: 'fr', + name: 'News 24', + number: '24', + showInEpg: false, + sortNumber: 24, + streamingMode: 'MPEG-TS' + }) + ], + channelStates: [ + stateRow({ + channelId: 1, + channelNumber: '5.1', + nowPlaying: { + finishUtc: '2026-07-04T21:30:00Z', + startUtc: '2026-07-04T21:00:00Z', + title: 'Saturday Morning Cartoons' + }, + onAir: true + }), + stateRow({ channelId: 2, channelNumber: '24', nowPlaying: null, onAir: false }) + ] + }); + + render(); + + expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument(); + expect(screen.getByText('Retro Cartoons')).toBeInTheDocument(); + expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0); + expect(screen.getByText('News 24')).toBeInTheDocument(); + expect(screen.getByText('Off air')).toBeInTheDocument(); + expect(screen.getByTitle('Disabled')).toHaveTextContent('D'); + expect(screen.getByTitle('Hidden from EPG')).toHaveTextContent('H'); + expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument(); + expect(screen.getByText('2 of 2 channels')).toBeInTheDocument(); + expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels/state', expect.any(Object)); + }); + + it('polls only channel state after the initial load', async () => { + const intervalHandlers: Array<() => void> = []; + vi.spyOn(window, 'setInterval').mockImplementation((handler) => { + if (typeof handler === 'function') { + intervalHandlers.push(handler as () => void); + } + + return 1; + }); + vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined); + const handle = mockApi({ + channels: [channelRow({ streamingMode: 'HLS Segmenter' })], + channelStates: [stateRow({ onAir: true })] + }); + + render(); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + const channelFetchesBeforePoll = fetchCount(handle, '/api/channels'); + const stateFetchesBeforePoll = fetchCount(handle, '/api/channels/state'); + + intervalHandlers.forEach((handler) => handler()); + + await waitFor(() => { + expect(fetchCount(handle, '/api/channels/state')).toBeGreaterThan(stateFetchesBeforePoll); + }); + expect(fetchCount(handle, '/api/channels')).toBe(channelFetchesBeforePoll); + }); + + it('keeps preview disabled because the list DTO lacks legacy preview support data', async () => { + mockApi({ + channels: [channelRow({ streamingMode: 'HLS Segmenter' })], + channelStates: [stateRow({ onAir: true })] + }); + + render(); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: 'Preview unavailable for Retro Cartoons' })).toBeDisabled(); + }); +}); + +describe('ChannelsScreen — filtering + selection', () => { + it('filters by on-air and disabled rows', async () => { + mockApi({ + channels: [ + channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }), + channelRow({ + ffmpegProfile: 'MPEG-TS', + group: 'News', + id: 2, + isEnabled: false, + language: 'fr', + name: 'News 24', + number: '24', + showInEpg: false, + sortNumber: 24, + streamingMode: 'MPEG-TS' + }) + ], + channelStates: [ + stateRow({ channelId: 1, channelNumber: '5.1', onAir: true }), + stateRow({ channelId: 2, channelNumber: '24', onAir: false }) + ] + }); + + render(); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'On air 1' })); + expect(screen.getByText('Retro Cartoons')).toBeInTheDocument(); + expect(screen.queryByText('News 24')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Disabled 1' })); + expect(screen.queryByText('Retro Cartoons')).not.toBeInTheDocument(); + expect(screen.getByText('News 24')).toBeInTheDocument(); + }); + + it('clears the selection when the channel view filter changes', async () => { + mockApi({ + channels: [ + channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }), + channelRow({ + ffmpegProfile: 'MPEG-TS', + group: 'News', + id: 2, + isEnabled: true, + language: 'fr', + name: 'News 24', + number: '24', + showInEpg: true, + sortNumber: 24, + streamingMode: 'MPEG-TS' + }) + ], + channelStates: [ + stateRow({ channelId: 1, channelNumber: '5.1', onAir: true }), + stateRow({ channelId: 2, channelNumber: '24', onAir: false }) + ] + }); + + render(); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' })); + expect(screen.getByRole('button', { name: 'Delete selected' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'On air 1' })); + expect(screen.queryByText('News 24')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); + fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + + expect(screen.queryByRole('button', { name: 'Delete selected' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument(); + }); +}); + +describe('ChannelsScreen — mutations', () => { + it('runs channel row delete and refetches', async () => { + const handle = mockApi({ channels: [channelRow()] }); + + render(); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + const channelFetchesBeforeDelete = fetchCount(handle, '/api/channels'); + fireEvent.click(screen.getByRole('button', { name: 'Delete Retro Cartoons' })); + + await screen.findByText('Retro Cartoons'); + expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels/1', expect.objectContaining({ method: 'DELETE' })); + await waitFor(() => expect(fetchCount(handle, '/api/channels')).toBeGreaterThan(channelFetchesBeforeDelete)); + }); + + it('runs bulk move then surfaces a ProblemDetails error on bulk delete', async () => { + vi.spyOn(window, 'prompt').mockReturnValue('Movies'); + const handle = mockApi({ + channels: [channelRow()], + mutationFailures: { + '/api/channels/bulk/delete': { + detail: 'Channel 1 cannot be deleted while active', + status: 422, + title: 'Validation failed' + } + } + }); + + render(); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); + fireEvent.click(screen.getByRole('button', { name: 'Move to group' })); + + expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels/bulk/group', expect.objectContaining({ + body: JSON.stringify({ channelIds: [1], group: 'Movies' }), + method: 'POST' + })); + + expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute( + 'aria-checked', + 'false' + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); + fireEvent.click(screen.getByRole('button', { name: 'Delete selected' })); + + expect(await screen.findByText('Channel 1 cannot be deleted while active')).toBeInTheDocument(); + }); + + it('runs bulk renumber and clears the selection afterward', async () => { + vi.spyOn(window, 'prompt').mockReturnValue('10'); + const handle = mockApi({ + channels: [ + channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }), + channelRow({ + ffmpegProfile: 'MPEG-TS', + group: 'News', + id: 2, + isEnabled: false, + language: 'fr', + name: 'News 24', + number: '24', + showInEpg: false, + sortNumber: 24, + streamingMode: 'MPEG-TS' + }) + ] + }); + + render(); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' })); + fireEvent.click(screen.getByRole('button', { name: 'Renumber' })); + + expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels/bulk/renumber', expect.objectContaining({ + body: JSON.stringify({ channels: [{ id: 1, number: '10' }, { id: 2, number: '11' }] }), + method: 'POST' + })); + + expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute( + 'aria-checked', + 'false' + ); + expect(screen.getByRole('checkbox', { name: 'Select News 24' })).toHaveAttribute( + 'aria-checked', + 'false' + ); + }); +}); + +describe('ChannelsScreen — bare create', () => { + it('creates a blank channel with Blazor-parity defaults and navigates to its editor', async () => { + const handle = mockApi({ + channels: [ + channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }), + channelRow({ + ffmpegProfile: 'MPEG-TS', + group: 'News', + id: 2, + isEnabled: true, + language: 'fr', + name: 'News 24', + number: '24', + showInEpg: false, + sortNumber: 24, + streamingMode: 'MPEG-TS' + }) + ], + createChannelResponse: { id: 42 }, + ffmpegSettings: ffmpegSettingsFixture({ defaultFFmpegProfileId: 7 }) + }); + + render(); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); + + await waitFor(() => + expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels', expect.objectContaining({ method: 'POST' })) + ); + + const body = requestBodyFor(handle, '/api/channels'); + expect(body).toMatchObject({ + categories: null, + fallbackFillerId: null, + ffmpegProfileId: 7, + group: 'ErsatzTV', + idleBehavior: 'StopOnDisconnect', + isEnabled: true, + logo: { path: null, contentType: null }, + mirrorSourceChannelId: null, + musicVideoCreditsMode: 'None', + musicVideoCreditsTemplate: null, + name: 'New Channel', + number: '25', + playoutMode: 'Continuous', + playoutOffset: null, + playoutSource: 'Generated', + preferredAudioLanguageCode: null, + preferredAudioTitle: null, + preferredSubtitleLanguageCode: null, + showInEpg: true, + slugSeconds: null, + songVideoMode: 'Default', + streamingMode: 'TransportStreamHybrid', + streamSelector: null, + streamSelectorMode: 'Default', + subtitleMode: 'None', + transcodeMode: 'OnDemand', + watermarkId: null + }); + + await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/42')); + }); + + it('surfaces a 422 error from bare-channel create without navigating', async () => { + mockApi({ + channels: [channelRow()], + createChannelFailure: { detail: 'A channel with that number already exists', status: 422, title: 'Validation failed' } + }); + + render(); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); + + expect(await screen.findByText('A channel with that number already exists')).toBeInTheDocument(); + expect(window.location.pathname).toBe('/app/channels'); + }); + + it('bare-creates the first channel from an empty lineup (#212 regression)', async () => { + // Fresh install: GET /api/channels returns []. The empty state must still offer bare-create — the + // old early return rendered a dead-end card before the action bar, so no channel could be created. + const handle = mockApi({ + channels: [], + createChannelResponse: { id: 7 }, + ffmpegSettings: ffmpegSettingsFixture({ defaultFFmpegProfileId: 3 }) + }); + + render(); + expect(await screen.findByText('No channels yet')).toBeInTheDocument(); + + // Both create affordances are reachable on an empty lineup. + expect(screen.getByRole('button', { name: 'Add Channel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'New blank channel' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); + + await waitFor(() => + expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels', expect.objectContaining({ method: 'POST' })) + ); + + const body = requestBodyFor(handle, '/api/channels'); + expect(body).toMatchObject({ + ffmpegProfileId: 3, + group: 'ErsatzTV', + name: 'New Channel', + // max(number)+1 degenerates to 1 on an empty lineup. + number: '1' + }); + + await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/7')); + }); + + it('surfaces a bare-create failure on an empty lineup (#242 review follow-up)', async () => { + // The fresh-install path #212 exists for: a new install may have no default ffmpeg profile, so + // bare-create can 4xx. The empty state must render that error, not swallow it behind a spinner + // that just re-enables. (Regression for the empty branch dropping mutationError.) + mockApi({ + channels: [], + createChannelFailure: { detail: 'No FFmpeg profile is configured', status: 422, title: 'Validation failed' }, + ffmpegSettings: ffmpegSettingsFixture({ defaultFFmpegProfileId: 0 }) + }); + + render(); + expect(await screen.findByText('No channels yet')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); + + expect(await screen.findByText('No FFmpeg profile is configured')).toBeInTheDocument(); + // Still on the empty lineup — no navigation, and the create affordances remain reachable. + expect(window.location.pathname).toBe('/app/channels'); + expect(screen.getByRole('button', { name: 'New blank channel' })).toBeInTheDocument(); + }); +}); diff --git a/web/src/screens/ChannelsScreen.tsx b/web/src/screens/ChannelsScreen.tsx new file mode 100644 index 000000000..33d6e222d --- /dev/null +++ b/web/src/screens/ChannelsScreen.tsx @@ -0,0 +1,577 @@ +import { useState, type ReactNode } from 'react'; +import { Folder, FolderInput, Hash, Pencil, Play, Plus, RefreshCw, Stethoscope, Trash2, TriangleAlert } from 'lucide-react'; +import { + Badge, + Button, + Card, + ChannelLogo, + Checkbox, + IconButton, + ProgressBar, + Spinner, + StatusDot +} from '../components'; +import { + bulkDeleteChannels, + bulkMoveChannelsToGroup, + bulkRenumberChannels, + createChannel, + deleteChannel, + getFfmpegSettings, + messageFromError, + useChannelsScreenQuery, + type ChannelState, + type ChannelSummary, + type CreateChannelRequest +} from '../api'; +import { navigateToPath } from '../routing'; + +type ChannelViewFilter = 'all' | 'onair' | 'disabled'; + +const streamingModeBadgeTones: Record = { + 'HLS Direct': 'neutral', + 'HLS Segmenter': 'accent', + 'MPEG-TS': 'accent', + 'MPEG-TS (Legacy)': 'neutral' +}; + +function stateByChannelId(states: ChannelState[]): Map { + return new Map(states.map((state) => [state.channelId, state])); +} + +function sortedChannels(channels: ChannelSummary[]): ChannelSummary[] { + return [...channels].sort((left, right) => left.sortNumber - right.sortNumber || left.number.localeCompare(right.number)); +} + +function groupedChannels(channels: ChannelSummary[]): Array<{ group: string; rows: ChannelSummary[] }> { + const groups: Array<{ group: string; rows: ChannelSummary[] }> = []; + const indexes = new Map(); + + for (const channel of channels) { + const group = channel.group || 'Ungrouped'; + const index = indexes.get(group); + + if (index == null) { + indexes.set(group, groups.length); + groups.push({ group, rows: [channel] }); + } else { + groups[index].rows.push(channel); + } + } + + return groups; +} + +// Inlined from App.tsx's Dashboard-owned progressFromNowPlaying during the #244 extraction: +// the Channels domain reuses the identical start/finish/now percentage math, and ChannelState's +// nowPlaying carries the same startUtc/finishUtc shape, so inlining keeps the screen self-contained +// with no cross-import back into App.tsx (behavior-identical). +function progressFromChannelState(state: ChannelState): number | null { + const nowPlaying = state.nowPlaying; + + if (!nowPlaying) { + return null; + } + + const start = new Date(nowPlaying.startUtc).getTime(); + const finish = new Date(nowPlaying.finishUtc).getTime(); + const now = Date.now(); + + if (!Number.isFinite(start) || !Number.isFinite(finish) || finish <= start) { + return null; + } + + return Math.round(((now - start) / (finish - start)) * 100); +} + +function Marker({ children, title }: { children: ReactNode; title: string }) { + return ( + + {children} + + ); +} + +function ChannelsLoadingState() { + return ( + +
+ + Loading channels +
+
+ ); +} + +function ChannelsErrorState({ error, refresh }: { error: string; refresh: () => void }) { + return ( + Channels unavailable} + subtitle="Live API request failed" + actions={} + > +
+ API request failed + {error} +
+
+ ); +} + +// Empty lineup (fresh install) must still let the user create their first channel — both the +// library-to-lineup ChannelBuilder flow ("Add Channel") and the bare-create flow ("New blank +// channel"). The bare-create handler is passed in from ChannelsScreen so the defaults (number = +// max+1, which degenerates to 1 on an empty lineup; group "ErsatzTV"; default ffmpeg profile) are +// identical to the action bar's. Regression for #212 (reopened): the old early return rendered a +// dead-end card before the action bar, so a fresh install could never create a channel. +// mutationError is rendered here too: a fresh install may have no default ffmpeg profile, so +// bare-create can 4xx — the non-empty screen shows that alert, and this branch must not swallow it +// (a silent spinner on the one screen that exists to bootstrap the first channel is the worst place +// to hide the failure). +function ChannelsEmptyState({ + creatingBlank, + mutationError, + onCreateBlank +}: { + creatingBlank: boolean; + mutationError: string | null; + onCreateBlank: () => void; +}) { + return ( + No channels} subtitle="No channels yet — create your first one."> +
+

No channels yet

+ {mutationError && ( +
+
+ )} +
+ + +
+
+
+ ); +} + +export function ChannelsScreen() { + const query = useChannelsScreenQuery(); + const [filter, setFilter] = useState('all'); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const [mutationError, setMutationError] = useState(null); + const [mutating, setMutating] = useState(false); + const [creatingBlank, setCreatingBlank] = useState(false); + + if (query.status === 'loading') { + return ; + } + + if (query.status === 'error') { + return ; + } + + const channels = sortedChannels(query.data.channels); + const statesById = stateByChannelId(query.data.channelStates); + const onAirCount = query.data.channelStates.filter((state) => state.onAir).length; + const disabledCount = channels.filter((channel) => !channel.isEnabled).length; + const visibleChannels = channels.filter((channel) => { + if (filter === 'onair') { + return statesById.get(channel.id)?.onAir === true; + } + + if (filter === 'disabled') { + return !channel.isEnabled; + } + + return true; + }); + const groups = groupedChannels(visibleChannels); + const visibleIds = visibleChannels.map((channel) => channel.id); + const selectedVisibleCount = visibleIds.filter((id) => selectedIds.has(id)).length; + const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length; + const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected; + const selectedChannels = visibleChannels.filter((channel) => selectedIds.has(channel.id)); + + const refreshAfterMutation = async (operation: () => Promise) => { + setMutationError(null); + setMutating(true); + + try { + await operation(); + setSelectedIds(new Set()); + query.refresh(); + } catch (error: unknown) { + setMutationError(messageFromError(error)); + } finally { + setMutating(false); + } + }; + + const toggleAll = () => { + setSelectedIds((current) => { + const next = new Set(current); + + if (allVisibleSelected) { + visibleIds.forEach((id) => next.delete(id)); + } else { + visibleIds.forEach((id) => next.add(id)); + } + + return next; + }); + }; + + const toggleOne = (channelId: number) => { + setSelectedIds((current) => { + const next = new Set(current); + + if (next.has(channelId)) { + next.delete(channelId); + } else { + next.add(channelId); + } + + return next; + }); + }; + + const moveSelectedToGroup = () => { + const group = window.prompt('Move selected channels to group', selectedChannels[0]?.group ?? ''); + + if (group == null) { + return; + } + + void refreshAfterMutation(() => bulkMoveChannelsToGroup({ + channelIds: selectedChannels.map((channel) => channel.id), + group + })); + }; + + const renumberSelected = () => { + const firstNumber = window.prompt('First channel number', selectedChannels[0]?.number ?? '1'); + + if (firstNumber == null) { + return; + } + + const start = Number.parseFloat(firstNumber); + + if (!Number.isFinite(start)) { + setMutationError('First channel number must be numeric'); + return; + } + + void refreshAfterMutation(() => bulkRenumberChannels({ + channels: selectedChannels.map((channel, index) => ({ + id: channel.id, + number: formatChannelNumber(start + index) + })) + })); + }; + + const deleteSelected = () => { + if (!window.confirm(`Delete ${selectedChannels.length} selected channel${selectedChannels.length === 1 ? '' : 's'}?`)) { + return; + } + + void refreshAfterMutation(() => bulkDeleteChannels({ channelIds: selectedChannels.map((channel) => channel.id) })); + }; + + const changeFilter = (nextFilter: ChannelViewFilter) => { + setFilter(nextFilter); + setSelectedIds(new Set()); + }; + + const deleteOne = (channel: ChannelSummary) => { + if (!window.confirm(`Delete ${channel.name}?`)) { + return; + } + + void refreshAfterMutation(() => deleteChannel(channel.id)); + }; + + // Blazor parity (ChannelEditor.razor "add" branch): a blank channel is created with these + // defaults, then the editor opens for the rest of the fields. Distinct from "Add Channel", + // which is the library-to-lineup ChannelBuilder flow (untouched here). + const createBlankChannel = async () => { + if (creatingBlank) { + return; + } + + setMutationError(null); + setCreatingBlank(true); + + try { + const ffmpegSettings = await getFfmpegSettings(); + const maxNumber = channels.reduce((max, channel) => { + const parsed = Number.parseInt(channel.number.split('.')[0] ?? '', 10); + return Number.isFinite(parsed) ? Math.max(max, parsed) : max; + }, 0); + + const body: CreateChannelRequest = { + categories: null, + fallbackFillerId: null, + ffmpegProfileId: ffmpegSettings.defaultFFmpegProfileId, + group: 'ErsatzTV', + idleBehavior: 'StopOnDisconnect', + isEnabled: true, + logo: { path: null, contentType: null }, + mirrorSourceChannelId: null, + musicVideoCreditsMode: 'None', + musicVideoCreditsTemplate: null, + name: 'New Channel', + number: String(maxNumber + 1), + playoutMode: 'Continuous', + playoutOffset: null, + playoutSource: 'Generated', + preferredAudioLanguageCode: null, + preferredAudioTitle: null, + preferredSubtitleLanguageCode: null, + showInEpg: true, + slugSeconds: null, + songVideoMode: 'Default', + streamingMode: 'TransportStreamHybrid', + streamSelector: null, + streamSelectorMode: 'Default', + subtitleMode: 'None', + transcodeMode: 'OnDemand', + watermarkId: null + }; + + const created = await createChannel(body); + navigateToPath(`/app/edit-channel/${created.id}`); + } catch (error: unknown) { + setMutationError(messageFromError(error)); + } finally { + setCreatingBlank(false); + } + }; + + if (channels.length === 0) { + return ( + void createBlankChannel()} + /> + ); + } + + return ( +
+
+ {selectedChannels.length > 0 ? ( + <> + {selectedChannels.length} selected + + + + + + + + ) : ( + <> +
+ + + +
+ + {onAirCount} on air + + + + )} +
+ + {mutationError && ( +
+
+ )} + +
+
+ + + + + + + + + + + + + {groups.map(({ group, rows }) => ( + + ))} + +
No.ChannelNow playingStreamingFFmpeg +
+
+
+ {visibleChannels.length} of {channels.length} channels + {groups.length} groups +
+
+
+ ); +} + +function ChannelGroupRows({ + channels, + group, + mutating, + onDelete, + onToggle, + selectedIds, + statesById +}: { + channels: ChannelSummary[]; + group: string; + mutating: boolean; + onDelete: (channel: ChannelSummary) => void; + onToggle: (channelId: number) => void; + selectedIds: Set; + statesById: Map; +}) { + return ( + <> + + + + + + {channels.map((channel) => ( + + ))} + + ); +} + +function formatChannelNumber(value: number): string { + return Number.isInteger(value) ? `${value}` : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} + +function ChannelTableRow({ + channel, + mutating, + onDelete, + onToggle, + selected, + state +}: { + channel: ChannelSummary; + mutating: boolean; + onDelete: (channel: ChannelSummary) => void; + onToggle: (channelId: number) => void; + selected: boolean; + state: ChannelState | null; +}) { + const live = state?.onAir === true; + const dim = !channel.isEnabled; + const progress = state ? progressFromChannelState(state) : null; + const streamingTone = streamingModeBadgeTones[channel.streamingMode] ?? 'neutral'; + + return ( + + + onToggle(channel.id)} /> + + + {live && }{channel.number} + + +
+ +
+ {channel.name} + + {!channel.isEnabled && D} + {!channel.showInEpg && H} + + {channel.language || 'Language unset'} +
+
+ + + {state?.nowPlaying ? ( +
+ {state.nowPlaying.title} + +
+ ) : ( + {channel.isEnabled ? 'Idle' : 'Off air'} + )} + + {channel.streamingMode} + {channel.ffmpegProfile || 'Unassigned'} + +
+ + + navigateToPath(`/app/edit-channel/${channel.id}`)} size="sm" title={`Edit ${channel.name}`}> + + navigateToPath(`/app/troubleshooting/playback?channel=${channel.id}`)} + size="sm" + title={`Troubleshoot ${channel.name}`} + > + + onDelete(channel)} size="sm" title={`Delete ${channel.name}`}> + +
+ + + ); +}