From 8da611ea58df2fcde44de326344319d8922795b0 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 20 Dec 2025 21:39:56 -0800 Subject: [PATCH] Add v5 manual import endpoints --- .../ManualImport/ManualImportController.cs | 82 ++++++++++++++++ .../ManualImportReprocessResource.cs | 26 +++++ .../ManualImport/ManualImportResource.cs | 95 +++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 src/Sonarr.Api.V5/ManualImport/ManualImportController.cs create mode 100644 src/Sonarr.Api.V5/ManualImport/ManualImportReprocessResource.cs create mode 100644 src/Sonarr.Api.V5/ManualImport/ManualImportResource.cs diff --git a/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs b/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs new file mode 100644 index 000000000..e3c54b429 --- /dev/null +++ b/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Languages; +using NzbDrone.Core.MediaFiles.EpisodeImport.Manual; +using NzbDrone.Core.Qualities; +using Sonarr.Http; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.ManualImport; + +[V5ApiController] +public class ManualImportController : Controller +{ + private readonly IManualImportService _manualImportService; + + public ManualImportController(IManualImportService manualImportService) + { + _manualImportService = manualImportService; + } + + [HttpGet] + [Produces("application/json")] + public List GetMediaFiles(string? folder, string? downloadId, int? seriesId, int? seasonNumber, bool filterExistingFiles = true) + { + if (seriesId.HasValue && downloadId.IsNullOrWhiteSpace()) + { + return _manualImportService.GetMediaFiles(seriesId.Value, seasonNumber).ToResource().Select(AddQualityWeight).ToList(); + } + + return _manualImportService.GetMediaFiles(folder, downloadId, seriesId, filterExistingFiles).ToResource().Select(AddQualityWeight).ToList(); + } + + [HttpPost] + [Consumes("application/json")] + public List ReprocessItems([FromBody] List items) + { + if (items is { Count: 0 }) + { + throw new BadRequestException("items must be provided"); + } + + var updatedItems = new List(); + + foreach (var item in items) + { + var processedItem = _manualImportService.ReprocessItem(item.Path, item.DownloadId, item.SeriesId, item.SeasonNumber, item.EpisodeIds ?? new List(), item.ReleaseGroup, item.Quality, item.Languages, item.IndexerFlags, item.ReleaseType); + + // Only use the processed item's languages, quality, and release group if the user hasn't specified them. + // Languages won't be returned when reprocessing if the season/episode isn't filled in yet and we don't want to return no languages to the client. + if (processedItem.Languages.Empty() || item.Languages.Count > 1 || (item.Languages.SingleOrDefault() ?? Language.Unknown) == Language.Unknown) + { + processedItem.Languages = item.Languages; + } + + if (item.Quality?.Quality != Quality.Unknown) + { + processedItem.Quality = item.Quality; + } + + if (item.ReleaseGroup.IsNotNullOrWhiteSpace()) + { + processedItem.ReleaseGroup = item.ReleaseGroup; + } + + updatedItems.Add(processedItem); + } + + return updatedItems.ToResource(); + } + + 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/Sonarr.Api.V5/ManualImport/ManualImportReprocessResource.cs b/src/Sonarr.Api.V5/ManualImport/ManualImportReprocessResource.cs new file mode 100644 index 000000000..bd5e3a587 --- /dev/null +++ b/src/Sonarr.Api.V5/ManualImport/ManualImportReprocessResource.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.Languages; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using Sonarr.Api.V5.CustomFormats; +using Sonarr.Api.V5.Episodes; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.ManualImport; + +public class ManualImportReprocessResource : RestResource +{ + public string? Path { get; set; } + public int SeriesId { get; set; } + public int? SeasonNumber { get; set; } + public List Episodes { get; set; } = []; + public List? EpisodeIds { get; set; } + public QualityModel? Quality { get; set; } + public List Languages { get; set; } = []; + public string? ReleaseGroup { get; set; } + public string? DownloadId { get; set; } + public List CustomFormats { get; set; } = []; + public int CustomFormatScore { get; set; } + public int IndexerFlags { get; set; } + public ReleaseType ReleaseType { get; set; } + public IEnumerable Rejections { get; set; } = []; +} diff --git a/src/Sonarr.Api.V5/ManualImport/ManualImportResource.cs b/src/Sonarr.Api.V5/ManualImport/ManualImportResource.cs new file mode 100644 index 000000000..ec1483755 --- /dev/null +++ b/src/Sonarr.Api.V5/ManualImport/ManualImportResource.cs @@ -0,0 +1,95 @@ +using NzbDrone.Common.Crypto; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Languages; +using NzbDrone.Core.MediaFiles.EpisodeImport; +using NzbDrone.Core.MediaFiles.EpisodeImport.Manual; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using Sonarr.Api.V5.CustomFormats; +using Sonarr.Api.V5.Episodes; +using Sonarr.Api.V5.Series; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.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 SeriesResource? Series { get; set; } + public int? SeasonNumber { get; set; } + public List Episodes { get; set; } = []; + public int? EpisodeFileId { get; set; } + public string? ReleaseGroup { get; set; } + public QualityModel? Quality { get; set; } + public List Languages { get; set; } = []; + public int QualityWeight { get; set; } + public string? DownloadId { get; set; } + public List CustomFormats { get; set; } = []; + public int CustomFormatScore { get; set; } + public int IndexerFlags { get; set; } + public ReleaseType ReleaseType { get; set; } + public IEnumerable Rejections { get; set; } = []; +} + +public static class ManualImportResourceMapper +{ + public static ManualImportResource ToResource(this ManualImportItem model) + { + var customFormats = model.CustomFormats; + var customFormatScore = model.Series?.QualityProfile?.Value?.CalculateCustomFormatScore(customFormats) ?? 0; + + return new ManualImportResource + { + Id = HashConverter.GetHashInt31(model.Path), + Path = model.Path, + RelativePath = model.RelativePath, + FolderName = model.FolderName, + Name = model.Name, + Size = model.Size, + Series = model.Series?.ToResource(), + SeasonNumber = model.SeasonNumber, + Episodes = model.Episodes.ToResource(), + EpisodeFileId = model.EpisodeFileId, + ReleaseGroup = model.ReleaseGroup, + Quality = model.Quality, + Languages = model.Languages, + CustomFormats = customFormats.ToResource(false), + CustomFormatScore = customFormatScore, + + // QualityWeight + DownloadId = model.DownloadId, + IndexerFlags = model.IndexerFlags, + ReleaseType = model.ReleaseType, + Rejections = model.Rejections.Select(r => r.ToResource()) + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } +} + +public class ImportRejectionResource +{ + public ImportRejectionReason Reason { get; set; } + public string? Message { get; set; } + public RejectionType Type { get; set; } +} + +public static class ImportRejectionResourceMapper +{ + public static ImportRejectionResource ToResource(this ImportRejection rejection) + { + return new ImportRejectionResource + { + Reason = rejection.Reason, + Message = rejection.Message, + Type = rejection.Type + }; + } +}