From 5c5b53d341ec57b3d8732ec4ebbbbb80c785160f Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Wed, 15 Apr 2026 16:15:23 -0700 Subject: [PATCH] New: Filter series by episode file quality Closes #8437 --- .../src/Series/Index/Table/SeriesIndexRow.css | 6 +++ .../Index/Table/SeriesIndexRow.css.d.ts | 1 + .../src/Series/Index/Table/SeriesIndexRow.tsx | 20 ++++++++ .../Index/Table/SeriesIndexTableHeader.css | 6 +++ .../Table/SeriesIndexTableHeader.css.d.ts | 1 + frontend/src/Series/Series.ts | 2 + frontend/src/Series/seriesOptionsStore.ts | 6 +++ frontend/src/Series/useSeries.ts | 18 ++++++++ src/NzbDrone.Core/Localization/Core/en.json | 1 + .../SeriesStats/SeasonStatistics.cs | 20 ++++++++ .../SeriesStats/SeriesStatistics.cs | 2 + .../SeriesStats/SeriesStatisticsRepository.cs | 7 ++- .../SeriesStats/SeriesStatisticsService.cs | 46 ++++++++++++++++--- src/NzbDrone.Core/Tv/SeriesRepository.cs | 10 ++++ src/NzbDrone.Core/Tv/SeriesService.cs | 6 +++ src/Sonarr.Api.V3/Series/SeriesController.cs | 2 +- .../Series/SeasonStatisticsResource.cs | 5 +- src/Sonarr.Api.V5/Series/SeriesController.cs | 2 +- .../Series/SeriesStatisticsResource.cs | 5 +- 19 files changed, 153 insertions(+), 13 deletions(-) diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css b/frontend/src/Series/Index/Table/SeriesIndexRow.css index e18a9ed74..4b4706a01 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css @@ -84,6 +84,12 @@ flex: 0 0 180px; } +.episodeFileQualities { + composes: cell; + + flex: 0 0 220px; +} + .seasonCount, .certification { composes: cell; diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts index 635b5a616..93da75bbf 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts @@ -11,6 +11,7 @@ interface CssExports { 'certification': string; 'checkInput': string; 'episodeCount': string; + 'episodeFileQualities': string; 'episodeProgress': string; 'genres': string; 'latestSeason': string; diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx index b8d83211b..6a997af97 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx @@ -149,6 +149,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { totalEpisodeCount = 0, sizeOnDisk = 0, releaseGroups = [], + episodeFileQualities = [], } = statistics; return ( @@ -447,6 +448,25 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { ); } + if (name === 'episodeFileQualities') { + const joinedQualities = episodeFileQualities + .map((q) => q.name) + .join(', '); + const truncatedQualities = + episodeFileQualities.length > 3 + ? `${episodeFileQualities + .slice(0, 3) + .map((q) => q.name) + .join(', ')}...` + : joinedQualities; + + return ( + + {truncatedQualities} + + ); + } + if (name === 'tags') { return ( diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css index a37cb5081..0fac2d75f 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css @@ -48,6 +48,12 @@ flex: 0 0 180px; } +.episodeFileQualities { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 220px; +} + .seasonCount, .certification { 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 1b566399d..63fd01bbe 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts @@ -8,6 +8,7 @@ interface CssExports { 'bannerGrow': string; 'certification': string; 'episodeCount': string; + 'episodeFileQualities': string; 'episodeProgress': string; 'genres': string; 'latestSeason': string; diff --git a/frontend/src/Series/Series.ts b/frontend/src/Series/Series.ts index 39d1ea1ff..47917ed2d 100644 --- a/frontend/src/Series/Series.ts +++ b/frontend/src/Series/Series.ts @@ -1,5 +1,6 @@ import ModelBase from 'App/ModelBase'; import Language from 'Language/Language'; +import Quality from 'Quality/Quality'; export type SeriesType = 'anime' | 'daily' | 'standard'; export type SeriesMonitor = @@ -34,6 +35,7 @@ export interface Statistics { percentOfEpisodes: number; previousAiring?: Date; releaseGroups: string[]; + episodeFileQualities: Quality[]; sizeOnDisk: number; totalEpisodeCount: number; monitoredEpisodeCount: number; diff --git a/frontend/src/Series/seriesOptionsStore.ts b/frontend/src/Series/seriesOptionsStore.ts index b71c1cbb9..51a37e3e0 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: 'episodeFileQualities', + label: () => translate('EpisodeFileQualities'), + isSortable: false, + isVisible: false, + }, { name: 'tags', label: () => translate('Tags'), diff --git a/frontend/src/Series/useSeries.ts b/frontend/src/Series/useSeries.ts index b41428f2f..6e62301bb 100644 --- a/frontend/src/Series/useSeries.ts +++ b/frontend/src/Series/useSeries.ts @@ -254,6 +254,18 @@ const FILTER_PREDICATES = { return predicate(releaseGroups, filterValue); }, + episodeFileQualities: ( + item: Series, + filterValue: number[], + type: FilterType + ) => { + const episodeFileQualities = ( + item.statistics?.episodeFileQualities ?? [] + ).map((q) => q.id); + const predicate = getFilterTypePredicate(type); + return predicate(episodeFileQualities, filterValue); + }, + seasonCount: (item: Series, filterValue: number, type: FilterType) => { const predicate = getFilterTypePredicate(type); const seasonCount = item.statistics?.seasonCount ?? 0; @@ -521,6 +533,12 @@ export const FILTER_BUILDER: FilterBuilderProp[] = [ label: () => translate('ReleaseGroups'), type: filterBuilderTypes.ARRAY, }, + { + name: 'episodeFileQualities', + label: () => translate('EpisodeFileQualities'), + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.QUALITY, + }, { name: 'ratings', label: () => translate('Rating'), diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 0c58bee40..c6d64ac6d 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -668,6 +668,7 @@ "EpisodeFileDeleted": "Episode File Deleted", "EpisodeFileDeletedTooltip": "Episode file deleted", "EpisodeFileMissingTooltip": "Episode file missing", + "EpisodeFileQualities": "Episode File Qualities", "EpisodeFileRenamed": "Episode File Renamed", "EpisodeFileRenamedTooltip": "Episode file renamed", "EpisodeFilesLoadError": "Unable to load episode files", diff --git a/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs index cf615a731..bcca6a4d8 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.Qualities; namespace NzbDrone.Core.SeriesStats { @@ -21,6 +22,7 @@ public class SeasonStatistics : ResultSet public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public string ReleaseGroupsString { get; set; } + public string EpisodeFileQualitiesString { get; set; } public DateTime? NextAiring { @@ -110,5 +112,23 @@ public List ReleaseGroups return releasegroups; } } + + public List EpisodeFileQualities + { + get + { + if (EpisodeFileQualitiesString.IsNullOrWhiteSpace()) + { + return new List(); + } + + return EpisodeFileQualitiesString + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(int.Parse) + .Distinct() + .Select(Quality.FindById) + .ToList(); + } + } } } diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs index e3f10d24f..41f637ee6 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.Qualities; namespace NzbDrone.Core.SeriesStats { @@ -16,6 +17,7 @@ public class SeriesStatistics : ResultSet public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List ReleaseGroups { 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 68b4f91ff..c7098117d 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.EpisodeFileQualitiesString = file?.EpisodeFileQualitiesString; }); return episodesResult; @@ -96,7 +97,8 @@ private SqlBuilder EpisodeFilesBuilder() .Select(@"""SeriesId"", ""SeasonNumber"", SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, - GROUP_CONCAT(""ReleaseGroup"", '|') AS ReleaseGroupsString") + GROUP_CONCAT(""ReleaseGroup"", '|') AS ReleaseGroupsString, + GROUP_CONCAT(JSON_EXTRACT(""Quality"", '$.quality'), '|') AS EpisodeFileQualitiesString") .GroupBy(x => x.SeriesId) .GroupBy(x => x.SeasonNumber); } @@ -105,7 +107,8 @@ private SqlBuilder EpisodeFilesBuilder() .Select(@"""SeriesId"", ""SeasonNumber"", SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, - string_agg(""ReleaseGroup"", '|') AS ReleaseGroupsString") + string_agg(""ReleaseGroup"", '|') AS ReleaseGroupsString, + 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 6e7b75d85..62d9ba9fa 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs @@ -1,31 +1,50 @@ using System.Collections.Generic; using System.Linq; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.SeriesStats { public interface ISeriesStatisticsService { List SeriesStatistics(); - SeriesStatistics SeriesStatistics(int seriesId); + SeriesStatistics SeriesStatistics(int seriesId, int qualityProfileId); } public class SeriesStatisticsService : ISeriesStatisticsService { private readonly ISeriesStatisticsRepository _seriesStatisticsRepository; + private readonly ISeriesService _seriesService; + private readonly IQualityProfileService _qualityProfileService; - public SeriesStatisticsService(ISeriesStatisticsRepository seriesStatisticsRepository) + public SeriesStatisticsService(ISeriesStatisticsRepository seriesStatisticsRepository, + ISeriesService seriesService, + IQualityProfileService qualityProfileService) { _seriesStatisticsRepository = seriesStatisticsRepository; + _seriesService = seriesService; + _qualityProfileService = qualityProfileService; } public List SeriesStatistics() { var seasonStatistics = _seriesStatisticsRepository.SeriesStatistics(); + var seriesProfiles = _seriesService.GetAllSeriesQualityProfiles(); + var profiles = _qualityProfileService.All().ToDictionary(p => p.Id); - return seasonStatistics.GroupBy(s => s.SeriesId).Select(s => MapSeriesStatistics(s.ToList())).ToList(); + return seasonStatistics + .GroupBy(s => s.SeriesId) + .Select(s => + { + var profileId = seriesProfiles.GetValueOrDefault(s.Key); + profiles.TryGetValue(profileId, out var profile); + return MapSeriesStatistics(s.ToList(), profile); + }) + .ToList(); } - public SeriesStatistics SeriesStatistics(int seriesId) + public SeriesStatistics SeriesStatistics(int seriesId, int qualityProfileId) { var stats = _seriesStatisticsRepository.SeriesStatistics(seriesId); @@ -34,10 +53,12 @@ public SeriesStatistics SeriesStatistics(int seriesId) return new SeriesStatistics(); } - return MapSeriesStatistics(stats); + var profile = _qualityProfileService.Get(qualityProfileId); + + return MapSeriesStatistics(stats, profile); } - private SeriesStatistics MapSeriesStatistics(List seasonStatistics) + private SeriesStatistics MapSeriesStatistics(List seasonStatistics, QualityProfile profile) { var seriesStatistics = new SeriesStatistics { @@ -48,7 +69,8 @@ private SeriesStatistics MapSeriesStatistics(List seasonStatis TotalEpisodeCount = seasonStatistics.Sum(s => s.TotalEpisodeCount), MonitoredEpisodeCount = seasonStatistics.Sum(s => s.MonitoredEpisodeCount), SizeOnDisk = seasonStatistics.Sum(s => s.SizeOnDisk), - ReleaseGroups = seasonStatistics.SelectMany(s => s.ReleaseGroups).Distinct().ToList() + ReleaseGroups = seasonStatistics.SelectMany(s => s.ReleaseGroups).Distinct().ToList(), + EpisodeFileQualities = SortQualities(seasonStatistics.SelectMany(s => s.EpisodeFileQualities).Distinct().ToList(), profile) }; var nextAiring = seasonStatistics.Where(s => s.NextAiring != null).MinBy(s => s.NextAiring); @@ -61,5 +83,15 @@ private SeriesStatistics MapSeriesStatistics(List seasonStatis return seriesStatistics; } + + private static List SortQualities(List qualities, QualityProfile profile) + { + if (profile == null) + { + return qualities; + } + + return qualities.OrderBy(q => profile.GetIndex(q.Id).Index).ToList(); + } } } diff --git a/src/NzbDrone.Core/Tv/SeriesRepository.cs b/src/NzbDrone.Core/Tv/SeriesRepository.cs index f83545cc8..a4f6e6365 100644 --- a/src/NzbDrone.Core/Tv/SeriesRepository.cs +++ b/src/NzbDrone.Core/Tv/SeriesRepository.cs @@ -19,6 +19,7 @@ public interface ISeriesRepository : IBasicRepository List AllSeriesTvdbIds(); Dictionary AllSeriesPaths(); Dictionary> AllSeriesTags(); + Dictionary AllSeriesQualityProfiles(); } public class SeriesRepository : BasicRepository, ISeriesRepository @@ -111,6 +112,15 @@ public Dictionary> AllSeriesTags() } } + public Dictionary AllSeriesQualityProfiles() + { + using (var conn = _database.OpenConnection()) + { + var strSql = "SELECT \"Id\" AS Key, \"QualityProfileId\" AS Value FROM \"Series\""; + return conn.Query>(strSql).ToDictionary(x => x.Key, x => x.Value); + } + } + private Series ReturnSingleSeriesOrThrow(List series) { if (series.Count == 0) diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 29cb6fac5..2430cb759 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -28,6 +28,7 @@ public interface ISeriesService Dictionary GetAllSeriesPaths(); Dictionary> GetAllSeriesTags(); List AllForTag(int tagId); + Dictionary GetAllSeriesQualityProfiles(); Series UpdateSeries(Series series, bool updateEpisodesToMatchSeason = true, bool publishUpdatedEvent = true); List UpdateSeries(List series, bool useExistingRelativeFolder); bool SeriesPathExists(string folder); @@ -186,6 +187,11 @@ public Dictionary> GetAllSeriesTags() return _seriesRepository.AllSeriesTags(); } + public Dictionary GetAllSeriesQualityProfiles() + { + return _seriesRepository.AllSeriesQualityProfiles(); + } + public List AllForTag(int tagId) { return GetAllSeries().Where(s => s.Tags.Contains(tagId)) diff --git a/src/Sonarr.Api.V3/Series/SeriesController.cs b/src/Sonarr.Api.V3/Series/SeriesController.cs index 341373167..8f155b9ff 100644 --- a/src/Sonarr.Api.V3/Series/SeriesController.cs +++ b/src/Sonarr.Api.V3/Series/SeriesController.cs @@ -237,7 +237,7 @@ private void MapCoversToLocal(params SeriesResource[] series) private void FetchAndLinkSeriesStatistics(SeriesResource resource) { - LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id)); + LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id, resource.QualityProfileId)); } private void LinkSeriesStatistics(List resources, Dictionary seriesStatistics) diff --git a/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs b/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs index c338cfbcb..d7cb364f4 100644 --- a/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs +++ b/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs @@ -1,3 +1,4 @@ +using NzbDrone.Core.Qualities; using NzbDrone.Core.SeriesStats; namespace Sonarr.Api.V5.Series; @@ -12,6 +13,7 @@ public class SeasonStatisticsResource public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List? ReleaseGroups { get; set; } + public List? EpisodeFileQualities { get; set; } public decimal PercentOfEpisodes { @@ -40,7 +42,8 @@ public static SeasonStatisticsResource ToResource(this SeasonStatistics model) TotalEpisodeCount = model.TotalEpisodeCount, MonitoredEpisodeCount = model.MonitoredEpisodeCount, SizeOnDisk = model.SizeOnDisk, - ReleaseGroups = model.ReleaseGroups + ReleaseGroups = model.ReleaseGroups, + EpisodeFileQualities = model.EpisodeFileQualities }; } } diff --git a/src/Sonarr.Api.V5/Series/SeriesController.cs b/src/Sonarr.Api.V5/Series/SeriesController.cs index 992d4c99d..cdadfe76a 100644 --- a/src/Sonarr.Api.V5/Series/SeriesController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesController.cs @@ -277,7 +277,7 @@ private void MapCoversToLocal(params SeriesResource[] series) private void FetchAndLinkSeriesStatistics(SeriesResource resource) { - LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id)); + LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id, resource.QualityProfileId)); } private void LinkSeriesStatistics(List resources, Dictionary seriesStatistics) diff --git a/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs b/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs index d04bed7a6..b9f58ff71 100644 --- a/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs +++ b/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs @@ -1,3 +1,4 @@ +using NzbDrone.Core.Qualities; using NzbDrone.Core.SeriesStats; namespace Sonarr.Api.V5.Series; @@ -11,6 +12,7 @@ public class SeriesStatisticsResource public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List? ReleaseGroups { get; set; } + public List? EpisodeFileQualities { get; set; } public decimal PercentOfEpisodes { @@ -38,7 +40,8 @@ public static SeriesStatisticsResource ToResource(this SeriesStatistics model, L TotalEpisodeCount = model.TotalEpisodeCount, MonitoredEpisodeCount = model.MonitoredEpisodeCount, SizeOnDisk = model.SizeOnDisk, - ReleaseGroups = model.ReleaseGroups + ReleaseGroups = model.ReleaseGroups, + EpisodeFileQualities = model.EpisodeFileQualities }; } }