diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index dc8c7f8157..3275f85807 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -1093,7 +1093,7 @@ stages:
projectVersion: '$(radarrVersion)'
extraProperties: |
sonar.exclusions=**/obj/**,**/*.dll,**/NzbDrone.Core.Test/Files/**/*,./frontend/**,**/ExternalModules/**,./src/Libraries/**
- sonar.coverage.exclusions=**/Radarr.Api.V3/**/*
+ sonar.coverage.exclusions=**/Radarr.Api.V*/**/*
sonar.cs.opencover.reportsPaths=$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml
sonar.cs.nunit.reportsPaths=$(Build.SourcesDirectory)/TestResult.xml
- bash: |
diff --git a/docs.sh b/docs.sh
index e4a8c87e82..1fa5b609aa 100644
--- a/docs.sh
+++ b/docs.sh
@@ -29,7 +29,7 @@ dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p
dotnet new tool-manifest
dotnet tool install --version 6.3.0 Swashbuckle.AspNetCore.Cli
-dotnet tool run swagger tofile --output ./src/Radarr.Api.V3/openapi.json "$outputFolder/net6.0/$RUNTIME/radarr.console.dll" v3 &
+dotnet tool run swagger tofile --output ./src/Radarr.Api.V4/openapi.json "$outputFolder/net6.0/$RUNTIME/radarr.console.dll" v4 &
sleep 45
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js
index 3f85a36734..11bfca4a1e 100644
--- a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js
+++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js
@@ -22,7 +22,7 @@ function getUrls(state) {
tags
} = state;
- let icalUrl = `${window.location.host}${window.Radarr.urlBase}/feed/v3/calendar/Radarr.ics?`;
+ let icalUrl = `${window.location.host}${window.Radarr.urlBase}/feed/v4/calendar/Radarr.ics?`;
if (unmonitored) {
icalUrl += 'unmonitored=true&';
diff --git a/src/NzbDrone.Host/Bootstrap.cs b/src/NzbDrone.Host/Bootstrap.cs
index 37aa8cbc07..2509fe81dc 100644
--- a/src/NzbDrone.Host/Bootstrap.cs
+++ b/src/NzbDrone.Host/Bootstrap.cs
@@ -38,6 +38,7 @@ public static class Bootstrap
"Radarr.Core",
"Radarr.SignalR",
"Radarr.Api.V3",
+ "Radarr.Api.V4",
"Radarr.Http"
};
diff --git a/src/NzbDrone.Host/Radarr.Host.csproj b/src/NzbDrone.Host/Radarr.Host.csproj
index 920208614e..a3be591b86 100644
--- a/src/NzbDrone.Host/Radarr.Host.csproj
+++ b/src/NzbDrone.Host/Radarr.Host.csproj
@@ -15,6 +15,7 @@
+
diff --git a/src/NzbDrone.Host/Startup.cs b/src/NzbDrone.Host/Startup.cs
index ef1a568b5e..366b8f73aa 100644
--- a/src/NzbDrone.Host/Startup.cs
+++ b/src/NzbDrone.Host/Startup.cs
@@ -24,7 +24,7 @@
using NzbDrone.Host.AccessControl;
using NzbDrone.Http.Authentication;
using NzbDrone.SignalR;
-using Radarr.Api.V3.System;
+using Radarr.Api.V4.System;
using Radarr.Http;
using Radarr.Http.Authentication;
using Radarr.Http.ErrorManagement;
@@ -87,6 +87,7 @@ public void ConfigureServices(IServiceCollection services)
options.ReturnHttpNotAcceptable = true;
})
.AddApplicationPart(typeof(SystemController).Assembly)
+ .AddApplicationPart(typeof(Radarr.Api.V3.System.SystemController).Assembly)
.AddApplicationPart(typeof(StaticResourceController).Assembly)
.AddJsonOptions(options =>
{
@@ -96,9 +97,9 @@ public void ConfigureServices(IServiceCollection services)
services.AddSwaggerGen(c =>
{
- c.SwaggerDoc("v3", new OpenApiInfo
+ c.SwaggerDoc("v4", new OpenApiInfo
{
- Version = "3.0.0",
+ Version = "4.0.0",
Title = "Radarr",
Description = "Radarr API docs",
License = new OpenApiLicense
@@ -275,6 +276,7 @@ public void Configure(IApplicationBuilder app,
app.UseMiddleware();
app.UseMiddleware();
app.UseMiddleware(new List { "/api/v3/command" });
+ app.UseMiddleware(new List { "/api/v4/command" });
app.UseWebSockets();
diff --git a/src/NzbDrone.Integration.Test/ApiTests/BlocklistFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/BlocklistFixture.cs
index 64c0164606..b6deb197e7 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/BlocklistFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/BlocklistFixture.cs
@@ -1,6 +1,6 @@
using FluentAssertions;
using NUnit.Framework;
-using Radarr.Api.V3.Movies;
+using Radarr.Api.V4.Movies;
namespace NzbDrone.Integration.Test.ApiTests
{
@@ -15,7 +15,7 @@ public void should_be_able_to_add_to_blocklist()
{
_movie = EnsureMovie(11, "The Blocklist");
- Blocklist.Post(new Radarr.Api.V3.Blocklist.BlocklistResource
+ Blocklist.Post(new Radarr.Api.V4.Blocklist.BlocklistResource
{
MovieId = _movie.Id,
SourceTitle = "Blocklist.S01E01.Brought.To.You.By-BoomBoxHD"
diff --git a/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs
index 5f3a180c12..dadf0cb9d3 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs
@@ -4,7 +4,7 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Integration.Test.Client;
-using Radarr.Api.V3.Movies;
+using Radarr.Api.V4.Movies;
namespace NzbDrone.Integration.Test.ApiTests
{
diff --git a/src/NzbDrone.Integration.Test/ApiTests/DiskSpaceFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/DiskSpaceFixture.cs
index 3eabc8b790..fa3fcdda9c 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/DiskSpaceFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/DiskSpaceFixture.cs
@@ -2,7 +2,7 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Integration.Test.Client;
-using Radarr.Api.V3.DiskSpace;
+using Radarr.Api.V4.DiskSpace;
namespace NzbDrone.Integration.Test.ApiTests
{
diff --git a/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs
index 9acb6a5a38..9ec90a0d27 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs
@@ -3,7 +3,7 @@
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using NzbDrone.Core.ThingiProvider;
-using Radarr.Api.V3.Indexers;
+using Radarr.Api.V4.Indexers;
using Radarr.Http.ClientSchema;
namespace NzbDrone.Integration.Test.ApiTests
diff --git a/src/NzbDrone.Integration.Test/ApiTests/MovieEditorFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/MovieEditorFixture.cs
index 209a36a901..48c7f2cbc9 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/MovieEditorFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/MovieEditorFixture.cs
@@ -3,7 +3,7 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Test.Common;
-using Radarr.Api.V3.Movies;
+using Radarr.Api.V4.Movies;
namespace NzbDrone.Integration.Test.ApiTests
{
diff --git a/src/NzbDrone.Integration.Test/ApiTests/ReleaseFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/ReleaseFixture.cs
index 8c4fdfb3f4..8411fb6a7b 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/ReleaseFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/ReleaseFixture.cs
@@ -2,7 +2,7 @@
using System.Net;
using FluentAssertions;
using NUnit.Framework;
-using Radarr.Api.V3.Indexers;
+using Radarr.Api.V4.Indexers;
namespace NzbDrone.Integration.Test.ApiTests
{
diff --git a/src/NzbDrone.Integration.Test/ApiTests/RootFolderFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/RootFolderFixture.cs
index 9d65ab4112..dffeba5705 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/RootFolderFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/RootFolderFixture.cs
@@ -1,7 +1,7 @@
using System;
using FluentAssertions;
using NUnit.Framework;
-using Radarr.Api.V3.RootFolders;
+using Radarr.Api.V4.RootFolders;
namespace NzbDrone.Integration.Test.ApiTests
{
diff --git a/src/NzbDrone.Integration.Test/ApiTests/TagFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/TagFixture.cs
index 15fc061cb1..15da0f1a36 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/TagFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/TagFixture.cs
@@ -1,7 +1,7 @@
using System.Linq;
using FluentAssertions;
using NUnit.Framework;
-using Radarr.Api.V3.Tags;
+using Radarr.Api.V4.Tags;
namespace NzbDrone.Integration.Test.ApiTests
{
diff --git a/src/NzbDrone.Integration.Test/Client/DownloadClientClient.cs b/src/NzbDrone.Integration.Test/Client/DownloadClientClient.cs
index 976a4b1c7a..1472cf14e4 100644
--- a/src/NzbDrone.Integration.Test/Client/DownloadClientClient.cs
+++ b/src/NzbDrone.Integration.Test/Client/DownloadClientClient.cs
@@ -1,5 +1,5 @@
using System.Collections.Generic;
-using Radarr.Api.V3.DownloadClient;
+using Radarr.Api.V4.DownloadClient;
using RestSharp;
namespace NzbDrone.Integration.Test.Client
diff --git a/src/NzbDrone.Integration.Test/Client/IndexerClient.cs b/src/NzbDrone.Integration.Test/Client/IndexerClient.cs
index 190ae13bbc..3545c64511 100644
--- a/src/NzbDrone.Integration.Test/Client/IndexerClient.cs
+++ b/src/NzbDrone.Integration.Test/Client/IndexerClient.cs
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
-using Radarr.Api.V3.Indexers;
+using Radarr.Api.V4.Indexers;
using RestSharp;
namespace NzbDrone.Integration.Test.Client
diff --git a/src/NzbDrone.Integration.Test/Client/MovieClient.cs b/src/NzbDrone.Integration.Test/Client/MovieClient.cs
index 87f10925c1..96a62a06f5 100644
--- a/src/NzbDrone.Integration.Test/Client/MovieClient.cs
+++ b/src/NzbDrone.Integration.Test/Client/MovieClient.cs
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using System.Net;
-using Radarr.Api.V3.Movies;
+using Radarr.Api.V4.Movies;
using RestSharp;
namespace NzbDrone.Integration.Test.Client
diff --git a/src/NzbDrone.Integration.Test/Client/MovieFileClient.cs b/src/NzbDrone.Integration.Test/Client/MovieFileClient.cs
index 52686ccabc..ce0f19a69a 100644
--- a/src/NzbDrone.Integration.Test/Client/MovieFileClient.cs
+++ b/src/NzbDrone.Integration.Test/Client/MovieFileClient.cs
@@ -1,7 +1,7 @@
using System.Collections.Generic;
using System.Net;
-using Radarr.Api.V3.MovieFiles;
-using Radarr.Api.V3.Movies;
+using Radarr.Api.V4.MovieFiles;
+using Radarr.Api.V4.Movies;
using RestSharp;
namespace NzbDrone.Integration.Test.Client
diff --git a/src/NzbDrone.Integration.Test/Client/NotificationClient.cs b/src/NzbDrone.Integration.Test/Client/NotificationClient.cs
index 80ed367bc7..51c6a93481 100644
--- a/src/NzbDrone.Integration.Test/Client/NotificationClient.cs
+++ b/src/NzbDrone.Integration.Test/Client/NotificationClient.cs
@@ -1,5 +1,5 @@
using System.Collections.Generic;
-using Radarr.Api.V3.Notifications;
+using Radarr.Api.V4.Notifications;
using RestSharp;
namespace NzbDrone.Integration.Test.Client
diff --git a/src/NzbDrone.Integration.Test/Client/ReleaseClient.cs b/src/NzbDrone.Integration.Test/Client/ReleaseClient.cs
index f502717a3f..15398a37d2 100644
--- a/src/NzbDrone.Integration.Test/Client/ReleaseClient.cs
+++ b/src/NzbDrone.Integration.Test/Client/ReleaseClient.cs
@@ -1,4 +1,4 @@
-using Radarr.Api.V3.Indexers;
+using Radarr.Api.V4.Indexers;
using RestSharp;
namespace NzbDrone.Integration.Test.Client
diff --git a/src/NzbDrone.Integration.Test/HttpLogFixture.cs b/src/NzbDrone.Integration.Test/HttpLogFixture.cs
index 10a16a71dc..db1bf45ba6 100644
--- a/src/NzbDrone.Integration.Test/HttpLogFixture.cs
+++ b/src/NzbDrone.Integration.Test/HttpLogFixture.cs
@@ -20,15 +20,15 @@ public void should_log_on_error()
var logFile = "radarr.trace.txt";
var logLines = Logs.GetLogFileLines(logFile);
- var resultPost = Movies.InvalidPost(new Radarr.Api.V3.Movies.MovieResource());
+ var resultPost = Movies.InvalidPost(new Radarr.Api.V4.Movies.MovieResource());
// Skip 2 and 1 to ignore the logs endpoint
logLines = Logs.GetLogFileLines(logFile).Skip(logLines.Length + 2).ToArray();
Array.Resize(ref logLines, logLines.Length - 1);
- logLines.Should().Contain(v => v.Contains("|Trace|Http|Req") && v.Contains("/api/v3/movie/"));
- logLines.Should().Contain(v => v.Contains("|Trace|Http|Res") && v.Contains("/api/v3/movie/: 400.BadRequest"));
- logLines.Should().Contain(v => v.Contains("|Debug|Api|") && v.Contains("/api/v3/movie/: 400.BadRequest"));
+ logLines.Should().Contain(v => v.Contains("|Trace|Http|Req") && v.Contains("/api/v4/movie/"));
+ logLines.Should().Contain(v => v.Contains("|Trace|Http|Res") && v.Contains("/api/v4/movie/: 400.BadRequest"));
+ logLines.Should().Contain(v => v.Contains("|Debug|Api|") && v.Contains("/api/v4/movie/: 400.BadRequest"));
}
}
}
diff --git a/src/NzbDrone.Integration.Test/IntegrationTest.cs b/src/NzbDrone.Integration.Test/IntegrationTest.cs
index 6dc3671e50..4cd6896150 100644
--- a/src/NzbDrone.Integration.Test/IntegrationTest.cs
+++ b/src/NzbDrone.Integration.Test/IntegrationTest.cs
@@ -53,7 +53,7 @@ protected override void InitializeTestTarget()
// Make sure tasks have been initialized so the config put below doesn't cause errors
WaitForCompletion(() => Tasks.All().SelectList(x => x.TaskName).Contains("RssSync"));
- Indexers.Post(new Radarr.Api.V3.Indexers.IndexerResource
+ Indexers.Post(new Radarr.Api.V4.Indexers.IndexerResource
{
EnableRss = false,
EnableInteractiveSearch = false,
diff --git a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs
index d9afb3091b..f94e16ed9e 100644
--- a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs
+++ b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs
@@ -17,16 +17,16 @@
using NzbDrone.Integration.Test.Client;
using NzbDrone.SignalR;
using NzbDrone.Test.Common.Categories;
-using Radarr.Api.V3.Blocklist;
-using Radarr.Api.V3.Config;
-using Radarr.Api.V3.DownloadClient;
-using Radarr.Api.V3.History;
-using Radarr.Api.V3.MovieFiles;
-using Radarr.Api.V3.Movies;
-using Radarr.Api.V3.Profiles.Quality;
-using Radarr.Api.V3.RootFolders;
-using Radarr.Api.V3.System.Tasks;
-using Radarr.Api.V3.Tags;
+using Radarr.Api.V4.Blocklist;
+using Radarr.Api.V4.Config;
+using Radarr.Api.V4.DownloadClient;
+using Radarr.Api.V4.History;
+using Radarr.Api.V4.MovieFiles;
+using Radarr.Api.V4.Movies;
+using Radarr.Api.V4.Profiles.Quality;
+using Radarr.Api.V4.RootFolders;
+using Radarr.Api.V4.System.Tasks;
+using Radarr.Api.V4.Tags;
using RestSharp;
namespace NzbDrone.Integration.Test
@@ -95,7 +95,7 @@ public void SmokeTestSetup()
protected virtual void InitRestClients()
{
- RestClient = new RestClient(RootUrl + "api/v3/");
+ RestClient = new RestClient(RootUrl + "api/v4/");
RestClient.AddDefaultHeader("Authentication", ApiKey);
RestClient.AddDefaultHeader("X-Api-Key", ApiKey);
diff --git a/src/NzbDrone.Integration.Test/Radarr.Integration.Test.csproj b/src/NzbDrone.Integration.Test/Radarr.Integration.Test.csproj
index cddca40c28..24929a9f19 100644
--- a/src/NzbDrone.Integration.Test/Radarr.Integration.Test.csproj
+++ b/src/NzbDrone.Integration.Test/Radarr.Integration.Test.csproj
@@ -8,6 +8,6 @@
-
+
diff --git a/src/NzbDrone.Test.Common/NzbDroneRunner.cs b/src/NzbDrone.Test.Common/NzbDroneRunner.cs
index 57f339e0e3..7b2320e2c7 100644
--- a/src/NzbDrone.Test.Common/NzbDroneRunner.cs
+++ b/src/NzbDrone.Test.Common/NzbDroneRunner.cs
@@ -31,7 +31,7 @@ public class NzbDroneRunner
public NzbDroneRunner(Logger logger, PostgresOptions postgresOptions, int port = 7878)
{
_processProvider = new ProcessProvider(logger);
- _restClient = new RestClient($"http://localhost:{port}/api/v3");
+ _restClient = new RestClient($"http://localhost:{port}/api/v4");
PostgresOptions = postgresOptions;
Port = port;
diff --git a/src/Radarr.Api.V3/Movies/MovieController.cs b/src/Radarr.Api.V3/Movies/MovieController.cs
index a065766f02..63b583d32e 100644
--- a/src/Radarr.Api.V3/Movies/MovieController.cs
+++ b/src/Radarr.Api.V3/Movies/MovieController.cs
@@ -82,7 +82,7 @@ public MovieController(IBroadcastSignalRMessage signalRBroadcaster,
_commandQueueManager = commandQueueManager;
_logger = logger;
- SharedValidator.RuleFor(s => s.QualityProfileId).ValidId().When(s => s.QualityProfileIds == null || s.QualityProfileIds.Empty());
+ SharedValidator.RuleFor(s => s.QualityProfileId).ValidId();
SharedValidator.RuleFor(s => s.Path)
.Cascade(CascadeMode.StopOnFirstFailure)
@@ -95,9 +95,6 @@ public MovieController(IBroadcastSignalRMessage signalRBroadcaster,
.SetValidator(systemFolderValidator)
.When(s => !s.Path.IsNullOrWhiteSpace());
- SharedValidator.RuleFor(s => s.QualityProfileIds).NotNull().When(s => s.QualityProfileId == 0);
- SharedValidator.RuleForEach(s => s.QualityProfileIds).SetValidator(profileExistsValidator);
-
PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.RootFolderPath)
.IsValidPath()
@@ -234,6 +231,8 @@ private void LinkMovieStatistics(List resources, List 0;
}
[RestPostById]
diff --git a/src/Radarr.Api.V3/Movies/MovieResource.cs b/src/Radarr.Api.V3/Movies/MovieResource.cs
index ffbc2a1ef9..85d2fa0b0b 100644
--- a/src/Radarr.Api.V3/Movies/MovieResource.cs
+++ b/src/Radarr.Api.V3/Movies/MovieResource.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Security.Policy;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaCover;
@@ -18,7 +19,6 @@ public MovieResource()
{
Monitored = true;
MinimumAvailability = MovieStatusType.Released;
- QualityProfileIds = new List();
}
// Todo: Sorters should be done completely on the client
@@ -51,10 +51,10 @@ public MovieResource()
// View & Edit
public string Path { get; set; }
- public List QualityProfileIds { get; set; }
// Compatabilitiy
public int QualityProfileId { get; set; }
+ public bool HasFile { get; set; }
// Editing Only
public bool Monitored { get; set; }
@@ -115,7 +115,6 @@ public static MovieResource ToResource(this Movie model, int availDelay, MovieTr
SecondaryYear = model.MovieMetadata.Value.SecondaryYear,
Path = model.Path,
- QualityProfileIds = model.QualityProfileIds,
QualityProfileId = model.QualityProfileIds.FirstOrDefault(),
Monitored = model.Monitored,
@@ -151,13 +150,6 @@ public static Movie ToModel(this MovieResource resource)
return null;
}
- var profiles = resource.QualityProfileIds;
-
- if (resource.QualityProfileIds.Count == 0)
- {
- profiles.Add(resource.QualityProfileId);
- }
-
return new Movie
{
Id = resource.Id,
@@ -186,7 +178,7 @@ public static Movie ToModel(this MovieResource resource)
},
Path = resource.Path,
- QualityProfileIds = resource.QualityProfileIds,
+ QualityProfileIds = new List { resource.QualityProfileId },
Monitored = resource.Monitored,
MinimumAvailability = resource.MinimumAvailability,
diff --git a/src/Radarr.Api.V4/Blocklist/BlocklistBulkResource.cs b/src/Radarr.Api.V4/Blocklist/BlocklistBulkResource.cs
new file mode 100644
index 0000000000..4eca8d2314
--- /dev/null
+++ b/src/Radarr.Api.V4/Blocklist/BlocklistBulkResource.cs
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+
+namespace Radarr.Api.V4.Blocklist
+{
+ public class BlocklistBulkResource
+ {
+ public List Ids { get; set; }
+ }
+}
diff --git a/src/Radarr.Api.V4/Blocklist/BlocklistController.cs b/src/Radarr.Api.V4/Blocklist/BlocklistController.cs
new file mode 100644
index 0000000000..63798d3b5d
--- /dev/null
+++ b/src/Radarr.Api.V4/Blocklist/BlocklistController.cs
@@ -0,0 +1,55 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Blocklisting;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.Datastore;
+using Radarr.Http;
+using Radarr.Http.Extensions;
+using Radarr.Http.REST.Attributes;
+
+namespace Radarr.Api.V4.Blocklist
+{
+ [V4ApiController]
+ public class BlocklistController : Controller
+ {
+ private readonly IBlocklistService _blocklistService;
+ private readonly ICustomFormatCalculationService _formatCalculator;
+
+ public BlocklistController(IBlocklistService blocklistService,
+ ICustomFormatCalculationService formatCalculator)
+ {
+ _blocklistService = blocklistService;
+ _formatCalculator = formatCalculator;
+ }
+
+ [HttpGet]
+ public PagingResource GetBlocklist()
+ {
+ var pagingResource = Request.ReadPagingResourceFromRequest();
+ var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending);
+
+ return pagingSpec.ApplyToPage(_blocklistService.Paged, model => BlocklistResourceMapper.MapToResource(model, _formatCalculator));
+ }
+
+ [HttpGet("movie")]
+ public List GetMovieBlocklist(int movieId)
+ {
+ return _blocklistService.GetByMovieId(movieId).Select(h => BlocklistResourceMapper.MapToResource(h, _formatCalculator)).ToList();
+ }
+
+ [RestDeleteById]
+ public void DeleteBlocklist(int id)
+ {
+ _blocklistService.Delete(id);
+ }
+
+ [HttpDelete("bulk")]
+ public object Remove([FromBody] BlocklistBulkResource resource)
+ {
+ _blocklistService.Delete(resource.Ids);
+
+ return new { };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Blocklist/BlocklistResource.cs b/src/Radarr.Api.V4/Blocklist/BlocklistResource.cs
new file mode 100644
index 0000000000..85b3bf6626
--- /dev/null
+++ b/src/Radarr.Api.V4/Blocklist/BlocklistResource.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.Indexers;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Qualities;
+using Radarr.Api.V4.CustomFormats;
+using Radarr.Api.V4.Movies;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Blocklist
+{
+ public class BlocklistResource : RestResource
+ {
+ public int MovieId { get; set; }
+ public string SourceTitle { get; set; }
+ public List Languages { get; set; }
+ public QualityModel Quality { get; set; }
+ public List CustomFormats { get; set; }
+ public DateTime Date { get; set; }
+ public DownloadProtocol Protocol { get; set; }
+ public string Indexer { get; set; }
+ public string Message { get; set; }
+
+ public MovieResource Movie { get; set; }
+ }
+
+ public static class BlocklistResourceMapper
+ {
+ public static BlocklistResource MapToResource(this NzbDrone.Core.Blocklisting.Blocklist model, ICustomFormatCalculationService formatCalculator)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new BlocklistResource
+ {
+ Id = model.Id,
+
+ MovieId = model.MovieId,
+ SourceTitle = model.SourceTitle,
+ Languages = model.Languages,
+ Quality = model.Quality,
+ CustomFormats = formatCalculator.ParseCustomFormat(model, model.Movie).ToResource(false),
+ Date = model.Date,
+ Protocol = model.Protocol,
+ Indexer = model.Indexer,
+ Message = model.Message,
+
+ Movie = model.Movie.ToResource(0)
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Calendar/CalendarController.cs b/src/Radarr.Api.V4/Calendar/CalendarController.cs
new file mode 100644
index 0000000000..1a58ff3e26
--- /dev/null
+++ b/src/Radarr.Api.V4/Calendar/CalendarController.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Translations;
+using NzbDrone.SignalR;
+using Radarr.Api.V4.Movies;
+using Radarr.Http;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Calendar
+{
+ [V4ApiController]
+ public class CalendarController : RestControllerWithSignalR
+ {
+ private readonly IMovieService _moviesService;
+ private readonly IMovieTranslationService _movieTranslationService;
+ private readonly IUpgradableSpecification _qualityUpgradableSpecification;
+ private readonly IConfigService _configService;
+
+ public CalendarController(IBroadcastSignalRMessage signalR,
+ IMovieService moviesService,
+ IMovieTranslationService movieTranslationService,
+ IUpgradableSpecification qualityUpgradableSpecification,
+ IConfigService configService)
+ : base(signalR)
+ {
+ _moviesService = moviesService;
+ _movieTranslationService = movieTranslationService;
+ _qualityUpgradableSpecification = qualityUpgradableSpecification;
+ _configService = configService;
+ }
+
+ protected override MovieResource GetResourceById(int id)
+ {
+ throw new NotImplementedException();
+ }
+
+ [HttpGet]
+ public List GetCalendar(DateTime? start, DateTime? end, bool unmonitored = false, bool includeArtist = false)
+ {
+ var startUse = start ?? DateTime.Today;
+ var endUse = end ?? DateTime.Today.AddDays(2);
+
+ var resources = _moviesService.GetMoviesBetweenDates(startUse, endUse, unmonitored).Select(MapToResource);
+
+ return resources.OrderBy(e => e.InCinemas).ToList();
+ }
+
+ protected MovieResource MapToResource(Movie movie)
+ {
+ if (movie == null)
+ {
+ return null;
+ }
+
+ var availDelay = _configService.AvailabilityDelay;
+ var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(movie.Id);
+ var translation = GetMovieTranslation(translations, movie.MovieMetadata);
+ var resource = movie.ToResource(availDelay, translation, _qualityUpgradableSpecification);
+
+ return resource;
+ }
+
+ private MovieTranslation GetMovieTranslation(List translations, MovieMetadata movie)
+ {
+ if ((Language)_configService.MovieInfoLanguage == Language.Original)
+ {
+ return new MovieTranslation
+ {
+ Title = movie.OriginalTitle,
+ Overview = movie.Overview
+ };
+ }
+
+ return translations.FirstOrDefault(t => t.Language == (Language)_configService.MovieInfoLanguage && t.MovieMetadataId == movie.Id);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Calendar/CalendarFeedController.cs b/src/Radarr.Api.V4/Calendar/CalendarFeedController.cs
new file mode 100644
index 0000000000..d14c9739cf
--- /dev/null
+++ b/src/Radarr.Api.V4/Calendar/CalendarFeedController.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Ical.Net;
+using Ical.Net.CalendarComponents;
+using Ical.Net.DataTypes;
+using Ical.Net.Serialization;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Tags;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Calendar
+{
+ [V4FeedController("calendar")]
+ public class CalendarFeedController : Controller
+ {
+ private readonly IMovieService _movieService;
+ private readonly ITagService _tagService;
+
+ public CalendarFeedController(IMovieService movieService, ITagService tagService)
+ {
+ _movieService = movieService;
+ _tagService = tagService;
+ }
+
+ [HttpGet("Radarr.ics")]
+ public IActionResult GetCalendarFeed(int pastDays = 7, int futureDays = 28, string tagList = "", bool unmonitored = false)
+ {
+ var start = DateTime.Today.AddDays(-pastDays);
+ var end = DateTime.Today.AddDays(futureDays);
+ var tags = new List();
+
+ if (tagList.IsNotNullOrWhiteSpace())
+ {
+ tags.AddRange(tagList.Split(',').Select(_tagService.GetTag).Select(t => t.Id));
+ }
+
+ var movies = _movieService.GetMoviesBetweenDates(start, end, unmonitored);
+ var calendar = new Ical.Net.Calendar
+ {
+ ProductId = "-//radarr.video//Radarr//EN"
+ };
+
+ var calendarName = "Radarr Movies Calendar";
+ calendar.AddProperty(new CalendarProperty("NAME", calendarName));
+ calendar.AddProperty(new CalendarProperty("X-WR-CALNAME", calendarName));
+
+ foreach (var movie in movies.OrderBy(v => v.Added))
+ {
+ if (tags.Any() && tags.None(movie.Tags.Contains))
+ {
+ continue;
+ }
+
+ CreateEvent(calendar, movie.MovieMetadata, "cinematic");
+ CreateEvent(calendar, movie.MovieMetadata, "digital");
+ CreateEvent(calendar, movie.MovieMetadata, "physical");
+ }
+
+ var serializer = (IStringSerializer)new SerializerFactory().Build(calendar.GetType(), new SerializationContext());
+ var icalendar = serializer.SerializeToString(calendar);
+
+ return Content(icalendar, "text/calendar");
+ }
+
+ private void CreateEvent(Ical.Net.Calendar calendar, MovieMetadata movie, string releaseType)
+ {
+ var date = movie.InCinemas;
+ string eventType = "_cinemas";
+ string summaryText = "(Theatrical Release)";
+
+ if (releaseType == "digital")
+ {
+ date = movie.DigitalRelease;
+ eventType = "_digital";
+ summaryText = "(Digital Release)";
+ }
+ else if (releaseType == "physical")
+ {
+ date = movie.PhysicalRelease;
+ eventType = "_physical";
+ summaryText = "(Physical Release)";
+ }
+
+ if (!date.HasValue)
+ {
+ return;
+ }
+
+ var occurrence = calendar.Create();
+ occurrence.Uid = "Radarr_movie_" + movie.Id + eventType;
+ occurrence.Status = movie.Status == MovieStatusType.Announced ? EventStatus.Tentative : EventStatus.Confirmed;
+
+ occurrence.Start = new CalDateTime(date.Value);
+ occurrence.End = occurrence.Start;
+ occurrence.IsAllDay = true;
+
+ occurrence.Description = movie.Overview;
+ occurrence.Categories = new List() { movie.Studio };
+
+ occurrence.Summary = $"{movie.Title} " + summaryText;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Collections/CollectionController.cs b/src/Radarr.Api.V4/Collections/CollectionController.cs
new file mode 100644
index 0000000000..13b5408169
--- /dev/null
+++ b/src/Radarr.Api.V4/Collections/CollectionController.cs
@@ -0,0 +1,188 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.Messaging.Commands;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Collections;
+using NzbDrone.Core.Movies.Commands;
+using NzbDrone.Core.Movies.Events;
+using NzbDrone.Core.Organizer;
+using NzbDrone.SignalR;
+using Radarr.Http;
+using Radarr.Http.REST;
+using Radarr.Http.REST.Attributes;
+
+namespace Radarr.Api.V4.Collections
+{
+ [V4ApiController]
+ public class CollectionController : RestControllerWithSignalR,
+ IHandle,
+ IHandle,
+ IHandle
+ {
+ private readonly IMovieCollectionService _collectionService;
+ private readonly IMovieService _movieService;
+ private readonly IMovieMetadataService _movieMetadataService;
+ private readonly IBuildFileNames _fileNameBuilder;
+ private readonly INamingConfigService _namingService;
+ private readonly IManageCommandQueue _commandQueueManager;
+
+ public CollectionController(IBroadcastSignalRMessage signalRBroadcaster,
+ IMovieCollectionService collectionService,
+ IMovieService movieService,
+ IMovieMetadataService movieMetadataService,
+ IBuildFileNames fileNameBuilder,
+ INamingConfigService namingService,
+ IManageCommandQueue commandQueueManager)
+ : base(signalRBroadcaster)
+ {
+ _collectionService = collectionService;
+ _movieService = movieService;
+ _movieMetadataService = movieMetadataService;
+ _fileNameBuilder = fileNameBuilder;
+ _namingService = namingService;
+ _commandQueueManager = commandQueueManager;
+ }
+
+ protected override CollectionResource GetResourceById(int id)
+ {
+ return MapToResource(_collectionService.GetCollection(id));
+ }
+
+ [HttpGet]
+ public List GetCollections(int? tmdbId)
+ {
+ var collectionResources = new List();
+
+ if (tmdbId.HasValue)
+ {
+ var collection = _collectionService.FindByTmdbId(tmdbId.Value);
+
+ if (collection != null)
+ {
+ collectionResources.AddIfNotNull(MapToResource(collection));
+ }
+ }
+ else
+ {
+ collectionResources = MapToResource(_collectionService.GetAllCollections()).ToList();
+ }
+
+ return collectionResources;
+ }
+
+ [RestPutById]
+ public ActionResult UpdateCollection(CollectionResource collectionResource)
+ {
+ var collection = _collectionService.GetCollection(collectionResource.Id);
+
+ var model = collectionResource.ToModel(collection);
+
+ var updatedMovie = _collectionService.UpdateCollection(model);
+
+ return Accepted(updatedMovie.Id);
+ }
+
+ [HttpPut]
+ public ActionResult UpdateCollections(CollectionUpdateResource resource)
+ {
+ var collectionsToUpdate = _collectionService.GetCollections(resource.CollectionIds);
+
+ foreach (var collection in collectionsToUpdate)
+ {
+ if (resource.Monitored.HasValue)
+ {
+ collection.Monitored = resource.Monitored.Value;
+ }
+
+ if (resource.QualityProfileIds != null && resource.QualityProfileIds.Any())
+ {
+ collection.QualityProfileIds = resource.QualityProfileIds;
+ }
+
+ if (resource.MinimumAvailability.HasValue)
+ {
+ collection.MinimumAvailability = resource.MinimumAvailability.Value;
+ }
+
+ if (resource.RootFolderPath.IsNotNullOrWhiteSpace())
+ {
+ collection.RootFolderPath = resource.RootFolderPath;
+ }
+
+ if (resource.MonitorMovies.HasValue)
+ {
+ var movies = _movieService.GetMoviesByCollectionTmdbId(collection.TmdbId);
+
+ movies.ForEach(c => c.Monitored = resource.MonitorMovies.Value);
+
+ _movieService.UpdateMovie(movies, true);
+ }
+ }
+
+ var updated = _collectionService.UpdateCollections(collectionsToUpdate.ToList()).ToResource();
+
+ _commandQueueManager.Push(new RefreshCollectionsCommand());
+
+ return Accepted(updated);
+ }
+
+ private IEnumerable MapToResource(List collections)
+ {
+ // Avoid calling for naming spec on every movie in filenamebuilder
+ var namingConfig = _namingService.GetConfig();
+ var collectionMovies = _movieMetadataService.GetMoviesWithCollections();
+
+ foreach (var collection in collections)
+ {
+ var resource = collection.ToResource();
+
+ foreach (var movie in collectionMovies.Where(m => m.CollectionTmdbId == collection.TmdbId))
+ {
+ var movieResource = movie.ToResource();
+ movieResource.Folder = _fileNameBuilder.GetMovieFolder(new Movie { MovieMetadata = movie }, namingConfig);
+
+ resource.Movies.Add(movieResource);
+ }
+
+ yield return resource;
+ }
+ }
+
+ private CollectionResource MapToResource(MovieCollection collection)
+ {
+ var resource = collection.ToResource();
+
+ foreach (var movie in _movieMetadataService.GetMoviesByCollectionTmdbId(collection.TmdbId))
+ {
+ var movieResource = movie.ToResource();
+ movieResource.Folder = _fileNameBuilder.GetMovieFolder(new Movie { MovieMetadata = movie });
+
+ resource.Movies.Add(movieResource);
+ }
+
+ return resource;
+ }
+
+ [NonAction]
+ public void Handle(CollectionAddedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Created, MapToResource(message.Collection));
+ }
+
+ [NonAction]
+ public void Handle(CollectionEditedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Collection));
+ }
+
+ [NonAction]
+ public void Handle(CollectionDeletedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Deleted, message.Collection.Id);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Collections/CollectionMovieResource.cs b/src/Radarr.Api.V4/Collections/CollectionMovieResource.cs
new file mode 100644
index 0000000000..f386ded63b
--- /dev/null
+++ b/src/Radarr.Api.V4/Collections/CollectionMovieResource.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Collections;
+
+namespace Radarr.Api.V4.Collections
+{
+ public class CollectionMovieResource
+ {
+ public int TmdbId { get; set; }
+ public string ImdbId { get; set; }
+ public string Title { get; set; }
+ public string CleanTitle { get; set; }
+ public string SortTitle { get; set; }
+ public string Overview { get; set; }
+ public int Runtime { get; set; }
+ public List Images { get; set; }
+ public int Year { get; set; }
+ public Ratings Ratings { get; set; }
+ public List Genres { get; set; }
+ public string Folder { get; set; }
+ }
+
+ public static class CollectionMovieResourceMapper
+ {
+ public static CollectionMovieResource ToResource(this MovieMetadata model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new CollectionMovieResource
+ {
+ TmdbId = model.TmdbId,
+ Title = model.Title,
+ Overview = model.Overview,
+ SortTitle = model.SortTitle,
+ Images = model.Images,
+ ImdbId = model.ImdbId,
+ Ratings = model.Ratings,
+ Runtime = model.Runtime,
+ CleanTitle = model.CleanTitle,
+ Genres = model.Genres,
+ Year = model.Year
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Collections/CollectionResource.cs b/src/Radarr.Api.V4/Collections/CollectionResource.cs
new file mode 100644
index 0000000000..fbcc2bc17d
--- /dev/null
+++ b/src/Radarr.Api.V4/Collections/CollectionResource.cs
@@ -0,0 +1,91 @@
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Collections;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Collections
+{
+ public class CollectionResource : RestResource
+ {
+ public CollectionResource()
+ {
+ Movies = new List();
+ }
+
+ public string Title { get; set; }
+ public string SortTitle { get; set; }
+ public int TmdbId { get; set; }
+ public List Images { get; set; }
+ public string Overview { get; set; }
+ public bool Monitored { get; set; }
+ public string RootFolderPath { get; set; }
+ public List QualityProfileIds { get; set; }
+ public bool SearchOnAdd { get; set; }
+ public MovieStatusType MinimumAvailability { get; set; }
+ public List Movies { get; set; }
+ }
+
+ public static class CollectionResourceMapper
+ {
+ public static CollectionResource ToResource(this MovieCollection model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new CollectionResource
+ {
+ Id = model.Id,
+ TmdbId = model.TmdbId,
+ Title = model.Title,
+ Overview = model.Overview,
+ SortTitle = model.SortTitle,
+ Monitored = model.Monitored,
+ Images = model.Images,
+ QualityProfileIds = model.QualityProfileIds,
+ RootFolderPath = model.RootFolderPath,
+ MinimumAvailability = model.MinimumAvailability,
+ SearchOnAdd = model.SearchOnAdd
+ };
+ }
+
+ public static List ToResource(this IEnumerable collections)
+ {
+ return collections.Select(ToResource).ToList();
+ }
+
+ public static MovieCollection ToModel(this CollectionResource resource)
+ {
+ if (resource == null)
+ {
+ return null;
+ }
+
+ return new MovieCollection
+ {
+ Id = resource.Id,
+ Title = resource.Title,
+ TmdbId = resource.TmdbId,
+ SortTitle = resource.SortTitle,
+ Overview = resource.Overview,
+ Monitored = resource.Monitored,
+ QualityProfileIds = resource.QualityProfileIds,
+ RootFolderPath = resource.RootFolderPath,
+ SearchOnAdd = resource.SearchOnAdd,
+ MinimumAvailability = resource.MinimumAvailability
+ };
+ }
+
+ public static MovieCollection ToModel(this CollectionResource resource, MovieCollection collection)
+ {
+ var updatedmovie = resource.ToModel();
+
+ collection.ApplyChanges(updatedmovie);
+
+ return collection;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Collections/CollectionUpdateCollectionResource.cs b/src/Radarr.Api.V4/Collections/CollectionUpdateCollectionResource.cs
new file mode 100644
index 0000000000..70b4752159
--- /dev/null
+++ b/src/Radarr.Api.V4/Collections/CollectionUpdateCollectionResource.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Radarr.Api.V4.Collections
+{
+ public class CollectionUpdateCollectionResource
+ {
+ public int Id { get; set; }
+ public bool? Monitored { get; set; }
+ }
+}
diff --git a/src/Radarr.Api.V4/Collections/CollectionUpdateResource.cs b/src/Radarr.Api.V4/Collections/CollectionUpdateResource.cs
new file mode 100644
index 0000000000..0ff2eb7525
--- /dev/null
+++ b/src/Radarr.Api.V4/Collections/CollectionUpdateResource.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Collections.Generic;
+using NzbDrone.Core.Movies;
+
+namespace Radarr.Api.V4.Collections
+{
+ public class CollectionUpdateResource
+ {
+ public List CollectionIds { get; set; }
+ public bool? Monitored { get; set; }
+ public bool? MonitorMovies { get; set; }
+ public List QualityProfileIds { get; set; }
+ public string RootFolderPath { get; set; }
+ public MovieStatusType? MinimumAvailability { get; set; }
+ }
+}
diff --git a/src/Radarr.Api.V4/Commands/CommandController.cs b/src/Radarr.Api.V4/Commands/CommandController.cs
new file mode 100644
index 0000000000..8b5ff1ea43
--- /dev/null
+++ b/src/Radarr.Api.V4/Commands/CommandController.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common;
+using NzbDrone.Common.Composition;
+using NzbDrone.Common.Serializer;
+using NzbDrone.Common.TPL;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.Messaging.Commands;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.ProgressMessaging;
+using NzbDrone.SignalR;
+using Radarr.Http;
+using Radarr.Http.REST;
+using Radarr.Http.REST.Attributes;
+using Radarr.Http.Validation;
+
+namespace Radarr.Api.V4.Commands
+{
+ [V4ApiController]
+ public class CommandController : RestControllerWithSignalR, IHandle
+ {
+ private readonly IManageCommandQueue _commandQueueManager;
+ private readonly KnownTypes _knownTypes;
+ private readonly Debouncer _debouncer;
+ private readonly Dictionary _pendingUpdates;
+
+ private readonly CommandPriorityComparer _commandPriorityComparer = new CommandPriorityComparer();
+
+ public CommandController(IManageCommandQueue commandQueueManager,
+ IBroadcastSignalRMessage signalRBroadcaster,
+ KnownTypes knownTypes)
+ : base(signalRBroadcaster)
+ {
+ _commandQueueManager = commandQueueManager;
+ _knownTypes = knownTypes;
+
+ _debouncer = new Debouncer(SendUpdates, TimeSpan.FromSeconds(0.1));
+ _pendingUpdates = new Dictionary();
+
+ PostValidator.RuleFor(c => c.Name).NotBlank();
+ }
+
+ protected override CommandResource GetResourceById(int id)
+ {
+ return _commandQueueManager.Get(id).ToResource();
+ }
+
+ [RestPostById]
+ public ActionResult StartCommand(CommandResource commandResource)
+ {
+ var commandType =
+ _knownTypes.GetImplementations(typeof(Command))
+ .Single(c => c.Name.Replace("Command", "")
+ .Equals(commandResource.Name, StringComparison.InvariantCultureIgnoreCase));
+
+ Request.Body.Seek(0, SeekOrigin.Begin);
+ using (var reader = new StreamReader(Request.Body))
+ {
+ var body = reader.ReadToEnd();
+
+ dynamic command = STJson.Deserialize(body, commandType);
+
+ command.Trigger = CommandTrigger.Manual;
+ command.SuppressMessages = !command.SendUpdatesToClient;
+ command.SendUpdatesToClient = true;
+ command.ClientUserAgent = Request.Headers["UserAgent"];
+
+ var trackedCommand = _commandQueueManager.Push(command, CommandPriority.Normal, CommandTrigger.Manual);
+ return Created(trackedCommand.Id);
+ }
+ }
+
+ [HttpGet]
+ public List GetStartedCommands()
+ {
+ return _commandQueueManager.All()
+ .OrderBy(c => c.Status, _commandPriorityComparer)
+ .ThenByDescending(c => c.Priority)
+ .ToResource();
+ }
+
+ [RestDeleteById]
+ public void CancelCommand(int id)
+ {
+ _commandQueueManager.Cancel(id);
+ }
+
+ [NonAction]
+ public void Handle(CommandUpdatedEvent message)
+ {
+ if (message.Command.Body.SendUpdatesToClient)
+ {
+ lock (_pendingUpdates)
+ {
+ _pendingUpdates[message.Command.Id] = message.Command.ToResource();
+ }
+
+ _debouncer.Execute();
+ }
+ }
+
+ private void SendUpdates()
+ {
+ lock (_pendingUpdates)
+ {
+ var pendingUpdates = _pendingUpdates.Values.ToArray();
+ _pendingUpdates.Clear();
+
+ foreach (var pendingUpdate in pendingUpdates)
+ {
+ BroadcastResourceChange(ModelAction.Updated, pendingUpdate);
+
+ if (pendingUpdate.Name == typeof(MessagingCleanupCommand).Name.Replace("Command", "") &&
+ pendingUpdate.Status == CommandStatus.Completed)
+ {
+ BroadcastResourceChange(ModelAction.Sync);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Commands/CommandResource.cs b/src/Radarr.Api.V4/Commands/CommandResource.cs
new file mode 100644
index 0000000000..46384e8d87
--- /dev/null
+++ b/src/Radarr.Api.V4/Commands/CommandResource.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Serialization;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Common.Http;
+using NzbDrone.Core.Messaging.Commands;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Commands
+{
+ public class CommandResource : RestResource
+ {
+ public string Name { get; set; }
+ public string CommandName { get; set; }
+ public string Message { get; set; }
+ public Command Body { get; set; }
+ public CommandPriority Priority { get; set; }
+ public CommandStatus Status { get; set; }
+ public DateTime Queued { get; set; }
+ public DateTime? Started { get; set; }
+ public DateTime? Ended { get; set; }
+ public TimeSpan? Duration { get; set; }
+ public string Exception { get; set; }
+ public CommandTrigger Trigger { get; set; }
+
+ public string ClientUserAgent { get; set; }
+
+ [JsonIgnore]
+ public string CompletionMessage { get; set; }
+
+ public DateTime? StateChangeTime
+ {
+ get
+ {
+ if (Started.HasValue)
+ {
+ return Started.Value;
+ }
+
+ return Ended;
+ }
+
+ set
+ {
+ }
+ }
+
+ public bool SendUpdatesToClient
+ {
+ get
+ {
+ if (Body != null)
+ {
+ return Body.SendUpdatesToClient;
+ }
+
+ return false;
+ }
+
+ set
+ {
+ }
+ }
+
+ public bool UpdateScheduledTask
+ {
+ get
+ {
+ if (Body != null)
+ {
+ return Body.UpdateScheduledTask;
+ }
+
+ return false;
+ }
+
+ set
+ {
+ }
+ }
+
+ public DateTime? LastExecutionTime { get; set; }
+ }
+
+ public static class CommandResourceMapper
+ {
+ public static CommandResource ToResource(this CommandModel model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new CommandResource
+ {
+ Id = model.Id,
+
+ Name = model.Name,
+ CommandName = model.Name.SplitCamelCase(),
+ Message = model.Message,
+ Body = model.Body,
+ Priority = model.Priority,
+ Status = model.Status,
+ Queued = model.QueuedAt,
+ Started = model.StartedAt,
+ Ended = model.EndedAt,
+ Duration = model.Duration,
+ Exception = model.Exception,
+ Trigger = model.Trigger,
+
+ ClientUserAgent = UserAgentParser.SimplifyUserAgent(model.Body.ClientUserAgent),
+
+ CompletionMessage = model.Body.CompletionMessage,
+ LastExecutionTime = model.Body.LastExecutionTime
+ };
+ }
+
+ public static List ToResource(this IEnumerable models)
+ {
+ return models.Select(ToResource).ToList();
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/ConfigController.cs b/src/Radarr.Api.V4/Config/ConfigController.cs
new file mode 100644
index 0000000000..ceda169932
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/ConfigController.cs
@@ -0,0 +1,48 @@
+using System.Linq;
+using System.Reflection;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Configuration;
+using Radarr.Http.REST;
+using Radarr.Http.REST.Attributes;
+
+namespace Radarr.Api.V4.Config
+{
+ public abstract class ConfigController : RestController
+ where TResource : RestResource, new()
+ {
+ protected readonly IConfigService _configService;
+
+ protected ConfigController(IConfigService configService)
+ {
+ _configService = configService;
+ }
+
+ protected override TResource GetResourceById(int id)
+ {
+ return GetConfig();
+ }
+
+ [HttpGet]
+ public TResource GetConfig()
+ {
+ var resource = ToResource(_configService);
+ resource.Id = 1;
+
+ return resource;
+ }
+
+ [RestPutById]
+ public virtual ActionResult SaveConfig(TResource resource)
+ {
+ var dictionary = resource.GetType()
+ .GetProperties(BindingFlags.Instance | BindingFlags.Public)
+ .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null));
+
+ _configService.SaveConfigDictionary(dictionary);
+
+ return Accepted(resource.Id);
+ }
+
+ protected abstract TResource ToResource(IConfigService model);
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/DownloadClientConfigController.cs b/src/Radarr.Api.V4/Config/DownloadClientConfigController.cs
new file mode 100644
index 0000000000..585025a949
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/DownloadClientConfigController.cs
@@ -0,0 +1,19 @@
+using NzbDrone.Core.Configuration;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Config
+{
+ [V4ApiController("config/downloadclient")]
+ public class DownloadClientConfigController : ConfigController
+ {
+ public DownloadClientConfigController(IConfigService configService)
+ : base(configService)
+ {
+ }
+
+ protected override DownloadClientConfigResource ToResource(IConfigService model)
+ {
+ return DownloadClientConfigResourceMapper.ToResource(model);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/DownloadClientConfigResource.cs b/src/Radarr.Api.V4/Config/DownloadClientConfigResource.cs
new file mode 100644
index 0000000000..84aa0339d8
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/DownloadClientConfigResource.cs
@@ -0,0 +1,31 @@
+using NzbDrone.Core.Configuration;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Config
+{
+ public class DownloadClientConfigResource : RestResource
+ {
+ public string DownloadClientWorkingFolders { get; set; }
+
+ public bool EnableCompletedDownloadHandling { get; set; }
+ public int CheckForFinishedDownloadInterval { get; set; }
+
+ public bool AutoRedownloadFailed { get; set; }
+ }
+
+ public static class DownloadClientConfigResourceMapper
+ {
+ public static DownloadClientConfigResource ToResource(IConfigService model)
+ {
+ return new DownloadClientConfigResource
+ {
+ DownloadClientWorkingFolders = model.DownloadClientWorkingFolders,
+
+ EnableCompletedDownloadHandling = model.EnableCompletedDownloadHandling,
+ CheckForFinishedDownloadInterval = model.CheckForFinishedDownloadInterval,
+
+ AutoRedownloadFailed = model.AutoRedownloadFailed
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/HostConfigController.cs b/src/Radarr.Api.V4/Config/HostConfigController.cs
new file mode 100644
index 0000000000..0675a71bf5
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/HostConfigController.cs
@@ -0,0 +1,121 @@
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Security.Cryptography.X509Certificates;
+using FluentValidation;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Authentication;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.Update;
+using NzbDrone.Core.Validation;
+using NzbDrone.Core.Validation.Paths;
+using Radarr.Http;
+using Radarr.Http.REST;
+using Radarr.Http.REST.Attributes;
+
+namespace Radarr.Api.V4.Config
+{
+ [V4ApiController("config/host")]
+ public class HostConfigController : RestController
+ {
+ private readonly IConfigFileProvider _configFileProvider;
+ private readonly IConfigService _configService;
+ private readonly IUserService _userService;
+
+ public HostConfigController(IConfigFileProvider configFileProvider,
+ IConfigService configService,
+ IUserService userService,
+ FileExistsValidator fileExistsValidator)
+ {
+ _configFileProvider = configFileProvider;
+ _configService = configService;
+ _userService = userService;
+
+ SharedValidator.RuleFor(c => c.BindAddress)
+ .ValidIpAddress()
+ .NotListenAllIp4Address()
+ .When(c => c.BindAddress != "*" && c.BindAddress != "localhost");
+
+ SharedValidator.RuleFor(c => c.Port).ValidPort();
+
+ SharedValidator.RuleFor(c => c.UrlBase).ValidUrlBase();
+ SharedValidator.RuleFor(c => c.InstanceName).ContainsRadarr().When(c => c.InstanceName.IsNotNullOrWhiteSpace());
+
+ SharedValidator.RuleFor(c => c.Username).NotEmpty().When(c => c.AuthenticationMethod != AuthenticationType.None);
+ SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => c.AuthenticationMethod != AuthenticationType.None);
+
+ SharedValidator.RuleFor(c => c.SslPort).ValidPort().When(c => c.EnableSsl);
+ SharedValidator.RuleFor(c => c.SslPort).NotEqual(c => c.Port).When(c => c.EnableSsl);
+
+ SharedValidator.RuleFor(c => c.SslCertPath)
+ .Cascade(CascadeMode.StopOnFirstFailure)
+ .NotEmpty()
+ .IsValidPath()
+ .SetValidator(fileExistsValidator)
+ .Must((resource, path) => IsValidSslCertificate(resource)).WithMessage("Invalid SSL certificate file or password")
+ .When(c => c.EnableSsl);
+
+ SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'master' is the default");
+ SharedValidator.RuleFor(c => c.UpdateScriptPath).IsValidPath().When(c => c.UpdateMechanism == UpdateMechanism.Script);
+
+ SharedValidator.RuleFor(c => c.BackupFolder).IsValidPath().When(c => Path.IsPathRooted(c.BackupFolder));
+ SharedValidator.RuleFor(c => c.BackupInterval).InclusiveBetween(1, 7);
+ SharedValidator.RuleFor(c => c.BackupRetention).InclusiveBetween(1, 90);
+ }
+
+ private bool IsValidSslCertificate(HostConfigResource resource)
+ {
+ X509Certificate2 cert;
+ try
+ {
+ cert = new X509Certificate2(resource.SslCertPath, resource.SslCertPassword, X509KeyStorageFlags.DefaultKeySet);
+ }
+ catch
+ {
+ return false;
+ }
+
+ return cert != null;
+ }
+
+ protected override HostConfigResource GetResourceById(int id)
+ {
+ return GetHostConfig();
+ }
+
+ [HttpGet]
+ public HostConfigResource GetHostConfig()
+ {
+ var resource = _configFileProvider.ToResource(_configService);
+ resource.Id = 1;
+
+ var user = _userService.FindUser();
+ if (user != null)
+ {
+ resource.Username = user.Username;
+ resource.Password = user.Password;
+ }
+
+ return resource;
+ }
+
+ [RestPutById]
+ public ActionResult SaveHostConfig(HostConfigResource resource)
+ {
+ var dictionary = resource.GetType()
+ .GetProperties(BindingFlags.Instance | BindingFlags.Public)
+ .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null));
+
+ _configFileProvider.SaveConfigDictionary(dictionary);
+ _configService.SaveConfigDictionary(dictionary);
+
+ if (resource.Username.IsNotNullOrWhiteSpace() && resource.Password.IsNotNullOrWhiteSpace())
+ {
+ _userService.Upsert(resource.Username, resource.Password);
+ }
+
+ return Accepted(resource.Id);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/HostConfigResource.cs b/src/Radarr.Api.V4/Config/HostConfigResource.cs
new file mode 100644
index 0000000000..8dd3fdfea2
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/HostConfigResource.cs
@@ -0,0 +1,93 @@
+using NzbDrone.Common.Http.Proxy;
+using NzbDrone.Core.Authentication;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.Security;
+using NzbDrone.Core.Update;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Config
+{
+ public class HostConfigResource : RestResource
+ {
+ public string BindAddress { get; set; }
+ public int Port { get; set; }
+ public int SslPort { get; set; }
+ public bool EnableSsl { get; set; }
+ public bool LaunchBrowser { get; set; }
+ public AuthenticationType AuthenticationMethod { get; set; }
+ public AuthenticationRequiredType AuthenticationRequired { get; set; }
+ public bool AnalyticsEnabled { get; set; }
+ public string Username { get; set; }
+ public string Password { get; set; }
+ public string LogLevel { get; set; }
+ public string ConsoleLogLevel { get; set; }
+ public string Branch { get; set; }
+ public string ApiKey { get; set; }
+ public string SslCertPath { get; set; }
+ public string SslCertPassword { get; set; }
+ public string UrlBase { get; set; }
+ public string InstanceName { get; set; }
+ public string ApplicationUrl { get; set; }
+ public bool UpdateAutomatically { get; set; }
+ public UpdateMechanism UpdateMechanism { get; set; }
+ public string UpdateScriptPath { get; set; }
+ public bool ProxyEnabled { get; set; }
+ public ProxyType ProxyType { get; set; }
+ public string ProxyHostname { get; set; }
+ public int ProxyPort { get; set; }
+ public string ProxyUsername { get; set; }
+ public string ProxyPassword { get; set; }
+ public string ProxyBypassFilter { get; set; }
+ public bool ProxyBypassLocalAddresses { get; set; }
+ public CertificateValidationType CertificateValidation { get; set; }
+ public string BackupFolder { get; set; }
+ public int BackupInterval { get; set; }
+ public int BackupRetention { get; set; }
+ }
+
+ public static class HostConfigResourceMapper
+ {
+ public static HostConfigResource ToResource(this IConfigFileProvider model, IConfigService configService)
+ {
+ // TODO: Clean this mess up. don't mix data from multiple classes, use sub-resources instead?
+ return new HostConfigResource
+ {
+ BindAddress = model.BindAddress,
+ Port = model.Port,
+ SslPort = model.SslPort,
+ EnableSsl = model.EnableSsl,
+ LaunchBrowser = model.LaunchBrowser,
+ AuthenticationMethod = model.AuthenticationMethod,
+ AuthenticationRequired = model.AuthenticationRequired,
+ AnalyticsEnabled = model.AnalyticsEnabled,
+
+ // Username
+ // Password
+ LogLevel = model.LogLevel,
+ ConsoleLogLevel = model.ConsoleLogLevel,
+ Branch = model.Branch,
+ ApiKey = model.ApiKey,
+ SslCertPath = model.SslCertPath,
+ SslCertPassword = model.SslCertPassword,
+ UrlBase = model.UrlBase,
+ InstanceName = model.InstanceName,
+ UpdateAutomatically = model.UpdateAutomatically,
+ UpdateMechanism = model.UpdateMechanism,
+ UpdateScriptPath = model.UpdateScriptPath,
+ ProxyEnabled = configService.ProxyEnabled,
+ ProxyType = configService.ProxyType,
+ ProxyHostname = configService.ProxyHostname,
+ ProxyPort = configService.ProxyPort,
+ ProxyUsername = configService.ProxyUsername,
+ ProxyPassword = configService.ProxyPassword,
+ ProxyBypassFilter = configService.ProxyBypassFilter,
+ ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses,
+ CertificateValidation = configService.CertificateValidation,
+ BackupFolder = configService.BackupFolder,
+ BackupInterval = configService.BackupInterval,
+ BackupRetention = configService.BackupRetention,
+ ApplicationUrl = configService.ApplicationUrl
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/ImportListConfigController.cs b/src/Radarr.Api.V4/Config/ImportListConfigController.cs
new file mode 100644
index 0000000000..3b6b8287d8
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/ImportListConfigController.cs
@@ -0,0 +1,23 @@
+using NzbDrone.Core.Configuration;
+using Radarr.Http;
+using Radarr.Http.Validation;
+
+namespace Radarr.Api.V4.Config
+{
+ [V4ApiController("config/importlist")]
+
+ public class ImportListConfigController : ConfigController
+ {
+ public ImportListConfigController(IConfigService configService)
+ : base(configService)
+ {
+ SharedValidator.RuleFor(c => c.ImportListSyncInterval)
+ .IsValidImportListSyncInterval();
+ }
+
+ protected override ImportListConfigResource ToResource(IConfigService model)
+ {
+ return ImportListConfigResourceMapper.ToResource(model);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/ImportListConfigResource.cs b/src/Radarr.Api.V4/Config/ImportListConfigResource.cs
new file mode 100644
index 0000000000..c0bd5f9a08
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/ImportListConfigResource.cs
@@ -0,0 +1,25 @@
+using NzbDrone.Core.Configuration;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Config
+{
+ public class ImportListConfigResource : RestResource
+ {
+ public int ImportListSyncInterval { get; set; }
+ public string ListSyncLevel { get; set; }
+ public string ImportExclusions { get; set; }
+ }
+
+ public static class ImportListConfigResourceMapper
+ {
+ public static ImportListConfigResource ToResource(IConfigService model)
+ {
+ return new ImportListConfigResource
+ {
+ ImportListSyncInterval = model.ImportListSyncInterval,
+ ListSyncLevel = model.ListSyncLevel,
+ ImportExclusions = model.ImportExclusions
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/IndexerConfigController.cs b/src/Radarr.Api.V4/Config/IndexerConfigController.cs
new file mode 100644
index 0000000000..0d0762e260
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/IndexerConfigController.cs
@@ -0,0 +1,32 @@
+using FluentValidation;
+using NzbDrone.Core.Configuration;
+using Radarr.Http;
+using Radarr.Http.Validation;
+
+namespace Radarr.Api.V4.Config
+{
+ [V4ApiController("config/indexer")]
+ public class IndexerConfigController : ConfigController
+ {
+ public IndexerConfigController(IConfigService configService)
+ : base(configService)
+ {
+ SharedValidator.RuleFor(c => c.MinimumAge)
+ .GreaterThanOrEqualTo(0);
+
+ SharedValidator.RuleFor(c => c.MaximumSize)
+ .GreaterThanOrEqualTo(0);
+
+ SharedValidator.RuleFor(c => c.Retention)
+ .GreaterThanOrEqualTo(0);
+
+ SharedValidator.RuleFor(c => c.RssSyncInterval)
+ .IsValidRssSyncInterval();
+ }
+
+ protected override IndexerConfigResource ToResource(IConfigService model)
+ {
+ return IndexerConfigResourceMapper.ToResource(model);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/IndexerConfigResource.cs b/src/Radarr.Api.V4/Config/IndexerConfigResource.cs
new file mode 100644
index 0000000000..bba0e39f7e
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/IndexerConfigResource.cs
@@ -0,0 +1,35 @@
+using NzbDrone.Core.Configuration;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Config
+{
+ public class IndexerConfigResource : RestResource
+ {
+ public int MinimumAge { get; set; }
+ public int MaximumSize { get; set; }
+ public int Retention { get; set; }
+ public int RssSyncInterval { get; set; }
+ public bool PreferIndexerFlags { get; set; }
+ public int AvailabilityDelay { get; set; }
+ public bool AllowHardcodedSubs { get; set; }
+ public string WhitelistedHardcodedSubs { get; set; }
+ }
+
+ public static class IndexerConfigResourceMapper
+ {
+ public static IndexerConfigResource ToResource(IConfigService model)
+ {
+ return new IndexerConfigResource
+ {
+ MinimumAge = model.MinimumAge,
+ MaximumSize = model.MaximumSize,
+ Retention = model.Retention,
+ RssSyncInterval = model.RssSyncInterval,
+ PreferIndexerFlags = model.PreferIndexerFlags,
+ AvailabilityDelay = model.AvailabilityDelay,
+ AllowHardcodedSubs = model.AllowHardcodedSubs,
+ WhitelistedHardcodedSubs = model.WhitelistedHardcodedSubs,
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/MediaManagementConfigController.cs b/src/Radarr.Api.V4/Config/MediaManagementConfigController.cs
new file mode 100644
index 0000000000..576399a216
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/MediaManagementConfigController.cs
@@ -0,0 +1,45 @@
+using FluentValidation;
+using NzbDrone.Common.EnvironmentInfo;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.Validation;
+using NzbDrone.Core.Validation.Paths;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Config
+{
+ [V4ApiController("config/mediamanagement")]
+ public class MediaManagementConfigController : ConfigController
+ {
+ public MediaManagementConfigController(IConfigService configService,
+ PathExistsValidator pathExistsValidator,
+ FolderChmodValidator folderChmodValidator,
+ FolderWritableValidator folderWritableValidator,
+ MoviePathValidator moviePathValidator,
+ StartupFolderValidator startupFolderValidator,
+ SystemFolderValidator systemFolderValidator,
+ RootFolderAncestorValidator rootFolderAncestorValidator,
+ RootFolderValidator rootFolderValidator)
+ : base(configService)
+ {
+ SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0);
+ SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && (OsInfo.IsLinux || OsInfo.IsOsx));
+
+ SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath()
+ .SetValidator(folderWritableValidator)
+ .SetValidator(rootFolderValidator)
+ .SetValidator(pathExistsValidator)
+ .SetValidator(rootFolderAncestorValidator)
+ .SetValidator(startupFolderValidator)
+ .SetValidator(systemFolderValidator)
+ .SetValidator(moviePathValidator)
+ .When(c => !string.IsNullOrWhiteSpace(c.RecycleBin));
+
+ SharedValidator.RuleFor(c => c.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100);
+ }
+
+ protected override MediaManagementConfigResource ToResource(IConfigService model)
+ {
+ return MediaManagementConfigResourceMapper.ToResource(model);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/MediaManagementConfigResource.cs b/src/Radarr.Api.V4/Config/MediaManagementConfigResource.cs
new file mode 100644
index 0000000000..8ac1d55ac3
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/MediaManagementConfigResource.cs
@@ -0,0 +1,62 @@
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.Qualities;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Config
+{
+ public class MediaManagementConfigResource : RestResource
+ {
+ public bool AutoUnmonitorPreviouslyDownloadedMovies { get; set; }
+ public string RecycleBin { get; set; }
+ public int RecycleBinCleanupDays { get; set; }
+ public ProperDownloadTypes DownloadPropersAndRepacks { get; set; }
+ public bool CreateEmptyMovieFolders { get; set; }
+ public bool DeleteEmptyFolders { get; set; }
+ public FileDateType FileDate { get; set; }
+ public RescanAfterRefreshType RescanAfterRefresh { get; set; }
+ public bool AutoRenameFolders { get; set; }
+ public bool PathsDefaultStatic { get; set; }
+
+ public bool SetPermissionsLinux { get; set; }
+ public string ChmodFolder { get; set; }
+ public string ChownGroup { get; set; }
+
+ public bool SkipFreeSpaceCheckWhenImporting { get; set; }
+ public int MinimumFreeSpaceWhenImporting { get; set; }
+ public bool CopyUsingHardlinks { get; set; }
+ public bool ImportExtraFiles { get; set; }
+ public string ExtraFileExtensions { get; set; }
+ public bool EnableMediaInfo { get; set; }
+ }
+
+ public static class MediaManagementConfigResourceMapper
+ {
+ public static MediaManagementConfigResource ToResource(IConfigService model)
+ {
+ return new MediaManagementConfigResource
+ {
+ AutoUnmonitorPreviouslyDownloadedMovies = model.AutoUnmonitorPreviouslyDownloadedMovies,
+ RecycleBin = model.RecycleBin,
+ RecycleBinCleanupDays = model.RecycleBinCleanupDays,
+ DownloadPropersAndRepacks = model.DownloadPropersAndRepacks,
+ CreateEmptyMovieFolders = model.CreateEmptyMovieFolders,
+ DeleteEmptyFolders = model.DeleteEmptyFolders,
+ FileDate = model.FileDate,
+ RescanAfterRefresh = model.RescanAfterRefresh,
+ AutoRenameFolders = model.AutoRenameFolders,
+
+ SetPermissionsLinux = model.SetPermissionsLinux,
+ ChmodFolder = model.ChmodFolder,
+ ChownGroup = model.ChownGroup,
+
+ SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
+ MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting,
+ CopyUsingHardlinks = model.CopyUsingHardlinks,
+ ImportExtraFiles = model.ImportExtraFiles,
+ ExtraFileExtensions = model.ExtraFileExtensions,
+ EnableMediaInfo = model.EnableMediaInfo
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/MetadataConfigController.cs b/src/Radarr.Api.V4/Config/MetadataConfigController.cs
new file mode 100644
index 0000000000..6fc328e343
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/MetadataConfigController.cs
@@ -0,0 +1,19 @@
+using NzbDrone.Core.Configuration;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Config
+{
+ [V4ApiController("config/metadata")]
+ public class MetadataConfigController : ConfigController
+ {
+ public MetadataConfigController(IConfigService configService)
+ : base(configService)
+ {
+ }
+
+ protected override MetadataConfigResource ToResource(IConfigService model)
+ {
+ return MetadataConfigResourceMapper.ToResource(model);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/MetadataConfigResource.cs b/src/Radarr.Api.V4/Config/MetadataConfigResource.cs
new file mode 100644
index 0000000000..a8b33c9956
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/MetadataConfigResource.cs
@@ -0,0 +1,22 @@
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.MetadataSource.SkyHook.Resource;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Config
+{
+ public class MetadataConfigResource : RestResource
+ {
+ public TMDbCountryCode CertificationCountry { get; set; }
+ }
+
+ public static class MetadataConfigResourceMapper
+ {
+ public static MetadataConfigResource ToResource(IConfigService model)
+ {
+ return new MetadataConfigResource
+ {
+ CertificationCountry = model.CertificationCountry,
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/NamingConfigController.cs b/src/Radarr.Api.V4/Config/NamingConfigController.cs
new file mode 100644
index 0000000000..c9b6c47a07
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/NamingConfigController.cs
@@ -0,0 +1,105 @@
+using System.Collections.Generic;
+using System.Linq;
+using FluentValidation;
+using FluentValidation.Results;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Organizer;
+using Radarr.Http;
+using Radarr.Http.REST;
+using Radarr.Http.REST.Attributes;
+
+namespace Radarr.Api.V4.Config
+{
+ [V4ApiController("config/naming")]
+ public class NamingConfigController : RestController
+ {
+ private readonly INamingConfigService _namingConfigService;
+ private readonly IFilenameSampleService _filenameSampleService;
+ private readonly IFilenameValidationService _filenameValidationService;
+ private readonly IBuildFileNames _filenameBuilder;
+
+ public NamingConfigController(INamingConfigService namingConfigService,
+ IFilenameSampleService filenameSampleService,
+ IFilenameValidationService filenameValidationService,
+ IBuildFileNames filenameBuilder)
+ {
+ _namingConfigService = namingConfigService;
+ _filenameSampleService = filenameSampleService;
+ _filenameValidationService = filenameValidationService;
+ _filenameBuilder = filenameBuilder;
+
+ SharedValidator.RuleFor(c => c.StandardMovieFormat).ValidMovieFormat();
+ SharedValidator.RuleFor(c => c.MovieFolderFormat).ValidMovieFolderFormat();
+ }
+
+ protected override NamingConfigResource GetResourceById(int id)
+ {
+ return GetNamingConfig();
+ }
+
+ [HttpGet]
+ public NamingConfigResource GetNamingConfig()
+ {
+ var nameSpec = _namingConfigService.GetConfig();
+ var resource = nameSpec.ToResource();
+
+ if (resource.StandardMovieFormat.IsNotNullOrWhiteSpace())
+ {
+ var basicConfig = _filenameBuilder.GetBasicNamingConfig(nameSpec);
+ basicConfig.AddToResource(resource);
+ }
+
+ return resource;
+ }
+
+ [RestPutById]
+ public ActionResult UpdateNamingConfig(NamingConfigResource resource)
+ {
+ var nameSpec = resource.ToModel();
+ ValidateFormatResult(nameSpec);
+
+ _namingConfigService.Save(nameSpec);
+
+ return Accepted(resource.Id);
+ }
+
+ [HttpGet("examples")]
+ public object GetExamples([FromQuery]NamingConfigResource config)
+ {
+ if (config.Id == 0)
+ {
+ config = GetNamingConfig();
+ }
+
+ var nameSpec = config.ToModel();
+ var sampleResource = new NamingExampleResource();
+
+ var movieSampleResult = _filenameSampleService.GetMovieSample(nameSpec);
+
+ sampleResource.MovieExample = nameSpec.StandardMovieFormat.IsNullOrWhiteSpace()
+ ? "Invalid Format"
+ : movieSampleResult.FileName;
+
+ sampleResource.MovieFolderExample = nameSpec.MovieFolderFormat.IsNullOrWhiteSpace()
+ ? "Invalid format"
+ : _filenameSampleService.GetMovieFolderSample(nameSpec);
+
+ return sampleResource;
+ }
+
+ private void ValidateFormatResult(NamingConfig nameSpec)
+ {
+ var movieSampleResult = _filenameSampleService.GetMovieSample(nameSpec);
+
+ var standardMovieValidationResult = _filenameValidationService.ValidateMovieFilename(movieSampleResult);
+
+ var validationFailures = new List();
+
+ if (validationFailures.Any())
+ {
+ throw new ValidationException(validationFailures.DistinctBy(v => v.PropertyName).ToArray());
+ }
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/NamingConfigResource.cs b/src/Radarr.Api.V4/Config/NamingConfigResource.cs
new file mode 100644
index 0000000000..118ca757db
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/NamingConfigResource.cs
@@ -0,0 +1,18 @@
+using NzbDrone.Core.Organizer;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Config
+{
+ public class NamingConfigResource : RestResource
+ {
+ public bool RenameMovies { get; set; }
+ public bool ReplaceIllegalCharacters { get; set; }
+ public ColonReplacementFormat ColonReplacementFormat { get; set; }
+ public string StandardMovieFormat { get; set; }
+ public string MovieFolderFormat { get; set; }
+ public bool IncludeQuality { get; set; }
+ public bool ReplaceSpaces { get; set; }
+ public string Separator { get; set; }
+ public string NumberStyle { get; set; }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/NamingExampleResource.cs b/src/Radarr.Api.V4/Config/NamingExampleResource.cs
new file mode 100644
index 0000000000..a43db04289
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/NamingExampleResource.cs
@@ -0,0 +1,54 @@
+using NzbDrone.Core.Organizer;
+
+namespace Radarr.Api.V4.Config
+{
+ public class NamingExampleResource
+ {
+ public string MovieExample { get; set; }
+ public string MovieFolderExample { get; set; }
+ }
+
+ public static class NamingConfigResourceMapper
+ {
+ public static NamingConfigResource ToResource(this NamingConfig model)
+ {
+ return new NamingConfigResource
+ {
+ Id = model.Id,
+
+ RenameMovies = model.RenameMovies,
+ ReplaceIllegalCharacters = model.ReplaceIllegalCharacters,
+ ColonReplacementFormat = model.ColonReplacementFormat,
+ StandardMovieFormat = model.StandardMovieFormat,
+ MovieFolderFormat = model.MovieFolderFormat,
+
+ // IncludeQuality
+ // ReplaceSpaces
+ // Separator
+ // NumberStyle
+ };
+ }
+
+ public static void AddToResource(this BasicNamingConfig basicNamingConfig, NamingConfigResource resource)
+ {
+ resource.IncludeQuality = basicNamingConfig.IncludeQuality;
+ resource.ReplaceSpaces = basicNamingConfig.ReplaceSpaces;
+ resource.Separator = basicNamingConfig.Separator;
+ resource.NumberStyle = basicNamingConfig.NumberStyle;
+ }
+
+ public static NamingConfig ToModel(this NamingConfigResource resource)
+ {
+ return new NamingConfig
+ {
+ Id = resource.Id,
+
+ RenameMovies = resource.RenameMovies,
+ ReplaceIllegalCharacters = resource.ReplaceIllegalCharacters,
+ ColonReplacementFormat = resource.ColonReplacementFormat,
+ StandardMovieFormat = resource.StandardMovieFormat,
+ MovieFolderFormat = resource.MovieFolderFormat,
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/UiConfigController.cs b/src/Radarr.Api.V4/Config/UiConfigController.cs
new file mode 100644
index 0000000000..41427aecb0
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/UiConfigController.cs
@@ -0,0 +1,39 @@
+using System.Linq;
+using System.Reflection;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Configuration;
+using Radarr.Http;
+using Radarr.Http.REST.Attributes;
+
+namespace Radarr.Api.V4.Config
+{
+ [V4ApiController("config/ui")]
+ public class UiConfigController : ConfigController
+ {
+ private readonly IConfigFileProvider _configFileProvider;
+
+ public UiConfigController(IConfigFileProvider configFileProvider, IConfigService configService)
+ : base(configService)
+ {
+ _configFileProvider = configFileProvider;
+ }
+
+ [RestPutById]
+ public override ActionResult SaveConfig(UiConfigResource resource)
+ {
+ var dictionary = resource.GetType()
+ .GetProperties(BindingFlags.Instance | BindingFlags.Public)
+ .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null));
+
+ _configFileProvider.SaveConfigDictionary(dictionary);
+ _configService.SaveConfigDictionary(dictionary);
+
+ return Accepted(resource.Id);
+ }
+
+ protected override UiConfigResource ToResource(IConfigService model)
+ {
+ return UiConfigResourceMapper.ToResource(_configFileProvider, model);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Config/UiConfigResource.cs b/src/Radarr.Api.V4/Config/UiConfigResource.cs
new file mode 100644
index 0000000000..5fe3a48f90
--- /dev/null
+++ b/src/Radarr.Api.V4/Config/UiConfigResource.cs
@@ -0,0 +1,50 @@
+using NzbDrone.Core.Configuration;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Config
+{
+ public class UiConfigResource : RestResource
+ {
+ // Calendar
+ public int FirstDayOfWeek { get; set; }
+ public string CalendarWeekColumnHeader { get; set; }
+
+ // Movies
+ public MovieRuntimeFormatType MovieRuntimeFormat { get; set; }
+
+ // Dates
+ public string ShortDateFormat { get; set; }
+ public string LongDateFormat { get; set; }
+ public string TimeFormat { get; set; }
+ public bool ShowRelativeDates { get; set; }
+
+ public bool EnableColorImpairedMode { get; set; }
+ public int MovieInfoLanguage { get; set; }
+ public int UILanguage { get; set; }
+ public string Theme { get; set; }
+ }
+
+ public static class UiConfigResourceMapper
+ {
+ public static UiConfigResource ToResource(IConfigFileProvider config, IConfigService model)
+ {
+ return new UiConfigResource
+ {
+ FirstDayOfWeek = model.FirstDayOfWeek,
+ CalendarWeekColumnHeader = model.CalendarWeekColumnHeader,
+
+ MovieRuntimeFormat = model.MovieRuntimeFormat,
+
+ ShortDateFormat = model.ShortDateFormat,
+ LongDateFormat = model.LongDateFormat,
+ TimeFormat = model.TimeFormat,
+ ShowRelativeDates = model.ShowRelativeDates,
+
+ EnableColorImpairedMode = model.EnableColorImpairedMode,
+ MovieInfoLanguage = model.MovieInfoLanguage,
+ UILanguage = model.UILanguage,
+ Theme = config.Theme
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Credits/CreditController.cs b/src/Radarr.Api.V4/Credits/CreditController.cs
new file mode 100644
index 0000000000..3b54238c1d
--- /dev/null
+++ b/src/Radarr.Api.V4/Credits/CreditController.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Credits;
+using Radarr.Http;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Credits
+{
+ [V4ApiController]
+ public class CreditController : RestController
+ {
+ private readonly ICreditService _creditService;
+ private readonly IMovieService _movieService;
+
+ public CreditController(ICreditService creditService, IMovieService movieService)
+ {
+ _creditService = creditService;
+ _movieService = movieService;
+ }
+
+ protected override CreditResource GetResourceById(int id)
+ {
+ return _creditService.GetById(id).ToResource();
+ }
+
+ [HttpGet]
+ public List GetCredits(int? movieId, int? movieMetadataId)
+ {
+ if (movieMetadataId.HasValue)
+ {
+ return _creditService.GetAllCreditsForMovieMetadata(movieMetadataId.Value).ToResource();
+ }
+
+ if (movieId.HasValue)
+ {
+ var movie = _movieService.GetMovie(movieId.Value);
+ return _creditService.GetAllCreditsForMovieMetadata(movie.MovieMetadataId).ToResource();
+ }
+
+ return _creditService.GetAllCredits().ToResource();
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Credits/CreditResource.cs b/src/Radarr.Api.V4/Credits/CreditResource.cs
new file mode 100644
index 0000000000..c7d77e2c1b
--- /dev/null
+++ b/src/Radarr.Api.V4/Credits/CreditResource.cs
@@ -0,0 +1,80 @@
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.Movies.Credits;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Credits
+{
+ public class CreditResource : RestResource
+ {
+ public CreditResource()
+ {
+ }
+
+ public string PersonName { get; set; }
+ public string CreditTmdbId { get; set; }
+ public int PersonTmdbId { get; set; }
+ public int MovieMetadataId { get; set; }
+ public List Images { get; set; }
+ public string Department { get; set; }
+ public string Job { get; set; }
+ public string Character { get; set; }
+ public int Order { get; set; }
+ public CreditType Type { get; set; }
+ }
+
+ public static class CreditResourceMapper
+ {
+ public static CreditResource ToResource(this Credit model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new CreditResource
+ {
+ Id = model.Id,
+ MovieMetadataId = model.MovieMetadataId,
+ CreditTmdbId = model.CreditTmdbId,
+ PersonTmdbId = model.PersonTmdbId,
+ PersonName = model.Name,
+ Order = model.Order,
+ Character = model.Character,
+ Department = model.Department,
+ Images = model.Images,
+ Job = model.Job,
+ Type = model.Type
+ };
+ }
+
+ public static List ToResource(this IEnumerable credits)
+ {
+ return credits.Select(ToResource).ToList();
+ }
+
+ public static Credit ToModel(this CreditResource resource)
+ {
+ if (resource == null)
+ {
+ return null;
+ }
+
+ return new Credit
+ {
+ Id = resource.Id,
+ MovieMetadataId = resource.MovieMetadataId,
+ Name = resource.PersonName,
+ Order = resource.Order,
+ Character = resource.Character,
+ Department = resource.Department,
+ Job = resource.Job,
+ Type = resource.Type,
+ Images = resource.Images,
+ CreditTmdbId = resource.CreditTmdbId,
+ PersonTmdbId = resource.PersonTmdbId
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/CustomFilters/CustomFilterController.cs b/src/Radarr.Api.V4/CustomFilters/CustomFilterController.cs
new file mode 100644
index 0000000000..fa003eed43
--- /dev/null
+++ b/src/Radarr.Api.V4/CustomFilters/CustomFilterController.cs
@@ -0,0 +1,52 @@
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.CustomFilters;
+using Radarr.Http;
+using Radarr.Http.REST;
+using Radarr.Http.REST.Attributes;
+
+namespace Radarr.Api.V4.CustomFilters
+{
+ [V4ApiController]
+ public class CustomFilterController : RestController
+ {
+ private readonly ICustomFilterService _customFilterService;
+
+ public CustomFilterController(ICustomFilterService customFilterService)
+ {
+ _customFilterService = customFilterService;
+ }
+
+ protected override CustomFilterResource GetResourceById(int id)
+ {
+ return _customFilterService.Get(id).ToResource();
+ }
+
+ [HttpGet]
+ public List GetCustomFilters()
+ {
+ return _customFilterService.All().ToResource();
+ }
+
+ [RestPostById]
+ public ActionResult AddCustomFilter(CustomFilterResource resource)
+ {
+ var customFilter = _customFilterService.Add(resource.ToModel());
+
+ return Created(customFilter.Id);
+ }
+
+ [RestPutById]
+ public ActionResult UpdateCustomFilter(CustomFilterResource resource)
+ {
+ _customFilterService.Update(resource.ToModel());
+ return Accepted(resource.Id);
+ }
+
+ [RestDeleteById]
+ public void DeleteCustomResource(int id)
+ {
+ _customFilterService.Delete(id);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/CustomFilters/CustomFilterResource.cs b/src/Radarr.Api.V4/CustomFilters/CustomFilterResource.cs
new file mode 100644
index 0000000000..0f26722e0b
--- /dev/null
+++ b/src/Radarr.Api.V4/CustomFilters/CustomFilterResource.cs
@@ -0,0 +1,56 @@
+using System.Collections.Generic;
+using System.Dynamic;
+using System.Linq;
+using NzbDrone.Common.Serializer;
+using NzbDrone.Core.CustomFilters;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.CustomFilters
+{
+ public class CustomFilterResource : RestResource
+ {
+ public string Type { get; set; }
+ public string Label { get; set; }
+ public List Filters { get; set; }
+ }
+
+ public static class CustomFilterResourceMapper
+ {
+ public static CustomFilterResource ToResource(this CustomFilter model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new CustomFilterResource
+ {
+ Id = model.Id,
+ Type = model.Type,
+ Label = model.Label,
+ Filters = STJson.Deserialize>(model.Filters)
+ };
+ }
+
+ public static CustomFilter ToModel(this CustomFilterResource resource)
+ {
+ if (resource == null)
+ {
+ return null;
+ }
+
+ return new CustomFilter
+ {
+ Id = resource.Id,
+ Type = resource.Type,
+ Label = resource.Label,
+ Filters = STJson.ToJson(resource.Filters)
+ };
+ }
+
+ public static List ToResource(this IEnumerable filters)
+ {
+ return filters.Select(ToResource).ToList();
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/CustomFormats/CustomFormatController.cs b/src/Radarr.Api.V4/CustomFormats/CustomFormatController.cs
new file mode 100644
index 0000000000..967bb7ec31
--- /dev/null
+++ b/src/Radarr.Api.V4/CustomFormats/CustomFormatController.cs
@@ -0,0 +1,170 @@
+using System.Collections.Generic;
+using System.Linq;
+using FluentValidation;
+using FluentValidation.Results;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.Validation;
+using Radarr.Http;
+using Radarr.Http.REST;
+using Radarr.Http.REST.Attributes;
+
+namespace Radarr.Api.V4.CustomFormats
+{
+ [V4ApiController]
+ public class CustomFormatController : RestController
+ {
+ private readonly ICustomFormatService _formatService;
+ private readonly List _specifications;
+
+ public CustomFormatController(ICustomFormatService formatService,
+ List specifications)
+ {
+ _formatService = formatService;
+ _specifications = specifications;
+
+ SharedValidator.RuleFor(c => c.Name).NotEmpty();
+ SharedValidator.RuleFor(c => c.Name)
+ .Must((v, c) => !_formatService.All().Any(f => f.Name == c && f.Id != v.Id)).WithMessage("Must be unique.");
+ SharedValidator.RuleFor(c => c).Custom((customFormat, context) =>
+ {
+ if (!customFormat.Specifications.Any())
+ {
+ context.AddFailure("Must contain at least one Condition");
+ }
+
+ if (customFormat.Specifications.Any(s => s.Name.IsNullOrWhiteSpace()))
+ {
+ context.AddFailure("Condition name(s) cannot be empty or consist of only spaces");
+ }
+ });
+ }
+
+ protected override CustomFormatResource GetResourceById(int id)
+ {
+ return _formatService.GetById(id).ToResource(true);
+ }
+
+ [RestPostById]
+ [Consumes("application/json")]
+ public ActionResult Create(CustomFormatResource customFormatResource)
+ {
+ var model = customFormatResource.ToModel(_specifications);
+
+ Validate(model);
+
+ return Created(_formatService.Insert(model).Id);
+ }
+
+ [RestPutById]
+ [Consumes("application/json")]
+ public ActionResult Update(CustomFormatResource resource)
+ {
+ var model = resource.ToModel(_specifications);
+
+ Validate(model);
+
+ _formatService.Update(model);
+
+ return Accepted(model.Id);
+ }
+
+ [HttpGet]
+ [Produces("application/json")]
+ public List GetAll()
+ {
+ return _formatService.All().ToResource(true);
+ }
+
+ [RestDeleteById]
+ public void DeleteFormat(int id)
+ {
+ _formatService.Delete(id);
+ }
+
+ [HttpGet("schema")]
+ public object GetTemplates()
+ {
+ var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList();
+
+ var presets = GetPresets();
+
+ foreach (var item in schema)
+ {
+ item.Presets = presets.Where(x => x.GetType().Name == item.Implementation).Select(x => x.ToSchema()).ToList();
+ }
+
+ return schema;
+ }
+
+ private void Validate(CustomFormat definition)
+ {
+ foreach (var spec in definition.Specifications)
+ {
+ var validationResult = spec.Validate();
+ VerifyValidationResult(validationResult);
+ }
+ }
+
+ protected void VerifyValidationResult(ValidationResult validationResult)
+ {
+ var result = new NzbDroneValidationResult(validationResult.Errors);
+
+ if (!result.IsValid)
+ {
+ throw new ValidationException(result.Errors);
+ }
+ }
+
+ private IEnumerable GetPresets()
+ {
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "x264",
+ Value = @"(x|h)\.?264"
+ };
+
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "x265",
+ Value = @"(((x|h)\.?265)|(HEVC))"
+ };
+
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "Simple Hardcoded Subs",
+ Value = @"subs?"
+ };
+
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "Hardcoded Subs",
+ Value = @"\b(?(\w+SUBS?)\b)|(?(HC|SUBBED))\b"
+ };
+
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "Surround Sound",
+ Value = @"DTS.?(HD|ES|X(?!\D))|TRUEHD|ATMOS|DD(\+|P).?([5-9])|EAC3.?([5-9])"
+ };
+
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "Preferred Words",
+ Value = @"\b(SPARKS|Framestor)\b"
+ };
+
+ var formats = _formatService.All();
+ foreach (var format in formats)
+ {
+ foreach (var condition in format.Specifications)
+ {
+ var preset = condition.Clone();
+ preset.Name = $"{format.Name}: {preset.Name}";
+ yield return preset;
+ }
+ }
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/CustomFormats/CustomFormatResource.cs b/src/Radarr.Api.V4/CustomFormats/CustomFormatResource.cs
new file mode 100644
index 0000000000..a03457d263
--- /dev/null
+++ b/src/Radarr.Api.V4/CustomFormats/CustomFormatResource.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Serialization;
+using NzbDrone.Core.CustomFormats;
+using Radarr.Http.ClientSchema;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.CustomFormats
+{
+ public class CustomFormatResource : RestResource
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public override int Id { get; set; }
+ public string Name { get; set; }
+ public bool? IncludeCustomFormatWhenRenaming { get; set; }
+ public List Specifications { get; set; }
+ }
+
+ public static class CustomFormatResourceMapper
+ {
+ public static CustomFormatResource ToResource(this CustomFormat model, bool includeDetails)
+ {
+ var resource = new CustomFormatResource
+ {
+ Id = model.Id,
+ Name = model.Name
+ };
+
+ if (includeDetails)
+ {
+ resource.IncludeCustomFormatWhenRenaming = model.IncludeCustomFormatWhenRenaming;
+ resource.Specifications = model.Specifications.Select(x => x.ToSchema()).ToList();
+ }
+
+ return resource;
+ }
+
+ public static List ToResource(this IEnumerable models, bool includeDetails)
+ {
+ return models.Select(m => m.ToResource(includeDetails)).ToList();
+ }
+
+ public static CustomFormat ToModel(this CustomFormatResource resource, List specifications)
+ {
+ return new CustomFormat
+ {
+ Id = resource.Id,
+ Name = resource.Name,
+ IncludeCustomFormatWhenRenaming = resource.IncludeCustomFormatWhenRenaming ?? false,
+ Specifications = resource.Specifications?.Select(x => MapSpecification(x, specifications)).ToList() ?? new List()
+ };
+ }
+
+ private static ICustomFormatSpecification MapSpecification(CustomFormatSpecificationSchema resource, List specifications)
+ {
+ var matchingSpec =
+ specifications.SingleOrDefault(x => x.GetType().Name == resource.Implementation);
+
+ if (matchingSpec is null)
+ {
+ throw new ArgumentException(
+ $"{resource.Implementation} is not a valid specification implementation");
+ }
+
+ var type = matchingSpec.GetType();
+
+ var spec = (ICustomFormatSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type);
+ spec.Name = resource.Name;
+ spec.Negate = resource.Negate;
+ spec.Required = resource.Required;
+ return spec;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/CustomFormats/CustomFormatSpecificationSchema.cs b/src/Radarr.Api.V4/CustomFormats/CustomFormatSpecificationSchema.cs
new file mode 100644
index 0000000000..ee371a1478
--- /dev/null
+++ b/src/Radarr.Api.V4/CustomFormats/CustomFormatSpecificationSchema.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+using NzbDrone.Core.CustomFormats;
+using Radarr.Http.ClientSchema;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.CustomFormats
+{
+ public class CustomFormatSpecificationSchema : RestResource
+ {
+ public string Name { get; set; }
+ public string Implementation { get; set; }
+ public string ImplementationName { get; set; }
+ public string InfoLink { get; set; }
+ public bool Negate { get; set; }
+ public bool Required { get; set; }
+ public List Fields { get; set; }
+ public List Presets { get; set; }
+ }
+
+ public static class CustomFormatSpecificationSchemaMapper
+ {
+ public static CustomFormatSpecificationSchema ToSchema(this ICustomFormatSpecification model)
+ {
+ return new CustomFormatSpecificationSchema
+ {
+ Name = model.Name,
+ Implementation = model.GetType().Name,
+ ImplementationName = model.ImplementationName,
+ InfoLink = model.InfoLink,
+ Negate = model.Negate,
+ Required = model.Required,
+ Fields = SchemaBuilder.ToSchema(model)
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/DiskSpace/DiskSpaceController.cs b/src/Radarr.Api.V4/DiskSpace/DiskSpaceController.cs
new file mode 100644
index 0000000000..f9cab88726
--- /dev/null
+++ b/src/Radarr.Api.V4/DiskSpace/DiskSpaceController.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.DiskSpace;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.DiskSpace
+{
+ [V4ApiController("diskspace")]
+ public class DiskSpaceController : Controller
+ {
+ private readonly IDiskSpaceService _diskSpaceService;
+
+ public DiskSpaceController(IDiskSpaceService diskSpaceService)
+ {
+ _diskSpaceService = diskSpaceService;
+ }
+
+ [HttpGet]
+ public List GetFreeSpace()
+ {
+ return _diskSpaceService.GetFreeSpace().ConvertAll(DiskSpaceResourceMapper.MapToResource);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/DiskSpace/DiskSpaceResource.cs b/src/Radarr.Api.V4/DiskSpace/DiskSpaceResource.cs
new file mode 100644
index 0000000000..5b5d877fe9
--- /dev/null
+++ b/src/Radarr.Api.V4/DiskSpace/DiskSpaceResource.cs
@@ -0,0 +1,31 @@
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.DiskSpace
+{
+ public class DiskSpaceResource : RestResource
+ {
+ public string Path { get; set; }
+ public string Label { get; set; }
+ public long FreeSpace { get; set; }
+ public long TotalSpace { get; set; }
+ }
+
+ public static class DiskSpaceResourceMapper
+ {
+ public static DiskSpaceResource MapToResource(this NzbDrone.Core.DiskSpace.DiskSpace model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new DiskSpaceResource
+ {
+ Path = model.Path,
+ Label = model.Label,
+ FreeSpace = model.FreeSpace,
+ TotalSpace = model.TotalSpace
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/DownloadClient/DownloadClientController.cs b/src/Radarr.Api.V4/DownloadClient/DownloadClientController.cs
new file mode 100644
index 0000000000..1512e13b19
--- /dev/null
+++ b/src/Radarr.Api.V4/DownloadClient/DownloadClientController.cs
@@ -0,0 +1,16 @@
+using NzbDrone.Core.Download;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.DownloadClient
+{
+ [V4ApiController]
+ public class DownloadClientController : ProviderControllerBase
+ {
+ public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper();
+
+ public DownloadClientController(IDownloadClientFactory downloadClientFactory)
+ : base(downloadClientFactory, "downloadclient", ResourceMapper)
+ {
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/DownloadClient/DownloadClientResource.cs b/src/Radarr.Api.V4/DownloadClient/DownloadClientResource.cs
new file mode 100644
index 0000000000..c985da714b
--- /dev/null
+++ b/src/Radarr.Api.V4/DownloadClient/DownloadClientResource.cs
@@ -0,0 +1,53 @@
+using NzbDrone.Core.Download;
+using NzbDrone.Core.Indexers;
+
+namespace Radarr.Api.V4.DownloadClient
+{
+ public class DownloadClientResource : ProviderResource
+ {
+ public bool Enable { get; set; }
+ public DownloadProtocol Protocol { get; set; }
+ public int Priority { get; set; }
+ public bool RemoveCompletedDownloads { get; set; }
+ public bool RemoveFailedDownloads { get; set; }
+ }
+
+ public class DownloadClientResourceMapper : ProviderResourceMapper
+ {
+ public override DownloadClientResource ToResource(DownloadClientDefinition definition)
+ {
+ if (definition == null)
+ {
+ return null;
+ }
+
+ var resource = base.ToResource(definition);
+
+ resource.Enable = definition.Enable;
+ resource.Protocol = definition.Protocol;
+ resource.Priority = definition.Priority;
+ resource.RemoveCompletedDownloads = definition.RemoveCompletedDownloads;
+ resource.RemoveFailedDownloads = definition.RemoveFailedDownloads;
+
+ return resource;
+ }
+
+ public override DownloadClientDefinition ToModel(DownloadClientResource resource)
+ {
+ if (resource == null)
+ {
+ return null;
+ }
+
+ var definition = base.ToModel(resource);
+
+ definition.Enable = resource.Enable;
+ definition.Protocol = resource.Protocol;
+ definition.Priority = resource.Priority;
+ definition.RemoveCompletedDownloads = resource.RemoveCompletedDownloads;
+ definition.RemoveFailedDownloads = resource.RemoveFailedDownloads;
+
+ return definition;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/ExtraFiles/ExtraFileController.cs b/src/Radarr.Api.V4/ExtraFiles/ExtraFileController.cs
new file mode 100644
index 0000000000..a3ff800199
--- /dev/null
+++ b/src/Radarr.Api.V4/ExtraFiles/ExtraFileController.cs
@@ -0,0 +1,41 @@
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Extras.Files;
+using NzbDrone.Core.Extras.Metadata.Files;
+using NzbDrone.Core.Extras.Others;
+using NzbDrone.Core.Extras.Subtitles;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.ExtraFiles
+{
+ [V4ApiController("extrafile")]
+ public class ExtraFileController : Controller
+ {
+ private readonly IExtraFileService _subtitleFileService;
+ private readonly IExtraFileService _metadataFileService;
+ private readonly IExtraFileService _otherFileService;
+
+ public ExtraFileController(IExtraFileService subtitleFileService, IExtraFileService metadataFileService, IExtraFileService otherExtraFileService)
+ {
+ _subtitleFileService = subtitleFileService;
+ _metadataFileService = metadataFileService;
+ _otherFileService = otherExtraFileService;
+ }
+
+ [HttpGet]
+ public List GetFiles(int movieId)
+ {
+ var extraFiles = new List();
+
+ List subtitleFiles = _subtitleFileService.GetFilesByMovie(movieId);
+ List metadataFiles = _metadataFileService.GetFilesByMovie(movieId);
+ List otherExtraFiles = _otherFileService.GetFilesByMovie(movieId);
+
+ extraFiles.AddRange(subtitleFiles.ToResource());
+ extraFiles.AddRange(metadataFiles.ToResource());
+ extraFiles.AddRange(otherExtraFiles.ToResource());
+
+ return extraFiles;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/ExtraFiles/ExtraFileResource.cs b/src/Radarr.Api.V4/ExtraFiles/ExtraFileResource.cs
new file mode 100644
index 0000000000..b1ca1a79a2
--- /dev/null
+++ b/src/Radarr.Api.V4/ExtraFiles/ExtraFileResource.cs
@@ -0,0 +1,91 @@
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.Extras.Files;
+using NzbDrone.Core.Extras.Metadata.Files;
+using NzbDrone.Core.Extras.Others;
+using NzbDrone.Core.Extras.Subtitles;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.ExtraFiles
+{
+ public class ExtraFileResource : RestResource
+ {
+ public int MovieId { get; set; }
+ public int? MovieFileId { get; set; }
+ public string RelativePath { get; set; }
+ public string Extension { get; set; }
+ public ExtraFileType Type { get; set; }
+ }
+
+ public static class ExtraFileResourceMapper
+ {
+ public static ExtraFileResource ToResource(this MetadataFile model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new ExtraFileResource
+ {
+ Id = model.Id,
+ MovieId = model.MovieId,
+ MovieFileId = model.MovieFileId,
+ RelativePath = model.RelativePath,
+ Extension = model.Extension,
+ Type = ExtraFileType.Metadata
+ };
+ }
+
+ public static ExtraFileResource ToResource(this SubtitleFile model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new ExtraFileResource
+ {
+ Id = model.Id,
+ MovieId = model.MovieId,
+ MovieFileId = model.MovieFileId,
+ RelativePath = model.RelativePath,
+ Extension = model.Extension,
+ Type = ExtraFileType.Subtitle
+ };
+ }
+
+ public static ExtraFileResource ToResource(this OtherExtraFile model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new ExtraFileResource
+ {
+ Id = model.Id,
+ MovieId = model.MovieId,
+ MovieFileId = model.MovieFileId,
+ RelativePath = model.RelativePath,
+ Extension = model.Extension,
+ Type = ExtraFileType.Other
+ };
+ }
+
+ public static List ToResource(this IEnumerable movies)
+ {
+ return movies.Select(ToResource).ToList();
+ }
+
+ public static List ToResource(this IEnumerable movies)
+ {
+ return movies.Select(ToResource).ToList();
+ }
+
+ public static List ToResource(this IEnumerable movies)
+ {
+ return movies.Select(ToResource).ToList();
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/FileSystem/FileSystemController.cs b/src/Radarr.Api.V4/FileSystem/FileSystemController.cs
new file mode 100644
index 0000000000..2a4b2460ed
--- /dev/null
+++ b/src/Radarr.Api.V4/FileSystem/FileSystemController.cs
@@ -0,0 +1,62 @@
+using System;
+using System.IO;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common.Disk;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.MediaFiles;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.FileSystem
+{
+ [V4ApiController]
+ public class FileSystemController : Controller
+ {
+ private readonly IFileSystemLookupService _fileSystemLookupService;
+ private readonly IDiskProvider _diskProvider;
+ private readonly IDiskScanService _diskScanService;
+
+ public FileSystemController(IFileSystemLookupService fileSystemLookupService,
+ IDiskProvider diskProvider,
+ IDiskScanService diskScanService)
+ {
+ _fileSystemLookupService = fileSystemLookupService;
+ _diskProvider = diskProvider;
+ _diskScanService = diskScanService;
+ }
+
+ [HttpGet]
+ public IActionResult GetContents(string path, bool includeFiles = false, bool allowFoldersWithoutTrailingSlashes = false)
+ {
+ return Ok(_fileSystemLookupService.LookupContents(path, includeFiles, allowFoldersWithoutTrailingSlashes));
+ }
+
+ [HttpGet("type")]
+ public object GetEntityType(string path)
+ {
+ if (_diskProvider.FileExists(path))
+ {
+ return new { type = "file" };
+ }
+
+ // Return folder even if it doesn't exist on disk to avoid leaking anything from the UI about the underlying system
+ return new { type = "folder" };
+ }
+
+ [HttpGet("mediafiles")]
+ public object GetMediaFiles(string path)
+ {
+ if (!_diskProvider.FolderExists(path))
+ {
+ return Array.Empty();
+ }
+
+ return _diskScanService.GetVideoFiles(path).Select(f => new
+ {
+ Path = f,
+ RelativePath = path.GetRelativePath(f),
+ Name = Path.GetFileName(f)
+ });
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Health/HealthController.cs b/src/Radarr.Api.V4/Health/HealthController.cs
new file mode 100644
index 0000000000..d3d5d5e628
--- /dev/null
+++ b/src/Radarr.Api.V4/Health/HealthController.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.HealthCheck;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.SignalR;
+using Radarr.Http;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Health
+{
+ [V4ApiController]
+ public class HealthController : RestControllerWithSignalR,
+ IHandle
+ {
+ private readonly IHealthCheckService _healthCheckService;
+
+ public HealthController(IBroadcastSignalRMessage signalRBroadcaster, IHealthCheckService healthCheckService)
+ : base(signalRBroadcaster)
+ {
+ _healthCheckService = healthCheckService;
+ }
+
+ protected override HealthResource GetResourceById(int id)
+ {
+ throw new NotImplementedException();
+ }
+
+ [HttpGet]
+ public List GetHealth()
+ {
+ return _healthCheckService.Results().ToResource();
+ }
+
+ [NonAction]
+ public void Handle(HealthCheckCompleteEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Sync);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Health/HealthResource.cs b/src/Radarr.Api.V4/Health/HealthResource.cs
new file mode 100644
index 0000000000..b7542fae8f
--- /dev/null
+++ b/src/Radarr.Api.V4/Health/HealthResource.cs
@@ -0,0 +1,41 @@
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Common.Http;
+using NzbDrone.Core.HealthCheck;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Health
+{
+ public class HealthResource : RestResource
+ {
+ public string Source { get; set; }
+ public HealthCheckResult Type { get; set; }
+ public string Message { get; set; }
+ public HttpUri WikiUrl { get; set; }
+ }
+
+ public static class HealthResourceMapper
+ {
+ public static HealthResource ToResource(this HealthCheck model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new HealthResource
+ {
+ Id = model.Id,
+ Source = model.Source.Name,
+ Type = model.Type,
+ Message = model.Message,
+ WikiUrl = model.WikiUrl
+ };
+ }
+
+ public static List ToResource(this IEnumerable models)
+ {
+ return models.Select(ToResource).ToList();
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/History/HistoryController.cs b/src/Radarr.Api.V4/History/HistoryController.cs
new file mode 100644
index 0000000000..184ba4e3dd
--- /dev/null
+++ b/src/Radarr.Api.V4/History/HistoryController.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.Datastore;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.Download;
+using NzbDrone.Core.History;
+using NzbDrone.Core.Movies;
+using Radarr.Api.V4.Movies;
+using Radarr.Http;
+using Radarr.Http.Extensions;
+
+namespace Radarr.Api.V4.History
+{
+ [V4ApiController]
+ public class HistoryController : Controller
+ {
+ private readonly IHistoryService _historyService;
+ private readonly IMovieService _movieService;
+ private readonly ICustomFormatCalculationService _formatCalculator;
+ private readonly IUpgradableSpecification _upgradableSpecification;
+ private readonly IFailedDownloadService _failedDownloadService;
+
+ public HistoryController(IHistoryService historyService,
+ IMovieService movieService,
+ ICustomFormatCalculationService formatCalculator,
+ IUpgradableSpecification upgradableSpecification,
+ IFailedDownloadService failedDownloadService)
+ {
+ _historyService = historyService;
+ _movieService = movieService;
+ _formatCalculator = formatCalculator;
+ _upgradableSpecification = upgradableSpecification;
+ _failedDownloadService = failedDownloadService;
+ }
+
+ protected HistoryResource MapToResource(MovieHistory model, bool includeMovie)
+ {
+ if (model.Movie == null)
+ {
+ model.Movie = _movieService.GetMovie(model.MovieId);
+ }
+
+ var resource = model.ToResource(_formatCalculator);
+
+ if (includeMovie)
+ {
+ resource.Movie = model.Movie.ToResource(0);
+ }
+
+ if (model.Movie != null)
+ {
+ resource.QualityCutoffNotMet = _upgradableSpecification.QualityCutoffNotMet(model.Movie.Profile, model.Quality);
+ }
+
+ return resource;
+ }
+
+ [HttpGet]
+ public PagingResource GetHistory(bool includeMovie)
+ {
+ var pagingResource = Request.ReadPagingResourceFromRequest();
+ var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending);
+
+ var eventTypeFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "eventType");
+ var downloadIdFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "downloadId");
+
+ if (eventTypeFilter != null)
+ {
+ var filterValue = (MovieHistoryEventType)Convert.ToInt32(eventTypeFilter.Value);
+ pagingSpec.FilterExpressions.Add(v => v.EventType == filterValue);
+ }
+
+ if (downloadIdFilter != null)
+ {
+ var downloadId = downloadIdFilter.Value;
+ pagingSpec.FilterExpressions.Add(h => h.DownloadId == downloadId);
+ }
+
+ return pagingSpec.ApplyToPage(_historyService.Paged, h => MapToResource(h, includeMovie));
+ }
+
+ [HttpGet("since")]
+ public List GetHistorySince(DateTime date, MovieHistoryEventType? eventType = null, bool includeMovie = false)
+ {
+ return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeMovie)).ToList();
+ }
+
+ [HttpGet("movie")]
+ public List GetMovieHistory(int movieId, MovieHistoryEventType? eventType = null, bool includeMovie = false)
+ {
+ return _historyService.GetByMovieId(movieId, eventType).Select(h => MapToResource(h, includeMovie)).ToList();
+ }
+
+ [HttpPost("failed/{id}")]
+ public object MarkAsFailed([FromRoute] int id)
+ {
+ _failedDownloadService.MarkAsFailed(id);
+ return new { };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/History/HistoryResource.cs b/src/Radarr.Api.V4/History/HistoryResource.cs
new file mode 100644
index 0000000000..6b3d1a1f3e
--- /dev/null
+++ b/src/Radarr.Api.V4/History/HistoryResource.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.History;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Qualities;
+using Radarr.Api.V4.CustomFormats;
+using Radarr.Api.V4.Movies;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.History
+{
+ public class HistoryResource : RestResource
+ {
+ public int MovieId { get; set; }
+ public string SourceTitle { get; set; }
+ public List Languages { get; set; }
+ public QualityModel Quality { get; set; }
+ public List CustomFormats { get; set; }
+ public int CustomFormatScore { get; set; }
+ public bool QualityCutoffNotMet { get; set; }
+ public DateTime Date { get; set; }
+ public string DownloadId { get; set; }
+
+ public MovieHistoryEventType EventType { get; set; }
+
+ public Dictionary Data { get; set; }
+
+ public MovieResource Movie { get; set; }
+ }
+
+ public static class HistoryResourceMapper
+ {
+ public static HistoryResource ToResource(this MovieHistory model, ICustomFormatCalculationService formatCalculator)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ var customFormats = formatCalculator.ParseCustomFormat(model, model.Movie);
+ var customFormatScore = model.Movie.Profile.CalculateCustomFormatScore(customFormats);
+
+ return new HistoryResource
+ {
+ Id = model.Id,
+
+ MovieId = model.MovieId,
+ SourceTitle = model.SourceTitle,
+ Languages = model.Languages,
+ Quality = model.Quality,
+ CustomFormats = customFormats.ToResource(false),
+ CustomFormatScore = customFormatScore,
+
+ // QualityCutoffNotMet
+ Date = model.Date,
+ DownloadId = model.DownloadId,
+
+ EventType = model.EventType,
+
+ Data = model.Data
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/ImportLists/ImportExclusionsController.cs b/src/Radarr.Api.V4/ImportLists/ImportExclusionsController.cs
new file mode 100644
index 0000000000..b6cf0cf8e5
--- /dev/null
+++ b/src/Radarr.Api.V4/ImportLists/ImportExclusionsController.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using FluentValidation;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.ImportLists.ImportExclusions;
+using Radarr.Http;
+using Radarr.Http.REST;
+using Radarr.Http.REST.Attributes;
+
+namespace Radarr.Api.V4.ImportLists
+{
+ [V4ApiController("exclusions")]
+ public class ImportExclusionsController : RestController
+ {
+ private readonly IImportExclusionsService _exclusionService;
+
+ public ImportExclusionsController(IImportExclusionsService exclusionService)
+ {
+ _exclusionService = exclusionService;
+
+ SharedValidator.RuleFor(c => c.TmdbId).GreaterThan(0);
+ SharedValidator.RuleFor(c => c.MovieTitle).NotEmpty();
+ SharedValidator.RuleFor(c => c.MovieYear).GreaterThan(0);
+ }
+
+ [HttpGet]
+ public List GetAll()
+ {
+ return _exclusionService.GetAllExclusions().ToResource();
+ }
+
+ protected override ImportExclusionsResource GetResourceById(int id)
+ {
+ return _exclusionService.GetById(id).ToResource();
+ }
+
+ [RestPutById]
+ public ActionResult UpdateExclusion(ImportExclusionsResource exclusionResource)
+ {
+ var model = exclusionResource.ToModel();
+ return Accepted(_exclusionService.Update(model));
+ }
+
+ [RestPostById]
+ public ActionResult AddExclusion(ImportExclusionsResource exclusionResource)
+ {
+ var model = exclusionResource.ToModel();
+
+ return Created(_exclusionService.AddExclusion(model).Id);
+ }
+
+ [HttpPost("bulk")]
+ public object AddExclusions([FromBody] List resource)
+ {
+ var newMovies = resource.ToModel();
+
+ return _exclusionService.AddExclusions(newMovies).ToResource();
+ }
+
+ [RestDeleteById]
+ public void RemoveExclusion(int id)
+ {
+ _exclusionService.RemoveExclusion(new ImportExclusion { Id = id });
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/ImportLists/ImportExclusionsResource.cs b/src/Radarr.Api.V4/ImportLists/ImportExclusionsResource.cs
new file mode 100644
index 0000000000..e5144d7871
--- /dev/null
+++ b/src/Radarr.Api.V4/ImportLists/ImportExclusionsResource.cs
@@ -0,0 +1,54 @@
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.ImportLists.ImportExclusions;
+
+namespace Radarr.Api.V4.ImportLists
+{
+ public class ImportExclusionsResource : ProviderResource
+ {
+ // public int Id { get; set; }
+ public int TmdbId { get; set; }
+ public string MovieTitle { get; set; }
+ public int MovieYear { get; set; }
+ }
+
+ public static class ImportExclusionsResourceMapper
+ {
+ public static ImportExclusionsResource ToResource(this ImportExclusion model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new ImportExclusionsResource
+ {
+ Id = model.Id,
+ TmdbId = model.TmdbId,
+ MovieTitle = model.MovieTitle,
+ MovieYear = model.MovieYear
+ };
+ }
+
+ public static List ToResource(this IEnumerable exclusions)
+ {
+ return exclusions.Select(ToResource).ToList();
+ }
+
+ public static ImportExclusion ToModel(this ImportExclusionsResource resource)
+ {
+ return new ImportExclusion
+ {
+ Id = resource.Id,
+ TmdbId = resource.TmdbId,
+ MovieTitle = resource.MovieTitle,
+ MovieYear = resource.MovieYear
+ };
+ }
+
+ public static List ToModel(this IEnumerable resources)
+ {
+ return resources.Select(ToModel).ToList();
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/ImportLists/ImportListController.cs b/src/Radarr.Api.V4/ImportLists/ImportListController.cs
new file mode 100644
index 0000000000..36c75ab38c
--- /dev/null
+++ b/src/Radarr.Api.V4/ImportLists/ImportListController.cs
@@ -0,0 +1,24 @@
+using FluentValidation;
+using NzbDrone.Core.ImportLists;
+using NzbDrone.Core.Validation;
+using NzbDrone.Core.Validation.Paths;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.ImportLists
+{
+ [V4ApiController]
+ public class ImportListController : ProviderControllerBase
+ {
+ public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper();
+
+ public ImportListController(IImportListFactory importListFactory,
+ ProfileExistsValidator profileExistsValidator)
+ : base(importListFactory, "importlist", ResourceMapper)
+ {
+ SharedValidator.RuleFor(c => c.RootFolderPath).IsValidPath();
+ SharedValidator.RuleFor(c => c.MinimumAvailability).NotNull();
+ SharedValidator.RuleForEach(c => c.QualityProfileIds).ValidId();
+ SharedValidator.RuleForEach(c => c.QualityProfileIds).SetValidator(profileExistsValidator);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/ImportLists/ImportListMoviesController.cs b/src/Radarr.Api.V4/ImportLists/ImportListMoviesController.cs
new file mode 100644
index 0000000000..c806b02476
--- /dev/null
+++ b/src/Radarr.Api.V4/ImportLists/ImportListMoviesController.cs
@@ -0,0 +1,158 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.ImportLists;
+using NzbDrone.Core.ImportLists.ImportExclusions;
+using NzbDrone.Core.ImportLists.ImportListMovies;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.MetadataSource;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Organizer;
+using Radarr.Api.V4.Movies;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.ImportLists
+{
+ [V4ApiController("importlist/movie")]
+ public class ImportListMoviesController : Controller
+ {
+ private readonly IMovieService _movieService;
+ private readonly IAddMovieService _addMovieService;
+ private readonly IProvideMovieInfo _movieInfo;
+ private readonly IBuildFileNames _fileNameBuilder;
+ private readonly IImportListMovieService _listMovieService;
+ private readonly IImportListFactory _importListFactory;
+ private readonly IImportExclusionsService _importExclusionService;
+ private readonly INamingConfigService _namingService;
+ private readonly IConfigService _configService;
+
+ public ImportListMoviesController(IMovieService movieService,
+ IAddMovieService addMovieService,
+ IProvideMovieInfo movieInfo,
+ IBuildFileNames fileNameBuilder,
+ IImportListMovieService listMovieService,
+ IImportListFactory importListFactory,
+ IImportExclusionsService importExclusionsService,
+ INamingConfigService namingService,
+ IConfigService configService)
+ {
+ _movieService = movieService;
+ _addMovieService = addMovieService;
+ _movieInfo = movieInfo;
+ _fileNameBuilder = fileNameBuilder;
+ _listMovieService = listMovieService;
+ _importListFactory = importListFactory;
+ _importExclusionService = importExclusionsService;
+ _namingService = namingService;
+ _configService = configService;
+ }
+
+ [HttpGet]
+ public object GetDiscoverMovies(bool includeRecommendations = false)
+ {
+ var movieLanguge = (Language)_configService.MovieInfoLanguage;
+
+ var realResults = new List();
+ var listExclusions = _importExclusionService.GetAllExclusions();
+ var existingTmdbIds = _movieService.AllMovieTmdbIds();
+
+ if (includeRecommendations)
+ {
+ var mapped = new List();
+
+ var results = _movieService.GetRecommendedTmdbIds();
+
+ if (results.Count > 0)
+ {
+ mapped = _movieInfo.GetBulkMovieInfo(results).Select(m => new Movie { MovieMetadata = m }).ToList();
+ }
+
+ realResults.AddRange(MapToResource(mapped.Where(x => x != null), movieLanguge));
+ realResults.ForEach(x => x.IsRecommendation = true);
+ }
+
+ var listMovies = MapToResource(_listMovieService.GetAllForLists(_importListFactory.Enabled().Select(x => x.Definition.Id).ToList()), movieLanguge).ToList();
+
+ realResults.AddRange(listMovies);
+
+ var groupedListMovies = realResults.GroupBy(x => x.TmdbId);
+
+ // Distinct Movies
+ realResults = groupedListMovies.Select(x =>
+ {
+ var movie = x.First();
+
+ movie.Lists = x.SelectMany(m => m.Lists).ToHashSet();
+ movie.IsExcluded = listExclusions.Any(e => e.TmdbId == movie.TmdbId);
+ movie.IsExisting = existingTmdbIds.Any(e => e == movie.TmdbId);
+ movie.IsRecommendation = x.Any(m => m.IsRecommendation);
+
+ return movie;
+ }).ToList();
+
+ return realResults;
+ }
+
+ [HttpPost]
+ public object AddMovies([FromBody] List resource)
+ {
+ var newMovies = resource.ToModel();
+
+ return _addMovieService.AddMovies(newMovies, true).ToResource(0);
+ }
+
+ private IEnumerable MapToResource(IEnumerable movies, Language language)
+ {
+ // Avoid calling for naming spec on every movie in filenamebuilder
+ var namingConfig = _namingService.GetConfig();
+
+ foreach (var currentMovie in movies)
+ {
+ var resource = DiscoverMoviesResourceMapper.ToResource(currentMovie);
+ var poster = currentMovie.MovieMetadata.Value.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster);
+ if (poster != null)
+ {
+ resource.RemotePoster = poster.Url;
+ }
+
+ var translation = currentMovie.MovieMetadata.Value.Translations.FirstOrDefault(t => t.Language == language);
+
+ resource.Title = translation?.Title ?? resource.Title;
+ resource.Overview = translation?.Overview ?? resource.Overview;
+ resource.Folder = _fileNameBuilder.GetMovieFolder(currentMovie, namingConfig);
+
+ yield return resource;
+ }
+ }
+
+ private IEnumerable MapToResource(IEnumerable movies, Language language)
+ {
+ // Avoid calling for naming spec on every movie in filenamebuilder
+ var namingConfig = _namingService.GetConfig();
+
+ foreach (var currentMovie in movies)
+ {
+ var resource = DiscoverMoviesResourceMapper.ToResource(currentMovie);
+ var poster = currentMovie.MovieMetadata.Value.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster);
+ if (poster != null)
+ {
+ resource.RemotePoster = poster.Url;
+ }
+
+ var translation = currentMovie.MovieMetadata.Value.Translations.FirstOrDefault(t => t.Language == language);
+
+ resource.Title = translation?.Title ?? resource.Title;
+ resource.Overview = translation?.Overview ?? resource.Overview;
+ resource.Folder = _fileNameBuilder.GetMovieFolder(new Movie
+ {
+ MovieMetadata = currentMovie.MovieMetadata
+ }, namingConfig);
+
+ yield return resource;
+ }
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/ImportLists/ImportListMoviesResource.cs b/src/Radarr.Api.V4/ImportLists/ImportListMoviesResource.cs
new file mode 100644
index 0000000000..45df89c43e
--- /dev/null
+++ b/src/Radarr.Api.V4/ImportLists/ImportListMoviesResource.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+using NzbDrone.Core.ImportLists.ImportListMovies;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Collections;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.ImportLists
+{
+ public class ImportListMoviesResource : RestResource
+ {
+ public ImportListMoviesResource()
+ {
+ Lists = new HashSet();
+ }
+
+ public string Title { get; set; }
+ public string SortTitle { get; set; }
+ public MovieStatusType Status { get; set; }
+ public string Overview { get; set; }
+ public DateTime? InCinemas { get; set; }
+ public DateTime? PhysicalRelease { get; set; }
+ public DateTime? DigitalRelease { get; set; }
+ public List Images { get; set; }
+ public string Website { get; set; }
+ public string RemotePoster { get; set; }
+ public int Year { get; set; }
+ public string YouTubeTrailerId { get; set; }
+ public string Studio { get; set; }
+
+ public int Runtime { get; set; }
+ public string ImdbId { get; set; }
+ public int TmdbId { get; set; }
+ public string Folder { get; set; }
+ public string Certification { get; set; }
+ public List Genres { get; set; }
+ public Ratings Ratings { get; set; }
+ public MovieCollection Collection { get; set; }
+ public bool IsExcluded { get; set; }
+ public bool IsExisting { get; set; }
+
+ public bool IsRecommendation { get; set; }
+ public HashSet Lists { get; set; }
+ }
+
+ public static class DiscoverMoviesResourceMapper
+ {
+ public static ImportListMoviesResource ToResource(this Movie model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new ImportListMoviesResource
+ {
+ TmdbId = model.TmdbId,
+ Title = model.Title,
+ SortTitle = model.MovieMetadata.Value.SortTitle,
+ InCinemas = model.MovieMetadata.Value.InCinemas,
+ PhysicalRelease = model.MovieMetadata.Value.PhysicalRelease,
+ DigitalRelease = model.MovieMetadata.Value.DigitalRelease,
+
+ Status = model.MovieMetadata.Value.Status,
+ Overview = model.MovieMetadata.Value.Overview,
+
+ Images = model.MovieMetadata.Value.Images,
+
+ Year = model.Year,
+
+ Runtime = model.MovieMetadata.Value.Runtime,
+ ImdbId = model.ImdbId,
+ Certification = model.MovieMetadata.Value.Certification,
+ Website = model.MovieMetadata.Value.Website,
+ Genres = model.MovieMetadata.Value.Genres,
+ Ratings = model.MovieMetadata.Value.Ratings,
+ YouTubeTrailerId = model.MovieMetadata.Value.YouTubeTrailerId,
+ Collection = new MovieCollection { Title = model.MovieMetadata.Value.CollectionTitle, TmdbId = model.MovieMetadata.Value.CollectionTmdbId },
+ Studio = model.MovieMetadata.Value.Studio
+ };
+ }
+
+ public static ImportListMoviesResource ToResource(this ImportListMovie model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new ImportListMoviesResource
+ {
+ TmdbId = model.TmdbId,
+ Title = model.Title,
+ SortTitle = model.MovieMetadata.Value.SortTitle,
+ InCinemas = model.MovieMetadata.Value.InCinemas,
+ PhysicalRelease = model.MovieMetadata.Value.PhysicalRelease,
+ DigitalRelease = model.MovieMetadata.Value.DigitalRelease,
+
+ Status = model.MovieMetadata.Value.Status,
+ Overview = model.MovieMetadata.Value.Overview,
+
+ Images = model.MovieMetadata.Value.Images,
+
+ Year = model.Year,
+
+ Runtime = model.MovieMetadata.Value.Runtime,
+ ImdbId = model.ImdbId,
+ Certification = model.MovieMetadata.Value.Certification,
+ Website = model.MovieMetadata.Value.Website,
+ Genres = model.MovieMetadata.Value.Genres,
+ Ratings = model.MovieMetadata.Value.Ratings,
+ YouTubeTrailerId = model.MovieMetadata.Value.YouTubeTrailerId,
+ Studio = model.MovieMetadata.Value.Studio,
+ Collection = new MovieCollection { Title = model.MovieMetadata.Value.CollectionTitle, TmdbId = model.MovieMetadata.Value.CollectionTmdbId },
+ Lists = new HashSet { model.ListId }
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/ImportLists/ImportListResource.cs b/src/Radarr.Api.V4/ImportLists/ImportListResource.cs
new file mode 100644
index 0000000000..8cddd7e956
--- /dev/null
+++ b/src/Radarr.Api.V4/ImportLists/ImportListResource.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using NzbDrone.Core.ImportLists;
+using NzbDrone.Core.Movies;
+
+namespace Radarr.Api.V4.ImportLists
+{
+ public class ImportListResource : ProviderResource
+ {
+ public bool Enabled { get; set; }
+ public bool EnableAuto { get; set; }
+ public MonitorTypes Monitor { get; set; }
+ public string RootFolderPath { get; set; }
+ public List QualityProfileIds { get; set; }
+ public bool SearchOnAdd { get; set; }
+ public MovieStatusType MinimumAvailability { get; set; }
+ public ImportListType ListType { get; set; }
+ public int ListOrder { get; set; }
+ }
+
+ public class ImportListResourceMapper : ProviderResourceMapper
+ {
+ public override ImportListResource ToResource(ImportListDefinition definition)
+ {
+ if (definition == null)
+ {
+ return null;
+ }
+
+ var resource = base.ToResource(definition);
+
+ resource.Enabled = definition.Enabled;
+ resource.EnableAuto = definition.EnableAuto;
+ resource.Monitor = definition.Monitor;
+ resource.SearchOnAdd = definition.SearchOnAdd;
+ resource.RootFolderPath = definition.RootFolderPath;
+ resource.QualityProfileIds = definition.QualityProfileIds;
+ resource.MinimumAvailability = definition.MinimumAvailability;
+ resource.ListType = definition.ListType;
+ resource.ListOrder = (int)definition.ListType;
+
+ return resource;
+ }
+
+ public override ImportListDefinition ToModel(ImportListResource resource)
+ {
+ if (resource == null)
+ {
+ return null;
+ }
+
+ var definition = base.ToModel(resource);
+
+ definition.Enabled = resource.Enabled;
+ definition.EnableAuto = resource.EnableAuto;
+ definition.Monitor = resource.Monitor;
+ definition.SearchOnAdd = resource.SearchOnAdd;
+ definition.RootFolderPath = resource.RootFolderPath;
+ definition.QualityProfileIds = resource.QualityProfileIds;
+ definition.MinimumAvailability = resource.MinimumAvailability;
+ definition.ListType = resource.ListType;
+
+ return definition;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Indexers/IndexerController.cs b/src/Radarr.Api.V4/Indexers/IndexerController.cs
new file mode 100644
index 0000000000..93f8cd6cd6
--- /dev/null
+++ b/src/Radarr.Api.V4/Indexers/IndexerController.cs
@@ -0,0 +1,16 @@
+using NzbDrone.Core.Indexers;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Indexers
+{
+ [V4ApiController]
+ public class IndexerController : ProviderControllerBase
+ {
+ public static readonly IndexerResourceMapper ResourceMapper = new IndexerResourceMapper();
+
+ public IndexerController(IndexerFactory indexerFactory)
+ : base(indexerFactory, "indexer", ResourceMapper)
+ {
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Indexers/IndexerFlagController.cs b/src/Radarr.Api.V4/Indexers/IndexerFlagController.cs
new file mode 100644
index 0000000000..81caebb1df
--- /dev/null
+++ b/src/Radarr.Api.V4/Indexers/IndexerFlagController.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Parser.Model;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Indexers
+{
+ [V4ApiController]
+ public class IndexerFlagController : Controller
+ {
+ [HttpGet]
+ public List GetAll()
+ {
+ return Enum.GetValues(typeof(IndexerFlags)).Cast().Select(f => new IndexerFlagResource
+ {
+ Id = (int)f,
+ Name = f.ToString()
+ }).ToList();
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Indexers/IndexerFlagResource.cs b/src/Radarr.Api.V4/Indexers/IndexerFlagResource.cs
new file mode 100644
index 0000000000..9e2e706c55
--- /dev/null
+++ b/src/Radarr.Api.V4/Indexers/IndexerFlagResource.cs
@@ -0,0 +1,13 @@
+using Newtonsoft.Json;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Indexers
+{
+ public class IndexerFlagResource : RestResource
+ {
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Include)]
+ public new int Id { get; set; }
+ public string Name { get; set; }
+ public string NameLower => Name.ToLowerInvariant();
+ }
+}
diff --git a/src/Radarr.Api.V4/Indexers/IndexerResource.cs b/src/Radarr.Api.V4/Indexers/IndexerResource.cs
new file mode 100644
index 0000000000..b28599a88f
--- /dev/null
+++ b/src/Radarr.Api.V4/Indexers/IndexerResource.cs
@@ -0,0 +1,58 @@
+using NzbDrone.Core.Indexers;
+
+namespace Radarr.Api.V4.Indexers
+{
+ public class IndexerResource : ProviderResource
+ {
+ public bool EnableRss { get; set; }
+ public bool EnableAutomaticSearch { get; set; }
+ public bool EnableInteractiveSearch { get; set; }
+ public bool SupportsRss { get; set; }
+ public bool SupportsSearch { get; set; }
+ public DownloadProtocol Protocol { get; set; }
+ public int Priority { get; set; }
+ public int DownloadClientId { get; set; }
+ }
+
+ public class IndexerResourceMapper : ProviderResourceMapper
+ {
+ public override IndexerResource ToResource(IndexerDefinition definition)
+ {
+ if (definition == null)
+ {
+ return null;
+ }
+
+ var resource = base.ToResource(definition);
+
+ resource.EnableRss = definition.EnableRss;
+ resource.EnableAutomaticSearch = definition.EnableAutomaticSearch;
+ resource.EnableInteractiveSearch = definition.EnableInteractiveSearch;
+ resource.SupportsRss = definition.SupportsRss;
+ resource.SupportsSearch = definition.SupportsSearch;
+ resource.Protocol = definition.Protocol;
+ resource.Priority = definition.Priority;
+ resource.DownloadClientId = definition.DownloadClientId;
+
+ return resource;
+ }
+
+ public override IndexerDefinition ToModel(IndexerResource resource)
+ {
+ if (resource == null)
+ {
+ return null;
+ }
+
+ var definition = base.ToModel(resource);
+
+ definition.EnableRss = resource.EnableRss;
+ definition.EnableAutomaticSearch = resource.EnableAutomaticSearch;
+ definition.EnableInteractiveSearch = resource.EnableInteractiveSearch;
+ definition.Priority = resource.Priority;
+ definition.DownloadClientId = resource.DownloadClientId;
+
+ return definition;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Indexers/ReleaseController.cs b/src/Radarr.Api.V4/Indexers/ReleaseController.cs
new file mode 100644
index 0000000000..e3f5148266
--- /dev/null
+++ b/src/Radarr.Api.V4/Indexers/ReleaseController.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Generic;
+using FluentValidation;
+using Microsoft.AspNetCore.Mvc;
+using NLog;
+using NzbDrone.Common.Cache;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Download;
+using NzbDrone.Core.Exceptions;
+using NzbDrone.Core.Indexers;
+using NzbDrone.Core.IndexerSearch;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Validation;
+using Radarr.Http;
+using HttpStatusCode = System.Net.HttpStatusCode;
+
+namespace Radarr.Api.V4.Indexers
+{
+ [V4ApiController]
+ public class ReleaseController : ReleaseControllerBase
+ {
+ private readonly IFetchAndParseRss _rssFetcherAndParser;
+ private readonly ISearchForReleases _releaseSearchService;
+ private readonly IMakeDownloadDecision _downloadDecisionMaker;
+ private readonly IPrioritizeDownloadDecision _prioritizeDownloadDecision;
+ private readonly IDownloadService _downloadService;
+ private readonly IMovieService _movieService;
+ private readonly Logger _logger;
+
+ private readonly ICached _remoteMovieCache;
+
+ public ReleaseController(IFetchAndParseRss rssFetcherAndParser,
+ ISearchForReleases releaseSearchService,
+ IMakeDownloadDecision downloadDecisionMaker,
+ IPrioritizeDownloadDecision prioritizeDownloadDecision,
+ IDownloadService downloadService,
+ IMovieService movieService,
+ ICacheManager cacheManager,
+ IProfileService qualityProfileService,
+ Logger logger)
+ : base(qualityProfileService)
+ {
+ _rssFetcherAndParser = rssFetcherAndParser;
+ _releaseSearchService = releaseSearchService;
+ _downloadDecisionMaker = downloadDecisionMaker;
+ _prioritizeDownloadDecision = prioritizeDownloadDecision;
+ _downloadService = downloadService;
+ _movieService = movieService;
+ _logger = logger;
+
+ PostValidator.RuleFor(s => s.IndexerId).ValidId();
+ PostValidator.RuleFor(s => s.Guid).NotEmpty();
+
+ _remoteMovieCache = cacheManager.GetCache(GetType(), "remoteMovies");
+ }
+
+ [HttpPost]
+ public object DownloadRelease(ReleaseResource release)
+ {
+ var remoteMovie = _remoteMovieCache.Find(GetCacheKey(release));
+
+ if (remoteMovie == null)
+ {
+ _logger.Debug("Couldn't find requested release in cache, cache timeout probably expired.");
+
+ throw new NzbDroneClientException(HttpStatusCode.NotFound, "Couldn't find requested release in cache, try searching again");
+ }
+
+ try
+ {
+ if (remoteMovie.Movie == null)
+ {
+ if (release.MovieId.HasValue)
+ {
+ var movie = _movieService.GetMovie(release.MovieId.Value);
+
+ remoteMovie.Movie = movie;
+ }
+ else
+ {
+ throw new NzbDroneClientException(HttpStatusCode.NotFound, "Unable to find matching movie");
+ }
+ }
+
+ _downloadService.DownloadReport(remoteMovie);
+ }
+ catch (ReleaseDownloadException ex)
+ {
+ _logger.Error(ex, ex.Message);
+ throw new NzbDroneClientException(HttpStatusCode.Conflict, "Getting release from indexer failed");
+ }
+
+ return release;
+ }
+
+ [HttpGet]
+ public List GetReleases(int? movieId)
+ {
+ if (movieId.HasValue)
+ {
+ return GetMovieReleases(movieId.Value);
+ }
+
+ return GetRss();
+ }
+
+ private List GetMovieReleases(int movieId)
+ {
+ try
+ {
+ var decisions = _releaseSearchService.MovieSearch(movieId, true, true);
+ var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisionsForMovies(decisions);
+
+ return MapDecisions(prioritizedDecisions);
+ }
+ catch (SearchFailedException ex)
+ {
+ throw new NzbDroneClientException(HttpStatusCode.BadRequest, ex.Message);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Movie search failed: " + ex.Message);
+ }
+
+ return new List();
+ }
+
+ private List GetRss()
+ {
+ var reports = _rssFetcherAndParser.Fetch();
+ var decisions = _downloadDecisionMaker.GetRssDecision(reports);
+ var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisionsForMovies(decisions);
+
+ return MapDecisions(prioritizedDecisions);
+ }
+
+ protected override ReleaseResource MapDecision(DownloadDecision decision, int initialWeight)
+ {
+ var resource = base.MapDecision(decision, initialWeight);
+ _remoteMovieCache.Set(GetCacheKey(resource), decision.RemoteMovie, TimeSpan.FromMinutes(30));
+
+ return resource;
+ }
+
+ private string GetCacheKey(ReleaseResource resource)
+ {
+ return string.Concat(resource.IndexerId, "_", resource.Guid);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Indexers/ReleaseControllerBase.cs b/src/Radarr.Api.V4/Indexers/ReleaseControllerBase.cs
new file mode 100644
index 0000000000..1c0a471908
--- /dev/null
+++ b/src/Radarr.Api.V4/Indexers/ReleaseControllerBase.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Generic;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Profiles;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Indexers
+{
+ public abstract class ReleaseControllerBase : RestController
+ {
+ private readonly Profile _qualityProfile;
+
+ public ReleaseControllerBase(IProfileService qualityProfileService)
+ {
+ _qualityProfile = qualityProfileService.GetDefaultProfile(string.Empty);
+ }
+
+ protected override ReleaseResource GetResourceById(int id)
+ {
+ throw new NotImplementedException();
+ }
+
+ protected virtual List MapDecisions(IEnumerable decisions)
+ {
+ var result = new List();
+
+ foreach (var downloadDecision in decisions)
+ {
+ var release = MapDecision(downloadDecision, result.Count);
+
+ result.Add(release);
+ }
+
+ return result;
+ }
+
+ protected virtual ReleaseResource MapDecision(DownloadDecision decision, int initialWeight)
+ {
+ var release = decision.ToResource();
+
+ release.ReleaseWeight = initialWeight;
+
+ release.QualityWeight = _qualityProfile.GetIndex(release.Quality.Quality).Index * 100;
+
+ release.QualityWeight += release.Quality.Revision.Real * 10;
+ release.QualityWeight += release.Quality.Revision.Version;
+
+ return release;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Indexers/ReleasePushController.cs b/src/Radarr.Api.V4/Indexers/ReleasePushController.cs
new file mode 100644
index 0000000000..196dc6bd75
--- /dev/null
+++ b/src/Radarr.Api.V4/Indexers/ReleasePushController.cs
@@ -0,0 +1,113 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using FluentValidation;
+using FluentValidation.Results;
+using Microsoft.AspNetCore.Mvc;
+using NLog;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Datastore;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Download;
+using NzbDrone.Core.Indexers;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.Profiles;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Indexers
+{
+ [V4ApiController("release/push")]
+ public class ReleasePushController : ReleaseControllerBase
+ {
+ private readonly IMakeDownloadDecision _downloadDecisionMaker;
+ private readonly IProcessDownloadDecisions _downloadDecisionProcessor;
+ private readonly IIndexerFactory _indexerFactory;
+ private readonly Logger _logger;
+
+ private static readonly object PushLock = new object();
+
+ public ReleasePushController(IMakeDownloadDecision downloadDecisionMaker,
+ IProcessDownloadDecisions downloadDecisionProcessor,
+ IIndexerFactory indexerFactory,
+ IProfileService qualityProfileService,
+ Logger logger)
+ : base(qualityProfileService)
+ {
+ _downloadDecisionMaker = downloadDecisionMaker;
+ _downloadDecisionProcessor = downloadDecisionProcessor;
+ _indexerFactory = indexerFactory;
+ _logger = logger;
+
+ PostValidator.RuleFor(s => s.Title).NotEmpty();
+ PostValidator.RuleFor(s => s.DownloadUrl).NotEmpty();
+ PostValidator.RuleFor(s => s.Protocol).NotEmpty();
+ PostValidator.RuleFor(s => s.PublishDate).NotEmpty();
+ }
+
+ [HttpPost]
+ public ActionResult> Create(ReleaseResource release)
+ {
+ _logger.Info("Release pushed: {0} - {1}", release.Title, release.DownloadUrl);
+
+ ValidateResource(release);
+
+ var info = release.ToModel();
+
+ info.Guid = "PUSH-" + info.DownloadUrl;
+
+ ResolveIndexer(info);
+
+ List decisions;
+
+ lock (PushLock)
+ {
+ decisions = _downloadDecisionMaker.GetRssDecision(new List { info });
+ _downloadDecisionProcessor.ProcessDecisions(decisions);
+ }
+
+ var firstDecision = decisions.FirstOrDefault();
+
+ if (firstDecision?.RemoteMovie.ParsedMovieInfo == null)
+ {
+ throw new ValidationException(new List { new ValidationFailure("Title", "Unable to parse", release.Title) });
+ }
+
+ return MapDecisions(new[] { firstDecision });
+ }
+
+ private void ResolveIndexer(ReleaseInfo release)
+ {
+ if (release.IndexerId == 0 && release.Indexer.IsNotNullOrWhiteSpace())
+ {
+ var indexer = _indexerFactory.All().FirstOrDefault(v => v.Name == release.Indexer);
+ if (indexer != null)
+ {
+ release.IndexerId = indexer.Id;
+ _logger.Debug("Push Release {0} associated with indexer {1} - {2}.", release.Title, release.IndexerId, release.Indexer);
+ }
+ else
+ {
+ _logger.Debug("Push Release {0} not associated with known indexer {1}.", release.Title, release.Indexer);
+ }
+ }
+ else if (release.IndexerId != 0 && release.Indexer.IsNullOrWhiteSpace())
+ {
+ try
+ {
+ var indexer = _indexerFactory.Get(release.IndexerId);
+ release.Indexer = indexer.Name;
+ _logger.Debug("Push Release {0} associated with indexer {1} - {2}.", release.Title, release.IndexerId, release.Indexer);
+ }
+ catch (ModelNotFoundException)
+ {
+ _logger.Debug("Push Release {0} not associated with known indexer {1}.", release.Title, release.IndexerId);
+ release.IndexerId = 0;
+ }
+ }
+ else
+ {
+ _logger.Debug("Push Release {0} not associated with an indexer.", release.Title);
+ }
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Indexers/ReleaseResource.cs b/src/Radarr.Api.V4/Indexers/ReleaseResource.cs
new file mode 100644
index 0000000000..9ec83775dc
--- /dev/null
+++ b/src/Radarr.Api.V4/Indexers/ReleaseResource.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Serialization;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Indexers;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.Qualities;
+using Radarr.Api.V4.CustomFormats;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Indexers
+{
+ public class ReleaseResource : RestResource
+ {
+ public string Guid { get; set; }
+ public QualityModel Quality { get; set; }
+ public List CustomFormats { get; set; }
+ public int CustomFormatScore { get; set; }
+ public int QualityWeight { get; set; }
+ public int Age { get; set; }
+ public double AgeHours { get; set; }
+ public double AgeMinutes { get; set; }
+ public long Size { get; set; }
+ public int IndexerId { get; set; }
+ public string Indexer { get; set; }
+ public string ReleaseGroup { get; set; }
+ public string SubGroup { get; set; }
+ public string ReleaseHash { get; set; }
+ public string Title { get; set; }
+ public bool SceneSource { get; set; }
+ public List MovieTitles { get; set; }
+ public List Languages { get; set; }
+ public bool Approved { get; set; }
+ public bool TemporarilyRejected { get; set; }
+ public bool Rejected { get; set; }
+ public int TmdbId { get; set; }
+ public int ImdbId { get; set; }
+ public IEnumerable Rejections { get; set; }
+ public DateTime PublishDate { get; set; }
+ public string CommentUrl { get; set; }
+ public string DownloadUrl { get; set; }
+ public string InfoUrl { get; set; }
+ public bool DownloadAllowed { get; set; }
+ public int ReleaseWeight { get; set; }
+ public IEnumerable IndexerFlags { get; set; }
+ public string Edition { get; set; }
+
+ public string MagnetUrl { get; set; }
+ public string InfoHash { get; set; }
+ public int? Seeders { get; set; }
+ public int? Leechers { get; set; }
+ public DownloadProtocol Protocol { get; set; }
+
+ // Sent when queuing an unknown release
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int? MovieId { get; set; }
+ }
+
+ public static class ReleaseResourceMapper
+ {
+ public static ReleaseResource ToResource(this DownloadDecision model)
+ {
+ var releaseInfo = model.RemoteMovie.Release;
+ var parsedMovieInfo = model.RemoteMovie.ParsedMovieInfo;
+ var remoteMovie = model.RemoteMovie;
+ var torrentInfo = (model.RemoteMovie.Release as TorrentInfo) ?? new TorrentInfo();
+ var indexerFlags = torrentInfo.IndexerFlags.ToString().Split(new string[] { ", " }, StringSplitOptions.None).Where(x => x != "0");
+
+ // TODO: Clean this mess up. don't mix data from multiple classes, use sub-resources instead? (Got a huge Deja Vu, didn't we talk about this already once?)
+ return new ReleaseResource
+ {
+ Guid = releaseInfo.Guid,
+ Quality = parsedMovieInfo.Quality,
+ CustomFormats = remoteMovie.CustomFormats.ToResource(false),
+ CustomFormatScore = remoteMovie.CustomFormatScore,
+
+ // QualityWeight
+ Age = releaseInfo.Age,
+ AgeHours = releaseInfo.AgeHours,
+ AgeMinutes = releaseInfo.AgeMinutes,
+ Size = releaseInfo.Size,
+ IndexerId = releaseInfo.IndexerId,
+ Indexer = releaseInfo.Indexer,
+ ReleaseGroup = parsedMovieInfo.ReleaseGroup,
+ ReleaseHash = parsedMovieInfo.ReleaseHash,
+ Title = releaseInfo.Title,
+ MovieTitles = parsedMovieInfo.MovieTitles,
+ Languages = parsedMovieInfo.Languages,
+ Approved = model.Approved,
+ TemporarilyRejected = model.TemporarilyRejected,
+ Rejected = model.Rejected,
+ TmdbId = releaseInfo.TmdbId,
+ ImdbId = releaseInfo.ImdbId,
+ Rejections = model.Rejections.Select(r => r.Reason).ToList(),
+ PublishDate = releaseInfo.PublishDate,
+ CommentUrl = releaseInfo.CommentUrl,
+ DownloadUrl = releaseInfo.DownloadUrl,
+ InfoUrl = releaseInfo.InfoUrl,
+ DownloadAllowed = remoteMovie.DownloadAllowed,
+ Edition = parsedMovieInfo.Edition,
+
+ // ReleaseWeight
+ MagnetUrl = torrentInfo.MagnetUrl,
+ InfoHash = torrentInfo.InfoHash,
+ Seeders = torrentInfo.Seeders,
+ Leechers = (torrentInfo.Peers.HasValue && torrentInfo.Seeders.HasValue) ? (torrentInfo.Peers.Value - torrentInfo.Seeders.Value) : (int?)null,
+ Protocol = releaseInfo.DownloadProtocol,
+ IndexerFlags = indexerFlags
+ };
+ }
+
+ public static ReleaseInfo ToModel(this ReleaseResource resource)
+ {
+ ReleaseInfo model;
+
+ if (resource.Protocol == DownloadProtocol.Torrent)
+ {
+ model = new TorrentInfo
+ {
+ MagnetUrl = resource.MagnetUrl,
+ InfoHash = resource.InfoHash,
+ Seeders = resource.Seeders,
+ Peers = (resource.Seeders.HasValue && resource.Leechers.HasValue) ? (resource.Seeders + resource.Leechers) : null
+ };
+ }
+ else
+ {
+ model = new ReleaseInfo();
+ }
+
+ model.Guid = resource.Guid;
+ model.Title = resource.Title;
+ model.Size = resource.Size;
+ model.DownloadUrl = resource.DownloadUrl;
+ model.InfoUrl = resource.InfoUrl;
+ model.CommentUrl = resource.CommentUrl;
+ model.IndexerId = resource.IndexerId;
+ model.Indexer = resource.Indexer;
+ model.DownloadProtocol = resource.Protocol;
+ model.TmdbId = resource.TmdbId;
+ model.ImdbId = resource.ImdbId;
+ model.PublishDate = resource.PublishDate.ToUniversalTime();
+
+ return model;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Localization/LocalizationController.cs b/src/Radarr.Api.V4/Localization/LocalizationController.cs
new file mode 100644
index 0000000000..5d48e1a08b
--- /dev/null
+++ b/src/Radarr.Api.V4/Localization/LocalizationController.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common.Serializer;
+using NzbDrone.Core.Localization;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Localization
+{
+ [V4ApiController]
+ public class LocalizationController : Controller
+ {
+ private readonly ILocalizationService _localizationService;
+ private readonly JsonSerializerOptions _serializerSettings;
+
+ public LocalizationController(ILocalizationService localizationService)
+ {
+ _localizationService = localizationService;
+ _serializerSettings = STJson.GetSerializerSettings();
+ _serializerSettings.DictionaryKeyPolicy = null;
+ _serializerSettings.PropertyNamingPolicy = null;
+ }
+
+ [HttpGet]
+ public string GetLocalizationDictionary()
+ {
+ return JsonSerializer.Serialize(_localizationService.GetLocalizationDictionary().ToResource(), _serializerSettings);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Localization/LocalizationResource.cs b/src/Radarr.Api.V4/Localization/LocalizationResource.cs
new file mode 100644
index 0000000000..2f4c28d1a5
--- /dev/null
+++ b/src/Radarr.Api.V4/Localization/LocalizationResource.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Localization
+{
+ public class LocalizationResource : RestResource
+ {
+ public Dictionary Strings { get; set; }
+ }
+
+ public static class LocalizationResourceMapper
+ {
+ public static LocalizationResource ToResource(this Dictionary localization)
+ {
+ if (localization == null)
+ {
+ return null;
+ }
+
+ return new LocalizationResource
+ {
+ Strings = localization,
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Logs/LogController.cs b/src/Radarr.Api.V4/Logs/LogController.cs
new file mode 100644
index 0000000000..0accffbddd
--- /dev/null
+++ b/src/Radarr.Api.V4/Logs/LogController.cs
@@ -0,0 +1,67 @@
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Instrumentation;
+using Radarr.Http;
+using Radarr.Http.Extensions;
+
+namespace Radarr.Api.V4.Logs
+{
+ [V4ApiController]
+ public class LogController : Controller
+ {
+ private readonly ILogService _logService;
+
+ public LogController(ILogService logService)
+ {
+ _logService = logService;
+ }
+
+ [HttpGet]
+ public PagingResource GetLogs()
+ {
+ var pagingResource = Request.ReadPagingResourceFromRequest();
+ var pageSpec = pagingResource.MapToPagingSpec();
+
+ if (pageSpec.SortKey == "time")
+ {
+ pageSpec.SortKey = "id";
+ }
+
+ var levelFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "level");
+
+ if (levelFilter != null)
+ {
+ switch (levelFilter.Value)
+ {
+ case "fatal":
+ pageSpec.FilterExpressions.Add(h => h.Level == "Fatal");
+ break;
+ case "error":
+ pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error");
+ break;
+ case "warn":
+ pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn");
+ break;
+ case "info":
+ pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info");
+ break;
+ case "debug":
+ pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug");
+ break;
+ case "trace":
+ pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug" || h.Level == "Trace");
+ break;
+ }
+ }
+
+ var response = pageSpec.ApplyToPage(_logService.Paged, LogResourceMapper.ToResource);
+
+ if (pageSpec.SortKey == "id")
+ {
+ response.SortKey = "time";
+ }
+
+ return response;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Logs/LogFileController.cs b/src/Radarr.Api.V4/Logs/LogFileController.cs
new file mode 100644
index 0000000000..af93662c1e
--- /dev/null
+++ b/src/Radarr.Api.V4/Logs/LogFileController.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using System.IO;
+using NzbDrone.Common.Disk;
+using NzbDrone.Common.EnvironmentInfo;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Configuration;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Logs
+{
+ [V4ApiController("log/file")]
+ public class LogFileController : LogFileControllerBase
+ {
+ private readonly IAppFolderInfo _appFolderInfo;
+ private readonly IDiskProvider _diskProvider;
+
+ public LogFileController(IAppFolderInfo appFolderInfo,
+ IDiskProvider diskProvider,
+ IConfigFileProvider configFileProvider)
+ : base(diskProvider, configFileProvider, "")
+ {
+ _appFolderInfo = appFolderInfo;
+ _diskProvider = diskProvider;
+ }
+
+ protected override IEnumerable GetLogFiles()
+ {
+ return _diskProvider.GetFiles(_appFolderInfo.GetLogFolder(), SearchOption.TopDirectoryOnly);
+ }
+
+ protected override string GetLogFilePath(string filename)
+ {
+ return Path.Combine(_appFolderInfo.GetLogFolder(), filename);
+ }
+
+ protected override string DownloadUrlRoot
+ {
+ get
+ {
+ return "logfile";
+ }
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Logs/LogFileControllerBase.cs b/src/Radarr.Api.V4/Logs/LogFileControllerBase.cs
new file mode 100644
index 0000000000..3fb5a1418e
--- /dev/null
+++ b/src/Radarr.Api.V4/Logs/LogFileControllerBase.cs
@@ -0,0 +1,73 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NLog;
+using NzbDrone.Common.Disk;
+using NzbDrone.Core.Configuration;
+
+namespace Radarr.Api.V4.Logs
+{
+ public abstract class LogFileControllerBase : Controller
+ {
+ protected const string LOGFILE_ROUTE = @"/(?[-.a-zA-Z0-9]+?\.txt)";
+ protected string _resource;
+
+ private readonly IDiskProvider _diskProvider;
+ private readonly IConfigFileProvider _configFileProvider;
+
+ public LogFileControllerBase(IDiskProvider diskProvider,
+ IConfigFileProvider configFileProvider,
+ string resource)
+ {
+ _diskProvider = diskProvider;
+ _configFileProvider = configFileProvider;
+ _resource = resource;
+ }
+
+ [HttpGet]
+ public List GetLogFilesResponse()
+ {
+ var result = new List();
+
+ var files = GetLogFiles().ToList();
+
+ for (int i = 0; i < files.Count; i++)
+ {
+ var file = files[i];
+ var filename = Path.GetFileName(file);
+
+ result.Add(new LogFileResource
+ {
+ Id = i + 1,
+ Filename = filename,
+ LastWriteTime = _diskProvider.FileGetLastWrite(file),
+ ContentsUrl = string.Format("{0}/api/v1/{1}/{2}", _configFileProvider.UrlBase, _resource, filename),
+ DownloadUrl = string.Format("{0}/{1}/{2}", _configFileProvider.UrlBase, DownloadUrlRoot, filename)
+ });
+ }
+
+ return result.OrderByDescending(l => l.LastWriteTime).ToList();
+ }
+
+ [HttpGet(@"{filename:regex([[-.a-zA-Z0-9]]+?\.txt)}")]
+ public IActionResult GetLogFileResponse(string filename)
+ {
+ LogManager.Flush();
+
+ var filePath = GetLogFilePath(filename);
+
+ if (!_diskProvider.FileExists(filePath))
+ {
+ return NotFound();
+ }
+
+ return PhysicalFile(filePath, "text/plain");
+ }
+
+ protected abstract IEnumerable GetLogFiles();
+ protected abstract string GetLogFilePath(string filename);
+
+ protected abstract string DownloadUrlRoot { get; }
+ }
+}
diff --git a/src/Radarr.Api.V4/Logs/LogFileResource.cs b/src/Radarr.Api.V4/Logs/LogFileResource.cs
new file mode 100644
index 0000000000..51eaa7352f
--- /dev/null
+++ b/src/Radarr.Api.V4/Logs/LogFileResource.cs
@@ -0,0 +1,13 @@
+using System;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Logs
+{
+ public class LogFileResource : RestResource
+ {
+ public string Filename { get; set; }
+ public DateTime LastWriteTime { get; set; }
+ public string ContentsUrl { get; set; }
+ public string DownloadUrl { get; set; }
+ }
+}
diff --git a/src/Radarr.Api.V4/Logs/LogResource.cs b/src/Radarr.Api.V4/Logs/LogResource.cs
new file mode 100644
index 0000000000..86bf03f6a1
--- /dev/null
+++ b/src/Radarr.Api.V4/Logs/LogResource.cs
@@ -0,0 +1,39 @@
+using System;
+using NzbDrone.Core.Instrumentation;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Logs
+{
+ public class LogResource : RestResource
+ {
+ public DateTime Time { get; set; }
+ public string Exception { get; set; }
+ public string ExceptionType { get; set; }
+ public string Level { get; set; }
+ public string Logger { get; set; }
+ public string Message { get; set; }
+ public string Method { get; set; }
+ }
+
+ public static class LogResourceMapper
+ {
+ public static LogResource ToResource(this Log model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new LogResource
+ {
+ Id = model.Id,
+ Time = model.Time,
+ Exception = model.Exception,
+ ExceptionType = model.ExceptionType,
+ Level = model.Level.ToLowerInvariant(),
+ Logger = model.Logger,
+ Message = model.Message
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Logs/UpdateLogFileController.cs b/src/Radarr.Api.V4/Logs/UpdateLogFileController.cs
new file mode 100644
index 0000000000..732f9bb4db
--- /dev/null
+++ b/src/Radarr.Api.V4/Logs/UpdateLogFileController.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using NzbDrone.Common.Disk;
+using NzbDrone.Common.EnvironmentInfo;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Configuration;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Logs
+{
+ [V4ApiController("log/file/update")]
+ public class UpdateLogFileController : LogFileControllerBase
+ {
+ private readonly IAppFolderInfo _appFolderInfo;
+ private readonly IDiskProvider _diskProvider;
+
+ public UpdateLogFileController(IAppFolderInfo appFolderInfo,
+ IDiskProvider diskProvider,
+ IConfigFileProvider configFileProvider)
+ : base(diskProvider, configFileProvider, "update")
+ {
+ _appFolderInfo = appFolderInfo;
+ _diskProvider = diskProvider;
+ }
+
+ protected override IEnumerable GetLogFiles()
+ {
+ if (!_diskProvider.FolderExists(_appFolderInfo.GetUpdateLogFolder()))
+ {
+ return Enumerable.Empty();
+ }
+
+ return _diskProvider.GetFiles(_appFolderInfo.GetUpdateLogFolder(), SearchOption.TopDirectoryOnly)
+ .Where(f => Regex.IsMatch(Path.GetFileName(f), LOGFILE_ROUTE.TrimStart('/'), RegexOptions.IgnoreCase))
+ .ToList();
+ }
+
+ protected override string GetLogFilePath(string filename)
+ {
+ return Path.Combine(_appFolderInfo.GetUpdateLogFolder(), filename);
+ }
+
+ protected override string DownloadUrlRoot
+ {
+ get
+ {
+ return "updatelogfile";
+ }
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/ManualImport/ManualImportController.cs b/src/Radarr.Api.V4/ManualImport/ManualImportController.cs
new file mode 100644
index 0000000000..cb9a831e74
--- /dev/null
+++ b/src/Radarr.Api.V4/ManualImport/ManualImportController.cs
@@ -0,0 +1,69 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.MediaFiles.MovieImport.Manual;
+using NzbDrone.Core.Qualities;
+using Radarr.Api.V4.Movies;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.ManualImport
+{
+ [V4ApiController]
+ public class ManualImportController : Controller
+ {
+ private readonly IManualImportService _manualImportService;
+
+ public ManualImportController(IManualImportService manualImportService)
+ {
+ _manualImportService = manualImportService;
+ }
+
+ [HttpGet]
+ public List GetMediaFiles(string folder, string downloadId, int? movieId, bool filterExistingFiles = true)
+ {
+ return _manualImportService.GetMediaFiles(folder, downloadId, movieId, filterExistingFiles).ToResource().Select(AddQualityWeight).ToList();
+ }
+
+ [HttpPost]
+ public object ReprocessItems([FromBody] List items)
+ {
+ foreach (var item in items)
+ {
+ var processedItem = _manualImportService.ReprocessItem(item.Path, item.DownloadId, item.MovieId, item.ReleaseGroup, item.Quality, item.Languages);
+
+ item.Movie = processedItem.Movie.ToResource(0);
+ item.Rejections = processedItem.Rejections;
+ if (item.Languages.Single() == Language.Unknown)
+ {
+ item.Languages = processedItem.Languages;
+ }
+
+ if (item.Quality?.Quality == Quality.Unknown)
+ {
+ item.Quality = processedItem.Quality;
+ }
+
+ if (item.ReleaseGroup.IsNotNullOrWhiteSpace())
+ {
+ item.ReleaseGroup = processedItem.ReleaseGroup;
+ }
+ }
+
+ return items;
+ }
+
+ private ManualImportResource AddQualityWeight(ManualImportResource item)
+ {
+ if (item.Quality != null)
+ {
+ item.QualityWeight = Quality.DefaultQualityDefinitions.Single(q => q.Quality == item.Quality.Quality).Weight;
+ item.QualityWeight += item.Quality.Revision.Real * 10;
+ item.QualityWeight += item.Quality.Revision.Version;
+ }
+
+ return item;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/ManualImport/ManualImportReprocessResource.cs b/src/Radarr.Api.V4/ManualImport/ManualImportReprocessResource.cs
new file mode 100644
index 0000000000..27ef68a695
--- /dev/null
+++ b/src/Radarr.Api.V4/ManualImport/ManualImportReprocessResource.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Qualities;
+using Radarr.Api.V4.Movies;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.ManualImport
+{
+ public class ManualImportReprocessResource : RestResource
+ {
+ public string Path { get; set; }
+ public int MovieId { get; set; }
+ public MovieResource Movie { get; set; }
+ public QualityModel Quality { get; set; }
+ public List Languages { get; set; }
+ public string ReleaseGroup { get; set; }
+ public string DownloadId { get; set; }
+
+ public IEnumerable Rejections { get; set; }
+ }
+}
diff --git a/src/Radarr.Api.V4/ManualImport/ManualImportResource.cs b/src/Radarr.Api.V4/ManualImport/ManualImportResource.cs
new file mode 100644
index 0000000000..1683f7efe3
--- /dev/null
+++ b/src/Radarr.Api.V4/ManualImport/ManualImportResource.cs
@@ -0,0 +1,62 @@
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Common.Crypto;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.MediaFiles.MovieImport.Manual;
+using NzbDrone.Core.Qualities;
+using Radarr.Api.V4.Movies;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.ManualImport
+{
+ public class ManualImportResource : RestResource
+ {
+ public string Path { get; set; }
+ public string RelativePath { get; set; }
+ public string FolderName { get; set; }
+ public string Name { get; set; }
+ public long Size { get; set; }
+ public MovieResource Movie { get; set; }
+ public QualityModel Quality { get; set; }
+ public List Languages { get; set; }
+ public string ReleaseGroup { get; set; }
+ public int QualityWeight { get; set; }
+ public string DownloadId { get; set; }
+ public IEnumerable Rejections { get; set; }
+ }
+
+ public static class ManualImportResourceMapper
+ {
+ public static ManualImportResource ToResource(this ManualImportItem model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new ManualImportResource
+ {
+ Id = HashConverter.GetHashInt31(model.Path),
+ Path = model.Path,
+ RelativePath = model.RelativePath,
+ FolderName = model.FolderName,
+ Name = model.Name,
+ Size = model.Size,
+ Movie = model.Movie.ToResource(0),
+ Quality = model.Quality,
+ Languages = model.Languages,
+ ReleaseGroup = model.ReleaseGroup,
+
+ // QualityWeight
+ DownloadId = model.DownloadId,
+ Rejections = model.Rejections
+ };
+ }
+
+ public static List ToResource(this IEnumerable models)
+ {
+ return models.Select(ToResource).ToList();
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/MediaCovers/MediaCoverController.cs b/src/Radarr.Api.V4/MediaCovers/MediaCoverController.cs
new file mode 100644
index 0000000000..c70d31dc06
--- /dev/null
+++ b/src/Radarr.Api.V4/MediaCovers/MediaCoverController.cs
@@ -0,0 +1,59 @@
+using System.IO;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.StaticFiles;
+using NzbDrone.Common.Disk;
+using NzbDrone.Common.EnvironmentInfo;
+using NzbDrone.Common.Extensions;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.MediaCovers
+{
+ [V4ApiController]
+ public class MediaCoverController : Controller
+ {
+ private static readonly Regex RegexResizedImage = new Regex(@"-\d+\.jpg$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ private readonly IAppFolderInfo _appFolderInfo;
+ private readonly IDiskProvider _diskProvider;
+ private readonly IContentTypeProvider _mimeTypeProvider;
+
+ public MediaCoverController(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider)
+ {
+ _appFolderInfo = appFolderInfo;
+ _diskProvider = diskProvider;
+ _mimeTypeProvider = new FileExtensionContentTypeProvider();
+ }
+
+ [HttpGet(@"{movieId:int}/{filename:regex((.+)\.(jpg|png|gif))}")]
+ public IActionResult GetMediaCover(int movieId, string filename)
+ {
+ var filePath = Path.Combine(_appFolderInfo.GetAppDataPath(), "MediaCover", movieId.ToString(), filename);
+
+ if (!_diskProvider.FileExists(filePath) || _diskProvider.GetFileSize(filePath) == 0)
+ {
+ // Return the full sized image if someone requests a non-existing resized one.
+ // TODO: This code can be removed later once everyone had the update for a while.
+ var basefilePath = RegexResizedImage.Replace(filePath, ".jpg");
+ if (basefilePath == filePath || !_diskProvider.FileExists(basefilePath))
+ {
+ return NotFound();
+ }
+
+ filePath = basefilePath;
+ }
+
+ return PhysicalFile(filePath, GetContentType(filePath));
+ }
+
+ private string GetContentType(string filePath)
+ {
+ if (!_mimeTypeProvider.TryGetContentType(filePath, out var contentType))
+ {
+ contentType = "application/octet-stream";
+ }
+
+ return contentType;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Metadata/MetadataController.cs b/src/Radarr.Api.V4/Metadata/MetadataController.cs
new file mode 100644
index 0000000000..79c77a787a
--- /dev/null
+++ b/src/Radarr.Api.V4/Metadata/MetadataController.cs
@@ -0,0 +1,16 @@
+using NzbDrone.Core.Extras.Metadata;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Metadata
+{
+ [V4ApiController]
+ public class MetadataController : ProviderControllerBase
+ {
+ public static readonly MetadataResourceMapper ResourceMapper = new MetadataResourceMapper();
+
+ public MetadataController(IMetadataFactory metadataFactory)
+ : base(metadataFactory, "metadata", ResourceMapper)
+ {
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Metadata/MetadataResource.cs b/src/Radarr.Api.V4/Metadata/MetadataResource.cs
new file mode 100644
index 0000000000..e1d1a02227
--- /dev/null
+++ b/src/Radarr.Api.V4/Metadata/MetadataResource.cs
@@ -0,0 +1,40 @@
+using NzbDrone.Core.Extras.Metadata;
+
+namespace Radarr.Api.V4.Metadata
+{
+ public class MetadataResource : ProviderResource
+ {
+ public bool Enable { get; set; }
+ }
+
+ public class MetadataResourceMapper : ProviderResourceMapper
+ {
+ public override MetadataResource ToResource(MetadataDefinition definition)
+ {
+ if (definition == null)
+ {
+ return null;
+ }
+
+ var resource = base.ToResource(definition);
+
+ resource.Enable = definition.Enable;
+
+ return resource;
+ }
+
+ public override MetadataDefinition ToModel(MetadataResource resource)
+ {
+ if (resource == null)
+ {
+ return null;
+ }
+
+ var definition = base.ToModel(resource);
+
+ definition.Enable = resource.Enable;
+
+ return definition;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/MovieFiles/MediaInfoResource.cs b/src/Radarr.Api.V4/MovieFiles/MediaInfoResource.cs
new file mode 100644
index 0000000000..f8f24e44c7
--- /dev/null
+++ b/src/Radarr.Api.V4/MovieFiles/MediaInfoResource.cs
@@ -0,0 +1,64 @@
+using System;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.MediaFiles.MediaInfo;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.MovieFiles
+{
+ public class MediaInfoResource : RestResource
+ {
+ public long AudioBitrate { get; set; }
+ public decimal AudioChannels { get; set; }
+ public string AudioCodec { get; set; }
+ public string AudioLanguages { get; set; }
+ public int AudioStreamCount { get; set; }
+ public int VideoBitDepth { get; set; }
+ public long VideoBitrate { get; set; }
+ public string VideoCodec { get; set; }
+ public string VideoDynamicRangeType { get; set; }
+ public decimal VideoFps { get; set; }
+ public string Resolution { get; set; }
+ public string RunTime { get; set; }
+ public string ScanType { get; set; }
+ public string Subtitles { get; set; }
+ }
+
+ public static class MediaInfoResourceMapper
+ {
+ public static MediaInfoResource ToResource(this MediaInfoModel model, string sceneName)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new MediaInfoResource
+ {
+ AudioBitrate = model.AudioBitrate,
+ AudioChannels = MediaInfoFormatter.FormatAudioChannels(model),
+ AudioLanguages = model.AudioLanguages.ConcatToString("/"),
+ AudioStreamCount = model.AudioStreamCount,
+ AudioCodec = MediaInfoFormatter.FormatAudioCodec(model, sceneName),
+ VideoBitDepth = model.VideoBitDepth,
+ VideoBitrate = model.VideoBitrate,
+ VideoCodec = MediaInfoFormatter.FormatVideoCodec(model, sceneName),
+ VideoDynamicRangeType = MediaInfoFormatter.FormatVideoDynamicRangeType(model),
+ VideoFps = Math.Round(model.VideoFps, 3),
+ Resolution = $"{model.Width}x{model.Height}",
+ RunTime = FormatRuntime(model.RunTime),
+ ScanType = model.ScanType,
+ Subtitles = model.Subtitles.ConcatToString("/")
+ };
+ }
+
+ private static string FormatRuntime(TimeSpan runTime)
+ {
+ if (runTime.Hours > 0)
+ {
+ return $"{runTime.Hours}:{runTime.Minutes:00}:{runTime.Seconds:00}";
+ }
+
+ return $"{runTime.Minutes}:{runTime.Seconds:00}";
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/MovieFiles/MovieFileController.cs b/src/Radarr.Api.V4/MovieFiles/MovieFileController.cs
new file mode 100644
index 0000000000..e711713d53
--- /dev/null
+++ b/src/Radarr.Api.V4/MovieFiles/MovieFileController.cs
@@ -0,0 +1,204 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.Exceptions;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.MediaFiles.Events;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Parser;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.SignalR;
+using Radarr.Http;
+using Radarr.Http.REST;
+using Radarr.Http.REST.Attributes;
+using BadRequestException = Radarr.Http.REST.BadRequestException;
+
+namespace Radarr.Api.V4.MovieFiles
+{
+ [V4ApiController]
+ public class MovieFileController : RestControllerWithSignalR,
+ IHandle,
+ IHandle
+ {
+ private readonly IMediaFileService _mediaFileService;
+ private readonly IDeleteMediaFiles _mediaFileDeletionService;
+ private readonly IMovieService _movieService;
+ private readonly ICustomFormatCalculationService _formatCalculator;
+ private readonly IUpgradableSpecification _qualityUpgradableSpecification;
+
+ public MovieFileController(IBroadcastSignalRMessage signalRBroadcaster,
+ IMediaFileService mediaFileService,
+ IDeleteMediaFiles mediaFileDeletionService,
+ IMovieService movieService,
+ ICustomFormatCalculationService formatCalculator,
+ IUpgradableSpecification qualityUpgradableSpecification)
+ : base(signalRBroadcaster)
+ {
+ _mediaFileService = mediaFileService;
+ _mediaFileDeletionService = mediaFileDeletionService;
+ _movieService = movieService;
+ _formatCalculator = formatCalculator;
+ _qualityUpgradableSpecification = qualityUpgradableSpecification;
+ }
+
+ protected override MovieFileResource GetResourceById(int id)
+ {
+ var movieFile = _mediaFileService.GetMovie(id);
+ var movie = _movieService.GetMovie(movieFile.MovieId);
+ movieFile.Movie = movie;
+
+ var resource = movieFile.ToResource(movie, _qualityUpgradableSpecification, _formatCalculator);
+ return resource;
+ }
+
+ [HttpGet]
+ public List GetMovieFiles(int? movieId, [FromQuery] List movieFileIds)
+ {
+ if (!movieId.HasValue && !movieFileIds.Any())
+ {
+ throw new BadRequestException("movieId or movieFileIds must be provided");
+ }
+
+ if (movieId.HasValue)
+ {
+ var movie = _movieService.GetMovie(movieId.Value);
+ var files = _mediaFileService.GetFilesByMovie(movieId.Value);
+
+ if (files == null)
+ {
+ return new List();
+ }
+
+ files.ForEach(x => x.Movie = movie);
+ var resources = files.Select(m => m.ToResource(movie, _qualityUpgradableSpecification, _formatCalculator)).ToList();
+
+ return resources;
+ }
+ else
+ {
+ var movieFiles = _mediaFileService.GetMovies(movieFileIds);
+
+ return movieFiles.GroupBy(e => e.MovieId)
+ .SelectMany(f => f.ToList()
+ .ConvertAll(e => e.ToResource(_movieService.GetMovie(f.Key), _qualityUpgradableSpecification, _formatCalculator)))
+ .ToList();
+ }
+ }
+
+ [RestPutById]
+ public ActionResult SetMovieFile(MovieFileResource movieFileResource)
+ {
+ var movieFile = _mediaFileService.GetMovie(movieFileResource.Id);
+ movieFile.IndexerFlags = (IndexerFlags)movieFileResource.IndexerFlags;
+ movieFile.Quality = movieFileResource.Quality;
+ movieFile.Languages = movieFileResource.Languages;
+ movieFile.Edition = movieFileResource.Edition;
+ if (movieFileResource.ReleaseGroup != null)
+ {
+ movieFile.ReleaseGroup = movieFileResource.ReleaseGroup;
+ }
+
+ if (movieFileResource.SceneName != null && SceneChecker.IsSceneTitle(movieFileResource.SceneName))
+ {
+ movieFile.SceneName = movieFileResource.SceneName;
+ }
+
+ _mediaFileService.Update(movieFile);
+ return Accepted(movieFile.Id);
+ }
+
+ [HttpPut("editor")]
+ public object SetMovieFile([FromBody] MovieFileListResource resource)
+ {
+ var movieFiles = _mediaFileService.GetMovies(resource.MovieFileIds);
+
+ foreach (var movieFile in movieFiles)
+ {
+ if (resource.Quality != null)
+ {
+ movieFile.Quality = resource.Quality;
+ }
+
+ if (resource.Languages != null)
+ {
+ // Don't allow user to set movieFile with 'Any' or 'Original' language
+ movieFile.Languages = resource.Languages.Where(l => l != Language.Any || l != Language.Original || l != null).ToList();
+ }
+
+ if (resource.IndexerFlags != null)
+ {
+ movieFile.IndexerFlags = (IndexerFlags)resource.IndexerFlags.Value;
+ }
+
+ if (resource.Edition != null)
+ {
+ movieFile.Edition = resource.Edition;
+ }
+
+ if (resource.ReleaseGroup != null)
+ {
+ movieFile.ReleaseGroup = resource.ReleaseGroup;
+ }
+
+ if (resource.SceneName != null && SceneChecker.IsSceneTitle(resource.SceneName))
+ {
+ movieFile.SceneName = resource.SceneName;
+ }
+ }
+
+ _mediaFileService.Update(movieFiles);
+
+ var movies = _movieService.GetMovies(movieFiles.Select(x => x.MovieId).Distinct());
+
+ movieFiles.ForEach(x => x.Movie = movies.SingleOrDefault(m => m.Id == x.MovieId));
+
+ return Accepted(movieFiles.ConvertAll(f => f.ToResource(f.Movie, _qualityUpgradableSpecification, _formatCalculator)));
+ }
+
+ [RestDeleteById]
+ public void DeleteMovieFile(int id)
+ {
+ var movieFile = _mediaFileService.GetMovie(id);
+
+ if (movieFile == null)
+ {
+ throw new NzbDroneClientException(global::System.Net.HttpStatusCode.NotFound, "Movie file not found");
+ }
+
+ var movie = _movieService.GetMovie(movieFile.MovieId);
+
+ _mediaFileDeletionService.DeleteMovieFile(movie, movieFile);
+ }
+
+ [HttpDelete("bulk")]
+ public object DeleteMovieFiles([FromBody] MovieFileListResource resource)
+ {
+ var movieFiles = _mediaFileService.GetMovies(resource.MovieFileIds);
+ var movie = _movieService.GetMovie(movieFiles.First().MovieId);
+
+ foreach (var movieFile in movieFiles)
+ {
+ _mediaFileDeletionService.DeleteMovieFile(movie, movieFile);
+ }
+
+ return new { };
+ }
+
+ [NonAction]
+ public void Handle(MovieFileAddedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.MovieFile.Id);
+ }
+
+ [NonAction]
+ public void Handle(MovieFileDeletedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Deleted, message.MovieFile.Id);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/MovieFiles/MovieFileListResource.cs b/src/Radarr.Api.V4/MovieFiles/MovieFileListResource.cs
new file mode 100644
index 0000000000..48ea74f493
--- /dev/null
+++ b/src/Radarr.Api.V4/MovieFiles/MovieFileListResource.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.Qualities;
+
+namespace Radarr.Api.V4.MovieFiles
+{
+ public class MovieFileListResource
+ {
+ public List MovieFileIds { get; set; }
+ public List Languages { get; set; }
+ public QualityModel Quality { get; set; }
+ public string Edition { get; set; }
+ public string ReleaseGroup { get; set; }
+ public string SceneName { get; set; }
+ public int? IndexerFlags { get; set; }
+ }
+}
diff --git a/src/Radarr.Api.V4/MovieFiles/MovieFileResource.cs b/src/Radarr.Api.V4/MovieFiles/MovieFileResource.cs
new file mode 100644
index 0000000000..d606ea24da
--- /dev/null
+++ b/src/Radarr.Api.V4/MovieFiles/MovieFileResource.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.Qualities;
+using Radarr.Api.V4.CustomFormats;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.MovieFiles
+{
+ public class MovieFileResource : RestResource
+ {
+ public int MovieId { get; set; }
+ public string RelativePath { get; set; }
+ public string Path { get; set; }
+ public long Size { get; set; }
+ public DateTime DateAdded { get; set; }
+ public string SceneName { get; set; }
+ public int IndexerFlags { get; set; }
+ public QualityModel Quality { get; set; }
+ public List CustomFormats { get; set; }
+ public MediaInfoResource MediaInfo { get; set; }
+ public string OriginalFilePath { get; set; }
+ public bool QualityCutoffNotMet { get; set; }
+ public List Languages { get; set; }
+ public string ReleaseGroup { get; set; }
+ public string Edition { get; set; }
+ }
+
+ public static class MovieFileResourceMapper
+ {
+ private static MovieFileResource ToResource(this MovieFile model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new MovieFileResource
+ {
+ Id = model.Id,
+
+ MovieId = model.MovieId,
+ RelativePath = model.RelativePath,
+
+ // Path
+ Size = model.Size,
+ DateAdded = model.DateAdded,
+ SceneName = model.SceneName,
+ IndexerFlags = (int)model.IndexerFlags,
+ Quality = model.Quality,
+ Languages = model.Languages,
+ ReleaseGroup = model.ReleaseGroup,
+ Edition = model.Edition,
+ MediaInfo = model.MediaInfo.ToResource(model.SceneName),
+ OriginalFilePath = model.OriginalFilePath
+ };
+ }
+
+ public static MovieFileResource ToResource(this MovieFile model, NzbDrone.Core.Movies.Movie movie, IUpgradableSpecification upgradableSpecification, ICustomFormatCalculationService formatCalculator)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new MovieFileResource
+ {
+ Id = model.Id,
+
+ MovieId = model.MovieId,
+ RelativePath = model.RelativePath,
+ Path = Path.Combine(movie.Path, model.RelativePath),
+ Size = model.Size,
+ DateAdded = model.DateAdded,
+ SceneName = model.SceneName,
+ IndexerFlags = (int)model.IndexerFlags,
+ Quality = model.Quality,
+ Languages = model.Languages,
+ Edition = model.Edition,
+ ReleaseGroup = model.ReleaseGroup,
+ MediaInfo = model.MediaInfo.ToResource(model.SceneName),
+ QualityCutoffNotMet = upgradableSpecification?.QualityCutoffNotMet(movie.Profile, model.Quality) ?? false,
+ OriginalFilePath = model.OriginalFilePath,
+ CustomFormats = formatCalculator.ParseCustomFormat(model).ToResource(false)
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Movies/AlternativeTitleController.cs b/src/Radarr.Api.V4/Movies/AlternativeTitleController.cs
new file mode 100644
index 0000000000..4173e5aade
--- /dev/null
+++ b/src/Radarr.Api.V4/Movies/AlternativeTitleController.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.AlternativeTitles;
+using Radarr.Http;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Movies
+{
+ [V4ApiController("alttitle")]
+ public class AlternativeTitleController : RestController
+ {
+ private readonly IAlternativeTitleService _altTitleService;
+ private readonly IMovieService _movieService;
+
+ public AlternativeTitleController(IAlternativeTitleService altTitleService, IMovieService movieService)
+ {
+ _altTitleService = altTitleService;
+ _movieService = movieService;
+ }
+
+ protected override AlternativeTitleResource GetResourceById(int id)
+ {
+ return _altTitleService.GetById(id).ToResource();
+ }
+
+ [HttpGet]
+ public List GetAltTitles(int? movieId, int? movieMetadataId)
+ {
+ if (movieMetadataId.HasValue)
+ {
+ return _altTitleService.GetAllTitlesForMovieMetadata(movieMetadataId.Value).ToResource();
+ }
+
+ if (movieId.HasValue)
+ {
+ var movie = _movieService.GetMovie(movieId.Value);
+ return _altTitleService.GetAllTitlesForMovieMetadata(movie.MovieMetadataId).ToResource();
+ }
+
+ return _altTitleService.GetAllTitles().ToResource();
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Movies/AlternativeTitleResource.cs b/src/Radarr.Api.V4/Movies/AlternativeTitleResource.cs
new file mode 100644
index 0000000000..a2e25d95fd
--- /dev/null
+++ b/src/Radarr.Api.V4/Movies/AlternativeTitleResource.cs
@@ -0,0 +1,76 @@
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Movies.AlternativeTitles;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Movies
+{
+ public class AlternativeTitleResource : RestResource
+ {
+ public AlternativeTitleResource()
+ {
+ }
+
+ // Todo: Sorters should be done completely on the client
+ // Todo: Is there an easy way to keep IgnoreArticlesWhenSorting in sync between, Series, History, Missing?
+ // Todo: We should get the entire Profile instead of ID and Name separately
+ public int MovieMetadataId { get; set; }
+ public string Title { get; set; }
+ public string CleanTitle { get; set; }
+ public int SourceId { get; set; }
+ public int Votes { get; set; }
+ public int VoteCount { get; set; }
+ public Language Language { get; set; }
+
+ // TODO: Add series statistics as a property of the series (instead of individual properties)
+ }
+
+ public static class AlternativeTitleResourceMapper
+ {
+ public static AlternativeTitleResource ToResource(this AlternativeTitle model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new AlternativeTitleResource
+ {
+ Id = model.Id,
+ SourceType = model.SourceType,
+ MovieMetadataId = model.MovieMetadataId,
+ Title = model.Title,
+ SourceId = model.SourceId,
+ Votes = model.Votes,
+ VoteCount = model.VoteCount,
+ Language = model.Language
+ };
+ }
+
+ public static AlternativeTitle ToModel(this AlternativeTitleResource resource)
+ {
+ if (resource == null)
+ {
+ return null;
+ }
+
+ return new AlternativeTitle
+ {
+ Id = resource.Id,
+ SourceType = resource.SourceType,
+ MovieMetadataId = resource.MovieMetadataId,
+ Title = resource.Title,
+ SourceId = resource.SourceId,
+ Votes = resource.Votes,
+ VoteCount = resource.VoteCount,
+ Language = resource.Language
+ };
+ }
+
+ public static List ToResource(this IEnumerable movies)
+ {
+ return movies.Select(ToResource).ToList();
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Movies/MovieController.cs b/src/Radarr.Api.V4/Movies/MovieController.cs
new file mode 100644
index 0000000000..7a1a777e09
--- /dev/null
+++ b/src/Radarr.Api.V4/Movies/MovieController.cs
@@ -0,0 +1,364 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using FluentValidation;
+using Microsoft.AspNetCore.Mvc;
+using NLog;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.MediaFiles.Events;
+using NzbDrone.Core.Messaging.Commands;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Commands;
+using NzbDrone.Core.Movies.Events;
+using NzbDrone.Core.Movies.Translations;
+using NzbDrone.Core.MovieStats;
+using NzbDrone.Core.Validation;
+using NzbDrone.Core.Validation.Paths;
+using NzbDrone.SignalR;
+using Radarr.Http;
+using Radarr.Http.Extensions;
+using Radarr.Http.REST;
+using Radarr.Http.REST.Attributes;
+
+namespace Radarr.Api.V4.Movies
+{
+ [V4ApiController]
+ public class MovieController : RestControllerWithSignalR,
+ IHandle,
+ IHandle,
+ IHandle,
+ IHandle,
+ IHandle,
+ IHandle,
+ IHandle
+ {
+ private readonly IMovieService _moviesService;
+ private readonly IMovieStatisticsService _movieStatisticsService;
+ private readonly IMovieTranslationService _movieTranslationService;
+ private readonly IAddMovieService _addMovieService;
+ private readonly IMapCoversToLocal _coverMapper;
+ private readonly IManageCommandQueue _commandQueueManager;
+ private readonly IUpgradableSpecification _qualityUpgradableSpecification;
+ private readonly IConfigService _configService;
+ private readonly Logger _logger;
+
+ public MovieController(IBroadcastSignalRMessage signalRBroadcaster,
+ IMovieService moviesService,
+ IMovieStatisticsService movieStatisticsService,
+ IMovieTranslationService movieTranslationService,
+ IAddMovieService addMovieService,
+ IMapCoversToLocal coverMapper,
+ IManageCommandQueue commandQueueManager,
+ IUpgradableSpecification qualityUpgradableSpecification,
+ IConfigService configService,
+ RootFolderValidator rootFolderValidator,
+ MappedNetworkDriveValidator mappedNetworkDriveValidator,
+ MoviePathValidator moviesPathValidator,
+ MovieExistsValidator moviesExistsValidator,
+ MovieAncestorValidator moviesAncestorValidator,
+ RecycleBinValidator recycleBinValidator,
+ SystemFolderValidator systemFolderValidator,
+ ProfileExistsValidator profileExistsValidator,
+ MovieFolderAsRootFolderValidator movieFolderAsRootFolderValidator,
+ Logger logger)
+ : base(signalRBroadcaster)
+ {
+ _moviesService = moviesService;
+ _movieStatisticsService = movieStatisticsService;
+ _movieTranslationService = movieTranslationService;
+ _addMovieService = addMovieService;
+ _qualityUpgradableSpecification = qualityUpgradableSpecification;
+ _configService = configService;
+ _coverMapper = coverMapper;
+ _commandQueueManager = commandQueueManager;
+ _logger = logger;
+
+ SharedValidator.RuleFor(s => s.QualityProfileId).ValidId().When(s => s.QualityProfileIds == null || s.QualityProfileIds.Empty());
+
+ SharedValidator.RuleFor(s => s.Path)
+ .Cascade(CascadeMode.StopOnFirstFailure)
+ .IsValidPath()
+ .SetValidator(rootFolderValidator)
+ .SetValidator(mappedNetworkDriveValidator)
+ .SetValidator(moviesPathValidator)
+ .SetValidator(moviesAncestorValidator)
+ .SetValidator(recycleBinValidator)
+ .SetValidator(systemFolderValidator)
+ .When(s => !s.Path.IsNullOrWhiteSpace());
+
+ SharedValidator.RuleFor(s => s.QualityProfileIds).NotNull().When(s => s.QualityProfileId == 0);
+ SharedValidator.RuleForEach(s => s.QualityProfileIds).SetValidator(profileExistsValidator);
+
+ PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace());
+ PostValidator.RuleFor(s => s.RootFolderPath)
+ .IsValidPath()
+ .SetValidator(movieFolderAsRootFolderValidator)
+ .When(s => s.Path.IsNullOrWhiteSpace());
+ PostValidator.RuleFor(s => s.Title).NotEmpty().When(s => s.TmdbId <= 0);
+ PostValidator.RuleFor(s => s.TmdbId).NotNull().NotEmpty().SetValidator(moviesExistsValidator);
+
+ PutValidator.RuleFor(s => s.Path).IsValidPath();
+ }
+
+ [HttpGet]
+ public List AllMovie(int? tmdbId)
+ {
+ var moviesResources = new List();
+
+ Dictionary coverFileInfos = null;
+
+ if (tmdbId.HasValue)
+ {
+ var movie = _moviesService.FindByTmdbId(tmdbId.Value);
+
+ if (movie != null)
+ {
+ moviesResources.AddIfNotNull(MapToResource(movie));
+ }
+ }
+ else
+ {
+ var configLanguage = (Language)_configService.MovieInfoLanguage;
+ var availDelay = _configService.AvailabilityDelay;
+ var movieStats = _movieStatisticsService.MovieStatistics();
+
+ var movieTask = Task.Run(() => _moviesService.GetAllMovies());
+
+ var translations = _movieTranslationService
+ .GetAllTranslationsForLanguage(configLanguage);
+
+ var tdict = translations.ToDictionary(x => x.MovieMetadataId);
+
+ coverFileInfos = _coverMapper.GetCoverFileInfos();
+
+ var movies = movieTask.GetAwaiter().GetResult();
+
+ moviesResources = new List(movies.Count);
+
+ foreach (var movie in movies)
+ {
+ var translation = GetTranslationFromDict(tdict, movie.MovieMetadata, configLanguage);
+ moviesResources.Add(movie.ToResource(availDelay, translation, _qualityUpgradableSpecification));
+ }
+
+ MapCoversToLocal(moviesResources, coverFileInfos);
+ LinkMovieStatistics(moviesResources, movieStats);
+ }
+
+ return moviesResources;
+ }
+
+ protected override MovieResource GetResourceById(int id)
+ {
+ var movie = _moviesService.GetMovie(id);
+
+ return MapToResource(movie);
+ }
+
+ protected MovieResource MapToResource(Movie movie)
+ {
+ if (movie == null)
+ {
+ return null;
+ }
+
+ var availDelay = _configService.AvailabilityDelay;
+
+ var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(movie.MovieMetadataId);
+ var translation = GetMovieTranslation(translations, movie.MovieMetadata, (Language)_configService.MovieInfoLanguage);
+
+ var resource = movie.ToResource(availDelay, translation, _qualityUpgradableSpecification);
+ MapCoversToLocal(resource);
+ FetchAndLinkMovieStatistics(resource);
+
+ return resource;
+ }
+
+ private MovieTranslation GetMovieTranslation(List translations, MovieMetadata movie, Language configLanguage)
+ {
+ if (configLanguage == Language.Original)
+ {
+ return new MovieTranslation
+ {
+ Title = movie.OriginalTitle,
+ Overview = movie.Overview
+ };
+ }
+
+ return translations.FirstOrDefault(t => t.Language == configLanguage && t.MovieMetadataId == movie.Id);
+ }
+
+ private MovieTranslation GetTranslationFromDict(Dictionary translations, MovieMetadata movie, Language configLanguage)
+ {
+ if (configLanguage == Language.Original)
+ {
+ return new MovieTranslation
+ {
+ Title = movie.OriginalTitle,
+ Overview = movie.Overview
+ };
+ }
+
+ translations.TryGetValue(movie.Id, out var translation);
+ return translation;
+ }
+
+ private void FetchAndLinkMovieStatistics(MovieResource resource)
+ {
+ LinkMovieStatistics(resource, _movieStatisticsService.MovieStatistics(resource.Id));
+ }
+
+ private void LinkMovieStatistics(List resources, List seriesStatistics)
+ {
+ foreach (var series in resources)
+ {
+ var stats = seriesStatistics.SingleOrDefault(ss => ss.MovieId == series.Id);
+ if (stats == null)
+ {
+ continue;
+ }
+
+ LinkMovieStatistics(series, stats);
+ }
+ }
+
+ private void LinkMovieStatistics(MovieResource resource, MovieStatistics seriesStatistics)
+ {
+ resource.Statistics = seriesStatistics.ToResource();
+ }
+
+ [RestPostById]
+ public ActionResult AddMovie(MovieResource moviesResource)
+ {
+ var movie = _addMovieService.AddMovie(moviesResource.ToModel());
+
+ return Created(movie.Id);
+ }
+
+ [RestPutById]
+ public ActionResult UpdateMovie(MovieResource moviesResource)
+ {
+ var moveFiles = Request.GetBooleanQueryParameter("moveFiles");
+ var movie = _moviesService.GetMovie(moviesResource.Id);
+
+ if (moveFiles)
+ {
+ var sourcePath = movie.Path;
+ var destinationPath = moviesResource.Path;
+
+ _commandQueueManager.Push(new MoveMovieCommand
+ {
+ MovieId = movie.Id,
+ SourcePath = sourcePath,
+ DestinationPath = destinationPath,
+ Trigger = CommandTrigger.Manual
+ });
+ }
+
+ var model = moviesResource.ToModel(movie);
+
+ var updatedMovie = _moviesService.UpdateMovie(model);
+ var availDelay = _configService.AvailabilityDelay;
+
+ var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(movie.MovieMetadataId);
+ var translation = GetMovieTranslation(translations, movie.MovieMetadata, (Language)_configService.MovieInfoLanguage);
+
+ BroadcastResourceChange(ModelAction.Updated, updatedMovie.ToResource(availDelay, translation, _qualityUpgradableSpecification));
+
+ return Accepted(moviesResource.Id);
+ }
+
+ [RestDeleteById]
+ public void DeleteMovie(int id)
+ {
+ var addExclusion = Request.GetBooleanQueryParameter("addImportExclusion");
+ var deleteFiles = Request.GetBooleanQueryParameter("deleteFiles");
+
+ _moviesService.DeleteMovie(id, deleteFiles, addExclusion);
+ }
+
+ private void MapCoversToLocal(MovieResource movie)
+ {
+ _coverMapper.ConvertToLocalUrls(movie.Id, movie.Images);
+ }
+
+ private void MapCoversToLocal(IEnumerable movies, Dictionary coverFileInfos)
+ {
+ _coverMapper.ConvertToLocalUrls(movies.Select(x => Tuple.Create(x.Id, x.Images.AsEnumerable())), coverFileInfos);
+ }
+
+ [NonAction]
+ public void Handle(MovieFileImportedEvent message)
+ {
+ var availDelay = _configService.AvailabilityDelay;
+ var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(message.ImportedMovie.Movie.MovieMetadataId);
+ var translation = GetMovieTranslation(translations, message.ImportedMovie.Movie.MovieMetadata, (Language)_configService.MovieInfoLanguage);
+ BroadcastResourceChange(ModelAction.Updated, message.ImportedMovie.Movie.ToResource(availDelay, translation, _qualityUpgradableSpecification));
+ }
+
+ [NonAction]
+ public void Handle(MovieFileDeletedEvent message)
+ {
+ if (message.Reason == DeleteMediaFileReason.Upgrade)
+ {
+ return;
+ }
+
+ BroadcastResourceChange(ModelAction.Updated, message.MovieFile.MovieId);
+ }
+
+ [NonAction]
+ public void Handle(MovieUpdatedEvent message)
+ {
+ var availDelay = _configService.AvailabilityDelay;
+ var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(message.Movie.MovieMetadataId);
+ var translation = GetMovieTranslation(translations, message.Movie.MovieMetadata, (Language)_configService.MovieInfoLanguage);
+ BroadcastResourceChange(ModelAction.Updated, message.Movie.ToResource(availDelay, translation, _qualityUpgradableSpecification));
+ }
+
+ [NonAction]
+ public void Handle(MovieEditedEvent message)
+ {
+ var availDelay = _configService.AvailabilityDelay;
+ var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(message.Movie.MovieMetadataId);
+ var translation = GetMovieTranslation(translations, message.Movie.MovieMetadata, (Language)_configService.MovieInfoLanguage);
+ BroadcastResourceChange(ModelAction.Updated, message.Movie.ToResource(availDelay, translation, _qualityUpgradableSpecification));
+ }
+
+ [NonAction]
+ public void Handle(MoviesDeletedEvent message)
+ {
+ foreach (var movie in message.Movies)
+ {
+ BroadcastResourceChange(ModelAction.Deleted, movie.Id);
+ }
+ }
+
+ [NonAction]
+ public void Handle(MovieRenamedEvent message)
+ {
+ var availDelay = _configService.AvailabilityDelay;
+ var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(message.Movie.MovieMetadataId);
+ var translation = GetMovieTranslation(translations, message.Movie.MovieMetadata, (Language)_configService.MovieInfoLanguage);
+ BroadcastResourceChange(ModelAction.Updated, message.Movie.ToResource(availDelay, translation, _qualityUpgradableSpecification));
+ }
+
+ [NonAction]
+ public void Handle(MediaCoversUpdatedEvent message)
+ {
+ if (message.Updated)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.Movie.Id);
+ }
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Movies/MovieEditorController.cs b/src/Radarr.Api.V4/Movies/MovieEditorController.cs
new file mode 100644
index 0000000000..7ec8d82ffb
--- /dev/null
+++ b/src/Radarr.Api.V4/Movies/MovieEditorController.cs
@@ -0,0 +1,100 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.Messaging.Commands;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Commands;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Movies
+{
+ [V4ApiController("movie/editor")]
+ public class MovieEditorController : Controller
+ {
+ private readonly IMovieService _movieService;
+ private readonly IManageCommandQueue _commandQueueManager;
+ private readonly IUpgradableSpecification _upgradableSpecification;
+
+ public MovieEditorController(IMovieService movieService, IManageCommandQueue commandQueueManager, IUpgradableSpecification upgradableSpecification)
+ {
+ _movieService = movieService;
+ _commandQueueManager = commandQueueManager;
+ _upgradableSpecification = upgradableSpecification;
+ }
+
+ [HttpPut]
+ public IActionResult SaveAll([FromBody] MovieEditorResource resource)
+ {
+ var moviesToUpdate = _movieService.GetMovies(resource.MovieIds);
+ var moviesToMove = new List();
+
+ foreach (var movie in moviesToUpdate)
+ {
+ if (resource.Monitored.HasValue)
+ {
+ movie.Monitored = resource.Monitored.Value;
+ }
+
+ if (resource.QualityProfileIds != null)
+ {
+ movie.QualityProfileIds = resource.QualityProfileIds;
+ }
+
+ if (resource.MinimumAvailability.HasValue)
+ {
+ movie.MinimumAvailability = resource.MinimumAvailability.Value;
+ }
+
+ if (resource.RootFolderPath.IsNotNullOrWhiteSpace())
+ {
+ movie.RootFolderPath = resource.RootFolderPath;
+ moviesToMove.Add(new BulkMoveMovie
+ {
+ MovieId = movie.Id,
+ SourcePath = movie.Path
+ });
+ }
+
+ if (resource.Tags != null)
+ {
+ var newTags = resource.Tags;
+ var applyTags = resource.ApplyTags;
+
+ switch (applyTags)
+ {
+ case ApplyTags.Add:
+ newTags.ForEach(t => movie.Tags.Add(t));
+ break;
+ case ApplyTags.Remove:
+ newTags.ForEach(t => movie.Tags.Remove(t));
+ break;
+ case ApplyTags.Replace:
+ movie.Tags = new HashSet(newTags);
+ break;
+ }
+ }
+ }
+
+ if (resource.MoveFiles && moviesToMove.Any())
+ {
+ _commandQueueManager.Push(new BulkMoveMovieCommand
+ {
+ DestinationRootFolder = resource.RootFolderPath,
+ Movies = moviesToMove
+ });
+ }
+
+ return Accepted(_movieService.UpdateMovie(moviesToUpdate, !resource.MoveFiles).ToResource(0, _upgradableSpecification));
+ }
+
+ [HttpDelete]
+ public object DeleteMovies([FromBody] MovieEditorResource resource)
+ {
+ _movieService.DeleteMovies(resource.MovieIds, resource.DeleteFiles, resource.AddImportExclusion);
+
+ return new { };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Movies/MovieEditorResource.cs b/src/Radarr.Api.V4/Movies/MovieEditorResource.cs
new file mode 100644
index 0000000000..79b7452d2b
--- /dev/null
+++ b/src/Radarr.Api.V4/Movies/MovieEditorResource.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using NzbDrone.Core.Movies;
+
+namespace Radarr.Api.V4.Movies
+{
+ public class MovieEditorResource
+ {
+ public List MovieIds { get; set; }
+ public bool? Monitored { get; set; }
+ public List QualityProfileIds { get; set; }
+ public MovieStatusType? MinimumAvailability { get; set; }
+ public string RootFolderPath { get; set; }
+ public List Tags { get; set; }
+ public ApplyTags ApplyTags { get; set; }
+ public bool MoveFiles { get; set; }
+ public bool DeleteFiles { get; set; }
+ public bool AddImportExclusion { get; set; }
+ }
+
+ public enum ApplyTags
+ {
+ Add,
+ Remove,
+ Replace
+ }
+}
diff --git a/src/Radarr.Api.V4/Movies/MovieFolderAsRootFolderValidator.cs b/src/Radarr.Api.V4/Movies/MovieFolderAsRootFolderValidator.cs
new file mode 100644
index 0000000000..ce6807178a
--- /dev/null
+++ b/src/Radarr.Api.V4/Movies/MovieFolderAsRootFolderValidator.cs
@@ -0,0 +1,54 @@
+using System;
+using System.IO;
+using FluentValidation.Validators;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Organizer;
+
+namespace Radarr.Api.V4.Movies
+{
+ public class MovieFolderAsRootFolderValidator : PropertyValidator
+ {
+ private readonly IBuildFileNames _fileNameBuilder;
+
+ public MovieFolderAsRootFolderValidator(IBuildFileNames fileNameBuilder)
+ : base("Root folder path contains movie folder")
+ {
+ _fileNameBuilder = fileNameBuilder;
+ }
+
+ protected override bool IsValid(PropertyValidatorContext context)
+ {
+ if (context.PropertyValue == null)
+ {
+ return true;
+ }
+
+ var movieResource = context.Instance as MovieResource;
+
+ if (movieResource == null)
+ {
+ return true;
+ }
+
+ var rootFolderPath = context.PropertyValue.ToString();
+
+ if (rootFolderPath.IsNullOrWhiteSpace())
+ {
+ return true;
+ }
+
+ var rootFolder = new DirectoryInfo(rootFolderPath).Name;
+ var movie = movieResource.ToModel();
+ var movieFolder = _fileNameBuilder.GetMovieFolder(movie);
+
+ if (movieFolder == rootFolder)
+ {
+ return false;
+ }
+
+ var distance = movieFolder.LevenshteinDistance(rootFolder);
+
+ return distance >= Math.Max(1, movieFolder.Length * 0.2);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Movies/MovieImportController.cs b/src/Radarr.Api.V4/Movies/MovieImportController.cs
new file mode 100644
index 0000000000..1061348d46
--- /dev/null
+++ b/src/Radarr.Api.V4/Movies/MovieImportController.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Movies;
+using Radarr.Http;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Movies
+{
+ [V4ApiController("movie/import")]
+ public class MovieImportController : RestController
+ {
+ private readonly IAddMovieService _addMovieService;
+
+ public MovieImportController(IAddMovieService addMovieService)
+ {
+ _addMovieService = addMovieService;
+ }
+
+ protected override MovieResource GetResourceById(int id)
+ {
+ throw new NotImplementedException();
+ }
+
+ [HttpPost]
+ public object Import([FromBody] List resource)
+ {
+ var newMovies = resource.ToModel();
+
+ return _addMovieService.AddMovies(newMovies).ToResource(0);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Movies/MovieLookupController.cs b/src/Radarr.Api.V4/Movies/MovieLookupController.cs
new file mode 100644
index 0000000000..4031e31ce0
--- /dev/null
+++ b/src/Radarr.Api.V4/Movies/MovieLookupController.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.MetadataSource;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Organizer;
+using Radarr.Http;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Movies
+{
+ [V4ApiController("movie/lookup")]
+ public class MovieLookupController : RestController
+ {
+ private readonly ISearchForNewMovie _searchProxy;
+ private readonly IProvideMovieInfo _movieInfo;
+ private readonly IBuildFileNames _fileNameBuilder;
+ private readonly IMapCoversToLocal _coverMapper;
+ private readonly IConfigService _configService;
+
+ public MovieLookupController(ISearchForNewMovie searchProxy,
+ IProvideMovieInfo movieInfo,
+ IBuildFileNames fileNameBuilder,
+ IMapCoversToLocal coverMapper,
+ IConfigService configService)
+ {
+ _movieInfo = movieInfo;
+ _searchProxy = searchProxy;
+ _fileNameBuilder = fileNameBuilder;
+ _coverMapper = coverMapper;
+ _configService = configService;
+ }
+
+ protected override MovieResource GetResourceById(int id)
+ {
+ throw new NotImplementedException();
+ }
+
+ [HttpGet("tmdb")]
+ public object SearchByTmdbId(int tmdbId)
+ {
+ var availDelay = _configService.AvailabilityDelay;
+ var result = new Movie { MovieMetadata = _movieInfo.GetMovieInfo(tmdbId).Item1 };
+ var translation = result.MovieMetadata.Value.Translations.FirstOrDefault(t => t.Language == (Language)_configService.MovieInfoLanguage);
+ return result.ToResource(availDelay, translation);
+ }
+
+ [HttpGet("imdb")]
+ public object SearchByImdbId(string imdbId)
+ {
+ var result = new Movie { MovieMetadata = _movieInfo.GetMovieByImdbId(imdbId) };
+
+ var availDelay = _configService.AvailabilityDelay;
+ var translation = result.MovieMetadata.Value.Translations.FirstOrDefault(t => t.Language == (Language)_configService.MovieInfoLanguage);
+ return result.ToResource(availDelay, translation);
+ }
+
+ [HttpGet]
+ public object Search([FromQuery] string term)
+ {
+ var searchResults = _searchProxy.SearchForNewMovie(term);
+
+ return MapToResource(searchResults);
+ }
+
+ private IEnumerable MapToResource(IEnumerable movies)
+ {
+ foreach (var currentMovie in movies)
+ {
+ var availDelay = _configService.AvailabilityDelay;
+ var translation = currentMovie.MovieMetadata.Value.Translations.FirstOrDefault(t => t.Language == (Language)_configService.MovieInfoLanguage);
+ var resource = currentMovie.ToResource(availDelay, translation);
+
+ _coverMapper.ConvertToLocalUrls(resource.Id, resource.Images);
+
+ var poster = currentMovie.MovieMetadata.Value.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster);
+ if (poster != null)
+ {
+ resource.RemotePoster = poster.RemoteUrl;
+ }
+
+ resource.Folder = _fileNameBuilder.GetMovieFolder(currentMovie);
+
+ yield return resource;
+ }
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Movies/MovieResource.cs b/src/Radarr.Api.V4/Movies/MovieResource.cs
new file mode 100644
index 0000000000..29d774dbe3
--- /dev/null
+++ b/src/Radarr.Api.V4/Movies/MovieResource.cs
@@ -0,0 +1,220 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Collections;
+using NzbDrone.Core.Movies.Translations;
+using NzbDrone.Core.Parser;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V4.Movies
+{
+ public class MovieResource : RestResource
+ {
+ public MovieResource()
+ {
+ Monitored = true;
+ MinimumAvailability = MovieStatusType.Released;
+ QualityProfileIds = new List();
+ }
+
+ // Todo: Sorters should be done completely on the client
+ // Todo: Is there an easy way to keep IgnoreArticlesWhenSorting in sync between, Series, History, Missing?
+ // Todo: We should get the entire Profile instead of ID and Name separately
+
+ // View Only
+ public string Title { get; set; }
+ public string OriginalTitle { get; set; }
+ public Language OriginalLanguage { get; set; }
+ public List AlternateTitles { get; set; }
+ public int? SecondaryYear { get; set; }
+ public int SecondaryYearSourceId { get; set; }
+ public string SortTitle { get; set; }
+ public MovieStatusType Status { get; set; }
+ public string Overview { get; set; }
+ public DateTime? InCinemas { get; set; }
+ public DateTime? PhysicalRelease { get; set; }
+ public DateTime? DigitalRelease { get; set; }
+ public string PhysicalReleaseNote { get; set; }
+ public List Images { get; set; }
+ public string Website { get; set; }
+
+ // public bool Downloaded { get; set; }
+ public string RemotePoster { get; set; }
+ public int Year { get; set; }
+ public string YouTubeTrailerId { get; set; }
+ public string Studio { get; set; }
+
+ // View & Edit
+ public string Path { get; set; }
+ public List QualityProfileIds { get; set; }
+
+ // Compatabilitiy
+ public int QualityProfileId { get; set; }
+
+ // Editing Only
+ public bool Monitored { get; set; }
+ public MovieStatusType MinimumAvailability { get; set; }
+ public bool IsAvailable { get; set; }
+ public string FolderName { get; set; }
+
+ public int Runtime { get; set; }
+ public string CleanTitle { get; set; }
+ public string ImdbId { get; set; }
+ public int TmdbId { get; set; }
+ public string TitleSlug { get; set; }
+ public string RootFolderPath { get; set; }
+ public string Folder { get; set; }
+ public string Certification { get; set; }
+ public List Genres { get; set; }
+ public HashSet Tags { get; set; }
+ public DateTime Added { get; set; }
+ public AddMovieOptions AddOptions { get; set; }
+ public Ratings Ratings { get; set; }
+ public MovieCollection Collection { get; set; }
+ public float Popularity { get; set; }
+ public MovieStatisticsResource Statistics { get; set; }
+ }
+
+ public static class MovieResourceMapper
+ {
+ public static MovieResource ToResource(this Movie model, int availDelay, MovieTranslation movieTranslation = null, IUpgradableSpecification upgradableSpecification = null)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ var translatedTitle = movieTranslation?.Title ?? model.Title;
+ var translatedOverview = movieTranslation?.Overview ?? model.MovieMetadata.Value.Overview;
+
+ var collection = model.MovieMetadata.Value.CollectionTmdbId > 0 ? new MovieCollection { Title = model.MovieMetadata.Value.CollectionTitle, TmdbId = model.MovieMetadata.Value.CollectionTmdbId } : null;
+
+ return new MovieResource
+ {
+ Id = model.Id,
+ TmdbId = model.TmdbId,
+ Title = translatedTitle,
+ OriginalTitle = model.MovieMetadata.Value.OriginalTitle,
+ OriginalLanguage = model.MovieMetadata.Value.OriginalLanguage,
+ SortTitle = translatedTitle.NormalizeTitle(),
+ InCinemas = model.MovieMetadata.Value.InCinemas,
+ PhysicalRelease = model.MovieMetadata.Value.PhysicalRelease,
+ DigitalRelease = model.MovieMetadata.Value.DigitalRelease,
+
+ Status = model.MovieMetadata.Value.Status,
+ Overview = translatedOverview,
+
+ Images = model.MovieMetadata.Value.Images,
+
+ Year = model.Year,
+ SecondaryYear = model.MovieMetadata.Value.SecondaryYear,
+
+ Path = model.Path,
+ QualityProfileIds = model.QualityProfileIds,
+ QualityProfileId = model.QualityProfileIds.FirstOrDefault(),
+
+ Monitored = model.Monitored,
+ MinimumAvailability = model.MinimumAvailability,
+
+ IsAvailable = model.IsAvailable(availDelay),
+ FolderName = model.FolderName(),
+
+ Runtime = model.MovieMetadata.Value.Runtime,
+ CleanTitle = model.MovieMetadata.Value.CleanTitle,
+ ImdbId = model.ImdbId,
+ TitleSlug = model.MovieMetadata.Value.TmdbId.ToString(),
+ RootFolderPath = model.RootFolderPath,
+ Certification = model.MovieMetadata.Value.Certification,
+ Website = model.MovieMetadata.Value.Website,
+ Genres = model.MovieMetadata.Value.Genres,
+ Tags = model.Tags,
+ Added = model.Added,
+ AddOptions = model.AddOptions,
+ AlternateTitles = model.MovieMetadata.Value.AlternativeTitles.ToResource(),
+ Ratings = model.MovieMetadata.Value.Ratings,
+ YouTubeTrailerId = model.MovieMetadata.Value.YouTubeTrailerId,
+ Studio = model.MovieMetadata.Value.Studio,
+ Collection = collection,
+ Popularity = model.MovieMetadata.Value.Popularity
+ };
+ }
+
+ public static Movie ToModel(this MovieResource resource)
+ {
+ if (resource == null)
+ {
+ return null;
+ }
+
+ var profiles = resource.QualityProfileIds;
+
+ if (resource.QualityProfileIds.Count == 0)
+ {
+ profiles.Add(resource.QualityProfileId);
+ }
+
+ return new Movie
+ {
+ Id = resource.Id,
+
+ MovieMetadata = new MovieMetadata
+ {
+ TmdbId = resource.TmdbId,
+ Title = resource.Title,
+ Genres = resource.Genres,
+ Images = resource.Images,
+ OriginalTitle = resource.OriginalTitle,
+ SortTitle = resource.SortTitle,
+ InCinemas = resource.InCinemas,
+ PhysicalRelease = resource.PhysicalRelease,
+ Year = resource.Year,
+ SecondaryYear = resource.SecondaryYear,
+ Overview = resource.Overview,
+ Certification = resource.Certification,
+ Website = resource.Website,
+ Ratings = resource.Ratings,
+ YouTubeTrailerId = resource.YouTubeTrailerId,
+ Studio = resource.Studio,
+ Runtime = resource.Runtime,
+ CleanTitle = resource.CleanTitle,
+ ImdbId = resource.ImdbId,
+ },
+
+ Path = resource.Path,
+ QualityProfileIds = resource.QualityProfileIds,
+
+ Monitored = resource.Monitored,
+ MinimumAvailability = resource.MinimumAvailability,
+
+ RootFolderPath = resource.RootFolderPath,
+
+ Tags = resource.Tags,
+ Added = resource.Added,
+ AddOptions = resource.AddOptions
+ };
+ }
+
+ public static Movie ToModel(this MovieResource resource, Movie movie)
+ {
+ var updatedmovie = resource.ToModel();
+
+ movie.ApplyChanges(updatedmovie);
+
+ return movie;
+ }
+
+ public static List ToResource(this IEnumerable movies, int availDelay, IUpgradableSpecification upgradableSpecification = null)
+ {
+ return movies.Select(x => ToResource(x, availDelay, null, upgradableSpecification)).ToList();
+ }
+
+ public static List ToModel(this IEnumerable resources)
+ {
+ return resources.Select(ToModel).ToList();
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Movies/MovieStatisticsResource.cs b/src/Radarr.Api.V4/Movies/MovieStatisticsResource.cs
new file mode 100644
index 0000000000..4df4045b7f
--- /dev/null
+++ b/src/Radarr.Api.V4/Movies/MovieStatisticsResource.cs
@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+using NzbDrone.Core.MovieStats;
+
+namespace Radarr.Api.V4.Movies
+{
+ public class MovieStatisticsResource
+ {
+ public int MovieFileCount { get; set; }
+ public long SizeOnDisk { get; set; }
+ public List ReleaseGroups { get; set; }
+ }
+
+ public static class SeriesStatisticsResourceMapper
+ {
+ public static MovieStatisticsResource ToResource(this MovieStatistics model)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new MovieStatisticsResource
+ {
+ MovieFileCount = model.MovieFileCount,
+ SizeOnDisk = model.SizeOnDisk,
+ ReleaseGroups = model.ReleaseGroups
+ };
+ }
+ }
+}
diff --git a/src/Radarr.Api.V4/Movies/RenameMovieController.cs b/src/Radarr.Api.V4/Movies/RenameMovieController.cs
new file mode 100644
index 0000000000..9165b6fa99
--- /dev/null
+++ b/src/Radarr.Api.V4/Movies/RenameMovieController.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.MediaFiles;
+using Radarr.Http;
+
+namespace Radarr.Api.V4.Movies
+{
+ [V4ApiController("rename")]
+ public class RenameMovieController : Controller
+ {
+ private readonly IRenameMovieFileService _renameMovieFileService;
+
+ public RenameMovieController(IRenameMovieFileService renameMovieFileService)
+ {
+ _renameMovieFileService = renameMovieFileService;
+ }
+
+ [HttpGet]
+ public List