From 42d111eaf389f9ede98200b9dda991a982646d8c Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Mon, 19 Jan 2026 08:52:13 -0700 Subject: [PATCH] Add Seeders Preference setting for torrent prioritization Adds a configurable dropdown in Indexer Options (advanced) that controls where seeder count appears in the release comparison priority chain: - Default: seeders evaluated in original position (low priority tiebreaker) - High: seeders evaluated after quality - Highest: seeders evaluated before quality (most important factor) Includes translations for French, Spanish, German, Chinese (Simplified), Portuguese (Brazilian), and Portuguese (European). --- .../Indexers/Options/IndexerOptions.tsx | 35 +++++ .../src/typings/Settings/IndexerOptions.ts | 1 + .../PrioritizeDownloadDecisionFixture.cs | 131 ++++++++++++++++++ .../Configuration/ConfigService.cs | 7 + .../Configuration/IConfigService.cs | 1 + .../Configuration/SeedersPreferenceType.cs | 9 ++ .../DownloadDecisionComparer.cs | 36 +++-- src/NzbDrone.Core/Localization/Core/de.json | 5 + src/NzbDrone.Core/Localization/Core/en.json | 5 + src/NzbDrone.Core/Localization/Core/es.json | 5 + src/NzbDrone.Core/Localization/Core/fr.json | 5 + src/NzbDrone.Core/Localization/Core/pt.json | 5 + .../Localization/Core/pt_BR.json | 5 + .../Localization/Core/zh_CN.json | 5 + .../Config/IndexerConfigResource.cs | 2 + 15 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 src/NzbDrone.Core/Configuration/SeedersPreferenceType.cs diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptions.tsx b/frontend/src/Settings/Indexers/Options/IndexerOptions.tsx index 4e9a23ede8..9bde1e94a5 100644 --- a/frontend/src/Settings/Indexers/Options/IndexerOptions.tsx +++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.tsx @@ -6,6 +6,7 @@ import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; +import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import useShowAdvancedSettings from 'Helpers/Hooks/useShowAdvancedSettings'; import { inputTypes, kinds } from 'Helpers/Props'; @@ -25,6 +26,27 @@ import translate from 'Utilities/String/translate'; const SECTION = 'indexerOptions'; +const seedersPreferenceOptions: EnhancedSelectInputValue[] = [ + { + key: 'default', + get value() { + return translate('SeedersPreferenceDefault'); + }, + }, + { + key: 'high', + get value() { + return translate('SeedersPreferenceHigh'); + }, + }, + { + key: 'highest', + get value() { + return translate('SeedersPreferenceHighest'); + }, + }, +]; + interface IndexerOptionsProps { setChildSave: SetChildSave; onChildStateChange: OnChildStateChange; @@ -148,6 +170,19 @@ function IndexerOptions({ /> + + {translate('SeedersPreference')} + + + + {translate('AvailabilityDelay')} diff --git a/frontend/src/typings/Settings/IndexerOptions.ts b/frontend/src/typings/Settings/IndexerOptions.ts index b8cce48b86..d411274659 100644 --- a/frontend/src/typings/Settings/IndexerOptions.ts +++ b/frontend/src/typings/Settings/IndexerOptions.ts @@ -4,6 +4,7 @@ export default interface IndexerOptions { maximumSize: number; rssSyncInterval: number; preferIndexerFlags: boolean; + seedersPreference: string; availabilityDelay: number; whitelistedHardcodedSubs: string[]; allowHardcodedSubs: boolean; diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs index 58576e4ff6..1853576a51 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs @@ -588,5 +588,136 @@ public void ensure_download_decisions_indexer_priority_is_not_perfered_over_qual qualifiedReports.Skip(2).First().RemoteMovie.Should().Be(remoteMovie1); qualifiedReports.Last().RemoteMovie.Should().Be(remoteMovie3); } + + [Test] + public void should_prefer_quality_over_seeders_when_seeders_preference_is_default() + { + Mocker.GetMock() + .Setup(s => s.SeedersPreference) + .Returns(SeedersPreferenceType.Default); + + var remoteMovie1 = GivenRemoteMovie(new QualityModel(Quality.Bluray1080p)); + var remoteMovie2 = GivenRemoteMovie(new QualityModel(Quality.SDTV)); + + var torrentInfo1 = new TorrentInfo(); + torrentInfo1.PublishDate = DateTime.Now; + torrentInfo1.DownloadProtocol = DownloadProtocol.Torrent; + torrentInfo1.Seeders = 10; + torrentInfo1.Size = 200.Megabytes(); + + var torrentInfo2 = torrentInfo1.JsonClone(); + torrentInfo2.Seeders = 1000; + + remoteMovie1.Release = torrentInfo1; + remoteMovie1.Release.Title = "A Movie 1998"; + remoteMovie2.Release = torrentInfo2; + remoteMovie2.Release.Title = "A Movie 1998"; + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteMovie1)); + decisions.Add(new DownloadDecision(remoteMovie2)); + + var qualifiedReports = Subject.PrioritizeDecisionsForMovies(decisions); + qualifiedReports.First().RemoteMovie.ParsedMovieInfo.Quality.Quality.Should().Be(Quality.Bluray1080p); + } + + [Test] + public void should_prefer_seeders_over_quality_when_seeders_preference_is_highest() + { + Mocker.GetMock() + .Setup(s => s.SeedersPreference) + .Returns(SeedersPreferenceType.Highest); + + var remoteMovie1 = GivenRemoteMovie(new QualityModel(Quality.Bluray1080p)); + var remoteMovie2 = GivenRemoteMovie(new QualityModel(Quality.SDTV)); + + var torrentInfo1 = new TorrentInfo(); + torrentInfo1.PublishDate = DateTime.Now; + torrentInfo1.DownloadProtocol = DownloadProtocol.Torrent; + torrentInfo1.Seeders = 10; + torrentInfo1.Size = 200.Megabytes(); + + var torrentInfo2 = torrentInfo1.JsonClone(); + torrentInfo2.Seeders = 1000; + + remoteMovie1.Release = torrentInfo1; + remoteMovie1.Release.Title = "A Movie 1998"; + remoteMovie2.Release = torrentInfo2; + remoteMovie2.Release.Title = "A Movie 1998"; + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteMovie1)); + decisions.Add(new DownloadDecision(remoteMovie2)); + + var qualifiedReports = Subject.PrioritizeDecisionsForMovies(decisions); + ((TorrentInfo)qualifiedReports.First().RemoteMovie.Release).Seeders.Should().Be(1000); + } + + [Test] + public void should_prefer_quality_over_seeders_when_seeders_preference_is_high() + { + Mocker.GetMock() + .Setup(s => s.SeedersPreference) + .Returns(SeedersPreferenceType.High); + + var remoteMovie1 = GivenRemoteMovie(new QualityModel(Quality.Bluray1080p)); + var remoteMovie2 = GivenRemoteMovie(new QualityModel(Quality.SDTV)); + + var torrentInfo1 = new TorrentInfo(); + torrentInfo1.PublishDate = DateTime.Now; + torrentInfo1.DownloadProtocol = DownloadProtocol.Torrent; + torrentInfo1.Seeders = 10; + torrentInfo1.Size = 200.Megabytes(); + + var torrentInfo2 = torrentInfo1.JsonClone(); + torrentInfo2.Seeders = 1000; + + remoteMovie1.Release = torrentInfo1; + remoteMovie1.Release.Title = "A Movie 1998"; + remoteMovie2.Release = torrentInfo2; + remoteMovie2.Release.Title = "A Movie 1998"; + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteMovie1)); + decisions.Add(new DownloadDecision(remoteMovie2)); + + var qualifiedReports = Subject.PrioritizeDecisionsForMovies(decisions); + qualifiedReports.First().RemoteMovie.ParsedMovieInfo.Quality.Quality.Should().Be(Quality.Bluray1080p); + } + + [Test] + public void should_prefer_seeders_over_custom_format_score_when_seeders_preference_is_high() + { + Mocker.GetMock() + .Setup(s => s.SeedersPreference) + .Returns(SeedersPreferenceType.High); + + var remoteMovie1 = GivenRemoteMovie(new QualityModel(Quality.HDTV720p)); + var remoteMovie2 = GivenRemoteMovie(new QualityModel(Quality.HDTV720p)); + + remoteMovie1.CustomFormatScore = 100; + remoteMovie2.CustomFormatScore = 0; + + var torrentInfo1 = new TorrentInfo(); + torrentInfo1.PublishDate = DateTime.Now; + torrentInfo1.DownloadProtocol = DownloadProtocol.Torrent; + torrentInfo1.Seeders = 10; + torrentInfo1.Size = 200.Megabytes(); + + var torrentInfo2 = torrentInfo1.JsonClone(); + torrentInfo2.Seeders = 1000; + + remoteMovie1.Release = torrentInfo1; + remoteMovie1.Release.Title = "A Movie 1998"; + remoteMovie2.Release = torrentInfo2; + remoteMovie2.Release.Title = "A Movie 1998"; + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteMovie1)); + decisions.Add(new DownloadDecision(remoteMovie2)); + + var qualifiedReports = Subject.PrioritizeDecisionsForMovies(decisions); + ((TorrentInfo)qualifiedReports.First().RemoteMovie.Release).Seeders.Should().Be(1000); + } } } diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 361d997eb0..4958e6f5f8 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -148,6 +148,13 @@ public int MinimumAge set { SetValue("MinimumAge", value); } } + public SeedersPreferenceType SeedersPreference + { + get { return GetValueEnum("SeedersPreference", SeedersPreferenceType.Default); } + + set { SetValue("SeedersPreference", 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 f425f540e7..f9ba04b682 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -53,6 +53,7 @@ public interface IConfigService int RssSyncInterval { get; set; } int MaximumSize { get; set; } int MinimumAge { get; set; } + SeedersPreferenceType SeedersPreference { get; set; } bool PreferIndexerFlags { get; set; } diff --git a/src/NzbDrone.Core/Configuration/SeedersPreferenceType.cs b/src/NzbDrone.Core/Configuration/SeedersPreferenceType.cs new file mode 100644 index 0000000000..4984faf05b --- /dev/null +++ b/src/NzbDrone.Core/Configuration/SeedersPreferenceType.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Configuration +{ + public enum SeedersPreferenceType + { + Default, + High, + Highest + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs index 712696eb15..dd32e56748 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs @@ -28,17 +28,33 @@ public DownloadDecisionComparer(IConfigService configService, IDelayProfileServi public int Compare(DownloadDecision x, DownloadDecision y) { - var comparers = new List + var comparers = new List(); + var seedersPreference = _configService.SeedersPreference; + + if (seedersPreference == SeedersPreferenceType.Highest) { - CompareQuality, - CompareCustomFormatScore, - CompareProtocol, - CompareIndexerPriority, - CompareIndexerFlags, - ComparePeersIfTorrent, - CompareAgeIfUsenet, - CompareSize - }; + comparers.Add(ComparePeersIfTorrent); + } + + comparers.Add(CompareQuality); + + if (seedersPreference == SeedersPreferenceType.High) + { + comparers.Add(ComparePeersIfTorrent); + } + + comparers.Add(CompareCustomFormatScore); + comparers.Add(CompareProtocol); + comparers.Add(CompareIndexerPriority); + comparers.Add(CompareIndexerFlags); + + if (seedersPreference == SeedersPreferenceType.Default) + { + comparers.Add(ComparePeersIfTorrent); + } + + comparers.Add(CompareAgeIfUsenet); + comparers.Add(CompareSize); return comparers.Select(comparer => comparer(x, y)).FirstOrDefault(result => result != 0); } diff --git a/src/NzbDrone.Core/Localization/Core/de.json b/src/NzbDrone.Core/Localization/Core/de.json index b77b5b5334..72aa244dd9 100644 --- a/src/NzbDrone.Core/Localization/Core/de.json +++ b/src/NzbDrone.Core/Localization/Core/de.json @@ -212,6 +212,11 @@ "Source": "Quelle", "Shutdown": "Herunterfahren", "Seeders": "Seeders", + "SeedersPreference": "Seeder-Präferenz", + "SeedersPreferenceDefault": "Standard", + "SeedersPreferenceHelpText": "Nur Torrent: Steuert, wie die Seeder-Anzahl die Release-Priorisierung beeinflusst. Standard verwendet Seeder als niedrig priorisierten Tiebreaker. Hoch priorisiert Seeder direkt nach der Qualität. Höchste macht Seeder zum wichtigsten Faktor.", + "SeedersPreferenceHigh": "Hoch (nach Qualität)", + "SeedersPreferenceHighest": "Höchste (vor Qualität)", "Save": "Speichern", "Restart": "Neu starten", "RemoveRootFolder": "Root-Ordner entfernen", diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 23a3b71ab8..7db95cdfd8 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1745,6 +1745,11 @@ "SecretToken": "Secret Token", "Security": "Security", "Seeders": "Seeders", + "SeedersPreference": "Seeders Preference", + "SeedersPreferenceDefault": "Default", + "SeedersPreferenceHelpText": "Torrent only: Controls how seeder count affects release prioritization. Default uses seeders as a low-priority tiebreaker. High prioritizes seeders right after quality. Highest makes seeders the most important factor.", + "SeedersPreferenceHigh": "High (after quality)", + "SeedersPreferenceHighest": "Highest (before quality)", "SelectAll": "Select All", "SelectDownloadClientModalTitle": "{modalTitle} - Select Download Client", "SelectDropdown": "Select...", diff --git a/src/NzbDrone.Core/Localization/Core/es.json b/src/NzbDrone.Core/Localization/Core/es.json index 7295ca3131..52a7114de7 100644 --- a/src/NzbDrone.Core/Localization/Core/es.json +++ b/src/NzbDrone.Core/Localization/Core/es.json @@ -213,6 +213,11 @@ "Source": "Fuente", "Shutdown": "Apagar", "Seeders": "Semillas", + "SeedersPreference": "Preferencia de semillas", + "SeedersPreferenceDefault": "Por defecto", + "SeedersPreferenceHelpText": "Solo torrent: Controla cómo el número de semillas afecta la priorización de releases. Por defecto usa las semillas como desempate de baja prioridad. Alto prioriza las semillas justo después de la calidad. Máximo hace que las semillas sean el factor más importante.", + "SeedersPreferenceHigh": "Alto (después de calidad)", + "SeedersPreferenceHighest": "Máximo (antes de calidad)", "Save": "Guardar", "Restart": "Reiniciar", "RemoveRootFolder": "Eliminar la carpeta raíz", diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index 4300863f99..beb1c840ef 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -227,6 +227,11 @@ "Source": "Source", "Shutdown": "Éteindre", "Seeders": "Seeders", + "SeedersPreference": "Préférence de seeders", + "SeedersPreferenceDefault": "Par défaut", + "SeedersPreferenceHelpText": "Torrent uniquement : Contrôle l'impact du nombre de seeders sur la priorité des releases. Par défaut utilise les seeders comme critère de départage de faible priorité. Élevé priorise les seeders juste après la qualité. Maximum fait des seeders le facteur le plus important.", + "SeedersPreferenceHigh": "Élevé (après la qualité)", + "SeedersPreferenceHighest": "Maximum (avant la qualité)", "Save": "Sauvegarder", "Restart": "Redémarrer", "RemoveRootFolder": "Supprimer le dossier racine", diff --git a/src/NzbDrone.Core/Localization/Core/pt.json b/src/NzbDrone.Core/Localization/Core/pt.json index 5c6ab9ec6f..d7f7ef3a87 100644 --- a/src/NzbDrone.Core/Localization/Core/pt.json +++ b/src/NzbDrone.Core/Localization/Core/pt.json @@ -75,6 +75,11 @@ "SelectFolder": "Selecionar pasta", "SelectAll": "Selecionar todos", "Seeders": "Semeadores", + "SeedersPreference": "Preferência de Semeadores", + "SeedersPreferenceDefault": "Padrão", + "SeedersPreferenceHelpText": "Apenas torrent: Controla como a contagem de semeadores afeta a priorização de releases. Padrão usa semeadores como desempate de baixa prioridade. Alto prioriza semeadores logo após a qualidade. Máximo faz dos semeadores o fator mais importante.", + "SeedersPreferenceHigh": "Alto (após qualidade)", + "SeedersPreferenceHighest": "Máximo (antes da qualidade)", "Security": "Segurança", "SearchSelected": "Pesquisar selecionado(s)", "SearchOnAdd": "Pesquisar ao adicionar", diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index 40871b92ab..1fd2926a1e 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -670,6 +670,11 @@ "SelectFolder": "Selecionar Pasta", "SelectAll": "Selecionar Todos", "Seeders": "Sementes", + "SeedersPreference": "Preferência de Sementes", + "SeedersPreferenceDefault": "Padrão", + "SeedersPreferenceHelpText": "Apenas torrent: Controla como a contagem de sementes afeta a priorização de releases. Padrão usa sementes como desempate de baixa prioridade. Alto prioriza sementes logo após a qualidade. Máximo faz das sementes o fator mais importante.", + "SeedersPreferenceHigh": "Alto (após qualidade)", + "SeedersPreferenceHighest": "Máximo (antes da qualidade)", "Security": "Segurança", "Seconds": "Segundos", "SearchSelected": "Pesquisar selecionado(s)", diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json index 0c10bd0ae1..f03dbb3261 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_CN.json +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -160,6 +160,11 @@ "SelectFolder": "选择文件夹", "SelectAll": "全选", "Seeders": "种子", + "SeedersPreference": "种子偏好", + "SeedersPreferenceDefault": "默认", + "SeedersPreferenceHelpText": "仅限种子:控制种子数量如何影响发布优先级。默认将种子作为低优先级的决胜因素。高优先级在质量之后优先考虑种子。最高优先级使种子成为最重要的因素。", + "SeedersPreferenceHigh": "高(质量之后)", + "SeedersPreferenceHighest": "最高(质量之前)", "Security": "安全", "Seconds": "秒", "SearchSelected": "搜索已选", diff --git a/src/Radarr.Api.V3/Config/IndexerConfigResource.cs b/src/Radarr.Api.V3/Config/IndexerConfigResource.cs index 66b0fdc281..72e0c83108 100644 --- a/src/Radarr.Api.V3/Config/IndexerConfigResource.cs +++ b/src/Radarr.Api.V3/Config/IndexerConfigResource.cs @@ -10,6 +10,7 @@ public class IndexerConfigResource : RestResource public int Retention { get; set; } public int RssSyncInterval { get; set; } public bool PreferIndexerFlags { get; set; } + public SeedersPreferenceType SeedersPreference { get; set; } public int AvailabilityDelay { get; set; } public bool AllowHardcodedSubs { get; set; } public string WhitelistedHardcodedSubs { get; set; } @@ -26,6 +27,7 @@ public static IndexerConfigResource ToResource(IConfigService model) Retention = model.Retention, RssSyncInterval = model.RssSyncInterval, PreferIndexerFlags = model.PreferIndexerFlags, + SeedersPreference = model.SeedersPreference, AvailabilityDelay = model.AvailabilityDelay, AllowHardcodedSubs = model.AllowHardcodedSubs, WhitelistedHardcodedSubs = model.WhitelistedHardcodedSubs,