feat(tv): add TV show frontend components

- Add TVShow, Season, Episode TypeScript types
- Add TV state management (actions, reducers, selectors)
- Add TVShowIndex and TVShowIndexRow components
- Add TVShowDetails and TVShowDetailsPage components
- Add TV routes to AppRoutes
- Add TV Shows sidebar navigation
- Add TV icon to icons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
admin 2025-12-29 16:09:21 -06:00
parent b9e086cca6
commit 89815c1d7a
21 changed files with 678 additions and 0 deletions

View file

@ -42,6 +42,8 @@ import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import Updates from 'System/Updates/Updates';
import TVShowDetailsPage from 'TVShow/Details/TVShowDetailsPage';
import TVShowIndex from 'TVShow/Index/TVShowIndex';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmet from 'Wanted/CutoffUnmet/CutoffUnmet';
import Missing from 'Wanted/Missing/Missing';
@ -124,6 +126,14 @@ function AppRoutes() {
<Route path="/bookseries/:id" component={BookSeriesDetailsPage} />
{/*
TV Shows
*/}
<Route exact={true} path="/tvshows" component={TVShowIndex} />
<Route path="/tvshow/:id" component={TVShowDetailsPage} />
{/*
Calendar
*/}

View file

@ -11,6 +11,7 @@ import CaptchaAppState from './CaptchaAppState';
import CommandAppState from './CommandAppState';
import CustomFiltersAppState from './CustomFiltersAppState';
import DashboardAppState from './DashboardAppState';
import EpisodesAppState from './EpisodesAppState';
import ExtraFilesAppState from './ExtraFilesAppState';
import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
@ -28,9 +29,11 @@ import ProviderOptionsAppState from './ProviderOptionsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
import SeasonsAppState from './SeasonsAppState';
import SettingsAppState from './SettingsAppState';
import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState';
import TVShowsAppState from './TVShowsAppState';
import WantedAppState from './WantedAppState';
interface FilterBuilderPropOption {
@ -99,6 +102,7 @@ interface AppState {
commands: CommandAppState;
customFilters: CustomFiltersAppState;
dashboard: DashboardAppState;
episodes: EpisodesAppState;
extraFiles: ExtraFilesAppState;
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
@ -118,9 +122,11 @@ interface AppState {
releases: ReleasesAppState;
rootFolders: RootFolderAppState;
bookSeries: BookSeriesAppState;
seasons: SeasonsAppState;
settings: SettingsAppState;
system: SystemAppState;
tags: TagsAppState;
tvShows: TVShowsAppState;
wanted: WantedAppState;
}

View file

@ -0,0 +1,12 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Episode from 'TVShow/Episode';
interface EpisodesAppState
extends AppSectionState<Episode>, AppSectionDeleteState, AppSectionSaveState {
pendingChanges: Partial<Episode>;
}
export default EpisodesAppState;

View file

@ -0,0 +1,12 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Season from 'TVShow/Season';
interface SeasonsAppState
extends AppSectionState<Season>, AppSectionDeleteState, AppSectionSaveState {
pendingChanges: Partial<Season>;
}
export default SeasonsAppState;

View file

@ -0,0 +1,12 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import TVShow from 'TVShow/TVShow';
interface TVShowsAppState
extends AppSectionState<TVShow>, AppSectionDeleteState, AppSectionSaveState {
pendingChanges: Partial<TVShow>;
}
export default TVShowsAppState;

View file

@ -119,6 +119,12 @@ const LINKS: SidebarItem[] = [
to: '/bookseries',
},
{
iconName: icons.TV,
title: () => translate('TVShows'),
to: '/tvshows',
},
{
iconName: icons.CALENDAR,
title: () => translate('Calendar'),

View file

@ -117,6 +117,7 @@ import {
faTimes as fasTimes,
faTimesCircle as fasTimesCircle,
faTrashAlt as fasTrashAlt,
faTv as fasTv,
faUser as fasUser,
faUserPlus as fasUserPlus,
faVial as fasVial,
@ -258,3 +259,4 @@ export const WARNING = fasExclamationTriangle;
export const WIKI = fasBookReader;
export const BLOCKLIST = fasBan;
export const BOOK = fasBook;
export const TV = fasTv;

View file

@ -0,0 +1,50 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createSaveProviderHandler from './Creators/createSaveProviderHandler';
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
export const section = 'episodes';
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
sortKey: 'episodeNumber',
sortDirection: sortDirections.ASCENDING,
pendingChanges: {}
};
export const FETCH_EPISODES = 'episodes/fetchEpisodes';
export const SET_EPISODE_VALUE = 'episodes/setEpisodeValue';
export const SAVE_EPISODE = 'episodes/saveEpisode';
export const DELETE_EPISODE = 'episodes/deleteEpisode';
export const fetchEpisodes = createThunk(FETCH_EPISODES);
export const saveEpisode = createThunk(SAVE_EPISODE);
export const deleteEpisode = createThunk(DELETE_EPISODE);
export const setEpisodeValue = createAction(SET_EPISODE_VALUE, (payload) => {
return {
section,
...payload
};
});
export const actionHandlers = handleThunks({
[FETCH_EPISODES]: createFetchHandler(section, '/episode'),
[SAVE_EPISODE]: createSaveProviderHandler(section, '/episode'),
[DELETE_EPISODE]: createRemoveItemHandler(section, '/episode')
});
export const reducers = createHandleActions({
[SET_EPISODE_VALUE]: createSetSettingValueReducer(section)
}, defaultState, section);

View file

@ -12,6 +12,7 @@ import * as captcha from './captchaActions';
import * as commands from './commandActions';
import * as customFilters from './customFilterActions';
import * as discoverMovie from './discoverMovieActions';
import * as episodes from './episodeActions';
import * as extraFiles from './extraFileActions';
import * as history from './historyActions';
import * as importMovie from './importMovieActions';
@ -32,9 +33,11 @@ import * as queue from './queueActions';
import * as releases from './releaseActions';
import * as rootFolders from './rootFolderActions';
import * as bookSeries from './bookSeriesActions';
import * as seasons from './seasonActions';
import * as settings from './settingsActions';
import * as system from './systemActions';
import * as tags from './tagActions';
import * as tvShows from './tvShowActions';
import * as wanted from './wantedActions';
export default [
@ -52,6 +55,7 @@ export default [
commands,
customFilters,
discoverMovie,
episodes,
movieFiles,
extraFiles,
history,
@ -72,8 +76,10 @@ export default [
movieIndex,
movieCredits,
bookSeries,
seasons,
settings,
system,
tags,
tvShows,
wanted
];

View file

@ -0,0 +1,50 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createSaveProviderHandler from './Creators/createSaveProviderHandler';
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
export const section = 'seasons';
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
sortKey: 'seasonNumber',
sortDirection: sortDirections.ASCENDING,
pendingChanges: {}
};
export const FETCH_SEASONS = 'seasons/fetchSeasons';
export const SET_SEASON_VALUE = 'seasons/setSeasonValue';
export const SAVE_SEASON = 'seasons/saveSeason';
export const DELETE_SEASON = 'seasons/deleteSeason';
export const fetchSeasons = createThunk(FETCH_SEASONS);
export const saveSeason = createThunk(SAVE_SEASON);
export const deleteSeason = createThunk(DELETE_SEASON);
export const setSeasonValue = createAction(SET_SEASON_VALUE, (payload) => {
return {
section,
...payload
};
});
export const actionHandlers = handleThunks({
[FETCH_SEASONS]: createFetchHandler(section, '/season'),
[SAVE_SEASON]: createSaveProviderHandler(section, '/season'),
[DELETE_SEASON]: createRemoveItemHandler(section, '/season')
});
export const reducers = createHandleActions({
[SET_SEASON_VALUE]: createSetSettingValueReducer(section)
}, defaultState, section);

View file

@ -0,0 +1,50 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createSaveProviderHandler from './Creators/createSaveProviderHandler';
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
export const section = 'tvShows';
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
sortKey: 'sortTitle',
sortDirection: sortDirections.ASCENDING,
pendingChanges: {}
};
export const FETCH_TV_SHOWS = 'tvShows/fetchTVShows';
export const SET_TV_SHOW_VALUE = 'tvShows/setTVShowValue';
export const SAVE_TV_SHOW = 'tvShows/saveTVShow';
export const DELETE_TV_SHOW = 'tvShows/deleteTVShow';
export const fetchTVShows = createThunk(FETCH_TV_SHOWS);
export const saveTVShow = createThunk(SAVE_TV_SHOW);
export const deleteTVShow = createThunk(DELETE_TV_SHOW);
export const setTVShowValue = createAction(SET_TV_SHOW_VALUE, (payload) => {
return {
section,
...payload
};
});
export const actionHandlers = handleThunks({
[FETCH_TV_SHOWS]: createFetchHandler(section, '/tvshow'),
[SAVE_TV_SHOW]: createSaveProviderHandler(section, '/tvshow'),
[DELETE_TV_SHOW]: createRemoveItemHandler(section, '/tvshow')
});
export const reducers = createHandleActions({
[SET_TV_SHOW_VALUE]: createSetSettingValueReducer(section)
}, defaultState, section);

View file

@ -0,0 +1,13 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createAllTVShowsSelector() {
return createSelector(
(state: AppState) => state.tvShows,
(tvShows) => {
return tvShows.items;
}
);
}
export default createAllTVShowsSelector;

View file

@ -0,0 +1,58 @@
.container {
padding: 20px;
}
.header {
display: flex;
align-items: flex-start;
margin-bottom: 20px;
}
.title {
font-size: 32px;
font-weight: 300;
margin: 0 0 10px;
}
.details {
margin-top: 20px;
}
.detailRow {
display: flex;
margin-bottom: 10px;
}
.label {
width: 150px;
font-weight: 500;
color: var(--labelColor);
}
.value {
flex: 1;
}
.overview {
margin-top: 20px;
padding: 15px;
background-color: var(--tableRowBackgroundColor);
border-radius: 4px;
}
.monitoredIcon {
margin-left: 10px;
}
.genres {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.genre {
background-color: var(--tableRowBackgroundColor);
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
}

View file

@ -0,0 +1,17 @@
interface CSSModule {
readonly container: string;
readonly header: string;
readonly title: string;
readonly details: string;
readonly detailRow: string;
readonly label: string;
readonly value: string;
readonly overview: string;
readonly monitoredIcon: string;
readonly genres: string;
readonly genre: string;
}
declare const styles: CSSModule;
export default styles;

View file

@ -0,0 +1,123 @@
import React from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Icon from 'Components/Icon';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './TVShowDetails.css';
interface TVShowDetailsProps {
readonly tvShowId: number;
}
function TVShowDetails({ tvShowId }: Readonly<TVShowDetailsProps>) {
const tvShow = useSelector((state: AppState) =>
state.tvShows.items.find((s) => s.id === tvShowId)
);
if (!tvShow) {
return null;
}
const {
title,
year,
network,
status,
overview,
monitored,
seriesType,
isAnime,
genres,
runtime,
certification,
firstAired,
} = tvShow;
return (
<PageContent title={title}>
<PageContentBody>
<div className={styles.container}>
<div className={styles.header}>
<h1 className={styles.title}>
{title} ({year})
<Icon
className={styles.monitoredIcon}
name={monitored ? icons.MONITORED : icons.UNMONITORED}
title={monitored ? 'Monitored' : 'Unmonitored'}
size={24}
/>
</h1>
</div>
<div className={styles.details}>
{network && (
<div className={styles.detailRow}>
<span className={styles.label}>{translate('Network')}:</span>
<span className={styles.value}>{network}</span>
</div>
)}
<div className={styles.detailRow}>
<span className={styles.label}>{translate('Status')}:</span>
<span className={styles.value}>{status}</span>
</div>
<div className={styles.detailRow}>
<span className={styles.label}>{translate('SeriesType')}:</span>
<span className={styles.value}>
{seriesType} {isAnime && '(Anime)'}
</span>
</div>
{runtime && (
<div className={styles.detailRow}>
<span className={styles.label}>{translate('Runtime')}:</span>
<span className={styles.value}>{runtime} min</span>
</div>
)}
{certification && (
<div className={styles.detailRow}>
<span className={styles.label}>
{translate('Certification')}:
</span>
<span className={styles.value}>{certification}</span>
</div>
)}
{firstAired && (
<div className={styles.detailRow}>
<span className={styles.label}>{translate('FirstAired')}:</span>
<span className={styles.value}>{firstAired}</span>
</div>
)}
{genres && genres.length > 0 && (
<div className={styles.detailRow}>
<span className={styles.label}>{translate('Genres')}:</span>
<div className={styles.genres}>
{genres.map((genre) => (
<span key={genre} className={styles.genre}>
{genre}
</span>
))}
</div>
</div>
)}
</div>
{overview && (
<div className={styles.overview}>
<p>{overview}</p>
</div>
)}
</div>
</PageContentBody>
</PageContent>
);
}
export default TVShowDetails;

View file

@ -0,0 +1,37 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useHistory, useParams } from 'react-router';
import NotFound from 'Components/NotFound';
import usePrevious from 'Helpers/Hooks/usePrevious';
import createAllTVShowsSelector from 'Store/Selectors/createAllTVShowsSelector';
import translate from 'Utilities/String/translate';
import TVShowDetails from './TVShowDetails';
function TVShowDetailsPage() {
const allTVShows = useSelector(createAllTVShowsSelector());
const { id } = useParams<{ id: string }>();
const history = useHistory();
const tvShowId = Number.parseInt(id);
const tvShowIndex = allTVShows.findIndex((tvShow) => tvShow.id === tvShowId);
const previousIndex = usePrevious(tvShowIndex);
useEffect(() => {
if (
tvShowIndex === -1 &&
previousIndex !== -1 &&
previousIndex !== undefined
) {
history.push(`${window.Radarr.urlBase}/tvshows`);
}
}, [tvShowIndex, previousIndex, history]);
if (tvShowIndex === -1) {
return <NotFound message={translate('TVShowCannotBeFound')} />;
}
return <TVShowDetails tvShowId={allTVShows[tvShowIndex].id} />;
}
export default TVShowDetailsPage;

View file

@ -0,0 +1,30 @@
import ModelBase from 'App/ModelBase';
interface Episode extends ModelBase {
tvShowId?: number;
seasonId?: number;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber?: number;
sceneSeasonNumber?: number;
sceneEpisodeNumber?: number;
sceneAbsoluteEpisodeNumber?: number;
title?: string;
overview?: string;
airDate?: string;
airDateUtc?: string;
runtime?: number;
isSpecial: boolean;
unverifiedSceneNumbering: boolean;
episodeFileId?: number;
monitored: boolean;
qualityProfileId: number;
path?: string;
rootFolderPath?: string;
added: string;
tags: number[];
lastSearchTime?: string;
isSaving?: boolean;
}
export default Episode;

View file

@ -0,0 +1,103 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds } from 'Helpers/Props';
import { fetchTVShows } from 'Store/Actions/tvShowActions';
import translate from 'Utilities/String/translate';
import TVShowIndexRow from './TVShowIndexRow';
const columns = [
{
name: 'title',
label: () => translate('Title'),
isVisible: true,
},
{
name: 'network',
label: () => translate('Network'),
isVisible: true,
},
{
name: 'status',
label: () => translate('Status'),
isVisible: true,
},
{
name: 'year',
label: () => translate('Year'),
isVisible: true,
},
{
name: 'monitored',
label: () => translate('Monitored'),
isVisible: true,
},
];
function TVShowIndex() {
const dispatch = useDispatch();
const { isFetching, isPopulated, error, items } = useSelector(
(state: AppState) => state.tvShows
);
useEffect(() => {
dispatch(fetchTVShows());
}, [dispatch]);
const onRefreshPress = useCallback(() => {
dispatch(fetchTVShows());
}, [dispatch]);
const hasNoTVShows = isPopulated && !items.length;
return (
<PageContent title={translate('TVShows')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RefreshAll')}
iconName={icons.REFRESH}
isSpinning={isFetching}
onPress={onRefreshPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('UnableToLoadTVShows')}</Alert>
) : null}
{isPopulated && !error && items.length > 0 ? (
<Table columns={columns}>
<TableBody>
{items.map((tvShow) => (
<TVShowIndexRow key={tvShow.id} {...tvShow} />
))}
</TableBody>
</Table>
) : null}
{hasNoTVShows ? (
<div style={{ padding: '20px', textAlign: 'center' }}>
<p>{translate('NoTVShows')}</p>
<p>Add TV shows to track your series.</p>
</div>
) : null}
</PageContentBody>
</PageContent>
);
}
export default TVShowIndex;

View file

@ -0,0 +1,30 @@
import React from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons } from 'Helpers/Props';
import TVShow from 'TVShow/TVShow';
function TVShowIndexRow(props: TVShow) {
const { id, title, network, status, year, monitored } = props;
return (
<TableRow>
<TableRowCell>
<Link to={`/tvshow/${id}`}>{title}</Link>
</TableRowCell>
<TableRowCell>{network || '-'}</TableRowCell>
<TableRowCell>{status}</TableRowCell>
<TableRowCell>{year}</TableRowCell>
<TableRowCell>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
title={monitored ? 'Monitored' : 'Unmonitored'}
/>
</TableRowCell>
</TableRow>
);
}
export default TVShowIndexRow;

View file

@ -0,0 +1,12 @@
import ModelBase from 'App/ModelBase';
interface Season extends ModelBase {
tvShowId?: number;
seasonNumber: number;
title?: string;
overview?: string;
monitored: boolean;
isSaving?: boolean;
}
export default Season;

View file

@ -0,0 +1,39 @@
import ModelBase from 'App/ModelBase';
export type TVShowStatus = 'continuing' | 'ended' | 'upcoming' | 'canceled';
export type SeriesType = 'standard' | 'daily' | 'anime';
interface TVShow extends ModelBase {
tvdbId?: number;
tmdbId?: number;
imdbId?: string;
aniDbId?: number;
title: string;
sortTitle: string;
cleanTitle: string;
overview?: string;
network?: string;
status: TVShowStatus;
runtime?: number;
airTime?: string;
certification?: string;
firstAired?: string;
year: number;
genres: string[];
originalLanguage?: string;
isAnime: boolean;
seriesType: SeriesType;
useSceneNumbering: boolean;
path?: string;
rootFolderPath?: string;
qualityProfileId: number;
seasonFolder: boolean;
monitored: boolean;
monitorNewItems: boolean;
added: string;
tags: number[];
lastSearchTime?: string;
isSaving?: boolean;
}
export default TVShow;