This commit is contained in:
Andrew Ukkonen 2026-05-04 21:52:46 +00:00 committed by GitHub
commit f7c1b361b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 394 additions and 65 deletions

View file

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

View file

@ -20,21 +20,31 @@ public virtual ImportListPageableRequestChain GetListItems()
private IEnumerable<ImportListRequest> 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?limit={Settings.Limit}";
requestBuilder
.Resource("/users/{userName}/lists/{listName}/items/show,season,episode")
.SetSegment("userName", Settings.Username.Trim())
.SetSegment("listName", Settings.Listname.ToUrlSlug())
.Accept(HttpAccept.Json);
var request = new ImportListRequest(link, HttpAccept.Json);
var filterParams = TraktQueryHelper.BuildFilterParameters(Settings.Rating, Settings.Genres, Settings.Years, Settings.Limit, Settings.TraktAdditionalParameters);
request.HttpRequest.Headers.Add("trakt-api-version", "2");
request.HttpRequest.Headers.Add("trakt-api-key", ClientId);
foreach (var param in filterParams)
{
requestBuilder.AddQueryParam(param.Key, param.Value);
}
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());
}
}
}

View file

@ -1,4 +1,5 @@
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
@ -10,6 +11,11 @@ public TraktListSettingsValidator()
{
RuleFor(c => c.Username).NotEmpty();
RuleFor(c => c.Listname).NotEmpty();
RuleFor(c => c.Years)
.Must(BeValidYearRange)
.When(c => c.Years.IsNotNullOrWhiteSpace())
.WithMessage("Not a valid year or range of years");
}
}
@ -23,6 +29,9 @@ public class TraktListSettings : TraktSettingsBase<TraktListSettings>
[FieldDefinition(2, Label = "ImportListsTraktSettingsListName", HelpText = "ImportListsTraktSettingsListNameHelpText")]
public string Listname { get; set; }
[FieldDefinition(3, Label = "ImportListsTraktSettingsYears", HelpText = "ImportListsTraktSettingsYearsSeriesHelpText")]
public string Years { get; set; }
public override NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));

View file

@ -21,63 +21,72 @@ public virtual ImportListPageableRequestChain GetListItems()
private IEnumerable<ImportListRequest> 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;
}
var filtersAndLimit = $"?years={Settings.Years}&genres={Settings.Genres?.ToLower()}&ratings={Settings.Rating}&limit={Settings.Limit}{Settings.TraktAdditionalParameters}";
link += filtersAndLimit;
requestBuilder
.Resource(resource)
.Accept(HttpAccept.Json);
var request = new ImportListRequest(link, HttpAccept.Json);
var filterParams = TraktQueryHelper.BuildFilterParameters(Settings.Rating, Settings.Genres, Settings.Years, Settings.Limit, Settings.TraktAdditionalParameters);
request.HttpRequest.Headers.Add("trakt-api-version", "2");
request.HttpRequest.Headers.Add("trakt-api-key", ClientId);
foreach (var param in filterParams)
{
requestBuilder.AddQueryParam(param.Key, param.Value);
}
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());
}
}
}

View file

@ -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");
}
@ -45,18 +37,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));

View file

@ -0,0 +1,91 @@
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<string> ReservedFilterParameters = new(StringComparer.OrdinalIgnoreCase)
{
"genres",
"ratings",
"years",
"limit"
};
public static Dictionary<string, string> BuildFilterParameters(string rating, string genres, string years, int limit, string additionalParameters)
{
var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
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())
{
var key = parts[0].Trim();
if (ReservedFilterParameters.Contains(key))
{
continue;
}
parameters[key] = parts[1].Trim();
}
}
}
if (genres.IsNotNullOrWhiteSpace())
{
parameters["genres"] = genres.ToLower();
}
if (rating.IsNotNullOrWhiteSpace())
{
parameters["ratings"] = rating;
}
if (years.IsNotNullOrWhiteSpace())
{
parameters["years"] = years;
}
parameters["limit"] = limit.ToString();
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<string, string> parameters)
{
return string.Join("&", parameters.Where(p => p.Value.IsNotNullOrWhiteSpace()).Select(p => $"{p.Key}={p.Value}"));
}
}
}

View file

@ -1,4 +1,5 @@
using System;
using System.Globalization;
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
@ -31,6 +32,73 @@ public TraktSettingsBaseValidator()
RuleFor(c => c.Limit)
.GreaterThan(0)
.WithMessage("Must be integer greater than 0");
RuleFor(c => c.Rating)
.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);
}
}
@ -59,6 +127,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; }

View file

@ -26,7 +26,9 @@ public virtual ImportListPageableRequestChain GetListItems()
private IEnumerable<ImportListRequest> 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,36 +41,37 @@ private IEnumerable<ImportListRequest> 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();
var filterParams = TraktQueryHelper.BuildFilterParameters(_settings.Rating, _settings.Genres, _settings.Years, _settings.Limit, _settings.TraktAdditionalParameters);
requestBuilder
.SetSegment("userName", userName)
.Accept(HttpAccept.Json)
.WithRateLimit(4)
.SetHeader("trakt-api-version", "2")
.SetHeader("trakt-api-key", _clientId)
.AddQueryParam("limit", _settings.Limit.ToString());
// Add extended parameter for watched list
if (_settings.TraktListType == (int)TraktUserListType.UserWatchedList)
{
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;
}
}
}

View file

@ -1,4 +1,5 @@
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
@ -11,6 +12,11 @@ public TraktUserSettingsValidator()
RuleFor(c => c.TraktListType).NotNull();
RuleFor(c => c.TraktWatchedListType).NotNull();
RuleFor(c => c.AuthUser).NotEmpty();
RuleFor(c => c.Years)
.Must(BeValidYearRange)
.When(c => c.Years.IsNotNullOrWhiteSpace())
.WithMessage("Not a valid year or range of years");
}
}
@ -37,6 +43,9 @@ public TraktUserSettings()
[FieldDefinition(4, Label = "Username", HelpText = "ImportListsTraktSettingsUserListUsernameHelpText")]
public string Username { get; set; }
[FieldDefinition(5, Label = "ImportListsTraktSettingsYears", HelpText = "ImportListsTraktSettingsYearsSeriesHelpText")]
public string Years { get; set; }
public override NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));

View file

@ -957,7 +957,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 slug, comma separated (action,comedy)",
"ImportListsTraktSettingsLimit": "Limit",
"ImportListsTraktSettingsLimitSeriesHelpText": "Limit the number of series to get",
"ImportListsTraktSettingsListName": "List Name",
@ -992,7 +992,8 @@
"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)",
"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.",