New: Filter series by episode file quality

Closes #8437
This commit is contained in:
Mark McDowall 2026-04-15 16:15:23 -07:00
parent e8e31def9b
commit 5c5b53d341
19 changed files with 153 additions and 13 deletions

View file

@ -84,6 +84,12 @@
flex: 0 0 180px;
}
.episodeFileQualities {
composes: cell;
flex: 0 0 220px;
}
.seasonCount,
.certification {
composes: cell;

View file

@ -11,6 +11,7 @@ interface CssExports {
'certification': string;
'checkInput': string;
'episodeCount': string;
'episodeFileQualities': string;
'episodeProgress': string;
'genres': string;
'latestSeason': string;

View file

@ -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 (
<VirtualTableRowCell key={name} className={styles[name]}>
<span title={joinedQualities}>{truncatedQualities}</span>
</VirtualTableRowCell>
);
}
if (name === 'tags') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>

View file

@ -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';

View file

@ -8,6 +8,7 @@ interface CssExports {
'bannerGrow': string;
'certification': string;
'episodeCount': string;
'episodeFileQualities': string;
'episodeProgress': string;
'genres': string;
'latestSeason': string;

View file

@ -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;

View file

@ -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'),

View file

@ -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<Series>[] = [
label: () => translate('ReleaseGroups'),
type: filterBuilderTypes.ARRAY,
},
{
name: 'episodeFileQualities',
label: () => translate('EpisodeFileQualities'),
type: filterBuilderTypes.ARRAY,
valueType: filterBuilderValueTypes.QUALITY,
},
{
name: 'ratings',
label: () => translate('Rating'),

View file

@ -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",

View file

@ -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<string> ReleaseGroups
return releasegroups;
}
}
public List<Quality> EpisodeFileQualities
{
get
{
if (EpisodeFileQualitiesString.IsNullOrWhiteSpace())
{
return new List<Quality>();
}
return EpisodeFileQualitiesString
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(int.Parse)
.Distinct()
.Select(Quality.FindById)
.ToList();
}
}
}
}

View file

@ -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<string> ReleaseGroups { get; set; }
public List<Quality> EpisodeFileQualities { get; set; }
public List<SeasonStatistics> SeasonStatistics { get; set; }
}
}

View file

@ -49,6 +49,7 @@ private List<SeasonStatistics> MapResults(List<SeasonStatistics> 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<EpisodeFile>(x => x.SeriesId)
.GroupBy<EpisodeFile>(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<EpisodeFile>(x => x.SeriesId)
.GroupBy<EpisodeFile>(x => x.SeasonNumber);
}

View file

@ -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 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> 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> seasonStatistics)
private SeriesStatistics MapSeriesStatistics(List<SeasonStatistics> seasonStatistics, QualityProfile profile)
{
var seriesStatistics = new SeriesStatistics
{
@ -48,7 +69,8 @@ private SeriesStatistics MapSeriesStatistics(List<SeasonStatistics> 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<SeasonStatistics> seasonStatis
return seriesStatistics;
}
private static List<Quality> SortQualities(List<Quality> qualities, QualityProfile profile)
{
if (profile == null)
{
return qualities;
}
return qualities.OrderBy(q => profile.GetIndex(q.Id).Index).ToList();
}
}
}

View file

@ -19,6 +19,7 @@ public interface ISeriesRepository : IBasicRepository<Series>
List<int> AllSeriesTvdbIds();
Dictionary<int, string> AllSeriesPaths();
Dictionary<int, List<int>> AllSeriesTags();
Dictionary<int, int> AllSeriesQualityProfiles();
}
public class SeriesRepository : BasicRepository<Series>, ISeriesRepository
@ -111,6 +112,15 @@ public Dictionary<int, List<int>> AllSeriesTags()
}
}
public Dictionary<int, int> AllSeriesQualityProfiles()
{
using (var conn = _database.OpenConnection())
{
var strSql = "SELECT \"Id\" AS Key, \"QualityProfileId\" AS Value FROM \"Series\"";
return conn.Query<KeyValuePair<int, int>>(strSql).ToDictionary(x => x.Key, x => x.Value);
}
}
private Series ReturnSingleSeriesOrThrow(List<Series> series)
{
if (series.Count == 0)

View file

@ -28,6 +28,7 @@ public interface ISeriesService
Dictionary<int, string> GetAllSeriesPaths();
Dictionary<int, List<int>> GetAllSeriesTags();
List<Series> AllForTag(int tagId);
Dictionary<int, int> GetAllSeriesQualityProfiles();
Series UpdateSeries(Series series, bool updateEpisodesToMatchSeason = true, bool publishUpdatedEvent = true);
List<Series> UpdateSeries(List<Series> series, bool useExistingRelativeFolder);
bool SeriesPathExists(string folder);
@ -186,6 +187,11 @@ public Dictionary<int, List<int>> GetAllSeriesTags()
return _seriesRepository.AllSeriesTags();
}
public Dictionary<int, int> GetAllSeriesQualityProfiles()
{
return _seriesRepository.AllSeriesQualityProfiles();
}
public List<Series> AllForTag(int tagId)
{
return GetAllSeries().Where(s => s.Tags.Contains(tagId))

View file

@ -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<SeriesResource> resources, Dictionary<int, SeriesStatistics> seriesStatistics)

View file

@ -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<string>? ReleaseGroups { get; set; }
public List<Quality>? 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
};
}
}

View file

@ -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<SeriesResource> resources, Dictionary<int, SeriesStatistics> seriesStatistics)

View file

@ -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<string>? ReleaseGroups { get; set; }
public List<Quality>? 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
};
}
}