Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 8m37s
New `/app/auto-tune` screen driving the PR1 endpoints: pick metadata axes (TV Shows / TV Genres / Movie Genres) + defaults, preview proposed channels grouped by axis with per-row/per-group selection and already-exists dedup, then bulk-create with a Created/Skipped/Failed summary. Additive/non-destructive. The per-channel DetailPanel from the design iteration is deferred to #383 (new endpoints backlogged) — this ships the 3-step wizard only. - web/src/api/autoTune.ts (+test), web/src/screens/AutoTuneScreen.tsx (+test) - route/nav (routes.tsx, ScreenContent.tsx), api barrel, App nav-smoke test - docs: domain-model (route), blazor-route-parity (net-new SPA screen) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
8.6 KiB
TypeScript
229 lines
8.6 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Info } from 'lucide-react';
|
|
import type { DashboardHealthQueryState } from '../api';
|
|
import { ChannelBuilderScreen } from '../builder/ChannelBuilder';
|
|
import { Card } from '../components';
|
|
import {
|
|
parseLibrariesSubRoute,
|
|
parseMediaSubRoute
|
|
} from '../routing';
|
|
import { ApiKeyScreen } from '../screens/ApiKeyScreen';
|
|
import { AutoTuneScreen } from '../screens/AutoTuneScreen';
|
|
import { BlockPlayoutTroubleshootingScreen } from '../screens/BlockPlayoutTroubleshootingScreen';
|
|
import { BlocksScreen } from '../screens/BlocksScreen';
|
|
import { ChannelEditScreen } from '../screens/ChannelEditScreen';
|
|
import { ChannelsScreen } from '../screens/ChannelsScreen';
|
|
import { CollectionsScreen } from '../screens/CollectionsScreen';
|
|
import { DashboardScreen } from '../screens/DashboardScreen';
|
|
import { DecoTemplatesScreen } from '../screens/DecoTemplatesScreen';
|
|
import { DecosScreen } from '../screens/DecosScreen';
|
|
import { FFmpegProfilesScreen } from '../screens/FFmpegProfilesScreen';
|
|
import { FillerPresetsScreen } from '../screens/FillerPresetsScreen';
|
|
import { GuideScreen } from '../screens/GuideScreen';
|
|
import { ImageBrowserScreen } from '../screens/ImageBrowserScreen';
|
|
import { LibrariesScreen } from '../screens/LibrariesScreen';
|
|
import { LocalLibraryEditScreen } from '../screens/LocalLibraryEditScreen';
|
|
import { LogsScreen } from '../screens/LogsScreen';
|
|
import { MediaBrowseScreen } from '../screens/MediaBrowseScreen';
|
|
import {
|
|
ArtistDetailScreen,
|
|
MovieDetailScreen,
|
|
SeasonDetailScreen,
|
|
ShowDetailScreen
|
|
} from '../screens/MediaDetailScreen';
|
|
import { MultiCollectionsScreen } from '../screens/MultiCollectionsScreen';
|
|
import { PathReplacementsEditScreen } from '../screens/PathReplacementsEditScreen';
|
|
import { PlaybackTroubleshootingScreen } from '../screens/PlaybackTroubleshootingScreen';
|
|
import { PlexSourceScreen } from '../screens/PlexSourceScreen';
|
|
import { PlaylistsScreen } from '../screens/PlaylistsScreen';
|
|
import { PlayoutsRouteScreen } from '../screens/PlayoutsScreen';
|
|
import { RemoteConnectionEditScreen } from '../screens/RemoteConnectionEditScreen';
|
|
import { RemoteLibrariesEditScreen } from '../screens/RemoteLibrariesEditScreen';
|
|
import { RemoteSourceScreen } from '../screens/RemoteSourceScreen';
|
|
import { RerunCollectionsScreen } from '../screens/RerunCollectionsScreen';
|
|
import { SchedulesScreen } from '../screens/SchedulesScreen';
|
|
import { SearchScreen } from '../screens/SearchScreen';
|
|
import { SettingsScreen } from '../screens/SettingsScreen';
|
|
import { TemplatesScreen } from '../screens/TemplatesScreen';
|
|
import { TraktListsScreen } from '../screens/TraktListsScreen';
|
|
import { TrashScreen } from '../screens/TrashScreen';
|
|
import { TroubleshootingScreen } from '../screens/TroubleshootingScreen';
|
|
import { WatermarksScreen } from '../screens/WatermarksScreen';
|
|
import { YamlValidatorScreen } from '../screens/YamlValidatorScreen';
|
|
import {
|
|
normalizeAppPath,
|
|
type ScreenId,
|
|
type ScreenRoute
|
|
} from './routes';
|
|
|
|
function NotFoundScreen() {
|
|
const pathname = normalizeAppPath(window.location.pathname);
|
|
|
|
return (
|
|
<div className="ctv-screen-stack">
|
|
<Card title={<h2>Unknown app route</h2>} subtitle="This URL does not match a ChicoryTV screen.">
|
|
<div className="ctv-placeholder-layout">
|
|
<div className="ctv-placeholder-icon"><Info aria-hidden="true" size={20} /></div>
|
|
<div>
|
|
<h3>Requested path</h3>
|
|
<p>{pathname}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The Media nav entry owns detail sub-pages (/app/media/{movies|shows|seasons|artists}/{id}) and the
|
|
// image folder browser (/app/media/images/browser). Like PlayoutsRouteScreen, this wrapper tracks
|
|
// pathname locally + listens for popstate, because routeFromLocation() returns the SAME 'media'
|
|
// ScreenRoute object for the base grid and every sub-path (Object.is bails App's setActiveRoute).
|
|
export function MediaRouteScreen() {
|
|
const [pathname, setPathname] = useState(() => window.location.pathname);
|
|
const [search, setSearch] = useState(() => window.location.search);
|
|
|
|
useEffect(() => {
|
|
const onPopState = () => {
|
|
setPathname(window.location.pathname);
|
|
setSearch(window.location.search);
|
|
};
|
|
window.addEventListener('popstate', onPopState);
|
|
return () => window.removeEventListener('popstate', onPopState);
|
|
}, []);
|
|
|
|
const sub = parseMediaSubRoute(pathname);
|
|
|
|
if (sub?.kind === 'movie') {
|
|
return <MovieDetailScreen key={pathname} id={sub.id} />;
|
|
}
|
|
if (sub?.kind === 'show') {
|
|
return <ShowDetailScreen key={pathname} id={sub.id} />;
|
|
}
|
|
if (sub?.kind === 'season') {
|
|
return <SeasonDetailScreen key={pathname} id={sub.id} />;
|
|
}
|
|
if (sub?.kind === 'artist') {
|
|
return <ArtistDetailScreen key={pathname} id={sub.id} />;
|
|
}
|
|
if (sub?.kind === 'images') {
|
|
return <ImageBrowserScreen key={pathname} />;
|
|
}
|
|
|
|
return <MediaBrowseScreen key={search} />;
|
|
}
|
|
|
|
// The Libraries nav entry owns the media-source editor sub-paths. CRITICAL (design §D.2, finding 4):
|
|
// unlike PlayoutsRouteScreen/MediaRouteScreen this wrapper does NOT register its own `popstate`
|
|
// listener. Its editor sub-screens register a dirty guard, so App must approve the path first and
|
|
// pass it down. This wrapper never reads a raw navigation event.
|
|
export function LibrariesRouteScreen({ subPath }: { subPath: string }) {
|
|
const sub = parseLibrariesSubRoute(subPath);
|
|
|
|
if (sub === null) {
|
|
return <LibrariesScreen />;
|
|
}
|
|
|
|
switch (sub.kind) {
|
|
case 'local-new':
|
|
case 'local-edit':
|
|
return <LocalLibraryEditScreen key={subPath} />;
|
|
case 'remote-source':
|
|
return sub.family === 'plex'
|
|
? <PlexSourceScreen key={subPath} />
|
|
: <RemoteSourceScreen key={subPath} family={sub.family} />;
|
|
case 'remote-connection':
|
|
return <RemoteConnectionEditScreen key={subPath} family={sub.family} />;
|
|
case 'remote-libraries':
|
|
return <RemoteLibrariesEditScreen key={subPath} family={sub.family} sourceId={sub.id} />;
|
|
case 'remote-path-replacements':
|
|
return <PathReplacementsEditScreen key={subPath} family={sub.family} sourceId={sub.id} />;
|
|
}
|
|
}
|
|
|
|
function assertNeverScreen(screenId: never): never {
|
|
throw new Error(`Unhandled screen route: ${String(screenId)}`);
|
|
}
|
|
|
|
export function ScreenContent({
|
|
healthState,
|
|
librariesSubPath,
|
|
route
|
|
}: {
|
|
healthState: DashboardHealthQueryState;
|
|
librariesSubPath: string;
|
|
route: ScreenRoute | null;
|
|
}) {
|
|
if (route === null) {
|
|
return <NotFoundScreen />;
|
|
}
|
|
|
|
const routeId: ScreenId = route.id;
|
|
|
|
switch (routeId) {
|
|
case 'dashboard':
|
|
return <DashboardScreen healthState={healthState} />;
|
|
case 'channels':
|
|
return <ChannelsScreen />;
|
|
case 'builder':
|
|
return <ChannelBuilderScreen />;
|
|
case 'autoTune':
|
|
return <AutoTuneScreen />;
|
|
case 'editChannel':
|
|
return <ChannelEditScreen key={window.location.pathname} />;
|
|
case 'guide':
|
|
return <GuideScreen />;
|
|
case 'schedules':
|
|
return <SchedulesScreen />;
|
|
case 'blocks':
|
|
return <BlocksScreen key={window.location.pathname} />;
|
|
case 'templates':
|
|
return <TemplatesScreen key={window.location.pathname} />;
|
|
case 'decos':
|
|
return <DecosScreen key={window.location.pathname} />;
|
|
case 'decoTemplates':
|
|
return <DecoTemplatesScreen key={window.location.pathname} />;
|
|
case 'playouts':
|
|
return <PlayoutsRouteScreen />;
|
|
case 'libraries':
|
|
return <LibrariesRouteScreen subPath={librariesSubPath} />;
|
|
case 'settings':
|
|
return <SettingsScreen />;
|
|
case 'apiKey':
|
|
return <ApiKeyScreen />;
|
|
case 'media':
|
|
return <MediaRouteScreen />;
|
|
case 'search':
|
|
return <SearchScreen key={window.location.search} />;
|
|
case 'trash':
|
|
return <TrashScreen />;
|
|
case 'collections':
|
|
return <CollectionsScreen />;
|
|
case 'multiCollections':
|
|
return <MultiCollectionsScreen />;
|
|
case 'rerunCollections':
|
|
return <RerunCollectionsScreen />;
|
|
case 'playlists':
|
|
return <PlaylistsScreen />;
|
|
case 'traktLists':
|
|
return <TraktListsScreen key={window.location.pathname} />;
|
|
case 'fillerPresets':
|
|
return <FillerPresetsScreen />;
|
|
case 'ffmpegProfiles':
|
|
return <FFmpegProfilesScreen />;
|
|
case 'watermarks':
|
|
return <WatermarksScreen />;
|
|
case 'logs':
|
|
return <LogsScreen />;
|
|
case 'troubleshooting':
|
|
return <TroubleshootingScreen />;
|
|
case 'blockPlayoutTroubleshooting':
|
|
return <BlockPlayoutTroubleshootingScreen />;
|
|
case 'playbackTroubleshooting':
|
|
return <PlaybackTroubleshootingScreen key={window.location.search} />;
|
|
case 'yamlValidator':
|
|
return <YamlValidatorScreen />;
|
|
default:
|
|
return assertNeverScreen(routeId);
|
|
}
|
|
}
|