From 824605325cc9b0fb707c15f674bb6c72493496e1 Mon Sep 17 00:00:00 2001 From: Andrew Nekowitsch Date: Sun, 15 Feb 2026 23:10:34 -0600 Subject: [PATCH 1/5] add rating, genres, years to all trakt import lists --- .../Trakt/List/TraktListRequestGenerator.cs | 20 +++++++++++++ .../Trakt/List/TraktListSettings.cs | 24 +++++++++++++++ .../Trakt/User/TraktUserRequestGenerator.cs | 30 +++++++++++++++++++ .../Trakt/User/TraktUserSettings.cs | 24 +++++++++++++++ src/NzbDrone.Core/Localization/Core/en.json | 4 +-- 5 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs index 08c09d9af..660bf9e36 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs @@ -24,6 +24,26 @@ private IEnumerable GetSeriesRequest() link += $"/users/{Settings.Username.Trim()}/lists/{Settings.Listname.ToUrlSlug()}/items/show,season,episode?limit={Settings.Limit}"; + if (Settings.Rating.IsNotNullOrWhiteSpace()) + { + link += $"&ratings={Settings.Rating}"; + } + + if (Settings.Genres.IsNotNullOrWhiteSpace()) + { + link += $"&genres={Settings.Genres.ToLower()}"; + } + + if (Settings.Years.IsNotNullOrWhiteSpace()) + { + link += $"&years={Settings.Years}"; + } + + if (Settings.TraktAdditionalParameters.IsNotNullOrWhiteSpace()) + { + link += $"&{Settings.TraktAdditionalParameters.TrimStart('?').TrimStart('&')}"; + } + var request = new ImportListRequest(link, HttpAccept.Json); request.HttpRequest.Headers.Add("trakt-api-version", "2"); diff --git a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs index 59ac1acd8..b51173598 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs @@ -1,4 +1,6 @@ +using System.Text.RegularExpressions; using FluentValidation; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; using NzbDrone.Core.Validation; @@ -10,6 +12,16 @@ public TraktListSettingsValidator() { RuleFor(c => c.Username).NotEmpty(); RuleFor(c => c.Listname).NotEmpty(); + + RuleFor(c => c.Rating) + .Matches(@"^\d+\-\d+$", RegexOptions.IgnoreCase) + .When(c => c.Rating.IsNotNullOrWhiteSpace()) + .WithMessage("Not a valid rating"); + + RuleFor(c => c.Years) + .Matches(@"^\d+(\-\d+)?$", RegexOptions.IgnoreCase) + .When(c => c.Years.IsNotNullOrWhiteSpace()) + .WithMessage("Not a valid year or range of years"); } } @@ -23,6 +35,18 @@ public class TraktListSettings : TraktSettingsBase [FieldDefinition(2, Label = "ImportListsTraktSettingsListName", HelpText = "ImportListsTraktSettingsListNameHelpText")] public string Listname { get; set; } + [FieldDefinition(3, Label = "ImportListsTraktSettingsRating", HelpText = "ImportListsTraktSettingsRatingSeriesHelpText")] + public string Rating { get; set; } + + [FieldDefinition(4, Label = "ImportListsTraktSettingsGenres", HelpText = "ImportListsTraktSettingsGenresSeriesHelpText")] + public string Genres { get; set; } + + [FieldDefinition(5, Label = "ImportListsTraktSettingsYears", HelpText = "ImportListsTraktSettingsYearsSeriesHelpText")] + public string Years { get; set; } + + [FieldDefinition(6, Label = "ImportListsTraktSettingsAdditionalParameters", HelpText = "ImportListsTraktSettingsAdditionalParametersHelpText", Advanced = true)] + public string TraktAdditionalParameters { get; set; } + public override NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs index 4ed21a84c..71d1c6ddf 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs @@ -63,11 +63,41 @@ private IEnumerable GetSeriesRequest() .SetHeader("trakt-api-key", _clientId) .AddQueryParam("limit", _settings.Limit.ToString()); + if (_settings.Rating.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddQueryParam("ratings", _settings.Rating); + } + + if (_settings.Genres.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddQueryParam("genres", _settings.Genres.ToLower()); + } + + if (_settings.Years.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddQueryParam("years", _settings.Years); + } + if (_settings.AccessToken.IsNotNullOrWhiteSpace()) { requestBuilder.SetHeader("Authorization", $"Bearer {_settings.AccessToken}"); } + if (_settings.TraktAdditionalParameters.IsNotNullOrWhiteSpace()) + { + var additionalParams = _settings.TraktAdditionalParameters.TrimStart('?').TrimStart('&'); + + foreach (var param in additionalParams.Split('&')) + { + var parts = param.Split('=', 2); + + if (parts.Length == 2) + { + requestBuilder.AddQueryParam(parts[0], parts[1]); + } + } + } + yield return new ImportListRequest(requestBuilder.Build()); } } diff --git a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs index a6ae19c4d..90b9c2bf7 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs @@ -1,4 +1,6 @@ +using System.Text.RegularExpressions; using FluentValidation; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; using NzbDrone.Core.Validation; @@ -11,6 +13,16 @@ public TraktUserSettingsValidator() RuleFor(c => c.TraktListType).NotNull(); RuleFor(c => c.TraktWatchedListType).NotNull(); RuleFor(c => c.AuthUser).NotEmpty(); + + RuleFor(c => c.Rating) + .Matches(@"^\d+\-\d+$", RegexOptions.IgnoreCase) + .When(c => c.Rating.IsNotNullOrWhiteSpace()) + .WithMessage("Not a valid rating"); + + RuleFor(c => c.Years) + .Matches(@"^\d+(\-\d+)?$", RegexOptions.IgnoreCase) + .When(c => c.Years.IsNotNullOrWhiteSpace()) + .WithMessage("Not a valid year or range of years"); } } @@ -37,6 +49,18 @@ public TraktUserSettings() [FieldDefinition(4, Label = "Username", HelpText = "ImportListsTraktSettingsUserListUsernameHelpText")] public string Username { get; set; } + [FieldDefinition(5, Label = "ImportListsTraktSettingsRating", HelpText = "ImportListsTraktSettingsRatingSeriesHelpText")] + public string Rating { get; set; } + + [FieldDefinition(6, Label = "ImportListsTraktSettingsGenres", HelpText = "ImportListsTraktSettingsGenresSeriesHelpText")] + public string Genres { get; set; } + + [FieldDefinition(7, Label = "ImportListsTraktSettingsYears", HelpText = "ImportListsTraktSettingsYearsSeriesHelpText")] + public string Years { get; set; } + + [FieldDefinition(8, Label = "ImportListsTraktSettingsAdditionalParameters", HelpText = "ImportListsTraktSettingsAdditionalParametersHelpText", Advanced = true)] + public string TraktAdditionalParameters { get; set; } + public override NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index cd49e021e..d4d7fe427 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -943,7 +943,7 @@ "ImportListsTraktSettingsAdditionalParametersHelpText": "Additional Trakt API parameters", "ImportListsTraktSettingsAuthenticateWithTrakt": "Authenticate with Trakt", "ImportListsTraktSettingsGenres": "Genres", - "ImportListsTraktSettingsGenresSeriesHelpText": "Filter series by Trakt Genre Slug (Comma Separated) Only for Popular Lists", + "ImportListsTraktSettingsGenresSeriesHelpText": "Filter series by Trakt Genre (action,-romance,-anime)", "ImportListsTraktSettingsLimit": "Limit", "ImportListsTraktSettingsLimitSeriesHelpText": "Limit the number of series to get", "ImportListsTraktSettingsListName": "List Name", @@ -978,7 +978,7 @@ "ImportListsTraktSettingsWatchedListTypeCompleted": "100% Watched", "ImportListsTraktSettingsWatchedListTypeInProgress": "In Progress", "ImportListsTraktSettingsYears": "Years", - "ImportListsTraktSettingsYearsSeriesHelpText": "Filter series by year or year range", + "ImportListsTraktSettingsYearsSeriesHelpText": "Filter series by minimum year or year range (1990-2000)", "ImportListsValidationInvalidApiKey": "API Key is invalid", "ImportListsValidationTestFailed": "Test was aborted due to an error: {exceptionMessage}", "ImportListsValidationUnableToConnectException": "Unable to connect to import list: {exceptionMessage}. Check the log surrounding this error for details.", From 4a0cce15db1a9ce2360fc7999257385ba3799880 Mon Sep 17 00:00:00 2001 From: Andrew Nekowitsch Date: Sun, 15 Feb 2026 23:42:47 -0600 Subject: [PATCH 2/5] add a query helper for trakt --- .../Trakt/List/TraktListRequestGenerator.cs | 23 +---- .../Popular/TraktPopularRequestGenerator.cs | 4 +- .../ImportLists/Trakt/TraktQueryHelper.cs | 87 +++++++++++++++++++ .../Trakt/User/TraktUserRequestGenerator.cs | 32 ++----- 4 files changed, 98 insertions(+), 48 deletions(-) create mode 100644 src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs diff --git a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs index 660bf9e36..a9f6f7c7d 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs @@ -22,27 +22,10 @@ private IEnumerable GetSeriesRequest() { var link = Settings.BaseUrl.Trim(); - link += $"/users/{Settings.Username.Trim()}/lists/{Settings.Listname.ToUrlSlug()}/items/show,season,episode?limit={Settings.Limit}"; + link += $"/users/{Settings.Username.Trim()}/lists/{Settings.Listname.ToUrlSlug()}/items/show,season,episode"; - if (Settings.Rating.IsNotNullOrWhiteSpace()) - { - link += $"&ratings={Settings.Rating}"; - } - - if (Settings.Genres.IsNotNullOrWhiteSpace()) - { - link += $"&genres={Settings.Genres.ToLower()}"; - } - - if (Settings.Years.IsNotNullOrWhiteSpace()) - { - link += $"&years={Settings.Years}"; - } - - if (Settings.TraktAdditionalParameters.IsNotNullOrWhiteSpace()) - { - link += $"&{Settings.TraktAdditionalParameters.TrimStart('?').TrimStart('&')}"; - } + var filterParams = TraktQueryHelper.BuildFilterParameters(Settings.Rating, Settings.Genres, Settings.Years, Settings.Limit, Settings.TraktAdditionalParameters); + link += "?" + filterParams.ToQueryString(); var request = new ImportListRequest(link, HttpAccept.Json); diff --git a/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularRequestGenerator.cs index c9272b3b0..3ff86cf57 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularRequestGenerator.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularRequestGenerator.cs @@ -64,8 +64,8 @@ private IEnumerable GetSeriesRequest() break; } - var filtersAndLimit = $"?years={Settings.Years}&genres={Settings.Genres?.ToLower()}&ratings={Settings.Rating}&limit={Settings.Limit}{Settings.TraktAdditionalParameters}"; - link += filtersAndLimit; + var filterParams = TraktQueryHelper.BuildFilterParameters(Settings.Rating, Settings.Genres, Settings.Years, Settings.Limit, Settings.TraktAdditionalParameters); + link += "?" + filterParams.ToQueryString(); var request = new ImportListRequest(link, HttpAccept.Json); diff --git a/src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs b/src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs new file mode 100644 index 000000000..46cb4e053 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.ImportLists.Trakt +{ + public static class TraktQueryHelper + { + private static readonly HashSet CommaSeparatedParams = new(StringComparer.OrdinalIgnoreCase) + { + "genres", + "certifications", + "networks", + "languages", + "countries", + "status" + }; + + public static Dictionary BuildFilterParameters(string rating, string genres, string years, int limit, string additionalParameters) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Parse additional parameters first (lower priority) + if (additionalParameters.IsNotNullOrWhiteSpace()) + { + var trimmed = additionalParameters.TrimStart('?').TrimStart('&'); + + foreach (var param in trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = param.Split('=', 2); + + if (parts.Length == 2 && parts[0].IsNotNullOrWhiteSpace()) + { + parameters[parts[0].Trim()] = parts[1].Trim(); + } + } + } + + // Apply explicit settings (higher priority) + // For comma-separated params like genres, combine values from both sources + if (genres.IsNotNullOrWhiteSpace()) + { + if (parameters.TryGetValue("genres", out var existingGenres) && existingGenres.IsNotNullOrWhiteSpace()) + { + var allGenres = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var g in genres.ToLower().Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + allGenres.Add(g.Trim()); + } + + foreach (var g in existingGenres.ToLower().Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + allGenres.Add(g.Trim()); + } + + parameters["genres"] = string.Join(",", allGenres); + } + else + { + parameters["genres"] = genres.ToLower(); + } + } + + // For ratings and years, explicit settings override additional parameters + if (rating.IsNotNullOrWhiteSpace()) + { + parameters["ratings"] = rating; + } + + if (years.IsNotNullOrWhiteSpace()) + { + parameters["years"] = years; + } + + parameters["limit"] = limit.ToString(); + + return parameters; + } + + public static string ToQueryString(this Dictionary parameters) + { + return string.Join("&", parameters.Where(p => p.Value.IsNotNullOrWhiteSpace()).Select(p => $"{p.Key}={p.Value}")); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs index 71d1c6ddf..a4a5e2088 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs @@ -63,19 +63,14 @@ private IEnumerable GetSeriesRequest() .SetHeader("trakt-api-key", _clientId) .AddQueryParam("limit", _settings.Limit.ToString()); - if (_settings.Rating.IsNotNullOrWhiteSpace()) - { - requestBuilder.AddQueryParam("ratings", _settings.Rating); - } + var filterParams = TraktQueryHelper.BuildFilterParameters(_settings.Rating, _settings.Genres, _settings.Years, _settings.Limit, _settings.TraktAdditionalParameters); - if (_settings.Genres.IsNotNullOrWhiteSpace()) + foreach (var param in filterParams) { - requestBuilder.AddQueryParam("genres", _settings.Genres.ToLower()); - } - - if (_settings.Years.IsNotNullOrWhiteSpace()) - { - requestBuilder.AddQueryParam("years", _settings.Years); + if (param.Key != "limit") + { + requestBuilder.AddQueryParam(param.Key, param.Value); + } } if (_settings.AccessToken.IsNotNullOrWhiteSpace()) @@ -83,21 +78,6 @@ private IEnumerable GetSeriesRequest() requestBuilder.SetHeader("Authorization", $"Bearer {_settings.AccessToken}"); } - if (_settings.TraktAdditionalParameters.IsNotNullOrWhiteSpace()) - { - var additionalParams = _settings.TraktAdditionalParameters.TrimStart('?').TrimStart('&'); - - foreach (var param in additionalParams.Split('&')) - { - var parts = param.Split('=', 2); - - if (parts.Length == 2) - { - requestBuilder.AddQueryParam(parts[0], parts[1]); - } - } - } - yield return new ImportListRequest(requestBuilder.Build()); } } From a410d0d57ebcf3466985490b9a924527eb20c06d Mon Sep 17 00:00:00 2001 From: Andrew Ukkonen Date: Mon, 16 Feb 2026 17:06:29 -0600 Subject: [PATCH 3/5] Standardize trakt settings (#1) * enforce consistency between generators * separate additional parameters from manually added parameters. update years text between popular and user lists --- .../Trakt/List/TraktListRequestGenerator.cs | 23 +++++---- .../Trakt/List/TraktListSettings.cs | 16 +------ .../Popular/TraktPopularRequestGenerator.cs | 45 +++++++++++------- .../Trakt/Popular/TraktPopularSettings.cs | 11 +---- .../ImportLists/Trakt/TraktQueryHelper.cs | 47 +++++-------------- .../ImportLists/Trakt/TraktSettingsBase.cs | 15 ++++++ .../Trakt/User/TraktUserRequestGenerator.cs | 43 +++++++---------- .../Trakt/User/TraktUserSettings.cs | 11 +---- src/NzbDrone.Core/Localization/Core/en.json | 3 +- 9 files changed, 93 insertions(+), 121 deletions(-) diff --git a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs index a9f6f7c7d..5623e8faa 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListRequestGenerator.cs @@ -20,24 +20,31 @@ public virtual ImportListPageableRequestChain GetListItems() private IEnumerable GetSeriesRequest() { - var link = Settings.BaseUrl.Trim(); + var requestBuilder = new HttpRequestBuilder(Settings.BaseUrl.Trim()); - link += $"/users/{Settings.Username.Trim()}/lists/{Settings.Listname.ToUrlSlug()}/items/show,season,episode"; + requestBuilder + .Resource("/users/{userName}/lists/{listName}/items/show,season,episode") + .SetSegment("userName", Settings.Username.Trim()) + .SetSegment("listName", Settings.Listname.ToUrlSlug()) + .Accept(HttpAccept.Json); var filterParams = TraktQueryHelper.BuildFilterParameters(Settings.Rating, Settings.Genres, Settings.Years, Settings.Limit, Settings.TraktAdditionalParameters); - link += "?" + filterParams.ToQueryString(); - var request = new ImportListRequest(link, HttpAccept.Json); + foreach (var param in filterParams) + { + requestBuilder.AddQueryParam(param.Key, param.Value); + } - request.HttpRequest.Headers.Add("trakt-api-version", "2"); - request.HttpRequest.Headers.Add("trakt-api-key", ClientId); + requestBuilder + .SetHeader("trakt-api-version", "2") + .SetHeader("trakt-api-key", ClientId); if (Settings.AccessToken.IsNotNullOrWhiteSpace()) { - request.HttpRequest.Headers.Add("Authorization", "Bearer " + Settings.AccessToken); + requestBuilder.SetHeader("Authorization", $"Bearer {Settings.AccessToken}"); } - yield return request; + yield return new ImportListRequest(requestBuilder.Build()); } } } diff --git a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs index b51173598..34f0fa223 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs @@ -13,11 +13,6 @@ public TraktListSettingsValidator() RuleFor(c => c.Username).NotEmpty(); RuleFor(c => c.Listname).NotEmpty(); - RuleFor(c => c.Rating) - .Matches(@"^\d+\-\d+$", RegexOptions.IgnoreCase) - .When(c => c.Rating.IsNotNullOrWhiteSpace()) - .WithMessage("Not a valid rating"); - RuleFor(c => c.Years) .Matches(@"^\d+(\-\d+)?$", RegexOptions.IgnoreCase) .When(c => c.Years.IsNotNullOrWhiteSpace()) @@ -35,18 +30,9 @@ public class TraktListSettings : TraktSettingsBase [FieldDefinition(2, Label = "ImportListsTraktSettingsListName", HelpText = "ImportListsTraktSettingsListNameHelpText")] public string Listname { get; set; } - [FieldDefinition(3, Label = "ImportListsTraktSettingsRating", HelpText = "ImportListsTraktSettingsRatingSeriesHelpText")] - public string Rating { get; set; } - - [FieldDefinition(4, Label = "ImportListsTraktSettingsGenres", HelpText = "ImportListsTraktSettingsGenresSeriesHelpText")] - public string Genres { get; set; } - - [FieldDefinition(5, Label = "ImportListsTraktSettingsYears", HelpText = "ImportListsTraktSettingsYearsSeriesHelpText")] + [FieldDefinition(3, Label = "ImportListsTraktSettingsYears", HelpText = "ImportListsTraktSettingsYearsSeriesHelpText")] public string Years { get; set; } - [FieldDefinition(6, Label = "ImportListsTraktSettingsAdditionalParameters", HelpText = "ImportListsTraktSettingsAdditionalParametersHelpText", Advanced = true)] - public string TraktAdditionalParameters { get; set; } - public override NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularRequestGenerator.cs index 3ff86cf57..715fbc475 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularRequestGenerator.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularRequestGenerator.cs @@ -21,63 +21,72 @@ public virtual ImportListPageableRequestChain GetListItems() private IEnumerable GetSeriesRequest() { - var link = Settings.BaseUrl.Trim(); + var requestBuilder = new HttpRequestBuilder(Settings.BaseUrl.Trim()); + + var resource = "/shows"; switch (Settings.TraktListType) { case (int)TraktPopularListType.Trending: - link += "/shows/trending"; + resource += "/trending"; break; case (int)TraktPopularListType.Popular: - link += "/shows/popular"; + resource += "/popular"; break; case (int)TraktPopularListType.Anticipated: - link += "/shows/anticipated"; + resource += "/anticipated"; break; case (int)TraktPopularListType.TopWatchedByWeek: - link += "/shows/watched/weekly"; + resource += "/watched/weekly"; break; case (int)TraktPopularListType.TopWatchedByMonth: - link += "/shows/watched/monthly"; + resource += "/watched/monthly"; break; #pragma warning disable CS0612 case (int)TraktPopularListType.TopWatchedByYear: #pragma warning restore CS0612 - link += "/shows/watched/yearly"; + resource += "/watched/yearly"; break; case (int)TraktPopularListType.TopWatchedByAllTime: - link += "/shows/watched/all"; + resource += "/watched/all"; break; case (int)TraktPopularListType.RecommendedByWeek: - link += "/shows/recommended/weekly"; + resource += "/recommended/weekly"; break; case (int)TraktPopularListType.RecommendedByMonth: - link += "/shows/recommended/monthly"; + resource += "/recommended/monthly"; break; #pragma warning disable CS0612 case (int)TraktPopularListType.RecommendedByYear: #pragma warning restore CS0612 - link += "/shows/recommended/yearly"; + resource += "/recommended/yearly"; break; case (int)TraktPopularListType.RecommendedByAllTime: - link += "/shows/recommended/all"; + resource += "/recommended/all"; break; } + requestBuilder + .Resource(resource) + .Accept(HttpAccept.Json); + var filterParams = TraktQueryHelper.BuildFilterParameters(Settings.Rating, Settings.Genres, Settings.Years, Settings.Limit, Settings.TraktAdditionalParameters); - link += "?" + filterParams.ToQueryString(); - var request = new ImportListRequest(link, HttpAccept.Json); + foreach (var param in filterParams) + { + requestBuilder.AddQueryParam(param.Key, param.Value); + } - request.HttpRequest.Headers.Add("trakt-api-version", "2"); - request.HttpRequest.Headers.Add("trakt-api-key", ClientId); + requestBuilder + .SetHeader("trakt-api-version", "2") + .SetHeader("trakt-api-key", ClientId); if (Settings.AccessToken.IsNotNullOrWhiteSpace()) { - request.HttpRequest.Headers.Add("Authorization", "Bearer " + Settings.AccessToken); + requestBuilder.SetHeader("Authorization", $"Bearer {Settings.AccessToken}"); } - yield return request; + yield return new ImportListRequest(requestBuilder.Build()); } } } diff --git a/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularSettings.cs b/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularSettings.cs index 9694e308c..0dcc34b44 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularSettings.cs @@ -45,18 +45,9 @@ public TraktPopularSettings() [FieldDefinition(1, Label = "ImportListsTraktSettingsListType", Type = FieldType.Select, SelectOptions = typeof(TraktPopularListType), HelpText = "ImportListsTraktSettingsListTypeHelpText")] public int TraktListType { get; set; } - [FieldDefinition(2, Label = "ImportListsTraktSettingsRating", HelpText = "ImportListsTraktSettingsRatingSeriesHelpText")] - public string Rating { get; set; } - - [FieldDefinition(4, Label = "ImportListsTraktSettingsGenres", HelpText = "ImportListsTraktSettingsGenresSeriesHelpText")] - public string Genres { get; set; } - - [FieldDefinition(5, Label = "ImportListsTraktSettingsYears", HelpText = "ImportListsTraktSettingsYearsSeriesHelpText")] + [FieldDefinition(2, Label = "ImportListsTraktSettingsYears", HelpText = "ImportListsTraktSettingsYearsSeriesHelpTextPopular")] public string Years { get; set; } - [FieldDefinition(6, Label = "ImportListsTraktSettingsAdditionalParameters", HelpText = "ImportListsTraktSettingsAdditionalParametersHelpText", Advanced = true)] - public string TraktAdditionalParameters { get; set; } - public override NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs b/src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs index 46cb4e053..003b2872d 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs @@ -7,16 +7,6 @@ namespace NzbDrone.Core.ImportLists.Trakt { public static class TraktQueryHelper { - private static readonly HashSet CommaSeparatedParams = new(StringComparer.OrdinalIgnoreCase) - { - "genres", - "certifications", - "networks", - "languages", - "countries", - "status" - }; - public static Dictionary BuildFilterParameters(string rating, string genres, string years, int limit, string additionalParameters) { var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -32,38 +22,27 @@ public static Dictionary BuildFilterParameters(string rating, st if (parts.Length == 2 && parts[0].IsNotNullOrWhiteSpace()) { - parameters[parts[0].Trim()] = parts[1].Trim(); + var key = parts[0].Trim(); + + // Skip explicitly handled parameters + if (key.Equals("genres", StringComparison.OrdinalIgnoreCase) || + key.Equals("ratings", StringComparison.OrdinalIgnoreCase) || + key.Equals("years", StringComparison.OrdinalIgnoreCase) || + key.Equals("limit", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + parameters[key] = parts[1].Trim(); } } } - // Apply explicit settings (higher priority) - // For comma-separated params like genres, combine values from both sources if (genres.IsNotNullOrWhiteSpace()) { - if (parameters.TryGetValue("genres", out var existingGenres) && existingGenres.IsNotNullOrWhiteSpace()) - { - var allGenres = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var g in genres.ToLower().Split(',', StringSplitOptions.RemoveEmptyEntries)) - { - allGenres.Add(g.Trim()); - } - - foreach (var g in existingGenres.ToLower().Split(',', StringSplitOptions.RemoveEmptyEntries)) - { - allGenres.Add(g.Trim()); - } - - parameters["genres"] = string.Join(",", allGenres); - } - else - { - parameters["genres"] = genres.ToLower(); - } + parameters["genres"] = genres.ToLower(); } - // For ratings and years, explicit settings override additional parameters if (rating.IsNotNullOrWhiteSpace()) { parameters["ratings"] = rating; diff --git a/src/NzbDrone.Core/ImportLists/Trakt/TraktSettingsBase.cs b/src/NzbDrone.Core/ImportLists/Trakt/TraktSettingsBase.cs index 03834afa6..73aa8d977 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/TraktSettingsBase.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/TraktSettingsBase.cs @@ -1,4 +1,5 @@ using System; +using System.Text.RegularExpressions; using FluentValidation; using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; @@ -31,6 +32,11 @@ public TraktSettingsBaseValidator() RuleFor(c => c.Limit) .GreaterThan(0) .WithMessage("Must be integer greater than 0"); + + RuleFor(c => c.Rating) + .Matches(@"^\d+\-\d+$", RegexOptions.IgnoreCase) + .When(c => c.Rating.IsNotNullOrWhiteSpace()) + .WithMessage("Not a valid rating"); } } @@ -59,6 +65,15 @@ public TraktSettingsBase() [FieldDefinition(0, Label = "ImportListsSettingsAuthUser", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] public string AuthUser { get; set; } + [FieldDefinition(95, Label = "ImportListsTraktSettingsRating", HelpText = "ImportListsTraktSettingsRatingSeriesHelpText")] + public string Rating { get; set; } + + [FieldDefinition(96, Label = "ImportListsTraktSettingsGenres", HelpText = "ImportListsTraktSettingsGenresSeriesHelpText")] + public string Genres { get; set; } + + [FieldDefinition(97, Label = "ImportListsTraktSettingsAdditionalParameters", HelpText = "ImportListsTraktSettingsAdditionalParametersHelpText", Advanced = true)] + public string TraktAdditionalParameters { get; set; } + [FieldDefinition(98, Label = "ImportListsTraktSettingsLimit", HelpText = "ImportListsTraktSettingsLimitSeriesHelpText")] public int Limit { get; set; } diff --git a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs index a4a5e2088..d4724c08b 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserRequestGenerator.cs @@ -26,7 +26,9 @@ public virtual ImportListPageableRequestChain GetListItems() private IEnumerable GetSeriesRequest() { - var requestBuilder = new HttpRequestBuilder(_settings.BaseUrl.Trim()); + var link = _settings.BaseUrl.Trim(); + + var userName = _settings.Username.IsNotNullOrWhiteSpace() ? _settings.Username.Trim() : _settings.AuthUser.Trim(); switch (_settings.TraktListType) { @@ -39,46 +41,37 @@ private IEnumerable GetSeriesRequest() _ => "rank" }; - requestBuilder - .Resource("/users/{userName}/watchlist/shows/{sorting}") - .SetSegment("sorting", watchSorting); + link += $"/users/{userName}/watchlist/shows/{watchSorting}"; break; case (int)TraktUserListType.UserWatchedList: - requestBuilder - .Resource("/users/{userName}/watched/shows") - .AddQueryParam("extended", "full"); + link += $"/users/{userName}/watched/shows"; break; case (int)TraktUserListType.UserCollectionList: - requestBuilder.Resource("/users/{userName}/collection/shows"); + link += $"/users/{userName}/collection/shows"; break; } - var userName = _settings.Username.IsNotNullOrWhiteSpace() ? _settings.Username.Trim() : _settings.AuthUser.Trim(); - - requestBuilder - .SetSegment("userName", userName) - .Accept(HttpAccept.Json) - .WithRateLimit(4) - .SetHeader("trakt-api-version", "2") - .SetHeader("trakt-api-key", _clientId) - .AddQueryParam("limit", _settings.Limit.ToString()); - var filterParams = TraktQueryHelper.BuildFilterParameters(_settings.Rating, _settings.Genres, _settings.Years, _settings.Limit, _settings.TraktAdditionalParameters); - foreach (var param in filterParams) + // Add extended parameter for watched list + if (_settings.TraktListType == (int)TraktUserListType.UserWatchedList) { - if (param.Key != "limit") - { - requestBuilder.AddQueryParam(param.Key, param.Value); - } + filterParams["extended"] = "full"; } + link += "?" + filterParams.ToQueryString(); + + var request = new ImportListRequest(link, HttpAccept.Json); + + request.HttpRequest.Headers.Add("trakt-api-version", "2"); + request.HttpRequest.Headers.Add("trakt-api-key", _clientId); + if (_settings.AccessToken.IsNotNullOrWhiteSpace()) { - requestBuilder.SetHeader("Authorization", $"Bearer {_settings.AccessToken}"); + request.HttpRequest.Headers.Add("Authorization", $"Bearer {_settings.AccessToken}"); } - yield return new ImportListRequest(requestBuilder.Build()); + yield return request; } } } diff --git a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs index 90b9c2bf7..1309a4e0e 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs @@ -49,18 +49,9 @@ public TraktUserSettings() [FieldDefinition(4, Label = "Username", HelpText = "ImportListsTraktSettingsUserListUsernameHelpText")] public string Username { get; set; } - [FieldDefinition(5, Label = "ImportListsTraktSettingsRating", HelpText = "ImportListsTraktSettingsRatingSeriesHelpText")] - public string Rating { get; set; } - - [FieldDefinition(6, Label = "ImportListsTraktSettingsGenres", HelpText = "ImportListsTraktSettingsGenresSeriesHelpText")] - public string Genres { get; set; } - - [FieldDefinition(7, Label = "ImportListsTraktSettingsYears", HelpText = "ImportListsTraktSettingsYearsSeriesHelpText")] + [FieldDefinition(5, Label = "ImportListsTraktSettingsYears", HelpText = "ImportListsTraktSettingsYearsSeriesHelpText")] public string Years { get; set; } - [FieldDefinition(8, Label = "ImportListsTraktSettingsAdditionalParameters", HelpText = "ImportListsTraktSettingsAdditionalParametersHelpText", Advanced = true)] - public string TraktAdditionalParameters { get; set; } - public override NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index d4d7fe427..75521aede 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -943,7 +943,7 @@ "ImportListsTraktSettingsAdditionalParametersHelpText": "Additional Trakt API parameters", "ImportListsTraktSettingsAuthenticateWithTrakt": "Authenticate with Trakt", "ImportListsTraktSettingsGenres": "Genres", - "ImportListsTraktSettingsGenresSeriesHelpText": "Filter series by Trakt Genre (action,-romance,-anime)", + "ImportListsTraktSettingsGenresSeriesHelpText": "Filter series by Trakt Genre slug (action,comedy)", "ImportListsTraktSettingsLimit": "Limit", "ImportListsTraktSettingsLimitSeriesHelpText": "Limit the number of series to get", "ImportListsTraktSettingsListName": "List Name", @@ -979,6 +979,7 @@ "ImportListsTraktSettingsWatchedListTypeInProgress": "In Progress", "ImportListsTraktSettingsYears": "Years", "ImportListsTraktSettingsYearsSeriesHelpText": "Filter series by minimum year or year range (1990-2000)", + "ImportListsTraktSettingsYearsSeriesHelpTextPopular": "Filter series by a specific year or year range (1990-2000)", "ImportListsValidationInvalidApiKey": "API Key is invalid", "ImportListsValidationTestFailed": "Test was aborted due to an error: {exceptionMessage}", "ImportListsValidationUnableToConnectException": "Unable to connect to import list: {exceptionMessage}. Check the log surrounding this error for details.", From 116c602992fc5cbdb713184375bbac3747d22b37 Mon Sep 17 00:00:00 2001 From: Andrew Ukkonen Date: Sun, 22 Mar 2026 19:04:24 -0500 Subject: [PATCH 4/5] Update src/NzbDrone.Core/Localization/Core/en.json Co-authored-by: Mark McDowall --- src/NzbDrone.Core/Localization/Core/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 348f77766..a932cbafb 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -948,7 +948,7 @@ "ImportListsTraktSettingsAdditionalParametersHelpText": "Additional Trakt API parameters", "ImportListsTraktSettingsAuthenticateWithTrakt": "Authenticate with Trakt", "ImportListsTraktSettingsGenres": "Genres", - "ImportListsTraktSettingsGenresSeriesHelpText": "Filter series by Trakt Genre slug (action,comedy)", + "ImportListsTraktSettingsGenresSeriesHelpText": "Filter series by Trakt Genre slug, comma separated (action,comedy)", "ImportListsTraktSettingsLimit": "Limit", "ImportListsTraktSettingsLimitSeriesHelpText": "Limit the number of series to get", "ImportListsTraktSettingsListName": "List Name", From 109738660f6b6b7f66d40876803f78132c2f8a00 Mon Sep 17 00:00:00 2001 From: Andrew Nekowitsch Date: Mon, 4 May 2026 16:52:15 -0500 Subject: [PATCH 5/5] Refactor Trakt settings validation and add unit tests for rating and year filters --- global.json | 2 +- .../Trakt/TraktValidationFixture.cs | 137 ++++++++++++++++++ .../Trakt/List/TraktListSettings.cs | 3 +- .../Trakt/Popular/TraktPopularSettings.cs | 10 +- .../ImportLists/Trakt/TraktQueryHelper.cs | 37 ++++- .../ImportLists/Trakt/TraktSettingsBase.cs | 66 ++++++++- .../Trakt/User/TraktUserSettings.cs | 8 +- 7 files changed, 236 insertions(+), 27 deletions(-) create mode 100644 src/NzbDrone.Core.Test/ImportListTests/Trakt/TraktValidationFixture.cs diff --git a/global.json b/global.json index c2af57a3f..058bafa62 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "10.0.102" + "version": "10.0.103" } } diff --git a/src/NzbDrone.Core.Test/ImportListTests/Trakt/TraktValidationFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Trakt/TraktValidationFixture.cs new file mode 100644 index 000000000..8a29e948e --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/Trakt/TraktValidationFixture.cs @@ -0,0 +1,137 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.ImportLists.Trakt; +using NzbDrone.Core.ImportLists.Trakt.List; +using NzbDrone.Core.ImportLists.Trakt.Popular; +using NzbDrone.Core.ImportLists.Trakt.User; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ImportListTests.Trakt +{ + [TestFixture] + public class TraktValidationFixture : CoreTest + { + [TestCase("0-100")] + [TestCase("50-50")] + [TestCase("100-100")] + public void should_accept_supported_rating_ranges(string rating) + { + CreateValidListSettings(rating: rating).Validate().IsValid.Should().BeTrue(); + CreateValidPopularSettings(rating: rating).Validate().IsValid.Should().BeTrue(); + CreateValidUserSettings(rating: rating).Validate().IsValid.Should().BeTrue(); + } + + [TestCase("10")] + [TestCase("10-5")] + [TestCase("00-10")] + [TestCase("10-101")] + [TestCase("1000-1000")] + [TestCase("10 - 20")] + public void should_reject_invalid_rating_ranges(string rating) + { + CreateValidListSettings(rating: rating).Validate().IsValid.Should().BeFalse(); + CreateValidPopularSettings(rating: rating).Validate().IsValid.Should().BeFalse(); + CreateValidUserSettings(rating: rating).Validate().IsValid.Should().BeFalse(); + } + + [TestCase("1990")] + [TestCase("1990-2000")] + public void should_accept_supported_year_filters(string years) + { + CreateValidListSettings(years: years).Validate().IsValid.Should().BeTrue(); + CreateValidPopularSettings(years: years).Validate().IsValid.Should().BeTrue(); + CreateValidUserSettings(years: years).Validate().IsValid.Should().BeTrue(); + } + + [TestCase("234923498237423-234723477")] + [TestCase("199")] + [TestCase("1990-1980")] + [TestCase("1990 - 2000")] + public void should_reject_invalid_year_filters(string years) + { + CreateValidListSettings(years: years).Validate().IsValid.Should().BeFalse(); + CreateValidPopularSettings(years: years).Validate().IsValid.Should().BeFalse(); + CreateValidUserSettings(years: years).Validate().IsValid.Should().BeFalse(); + } + + [TestCase("genres=comedy")] + [TestCase("ratings=80-100")] + [TestCase("years=1990-2000")] + [TestCase("limit=10")] + public void should_reject_reserved_additional_parameters(string additionalParameters) + { + CreateValidListSettings(additionalParameters: additionalParameters).Validate().IsValid.Should().BeFalse(); + CreateValidPopularSettings(additionalParameters: additionalParameters).Validate().IsValid.Should().BeFalse(); + CreateValidUserSettings(additionalParameters: additionalParameters).Validate().IsValid.Should().BeFalse(); + } + + [Test] + public void should_allow_non_reserved_additional_parameters() + { + CreateValidListSettings(additionalParameters: "languages=en").Validate().IsValid.Should().BeTrue(); + CreateValidPopularSettings(additionalParameters: "languages=en").Validate().IsValid.Should().BeTrue(); + CreateValidUserSettings(additionalParameters: "languages=en").Validate().IsValid.Should().BeTrue(); + } + + [Test] + public void should_ignore_reserved_additional_parameters_when_building_filter_parameters() + { + var parameters = TraktQueryHelper.BuildFilterParameters( + "80-100", + "Drama", + "1990-2000", + 25, + "genres=comedy&ratings=10-20&years=2000-2010&limit=10&languages=en"); + + parameters.Should().ContainKey("genres").WhoseValue.Should().Be("drama"); + parameters.Should().ContainKey("ratings").WhoseValue.Should().Be("80-100"); + parameters.Should().ContainKey("years").WhoseValue.Should().Be("1990-2000"); + parameters.Should().ContainKey("limit").WhoseValue.Should().Be("25"); + parameters.Should().ContainKey("languages").WhoseValue.Should().Be("en"); + parameters.Should().HaveCount(5); + } + + private static TraktListSettings CreateValidListSettings(string rating = "80-100", string years = "1990-2000", string additionalParameters = "languages=en") + { + return new TraktListSettings + { + AccessToken = "access-token", + RefreshToken = "refresh-token", + Expires = DateTime.UtcNow.AddDays(1), + Username = "sonarr", + Listname = "watchlist", + Rating = rating, + Years = years, + TraktAdditionalParameters = additionalParameters + }; + } + + private static TraktPopularSettings CreateValidPopularSettings(string rating = "80-100", string years = "1990-2000", string additionalParameters = "languages=en") + { + return new TraktPopularSettings + { + AccessToken = "access-token", + RefreshToken = "refresh-token", + Expires = DateTime.UtcNow.AddDays(1), + Rating = rating, + Years = years, + TraktAdditionalParameters = additionalParameters + }; + } + + private static TraktUserSettings CreateValidUserSettings(string rating = "80-100", string years = "1990-2000", string additionalParameters = "languages=en") + { + return new TraktUserSettings + { + AccessToken = "access-token", + RefreshToken = "refresh-token", + Expires = DateTime.UtcNow.AddDays(1), + AuthUser = "sonarr-user", + Rating = rating, + Years = years, + TraktAdditionalParameters = additionalParameters + }; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs index 34f0fa223..4d3100f24 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/List/TraktListSettings.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using FluentValidation; using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; @@ -14,7 +13,7 @@ public TraktListSettingsValidator() RuleFor(c => c.Listname).NotEmpty(); RuleFor(c => c.Years) - .Matches(@"^\d+(\-\d+)?$", RegexOptions.IgnoreCase) + .Must(BeValidYearRange) .When(c => c.Years.IsNotNullOrWhiteSpace()) .WithMessage("Not a valid year or range of years"); } diff --git a/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularSettings.cs b/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularSettings.cs index 0dcc34b44..1a8b2f227 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/Popular/TraktPopularSettings.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using FluentValidation; using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; @@ -19,15 +18,8 @@ public TraktPopularSettingsValidator() .WithMessage("Yearly lists are no longer supported"); #pragma warning restore CS0612 - // Loose validation @TODO - RuleFor(c => c.Rating) - .Matches(@"^\d+\-\d+$", RegexOptions.IgnoreCase) - .When(c => c.Rating.IsNotNullOrWhiteSpace()) - .WithMessage("Not a valid rating"); - - // Loose validation @TODO RuleFor(c => c.Years) - .Matches(@"^\d+(\-\d+)?$", RegexOptions.IgnoreCase) + .Must(BeValidYearRange) .When(c => c.Years.IsNotNullOrWhiteSpace()) .WithMessage("Not a valid year or range of years"); } diff --git a/src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs b/src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs index 003b2872d..133068365 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/TraktQueryHelper.cs @@ -7,11 +7,18 @@ namespace NzbDrone.Core.ImportLists.Trakt { public static class TraktQueryHelper { + private static readonly HashSet ReservedFilterParameters = new(StringComparer.OrdinalIgnoreCase) + { + "genres", + "ratings", + "years", + "limit" + }; + public static Dictionary BuildFilterParameters(string rating, string genres, string years, int limit, string additionalParameters) { var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); - // Parse additional parameters first (lower priority) if (additionalParameters.IsNotNullOrWhiteSpace()) { var trimmed = additionalParameters.TrimStart('?').TrimStart('&'); @@ -24,11 +31,7 @@ public static Dictionary BuildFilterParameters(string rating, st { var key = parts[0].Trim(); - // Skip explicitly handled parameters - if (key.Equals("genres", StringComparison.OrdinalIgnoreCase) || - key.Equals("ratings", StringComparison.OrdinalIgnoreCase) || - key.Equals("years", StringComparison.OrdinalIgnoreCase) || - key.Equals("limit", StringComparison.OrdinalIgnoreCase)) + if (ReservedFilterParameters.Contains(key)) { continue; } @@ -58,6 +61,28 @@ public static Dictionary BuildFilterParameters(string rating, st return parameters; } + public static bool ContainsReservedFilterParameters(string additionalParameters) + { + if (additionalParameters.IsNullOrWhiteSpace()) + { + return false; + } + + var trimmed = additionalParameters.TrimStart('?').TrimStart('&'); + + foreach (var param in trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = param.Split('=', 2); + + if (parts.Length == 2 && parts[0].IsNotNullOrWhiteSpace() && ReservedFilterParameters.Contains(parts[0].Trim())) + { + return true; + } + } + + return false; + } + public static string ToQueryString(this Dictionary parameters) { return string.Join("&", parameters.Where(p => p.Value.IsNotNullOrWhiteSpace()).Select(p => $"{p.Key}={p.Value}")); diff --git a/src/NzbDrone.Core/ImportLists/Trakt/TraktSettingsBase.cs b/src/NzbDrone.Core/ImportLists/Trakt/TraktSettingsBase.cs index 73aa8d977..c7757fbad 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/TraktSettingsBase.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/TraktSettingsBase.cs @@ -1,5 +1,5 @@ using System; -using System.Text.RegularExpressions; +using System.Globalization; using FluentValidation; using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; @@ -34,9 +34,71 @@ public TraktSettingsBaseValidator() .WithMessage("Must be integer greater than 0"); RuleFor(c => c.Rating) - .Matches(@"^\d+\-\d+$", RegexOptions.IgnoreCase) + .Must(BeValidRatingRange) .When(c => c.Rating.IsNotNullOrWhiteSpace()) .WithMessage("Not a valid rating"); + + RuleFor(c => c.TraktAdditionalParameters) + .Must(additionalParameters => !TraktQueryHelper.ContainsReservedFilterParameters(additionalParameters)) + .When(c => c.TraktAdditionalParameters.IsNotNullOrWhiteSpace()) + .WithMessage("Additional parameters cannot include genres, ratings, years, or limit"); + } + + protected static bool BeValidYearRange(string years) + { + var parts = years.Split('-', StringSplitOptions.None); + + if (parts.Length == 1) + { + return TryParseYear(parts[0], out _); + } + + if (parts.Length != 2) + { + return false; + } + + return TryParseYear(parts[0], out var startYear) && + TryParseYear(parts[1], out var endYear) && + startYear <= endYear; + } + + private static bool BeValidRatingRange(string rating) + { + var parts = rating.Split('-', StringSplitOptions.None); + + if (parts.Length != 2) + { + return false; + } + + return TryParseRating(parts[0], out var minimumRating) && + TryParseRating(parts[1], out var maximumRating) && + minimumRating <= maximumRating; + } + + private static bool TryParseYear(string token, out int year) + { + year = default; + + return token.Length == 4 && + int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out year) && + year >= 1000; + } + + private static bool TryParseRating(string token, out int rating) + { + if (!int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out rating)) + { + return false; + } + + if (rating is < 0 or > 100) + { + return false; + } + + return token == rating.ToString(CultureInfo.InvariantCulture); } } diff --git a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs index 1309a4e0e..32e55cf40 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/User/TraktUserSettings.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using FluentValidation; using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; @@ -14,13 +13,8 @@ public TraktUserSettingsValidator() RuleFor(c => c.TraktWatchedListType).NotNull(); RuleFor(c => c.AuthUser).NotEmpty(); - RuleFor(c => c.Rating) - .Matches(@"^\d+\-\d+$", RegexOptions.IgnoreCase) - .When(c => c.Rating.IsNotNullOrWhiteSpace()) - .WithMessage("Not a valid rating"); - RuleFor(c => c.Years) - .Matches(@"^\d+(\-\d+)?$", RegexOptions.IgnoreCase) + .Must(BeValidYearRange) .When(c => c.Years.IsNotNullOrWhiteSpace()) .WithMessage("Not a valid year or range of years"); }