Add AnimeSeasonSearchFallback enum for per-episode fallback control

Configurable setting (default: FullSeasonNotAired) that controls when
per-episode fallback fires after season search finds no approved results:
- Never: season search only, no fallback
- Always: always fall back to per-episode
- FullSeasonAired: fall back only for completed seasons
- FullSeasonNotAired: fall back only for still-airing seasons (default)

The default avoids wasting API hits on completed seasons where packs
should exist, while still searching per-episode for airing seasons
where no pack can exist yet.

Based on design discussion with markus101 on Discord.
This commit is contained in:
Oscar 2026-04-06 21:13:49 +02:00
parent 018f66ab6f
commit 32c3836a06
6 changed files with 58 additions and 5 deletions

View file

@ -7,6 +7,7 @@
using NzbDrone.Common.Http.Proxy;
using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.EpisodeImport;
@ -129,6 +130,13 @@ public int MinimumAge
set { SetValue("MinimumAge", value); }
}
public AnimeSeasonSearchFallback AnimeSeasonSearchFallback
{
get { return GetValueEnum("AnimeSeasonSearchFallback", AnimeSeasonSearchFallback.FullSeasonNotAired); }
set { SetValue("AnimeSeasonSearchFallback", value); }
}
public ProperDownloadTypes DownloadPropersAndRepacks
{
get { return GetValueEnum("DownloadPropersAndRepacks", ProperDownloadTypes.PreferAndUpgrade); }

View file

@ -1,6 +1,7 @@
using System.Collections.Generic;
using NzbDrone.Common.Http.Proxy;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.EpisodeImport;
using NzbDrone.Core.Qualities;
@ -52,6 +53,9 @@ public interface IConfigService
string ChmodFolder { get; set; }
string ChownGroup { get; set; }
// Anime Season Search
AnimeSeasonSearchFallback AnimeSeasonSearchFallback { get; set; }
// Indexers
int Retention { get; set; }
int RssSyncInterval { get; set; }

View file

@ -0,0 +1,10 @@
namespace NzbDrone.Core.IndexerSearch
{
public enum AnimeSeasonSearchFallback
{
Never = 0,
Always = 1,
FullSeasonAired = 2,
FullSeasonNotAired = 3
}
}

View file

@ -7,6 +7,7 @@
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.DataAugmentation.Scene;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.Indexers;
@ -32,6 +33,7 @@ public class ReleaseSearchService : ISearchForReleases
private readonly ISeriesService _seriesService;
private readonly IEpisodeService _episodeService;
private readonly IMakeDownloadDecision _makeDownloadDecision;
private readonly IConfigService _configService;
private readonly Logger _logger;
public ReleaseSearchService(IIndexerFactory indexerFactory,
@ -39,6 +41,7 @@ public ReleaseSearchService(IIndexerFactory indexerFactory,
ISeriesService seriesService,
IEpisodeService episodeService,
IMakeDownloadDecision makeDownloadDecision,
IConfigService configService,
Logger logger)
{
_indexerFactory = indexerFactory;
@ -46,6 +49,7 @@ public ReleaseSearchService(IIndexerFactory indexerFactory,
_seriesService = seriesService;
_episodeService = episodeService;
_makeDownloadDecision = makeDownloadDecision;
_configService = configService;
_logger = logger;
}
@ -430,11 +434,32 @@ private async Task<List<DownloadDecision>> SearchAnimeSeason(Series series, List
}
else
{
_logger.Debug("No approved season results for {0}, falling back to per-episode search for {1} episodes", series.Title, episodesToSearch.Count);
var fallbackSetting = _configService.AnimeSeasonSearchFallback;
var allEpisodesAired = episodesToSearch.All(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.Before(DateTime.UtcNow));
foreach (var episode in episodesToSearch)
var shouldFallback = fallbackSetting switch
{
downloadDecisions.AddRange(await SearchAnime(series, episode, monitoredOnly, userInvokedSearch, interactiveSearch, true));
AnimeSeasonSearchFallback.Never => false,
AnimeSeasonSearchFallback.Always => true,
AnimeSeasonSearchFallback.FullSeasonAired => allEpisodesAired,
AnimeSeasonSearchFallback.FullSeasonNotAired => !allEpisodesAired,
_ => !allEpisodesAired
};
if (shouldFallback)
{
_logger.Debug("No approved season results for {0}, falling back to per-episode search for {1} episodes (fallback: {2}, allAired: {3})",
series.Title, episodesToSearch.Count, fallbackSetting, allEpisodesAired);
foreach (var episode in episodesToSearch)
{
downloadDecisions.AddRange(await SearchAnime(series, episode, monitoredOnly, userInvokedSearch, interactiveSearch, true));
}
}
else
{
_logger.Debug("No approved season results for {0}, per-episode fallback skipped (fallback: {1}, allAired: {2})",
series.Title, fallbackSetting, allEpisodesAired);
}
}

View file

@ -1,4 +1,5 @@
using NzbDrone.Core.Configuration;
using NzbDrone.Core.IndexerSearch;
using Sonarr.Http.REST;
namespace Sonarr.Api.V3.Config
@ -9,6 +10,7 @@ public class IndexerConfigResource : RestResource
public int Retention { get; set; }
public int MaximumSize { get; set; }
public int RssSyncInterval { get; set; }
public AnimeSeasonSearchFallback AnimeSeasonSearchFallback { get; set; }
}
public static class IndexerConfigResourceMapper
@ -20,7 +22,8 @@ public static IndexerConfigResource ToResource(IConfigService model)
MinimumAge = model.MinimumAge,
Retention = model.Retention,
MaximumSize = model.MaximumSize,
RssSyncInterval = model.RssSyncInterval
RssSyncInterval = model.RssSyncInterval,
AnimeSeasonSearchFallback = model.AnimeSeasonSearchFallback
};
}
}

View file

@ -1,4 +1,5 @@
using NzbDrone.Core.Configuration;
using NzbDrone.Core.IndexerSearch;
using Sonarr.Http.REST;
namespace Sonarr.Api.V5.Settings
@ -9,6 +10,7 @@ public class IndexerSettingsResource : RestResource
public int Retention { get; set; }
public int MaximumSize { get; set; }
public int RssSyncInterval { get; set; }
public AnimeSeasonSearchFallback AnimeSeasonSearchFallback { get; set; }
}
public static class IndexerConfigResourceMapper
@ -20,7 +22,8 @@ public static IndexerSettingsResource ToResource(IConfigService model)
MinimumAge = model.MinimumAge,
Retention = model.Retention,
MaximumSize = model.MaximumSize,
RssSyncInterval = model.RssSyncInterval
RssSyncInterval = model.RssSyncInterval,
AnimeSeasonSearchFallback = model.AnimeSeasonSearchFallback
};
}
}