From 39f043a96c017c19e6a65d2475466be66d6beea2 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 21 Apr 2026 19:45:38 +0300 Subject: [PATCH] New: Filter series by episode release types --- .../Filter/Builder/FilterBuilderRow.tsx | 4 ++ .../ReleaseTypeFilterBuilderRowValue.tsx | 45 +++++++++++++++++++ .../Helpers/Props/filterBuilderValueTypes.ts | 2 + .../src/Series/Index/Table/SeriesIndexRow.css | 1 + .../Index/Table/SeriesIndexRow.css.d.ts | 1 + .../src/Series/Index/Table/SeriesIndexRow.tsx | 21 +++++++++ .../Index/Table/SeriesIndexTableHeader.css | 1 + .../Table/SeriesIndexTableHeader.css.d.ts | 1 + frontend/src/Series/Series.ts | 2 + frontend/src/Series/seriesOptionsStore.ts | 6 +++ frontend/src/Series/useSeries.ts | 12 +++++ src/NzbDrone.Core/Localization/Core/en.json | 1 + .../SeriesStats/SeasonStatistics.cs | 21 +++++++++ .../SeriesStats/SeriesStatistics.cs | 2 + .../SeriesStats/SeriesStatisticsRepository.cs | 3 ++ .../SeriesStats/SeriesStatisticsService.cs | 1 + .../Series/SeasonStatisticsResource.cs | 3 ++ .../Series/SeriesStatisticsResource.cs | 3 ++ 18 files changed, 130 insertions(+) create mode 100644 frontend/src/Components/Filter/Builder/ReleaseTypeFilterBuilderRowValue.tsx diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx b/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx index 00aa58936..0f437a642 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx @@ -22,6 +22,7 @@ import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import QualityFilterBuilderRowValue from './QualityFilterBuilderRowValue'; import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue'; import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue'; +import ReleaseTypeFilterBuilderRowValue from './ReleaseTypeFilterBuilderRowValue'; import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue'; import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue'; import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue'; @@ -113,6 +114,9 @@ function getRowValueConnector( case filterBuilderValueTypes.MONITORED_STATUS: return MonitoredStatusFilterBuilderRowValue; + case filterBuilderValueTypes.RELEASE_TYPES: + return ReleaseTypeFilterBuilderRowValue; + case filterBuilderValueTypes.SERIES: return SeriesFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/ReleaseTypeFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/ReleaseTypeFilterBuilderRowValue.tsx new file mode 100644 index 000000000..d76c28110 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/ReleaseTypeFilterBuilderRowValue.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue, { + FilterBuilderRowValueProps, +} from './FilterBuilderRowValue'; + +const releaseTypeList = [ + { + id: 'unknown', + get name() { + return translate('Unknown'); + }, + }, + { + id: 'singleEpisode', + get name() { + return translate('SingleEpisode'); + }, + }, + { + id: 'multiEpisode', + get name() { + return translate('MultiEpisode'); + }, + }, + { + id: 'seasonPack', + get name() { + return translate('SeasonPack'); + }, + }, +]; + +type ReleaseTypeFilterBuilderRowValueProps = Omit< + FilterBuilderRowValueProps, + 'tagList' +>; + +function ReleaseTypeFilterBuilderRowValue( + props: ReleaseTypeFilterBuilderRowValueProps +) { + return ; +} + +export default ReleaseTypeFilterBuilderRowValue; diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.ts b/frontend/src/Helpers/Props/filterBuilderValueTypes.ts index 4a12865c2..0110b76b1 100644 --- a/frontend/src/Helpers/Props/filterBuilderValueTypes.ts +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.ts @@ -10,6 +10,7 @@ export const QUALITY = 'quality'; export const QUALITY_PROFILE = 'qualityProfile'; export const QUEUE_STATUS = 'queueStatus'; export const MONITORED_STATUS = 'monitoredStatus'; +export const RELEASE_TYPES = 'releaseTypes'; export const SERIES = 'series'; export const SERIES_STATUS = 'seriesStatus'; export const SERIES_TYPES = 'seriesType'; @@ -28,6 +29,7 @@ export type FilterBuildValueType = | 'qualityProfile' | 'queueStatus' | 'monitoredStatus' + | 'releaseTypes' | 'series' | 'seriesStatus' | 'seriesType' diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css b/frontend/src/Series/Index/Table/SeriesIndexRow.css index 4b4706a01..414f536bc 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css @@ -75,6 +75,7 @@ } .releaseGroups, +.releaseTypes, .nextAiring, .previousAiring, .added, diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts index 93da75bbf..eb9fbee78 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts @@ -27,6 +27,7 @@ interface CssExports { 'qualityProfileId': string; 'ratings': string; 'releaseGroups': string; + 'releaseTypes': string; 'seasonCount': string; 'seasonFolder': string; 'seriesType': string; diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx index 6a997af97..50faea08a 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx @@ -13,6 +13,7 @@ import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; import Column from 'Components/Table/Column'; +import getReleaseTypeName from 'Episode/getReleaseTypeName'; import { icons } from 'Helpers/Props'; import useCountryName from 'Internationalization/useCountryName'; import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; @@ -149,6 +150,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { totalEpisodeCount = 0, sizeOnDisk = 0, releaseGroups = [], + releaseTypes = [], episodeFileQualities = [], } = statistics; @@ -448,6 +450,25 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { ); } + if (name === 'releaseTypes') { + const joinedReleaseTypes = releaseTypes + .map(getReleaseTypeName) + .join(', '); + const truncatedReleaseTypes = + releaseTypes.length > 3 + ? `${releaseTypes + .slice(0, 3) + .map(getReleaseTypeName) + .join(', ')}...` + : joinedReleaseTypes; + + return ( + + {truncatedReleaseTypes} + + ); + } + if (name === 'episodeFileQualities') { const joinedQualities = episodeFileQualities .map((q) => q.name) diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css index 0fac2d75f..a624b1ff4 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css @@ -39,6 +39,7 @@ } .releaseGroups, +.releaseTypes, .nextAiring, .previousAiring, .added, diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts index 63fd01bbe..8c1ff9e03 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts @@ -22,6 +22,7 @@ interface CssExports { 'qualityProfileId': string; 'ratings': string; 'releaseGroups': string; + 'releaseTypes': string; 'seasonCount': string; 'seasonFolder': string; 'seriesType': string; diff --git a/frontend/src/Series/Series.ts b/frontend/src/Series/Series.ts index 9a7793abe..107614411 100644 --- a/frontend/src/Series/Series.ts +++ b/frontend/src/Series/Series.ts @@ -1,4 +1,5 @@ import ModelBase from 'App/ModelBase'; +import ReleaseType from 'InteractiveImport/ReleaseType'; import Language from 'Language/Language'; import Quality from 'Quality/Quality'; @@ -35,6 +36,7 @@ export interface Statistics { percentOfEpisodes: number; previousAiring?: Date; releaseGroups: string[]; + releaseTypes: ReleaseType[]; episodeFileQualities: Quality[]; sizeOnDisk: number; totalEpisodeCount: number; diff --git a/frontend/src/Series/seriesOptionsStore.ts b/frontend/src/Series/seriesOptionsStore.ts index 51a37e3e0..a833e9b5d 100644 --- a/frontend/src/Series/seriesOptionsStore.ts +++ b/frontend/src/Series/seriesOptionsStore.ts @@ -221,6 +221,12 @@ const { useOptions, useOption, setOptions, setOption, setSort, getOptions } = isSortable: false, isVisible: false, }, + { + name: 'releaseTypes', + label: () => translate('ReleaseTypes'), + isSortable: false, + isVisible: false, + }, { name: 'episodeFileQualities', label: () => translate('EpisodeFileQualities'), diff --git a/frontend/src/Series/useSeries.ts b/frontend/src/Series/useSeries.ts index 6e62301bb..6d1bff479 100644 --- a/frontend/src/Series/useSeries.ts +++ b/frontend/src/Series/useSeries.ts @@ -254,6 +254,12 @@ const FILTER_PREDICATES = { return predicate(releaseGroups, filterValue); }, + releaseTypes: (item: Series, filterValue: string[], type: FilterType) => { + const releaseTypes = item.statistics?.releaseTypes ?? []; + const predicate = getFilterTypePredicate(type); + return predicate(releaseTypes, filterValue); + }, + episodeFileQualities: ( item: Series, filterValue: number[], @@ -533,6 +539,12 @@ export const FILTER_BUILDER: FilterBuilderProp[] = [ label: () => translate('ReleaseGroups'), type: filterBuilderTypes.ARRAY, }, + { + name: 'releaseTypes', + label: () => translate('ReleaseTypes'), + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.RELEASE_TYPES, + }, { name: 'episodeFileQualities', label: () => translate('EpisodeFileQualities'), diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index c6d64ac6d..5921e10b8 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1776,6 +1776,7 @@ "ReleaseSource": "Release Source", "ReleaseTitle": "Release Title", "ReleaseType": "Release Type", + "ReleaseTypes": "Release Types", "Reload": "Reload", "RemotePath": "Remote Path", "RemotePathMappingBadDockerPathHealthCheckMessage": "You are using docker; download client {downloadClientName} places downloads in {path} but this is not a valid {osName} path. Review your remote path mappings and download client settings.", diff --git a/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs index bcca6a4d8..5fdf01519 100644 --- a/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs +++ b/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs @@ -4,6 +4,7 @@ using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; namespace NzbDrone.Core.SeriesStats @@ -22,6 +23,7 @@ public class SeasonStatistics : ResultSet public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public string ReleaseGroupsString { get; set; } + public string ReleaseTypesString { get; set; } public string EpisodeFileQualitiesString { get; set; } public DateTime? NextAiring @@ -113,6 +115,25 @@ public List ReleaseGroups } } + public List ReleaseTypes + { + get + { + if (ReleaseTypesString.IsNullOrWhiteSpace()) + { + return []; + } + + return ReleaseTypesString + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(int.Parse) + .Distinct() + .Where(type => Enum.IsDefined(typeof(ReleaseType), type)) + .Select(type => (ReleaseType)type) + .ToList(); + } + } + public List EpisodeFileQualities { get diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs index 41f637ee6..1d4d57902 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; namespace NzbDrone.Core.SeriesStats @@ -17,6 +18,7 @@ public class SeriesStatistics : ResultSet public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List ReleaseGroups { get; set; } + public List ReleaseTypes { get; set; } public List EpisodeFileQualities { get; set; } public List SeasonStatistics { get; set; } } diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs index c7098117d..bafb7f457 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs @@ -49,6 +49,7 @@ private List MapResults(List episodesResult, e.SizeOnDisk = file?.SizeOnDisk ?? 0; e.ReleaseGroupsString = file?.ReleaseGroupsString; + e.ReleaseTypesString = file?.ReleaseTypesString; e.EpisodeFileQualitiesString = file?.EpisodeFileQualitiesString; }); @@ -98,6 +99,7 @@ private SqlBuilder EpisodeFilesBuilder() ""SeasonNumber"", SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, GROUP_CONCAT(""ReleaseGroup"", '|') AS ReleaseGroupsString, + GROUP_CONCAT(""ReleaseType"", '|') AS ReleaseTypesString, GROUP_CONCAT(JSON_EXTRACT(""Quality"", '$.quality'), '|') AS EpisodeFileQualitiesString") .GroupBy(x => x.SeriesId) .GroupBy(x => x.SeasonNumber); @@ -108,6 +110,7 @@ private SqlBuilder EpisodeFilesBuilder() ""SeasonNumber"", SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, string_agg(""ReleaseGroup"", '|') AS ReleaseGroupsString, + string_agg(""ReleaseType""::text, '|') AS ReleaseTypesString, string_agg(""Quality""::json->>'quality', '|') AS EpisodeFileQualitiesString") .GroupBy(x => x.SeriesId) .GroupBy(x => x.SeasonNumber); diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs index 62d9ba9fa..693eab6e1 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs @@ -70,6 +70,7 @@ private SeriesStatistics MapSeriesStatistics(List seasonStatis MonitoredEpisodeCount = seasonStatistics.Sum(s => s.MonitoredEpisodeCount), SizeOnDisk = seasonStatistics.Sum(s => s.SizeOnDisk), ReleaseGroups = seasonStatistics.SelectMany(s => s.ReleaseGroups).Distinct().ToList(), + ReleaseTypes = seasonStatistics.SelectMany(s => s.ReleaseTypes).Distinct().OrderBy(s => s).ToList(), EpisodeFileQualities = SortQualities(seasonStatistics.SelectMany(s => s.EpisodeFileQualities).Distinct().ToList(), profile) }; diff --git a/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs b/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs index d7cb364f4..1387147a8 100644 --- a/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs +++ b/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs @@ -1,3 +1,4 @@ +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.SeriesStats; @@ -13,6 +14,7 @@ public class SeasonStatisticsResource public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List? ReleaseGroups { get; set; } + public List? ReleaseTypes { get; set; } public List? EpisodeFileQualities { get; set; } public decimal PercentOfEpisodes @@ -43,6 +45,7 @@ public static SeasonStatisticsResource ToResource(this SeasonStatistics model) MonitoredEpisodeCount = model.MonitoredEpisodeCount, SizeOnDisk = model.SizeOnDisk, ReleaseGroups = model.ReleaseGroups, + ReleaseTypes = model.ReleaseTypes, EpisodeFileQualities = model.EpisodeFileQualities }; } diff --git a/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs b/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs index b9f58ff71..ffdca151d 100644 --- a/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs +++ b/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs @@ -1,3 +1,4 @@ +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.SeriesStats; @@ -12,6 +13,7 @@ public class SeriesStatisticsResource public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List? ReleaseGroups { get; set; } + public List? ReleaseTypes { get; set; } public List? EpisodeFileQualities { get; set; } public decimal PercentOfEpisodes @@ -41,6 +43,7 @@ public static SeriesStatisticsResource ToResource(this SeriesStatistics model, L MonitoredEpisodeCount = model.MonitoredEpisodeCount, SizeOnDisk = model.SizeOnDisk, ReleaseGroups = model.ReleaseGroups, + ReleaseTypes = model.ReleaseTypes, EpisodeFileQualities = model.EpisodeFileQualities }; }