diff --git a/src/NzbDrone.Core.Test/ImportListTests/Filmweb/FilmwebParserFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Filmweb/FilmwebParserFixture.cs new file mode 100644 index 0000000000..7fa8d4c2e0 --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/Filmweb/FilmwebParserFixture.cs @@ -0,0 +1,212 @@ +using System.Linq; +using System.Net; +using System.Text; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.ImportLists.Filmweb; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ImportListTests.Filmweb +{ + [TestFixture] + public class FilmwebParserFixture : CoreTest + { + private Mock _httpClient; + + [SetUp] + public void Setup() + { + _httpClient = new Mock(); + } + + private ImportListResponse CreateResponse(string url, string content, HttpStatusCode statusCode = HttpStatusCode.OK) + { + var httpRequest = new HttpRequest(url); + var httpResponse = new HttpResponse(httpRequest, new HttpHeader(), Encoding.UTF8.GetBytes(content), statusCode); + + return new ImportListResponse(new ImportListRequest(httpRequest), httpResponse); + } + + private void SetupMovieInfoResponse(long entityId, string title, string originalTitle, int year) + { + var movieInfoJson = $@"{{ + ""id"": {entityId}, + ""title"": ""{title}"", + ""originalTitle"": ""{originalTitle}"", + ""year"": {year}, + ""type"": ""film"", + ""subType"": ""movie"", + ""posterPath"": ""/path/to/poster.jpg"" + }}"; + + var request = new HttpRequest($"https://www.filmweb.pl/api/v1/title/{entityId}/info"); + var response = new HttpResponse(request, new HttpHeader(), Encoding.UTF8.GetBytes(movieInfoJson), HttpStatusCode.OK); + + _httpClient.Setup(c => c.Get(It.Is(r => r.Url.ToString().Contains($"/api/v1/title/{entityId}/info")))) + .Returns(response); + } + + [Test] + public void should_parse_filmweb_want2see_list() + { + var listJson = @"[ + {""entity"": 123456, ""timestamp"": 1693737600, ""level"": 5}, + {""entity"": 789012, ""timestamp"": 1693824000, ""level"": 4} + ]"; + + SetupMovieInfoResponse(123456, "Blade Runner 2049", "Blade Runner 2049", 2017); + SetupMovieInfoResponse(789012, "Dune", "Dune", 2021); + + var parser = new FilmwebParser(_httpClient.Object, 100); + + var result = parser.ParseResponse(CreateResponse("https://www.filmweb.pl/api/v1/user/testuser/want2see/film", listJson)); + + result.Should().HaveCount(2); + + result.First().Title.Should().Be("Blade Runner 2049"); + result.First().Year.Should().Be(2017); + + result[1].Title.Should().Be("Dune"); + result[1].Year.Should().Be(2021); + } + + [Test] + public void should_respect_limit_parameter() + { + var listJson = @"[ + {""entity"": 111111, ""timestamp"": 1693737600, ""level"": 5}, + {""entity"": 222222, ""timestamp"": 1693824000, ""level"": 4}, + {""entity"": 333333, ""timestamp"": 1693910400, ""level"": 3} + ]"; + + SetupMovieInfoResponse(111111, "Movie 1", "Movie 1", 2020); + SetupMovieInfoResponse(222222, "Movie 2", "Movie 2", 2021); + + var parser = new FilmwebParser(_httpClient.Object, 2); + + var result = parser.ParseResponse(CreateResponse("https://www.filmweb.pl/api/v1/user/testuser/want2see/film", listJson)); + + result.Should().HaveCount(2); + result.First().Title.Should().Be("Movie 1"); + result[1].Title.Should().Be("Movie 2"); + + _httpClient.Verify(c => c.Get(It.Is(r => r.Url.ToString().Contains("/api/v1/title/333333/info"))), Times.Never); + } + + [Test] + public void should_handle_empty_list() + { + var listJson = "[]"; + var parser = new FilmwebParser(_httpClient.Object, 100); + + var result = parser.ParseResponse(CreateResponse("https://www.filmweb.pl/api/v1/user/testuser/want2see/film", listJson)); + + result.Should().BeEmpty(); + } + + [Test] + public void should_skip_movies_with_failed_info_requests() + { + var listJson = @"[ + {""entity"": 123456, ""timestamp"": 1693737600, ""level"": 5}, + {""entity"": 789012, ""timestamp"": 1693824000, ""level"": 4} + ]"; + + SetupMovieInfoResponse(123456, "Working Movie", "Working Movie", 2020); + + var failedRequest = new HttpRequest("https://www.filmweb.pl/api/v1/title/789012/info"); + var failedResponse = new HttpResponse(failedRequest, new HttpHeader(), System.Array.Empty(), HttpStatusCode.NotFound); + _httpClient.Setup(c => c.Get(It.Is(r => r.Url.ToString().Contains("/api/v1/title/789012/info")))) + .Returns(failedResponse); + + var parser = new FilmwebParser(_httpClient.Object, 100); + + var result = parser.ParseResponse(CreateResponse("https://www.filmweb.pl/api/v1/user/testuser/want2see/film", listJson)); + + result.Should().HaveCount(1); + result.First().Title.Should().Be("Working Movie"); + } + + [Test] + public void should_use_original_title_when_title_is_empty() + { + var listJson = @"[{""entity"": 123456, ""timestamp"": 1693737600, ""level"": 5}]"; + + var movieInfoJson = @"{ + ""id"": 123456, + ""title"": """", + ""originalTitle"": ""Original Title"", + ""year"": 2020, + ""type"": ""film"" + }"; + + var request = new HttpRequest("https://www.filmweb.pl/api/v1/title/123456/info"); + var response = new HttpResponse(request, new HttpHeader(), Encoding.UTF8.GetBytes(movieInfoJson), HttpStatusCode.OK); + _httpClient.Setup(c => c.Get(It.IsAny())).Returns(response); + + var parser = new FilmwebParser(_httpClient.Object, 100); + + var result = parser.ParseResponse(CreateResponse("https://www.filmweb.pl/api/v1/user/testuser/want2see/film", listJson)); + + result.Should().HaveCount(1); + result.First().Title.Should().Be("Original Title"); + } + + [Test] + public void should_enforce_limit_bounds() + { + var parser1 = new FilmwebParser(_httpClient.Object, -5); + var parser2 = new FilmwebParser(_httpClient.Object, 1500); + + parser1.Should().NotBeNull(); + parser2.Should().NotBeNull(); + } + + [Test] + public void should_handle_invalid_json() + { + var invalidJson = "invalid json content"; + var parser = new FilmwebParser(_httpClient.Object, 100); + + var result = parser.ParseResponse(CreateResponse("https://www.filmweb.pl/api/v1/user/testuser/want2see/film", invalidJson)); + result.Should().BeEmpty(); + } + + [Test] + public void should_handle_null_movie_info() + { + var listJson = @"[{""entity"": 123456, ""timestamp"": 1693737600, ""level"": 5}]"; + + var request = new HttpRequest("https://www.filmweb.pl/api/v1/title/123456/info"); + var response = new HttpResponse(request, new HttpHeader(), System.Array.Empty(), HttpStatusCode.InternalServerError); + _httpClient.Setup(c => c.Get(It.IsAny())).Returns(response); + + var parser = new FilmwebParser(_httpClient.Object, 100); + + var result = parser.ParseResponse(CreateResponse("https://www.filmweb.pl/api/v1/user/testuser/want2see/film", listJson)); + + result.Should().BeEmpty(); + } + + [Test] + public void should_handle_malformed_entity_data() + { + var listJson = @"[ + {""timestamp"": 1693737600, ""level"": 5}, + {""entity"": 789012, ""timestamp"": 1693824000, ""level"": 4} + ]"; + + SetupMovieInfoResponse(789012, "Valid Movie", "Valid Movie", 2021); + + var parser = new FilmwebParser(_httpClient.Object, 100); + + var result = parser.ParseResponse(CreateResponse("https://www.filmweb.pl/api/v1/user/testuser/want2see/film", listJson)); + + result.Should().HaveCount(1); + result.First().Title.Should().Be("Valid Movie"); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Filmweb/FilmwebImportBase.cs b/src/NzbDrone.Core/ImportLists/Filmweb/FilmwebImportBase.cs new file mode 100644 index 0000000000..5fc072af77 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Filmweb/FilmwebImportBase.cs @@ -0,0 +1,29 @@ +using System; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.Filmweb +{ + public abstract class FilmwebImportBase : HttpImportListBase + where TSettings : FilmwebSettingsBase, new() + { + public override ImportListType ListType => ImportListType.Filmweb; + public override TimeSpan MinRefreshInterval => TimeSpan.FromHours(12); + + protected FilmwebImportBase(IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override IParseImportListResponse GetParser() + { + return new FilmwebParser(_httpClient, Settings.Limit); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Filmweb/FilmwebParser.cs b/src/NzbDrone.Core/ImportLists/Filmweb/FilmwebParser.cs new file mode 100644 index 0000000000..4d51c3f465 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Filmweb/FilmwebParser.cs @@ -0,0 +1,156 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.ImportLists.Exceptions; +using NzbDrone.Core.ImportLists.ImportListMovies; + +namespace NzbDrone.Core.ImportLists.Filmweb +{ + public class FilmwebParser : IParseImportListResponse + { + private readonly IHttpClient _httpClient; + private readonly int _limit; + private ImportListResponse _importResponse; + + public FilmwebParser(IHttpClient httpClient, int limit) + { + _httpClient = httpClient; + + if (limit <= 0) + { + limit = 100; + } + + if (limit > 1000) + { + limit = 1000; + } + + _limit = limit; + } + + public virtual IList ParseResponse(ImportListResponse importResponse) + { + _importResponse = importResponse; + + var movies = new List(); + + if (!PreProcess(_importResponse)) + { + return movies; + } + + var content = _importResponse.Content; + + try + { + var movieEntities = JsonSerializer.Deserialize>(content); + + if (movieEntities != null) + { + var limitedEntities = movieEntities.Take(_limit).ToList(); + + foreach (var entity in limitedEntities) + { + try + { + var movieInfo = GetMovieInfo(entity.Entity); + if (movieInfo != null) + { + var movie = new ImportListMovie() + { + Title = !string.IsNullOrEmpty(movieInfo.Title) ? movieInfo.Title : movieInfo.OriginalTitle, + Year = movieInfo.Year + }; + + movies.AddIfNotNull(movie); + } + } + catch + { + continue; + } + } + } + } + catch (JsonException) + { + } + + return movies; + } + + private FilmwebMovieInfo GetMovieInfo(long entityId) + { + try + { + var request = new HttpRequestBuilder("https://www.filmweb.pl") + .Resource($"/api/v1/title/{entityId}/info") + .Accept(HttpAccept.Json) + .Build(); + + var response = _httpClient.Get(request); + + if (response.StatusCode == HttpStatusCode.OK) + { + return JsonSerializer.Deserialize(response.Content); + } + } + catch + { + } + + return null; + } + + protected virtual bool PreProcess(ImportListResponse importListResponse) + { + if (importListResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new ImportListException(importListResponse, "Filmweb call resulted in an unexpected StatusCode [{0}]", importListResponse.HttpResponse.StatusCode); + } + + return true; + } + } + + public class FilmwebMovieEntity + { + [JsonPropertyName("entity")] + public long Entity { get; set; } + + [JsonPropertyName("timestamp")] + public long Timestamp { get; set; } + + [JsonPropertyName("level")] + public int Level { get; set; } + } + + public class FilmwebMovieInfo + { + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("originalTitle")] + public string OriginalTitle { get; set; } + + [JsonPropertyName("year")] + public int Year { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("subType")] + public string SubType { get; set; } + + [JsonPropertyName("posterPath")] + public string PosterPath { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Filmweb/FilmwebSettingsBase.cs b/src/NzbDrone.Core/ImportLists/Filmweb/FilmwebSettingsBase.cs new file mode 100644 index 0000000000..5565711a32 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Filmweb/FilmwebSettingsBase.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Filmweb +{ + public class FilmwebSettingsBaseValidator : AbstractValidator + where TSettings : FilmwebSettingsBase + { + public FilmwebSettingsBaseValidator() + { + RuleFor(c => c.Username).NotEmpty() + .WithMessage("Username is required"); + + RuleFor(c => c.Limit) + .GreaterThan(0) + .LessThanOrEqualTo(1000) + .WithMessage("Must be integer between 1 and 1000"); + } + } + + public class FilmwebSettingsBase : ImportListSettingsBase + where TSettings : FilmwebSettingsBase + { + private static readonly FilmwebSettingsBaseValidator Validator = new (); + + public FilmwebSettingsBase() + { + Limit = 100; + } + + public string Link => "https://www.filmweb.pl"; + + [FieldDefinition(1, Label = "Username", HelpText = "Filmweb username")] + public string Username { get; set; } + + [FieldDefinition(98, Label = "Limit", HelpText = "Limit the number of movies to get")] + public int Limit { get; set; } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate((TSettings)this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserImport.cs b/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserImport.cs new file mode 100644 index 0000000000..280982697e --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserImport.cs @@ -0,0 +1,33 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.Filmweb.User +{ + public class FilmwebUserImport : FilmwebImportBase + { + public FilmwebUserImport(IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override string Name => "Filmweb Watchlist"; + public override bool Enabled => true; + public override bool EnableAuto => false; + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new FilmwebUserRequestGenerator + { + Settings = Settings, + HttpClient = _httpClient, + Logger = _logger + }; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserListType.cs b/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserListType.cs new file mode 100644 index 0000000000..49b196573c --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserListType.cs @@ -0,0 +1,14 @@ +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.ImportLists.Filmweb.User +{ + public enum FilmwebUserListType + { + [FieldOption(Label = "Want to See")] + WantToSee = 0, + [FieldOption(Label = "Rated")] + Rated = 1, + [FieldOption(Label = "Favorites")] + Favorites = 2 + } +} diff --git a/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserRequestGenerator.cs new file mode 100644 index 0000000000..3478739954 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserRequestGenerator.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.ImportLists.Filmweb.User +{ + public class FilmwebUserRequestGenerator : IImportListRequestGenerator + { + public FilmwebUserSettings Settings { get; set; } + public IHttpClient HttpClient { get; set; } + public Logger Logger { get; set; } + + public virtual ImportListPageableRequestChain GetMovies() + { + var pageableRequests = new ImportListPageableRequestChain(); + pageableRequests.Add(GetMoviesRequest()); + return pageableRequests; + } + + private IEnumerable GetMoviesRequest() + { + var requestBuilder = new HttpRequestBuilder(Settings.Link.Trim()) + .Accept(HttpAccept.Json); + + switch (Settings.FilmwebListType) + { + case (int)FilmwebUserListType.WantToSee: + requestBuilder.Resource($"/api/v1/user/{Settings.Username.Trim()}/want2see/film"); + break; + case (int)FilmwebUserListType.Rated: + requestBuilder.Resource($"/api/v1/user/{Settings.Username.Trim()}/votes/film"); + break; + case (int)FilmwebUserListType.Favorites: + requestBuilder.Resource($"/api/v1/user/{Settings.Username.Trim()}/favorites/film"); + break; + } + + var request = new ImportListRequest(requestBuilder.Build()); + yield return request; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserSettings.cs b/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserSettings.cs new file mode 100644 index 0000000000..1d22e1475b --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Filmweb/User/FilmwebUserSettings.cs @@ -0,0 +1,32 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Filmweb.User +{ + public class FilmwebUserSettingsValidator : FilmwebSettingsBaseValidator + { + public FilmwebUserSettingsValidator() + { + RuleFor(c => c.FilmwebListType).NotNull(); + } + } + + public class FilmwebUserSettings : FilmwebSettingsBase + { + private static readonly FilmwebUserSettingsValidator Validator = new (); + + public FilmwebUserSettings() + { + FilmwebListType = (int)FilmwebUserListType.WantToSee; + } + + [FieldDefinition(2, Label = "List Type", Type = FieldType.Select, SelectOptions = typeof(FilmwebUserListType), HelpText = "Type of list to import from Filmweb")] + public int FilmwebListType { get; set; } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListType.cs b/src/NzbDrone.Core/ImportLists/ImportListType.cs index e24ac28d7d..c9eae0ae2b 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListType.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListType.cs @@ -7,6 +7,7 @@ public enum ImportListType Trakt, Plex, Simkl, + Filmweb, Other, Advanced }