From a0e0c148f98e0ad53d94e12f1214033777977ec3 Mon Sep 17 00:00:00 2001 From: Oscar Date: Mon, 6 Apr 2026 10:12:26 +0200 Subject: [PATCH 1/6] Prefer season search over per-episode for anime When searching for anime seasons, try the season pack search first across all indexers. Only fall back to per-episode search if the season search returns zero results. Previously, SearchAnimeSeason always ran both: a season query followed by individual queries for every episode. For a 10-episode season this meant 10+ separate indexer requests on top of the season query. On indexers like Nyaa (via Prowlarr/Torznab), each per-episode request can take 10-15s, so a full season search could exceed 120s and trigger timeout cascades that disable indexers mid-search. The season query alone consistently returns results for actively airing anime on Newznab, Torznab, and Nyaa indexers. Per-episode fallback is still available when the season search genuinely finds nothing (e.g. older or niche titles). Also removes the AnimeStandardFormatSearch gate from NyaaRequestGenerator and NewznabRequestGenerator for season-level searches. The toggle still controls per-episode search format, but season pack queries should always fire regardless of that setting. --- .../IndexerSearch/ReleaseSearchService.cs | 13 +++++++++++-- .../Indexers/Newznab/NewznabRequestGenerator.cs | 2 +- .../Indexers/Nyaa/NyaaRequestGenerator.cs | 4 ++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs index 3af963f78..13fcddc62 100644 --- a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs @@ -421,9 +421,18 @@ private async Task> SearchAnimeSeason(Series series, List downloadDecisions.AddRange(decisions); } - foreach (var episode in episodesToSearch) + if (downloadDecisions.Any()) { - downloadDecisions.AddRange(await SearchAnime(series, episode, monitoredOnly, userInvokedSearch, interactiveSearch, true)); + _logger.Debug("Season search returned results for {0}, skipping per-episode search for {1} episodes", series.Title, episodesToSearch.Count); + } + else + { + _logger.Debug("No season results for {0}, falling back to per-episode search for {1} episodes", series.Title, episodesToSearch.Count); + + foreach (var episode in episodesToSearch) + { + downloadDecisions.AddRange(await SearchAnime(series, episode, monitoredOnly, userInvokedSearch, interactiveSearch, true)); + } } return DeDupeDecisions(downloadDecisions); diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs index 4dd06eb2a..b0ca4e6f4 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs @@ -450,7 +450,7 @@ public virtual IndexerPageableRequestChain GetSearchRequests(AnimeSeasonSearchCr { var pageableRequests = new IndexerPageableRequestChain(); - if (SupportsSearch && Settings.AnimeStandardFormatSearch && searchCriteria.SeasonNumber > 0) + if (SupportsSearch && searchCriteria.SeasonNumber > 0) { AddTvIdPageableRequests(pageableRequests, Settings.AnimeCategories, diff --git a/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs index 92e9d6254..367fed877 100644 --- a/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs @@ -87,9 +87,9 @@ public virtual IndexerPageableRequestChain GetSearchRequests(AnimeSeasonSearchCr { var pageableRequests = new IndexerPageableRequestChain(); - foreach (var searchTitle in searchCriteria.SceneTitles.Select(PrepareQuery)) + if (searchCriteria.SeasonNumber > 0) { - if (Settings.AnimeStandardFormatSearch && searchCriteria.SeasonNumber > 0) + foreach (var searchTitle in searchCriteria.SceneTitles.Select(PrepareQuery)) { pageableRequests.Add(GetPagedRequests($"{searchTitle}+s{searchCriteria.SeasonNumber:00}")); } From 018f66ab6f792c7f8f2d65d5798ac47dad5e00ac Mon Sep 17 00:00:00 2001 From: Oscar Date: Mon, 6 Apr 2026 20:34:47 +0200 Subject: [PATCH 2/6] Check for approved results before skipping per-episode fallback Indexers that don't support season/episode filtering (e.g. AB) return all results for a title regardless of query params. These results are all rejected by the decision engine, but downloadDecisions.Any() was true, preventing the per-episode fallback from ever running. Check for approved decisions instead of just any decisions. --- src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs index 13fcddc62..0d6762674 100644 --- a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs @@ -421,13 +421,16 @@ private async Task> SearchAnimeSeason(Series series, List downloadDecisions.AddRange(decisions); } - if (downloadDecisions.Any()) + // Only skip per-episode fallback if we got approved season results. + // Indexers like AB return all results for a title regardless of season + // params, so raw result count alone is not reliable. + if (downloadDecisions.Any(d => d.Approved)) { - _logger.Debug("Season search returned results for {0}, skipping per-episode search for {1} episodes", series.Title, episodesToSearch.Count); + _logger.Debug("Season search returned approved results for {0}, skipping per-episode search for {1} episodes", series.Title, episodesToSearch.Count); } else { - _logger.Debug("No season results for {0}, falling back to per-episode search for {1} episodes", series.Title, episodesToSearch.Count); + _logger.Debug("No approved season results for {0}, falling back to per-episode search for {1} episodes", series.Title, episodesToSearch.Count); foreach (var episode in episodesToSearch) { From 32c3836a060d8b52e675965907c49e365ba17551 Mon Sep 17 00:00:00 2001 From: Oscar Date: Mon, 6 Apr 2026 21:13:49 +0200 Subject: [PATCH 3/6] 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. --- .../Configuration/ConfigService.cs | 8 +++++ .../Configuration/IConfigService.cs | 4 +++ .../AnimeSeasonSearchFallback.cs | 10 ++++++ .../IndexerSearch/ReleaseSearchService.cs | 31 +++++++++++++++++-- .../Config/IndexerConfigResource.cs | 5 ++- .../Settings/IndexerSettingsResource.cs | 5 ++- 6 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 src/NzbDrone.Core/IndexerSearch/AnimeSeasonSearchFallback.cs diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 72ae79891..821a70173 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -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); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 5ebb51b94..6b352950c 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -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; } diff --git a/src/NzbDrone.Core/IndexerSearch/AnimeSeasonSearchFallback.cs b/src/NzbDrone.Core/IndexerSearch/AnimeSeasonSearchFallback.cs new file mode 100644 index 000000000..80ffd7934 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/AnimeSeasonSearchFallback.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.IndexerSearch +{ + public enum AnimeSeasonSearchFallback + { + Never = 0, + Always = 1, + FullSeasonAired = 2, + FullSeasonNotAired = 3 + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs index 0d6762674..2ffc7c236 100644 --- a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs @@ -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> 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); } } diff --git a/src/Sonarr.Api.V3/Config/IndexerConfigResource.cs b/src/Sonarr.Api.V3/Config/IndexerConfigResource.cs index 6082d18b1..2ac3ae900 100644 --- a/src/Sonarr.Api.V3/Config/IndexerConfigResource.cs +++ b/src/Sonarr.Api.V3/Config/IndexerConfigResource.cs @@ -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 }; } } diff --git a/src/Sonarr.Api.V5/Settings/IndexerSettingsResource.cs b/src/Sonarr.Api.V5/Settings/IndexerSettingsResource.cs index 3c1ffaac5..282a5657a 100644 --- a/src/Sonarr.Api.V5/Settings/IndexerSettingsResource.cs +++ b/src/Sonarr.Api.V5/Settings/IndexerSettingsResource.cs @@ -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 }; } } From ab53c8cb350eed7469e473a8bd465b5675cc3d79 Mon Sep 17 00:00:00 2001 From: Oscar Date: Wed, 8 Apr 2026 20:59:28 +0200 Subject: [PATCH 4/6] Default AnimeSeasonSearchFallback to Always The FullSeasonNotAired default skipped per-episode fallback for completed seasons that returned no approved pack. On usenet, completed seasons frequently exist as individual episodes without a season pack ever being posted, so the default left those users strictly worse off than the current main behavior. The timeout fix this PR is trying to solve comes from the early-exit on a successful season query, not from suppressing the fallback. When the season query returns approved results we stop regardless of the enum value, which is where the vast majority of the per-episode storm was happening. The fallback branch only runs when no pack exists on any configured indexer, and in that case firing per-episode is the only way to find anything. The enum is kept so torrent-only users who specifically want to cap worst-case query count can set it to FullSeasonNotAired or Never, but the out-of-box behavior now matches what fryfrog pointed out on Discord as the correct expectation. --- src/NzbDrone.Core/Configuration/ConfigService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 821a70173..3e175fecc 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -132,7 +132,7 @@ public int MinimumAge public AnimeSeasonSearchFallback AnimeSeasonSearchFallback { - get { return GetValueEnum("AnimeSeasonSearchFallback", AnimeSeasonSearchFallback.FullSeasonNotAired); } + get { return GetValueEnum("AnimeSeasonSearchFallback", AnimeSeasonSearchFallback.Always); } set { SetValue("AnimeSeasonSearchFallback", value); } } From d4a2e95233154846b8807980aa229ac9c41be5ea Mon Sep 17 00:00:00 2001 From: Oscar Date: Wed, 8 Apr 2026 21:43:03 +0200 Subject: [PATCH 5/6] Always run per-episode fallback for interactive anime season search When a user triggers an interactive season search, they expect to see and choose from every candidate release, not just what the decision engine approved. The early-exit on approved season results silently removed per-episode releases from the interactive picker, which is a regression compared to the previous behavior where SearchAnimeSeason unconditionally ran both the season and per-episode queries. Automated searches still benefit from the early-exit: if the season query returns an approved pack, we stop and avoid the per-episode storm that caused the Nyaa/Prowlarr timeouts. For interactive searches we also force shouldFallback to true so the enum setting cannot accidentally hide per-episode results from the user in the picker. The enum still gates automated search behavior. --- src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs index 2ffc7c236..6da06b308 100644 --- a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs @@ -428,7 +428,9 @@ private async Task> SearchAnimeSeason(Series series, List // Only skip per-episode fallback if we got approved season results. // Indexers like AB return all results for a title regardless of season // params, so raw result count alone is not reliable. - if (downloadDecisions.Any(d => d.Approved)) + // For interactive search, always run per-episode so the user can see + // and pick individual releases regardless of whether a pack was found. + if (!interactiveSearch && downloadDecisions.Any(d => d.Approved)) { _logger.Debug("Season search returned approved results for {0}, skipping per-episode search for {1} episodes", series.Title, episodesToSearch.Count); } @@ -437,14 +439,14 @@ private async Task> SearchAnimeSeason(Series series, List var fallbackSetting = _configService.AnimeSeasonSearchFallback; var allEpisodesAired = episodesToSearch.All(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.Before(DateTime.UtcNow)); - var shouldFallback = fallbackSetting switch + var shouldFallback = interactiveSearch || (fallbackSetting switch { AnimeSeasonSearchFallback.Never => false, AnimeSeasonSearchFallback.Always => true, AnimeSeasonSearchFallback.FullSeasonAired => allEpisodesAired, AnimeSeasonSearchFallback.FullSeasonNotAired => !allEpisodesAired, _ => !allEpisodesAired - }; + }); if (shouldFallback) { From ee210600b606428d26310e719b3c8bd7a7ce8cfe Mon Sep 17 00:00:00 2001 From: Oscar Date: Wed, 8 Apr 2026 21:43:03 +0200 Subject: [PATCH 6/6] Mock AnimeSeasonSearchFallback in ReleaseSearchService tests ReleaseSearchService now depends on IConfigService to read the AnimeSeasonSearchFallback setting. Without an explicit mock setup, Moq returns the default enum value (Never), which suppresses the per-episode fallback and breaks season_search_for_anime_should_ search_for_each_monitored_episode. Mock the setting to Always in the fixture SetUp so existing tests continue to exercise the per-episode fallback path. The other anime season tests pass independently because their episode lists are already empty by the time SearchAnimeSeason runs (filtered by monitoredOnly, AirDateUtc, or missingOnly+HasFile). --- .../IndexerSearchTests/ReleaseSearchServiceFixture.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs index fbbe4502b..88d843f16 100644 --- a/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs @@ -7,6 +7,7 @@ using FluentAssertions; using Moq; using NUnit.Framework; +using NzbDrone.Core.Configuration; using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Indexers; @@ -60,6 +61,10 @@ public void SetUp() Mocker.GetMock() .Setup(s => s.GetSceneNames(It.IsAny(), It.IsAny>(), It.IsAny>())) .Returns(new List()); + + Mocker.GetMock() + .SetupGet(s => s.AnimeSeasonSearchFallback) + .Returns(AnimeSeasonSearchFallback.Always); } private void WithEpisode(int seasonNumber, int episodeNumber, int? sceneSeasonNumber, int? sceneEpisodeNumber, string airDate = null)