From c6f394ccd7b5a97909787999216bce0d6bc2c740 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Wed, 15 Apr 2026 16:46:51 -0700 Subject: [PATCH] New: Average Size per Episode column on Series list Closes #8513 --- .../Index/Menus/SeriesIndexSortMenu.tsx | 9 ++++++ .../src/Series/Index/Table/SeriesIndexRow.css | 6 ++++ .../Index/Table/SeriesIndexRow.css.d.ts | 1 + .../src/Series/Index/Table/SeriesIndexRow.tsx | 11 ++++++++ .../Index/Table/SeriesIndexTableHeader.css | 6 ++++ .../Table/SeriesIndexTableHeader.css.d.ts | 1 + frontend/src/Series/seriesOptionsStore.ts | 6 ++++ frontend/src/Series/useSeries.ts | 28 +++++++++++++++++++ src/NzbDrone.Core/Localization/Core/en.json | 2 ++ 9 files changed, 70 insertions(+) diff --git a/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx index 29805a9bd..d99645c8a 100644 --- a/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx +++ b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx @@ -154,6 +154,15 @@ function SeriesIndexSortMenu(props: SeriesIndexSortMenuProps) { {translate('SizeOnDisk')} + + {translate('AverageSizePerEpisode')} + + 0 ? sizeOnDisk / totalEpisodeCount : 0; + + return ( + + {averageSize ? formatBytes(averageSize) : null} + + ); + } + if (name === 'genres') { const joinedGenres = genres.join(', '); diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css index df574576e..a37cb5081 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css @@ -92,6 +92,12 @@ flex: 0 0 120px; } +.averageSizePerEpisode { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 160px; +} + .ratings { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts index f30a9e786..1b566399d 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts @@ -3,6 +3,7 @@ interface CssExports { 'actions': string; 'added': string; + 'averageSizePerEpisode': string; 'banner': string; 'bannerGrow': string; 'certification': string; diff --git a/frontend/src/Series/seriesOptionsStore.ts b/frontend/src/Series/seriesOptionsStore.ts index 022bcb90d..b71c1cbb9 100644 --- a/frontend/src/Series/seriesOptionsStore.ts +++ b/frontend/src/Series/seriesOptionsStore.ts @@ -191,6 +191,12 @@ const { useOptions, useOption, setOptions, setOption, setSort, getOptions } = isSortable: true, isVisible: false, }, + { + name: 'averageSizePerEpisode', + label: () => translate('AverageSize'), + isSortable: true, + isVisible: false, + }, { name: 'genres', label: () => translate('Genres'), diff --git a/frontend/src/Series/useSeries.ts b/frontend/src/Series/useSeries.ts index 63497121d..b41428f2f 100644 --- a/frontend/src/Series/useSeries.ts +++ b/frontend/src/Series/useSeries.ts @@ -113,6 +113,14 @@ const SORT_PREDICATES = { return item.statistics?.sizeOnDisk ?? 0; }, + averageSizePerEpisode: (item: Series, _direction: SortDirection) => { + const totalEpisodeCount = item.statistics?.totalEpisodeCount ?? 0; + + return totalEpisodeCount > 0 + ? (item.statistics?.sizeOnDisk ?? 0) / totalEpisodeCount + : 0; + }, + network: (item: Series, _direction: SortDirection) => { const network = item.network; @@ -258,6 +266,20 @@ const FILTER_PREDICATES = { return predicate(sizeOnDisk, filterValue); }, + averageSizePerEpisode: ( + item: Series, + filterValue: number, + type: FilterType + ) => { + const predicate = getFilterTypePredicate(type); + const totalEpisodeCount = item.statistics?.totalEpisodeCount ?? 0; + const averageSize = + totalEpisodeCount > 0 + ? (item.statistics?.sizeOnDisk ?? 0) / totalEpisodeCount + : 0; + return predicate(averageSize, filterValue); + }, + hasMissingSeason: (item: Series, filterValue: boolean, type: FilterType) => { const predicate = getFilterTypePredicate(type); const seasons = item.seasons ?? []; @@ -444,6 +466,12 @@ export const FILTER_BUILDER: FilterBuilderProp[] = [ type: filterBuilderTypes.NUMBER, valueType: filterBuilderValueTypes.BYTES, }, + { + name: 'averageSizePerEpisode', + label: () => translate('AverageSizePerEpisode'), + type: filterBuilderTypes.NUMBER, + valueType: filterBuilderValueTypes.BYTES, + }, { name: 'genres', label: () => translate('Genres'), diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 51d7e3c78..5d0d58a94 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -148,6 +148,8 @@ "AutomaticAdd": "Automatic Add", "AutomaticSearch": "Automatic Search", "AutomaticUpdatesDisabledDocker": "Automatic updates are not directly supported when using the Docker update mechanism. You will need to update the container image outside of {appName} or use a script", + "AverageSize": "Average Size", + "AverageSizePerEpisode": "Average Size per Episode", "Backup": "Backup", "BackupFolderHelpText": "Relative paths will be under {appName}'s AppData directory", "BackupIntervalHelpText": "Interval between automatic backups",