From e7f2f44dce6190e1a29d350d01e2797b33686adc Mon Sep 17 00:00:00 2001 From: Will Burland Date: Thu, 8 Jan 2026 19:45:33 +0000 Subject: [PATCH] New: Add "Download Status" filter option for custom filters --- .../DownloadStatusFilterBuilderRowValue.js | 53 +++++++++++++++++++ .../Filter/Builder/FilterBuilderRow.js | 4 ++ .../Helpers/Props/filterBuilderValueTypes.js | 1 + .../src/Movie/Index/Table/MovieIndexRow.tsx | 9 ++++ frontend/src/Movie/Movie.ts | 3 ++ .../src/Store/Actions/movieIndexActions.js | 6 +++ src/NzbDrone.Core/Localization/Core/en.json | 1 + src/Radarr.Api.V3/Movies/MovieController.cs | 10 ++-- .../Movies/MovieControllerWithSignalR.cs | 35 ++++++++++-- .../Movies/MovieEditorController.cs | 8 ++- src/Radarr.Api.V3/Movies/MovieResource.cs | 40 ++++++++++++-- 11 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 frontend/src/Components/Filter/Builder/DownloadStatusFilterBuilderRowValue.js diff --git a/frontend/src/Components/Filter/Builder/DownloadStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/DownloadStatusFilterBuilderRowValue.js new file mode 100644 index 0000000000..d97eb3fa13 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/DownloadStatusFilterBuilderRowValue.js @@ -0,0 +1,53 @@ +import React from 'react'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { + id: 'downloaded', + get name() { + return translate('DownloadedAndMonitored'); + } + }, + { + id: 'unmonitored', + get name() { + return translate('DownloadedButNotMonitored'); + } + }, + { + id: 'missingMonitored', + get name() { + return translate('MissingMonitoredAndConsideredAvailable'); + } + }, + { + id: 'missingUnmonitored', + get name() { + return translate('MissingNotMonitored'); + } + }, + { + id: 'queue', + get name() { + return translate('Queued'); + } + }, + { + id: 'continuing', + get name() { + return translate('Unreleased'); + } + }, +]; + +function DownloadStatusFilterBuilderRowValue(props) { + return ( + + ); +} + +export default DownloadStatusFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js index 9fd450d2ef..cc60be009b 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -13,6 +13,7 @@ import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValu import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue'; import MinimumAvailabilityFilterBuilderRowValue from './MinimumAvailabilityFilterBuilderRowValue'; import MovieFilterBuilderRowValue from './MovieFilterBuilderRowValue'; +import DownloadStatusFilterBuilderRowValue from './DownloadStatusFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue'; @@ -87,6 +88,9 @@ function getRowValueConnector(selectedFilterBuilderProp) { case filterBuilderValueTypes.MOVIE: return MovieFilterBuilderRowValue; + case filterBuilderValueTypes.DOWNLOAD_STATUS: + return DownloadStatusFilterBuilderRowValue; + case filterBuilderValueTypes.RELEASE_STATUS: return ReleaseStatusFilterBuilderRowValue; diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js index a27b11dfc3..d1b94f45b7 100644 --- a/frontend/src/Helpers/Props/filterBuilderValueTypes.js +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -10,6 +10,7 @@ export const QUALITY = 'quality'; export const QUALITY_PROFILE = 'qualityProfile'; export const QUEUE_STATUS = 'queueStatus'; export const MOVIE = 'movie'; +export const DOWNLOAD_STATUS = 'downloadStatus'; export const RELEASE_STATUS = 'releaseStatus'; export const MINIMUM_AVAILABILITY = 'minimumAvailability'; export const TAG = 'tag'; diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.tsx b/frontend/src/Movie/Index/Table/MovieIndexRow.tsx index d546a8c511..195c746672 100644 --- a/frontend/src/Movie/Index/Table/MovieIndexRow.tsx +++ b/frontend/src/Movie/Index/Table/MovieIndexRow.tsx @@ -70,6 +70,7 @@ function MovieIndexRow(props: MovieIndexRowProps) { releaseDate, runtime, minimumAvailability, + downloadStatus, path, genres = [], keywords = [], @@ -314,6 +315,14 @@ function MovieIndexRow(props: MovieIndexRowProps) { ); } + if (name === 'downloadStatus') { + return ( + + {translate(firstCharToUpper(downloadStatus))} + + ); + } + if (name === 'path') { return ( diff --git a/frontend/src/Movie/Movie.ts b/frontend/src/Movie/Movie.ts index b7b4ee6b28..da7a4630a7 100644 --- a/frontend/src/Movie/Movie.ts +++ b/frontend/src/Movie/Movie.ts @@ -13,6 +13,8 @@ export type MovieStatus = export type MovieAvailability = 'announced' | 'inCinemas' | 'released'; +export type DownloadStatus = 'downloaded' | 'unmonitored' | 'missingMonitored' | 'missingUnmonitored' | 'queue' | 'continuing'; + export type CoverType = 'poster' | 'fanart' | 'headshot'; export interface Image { @@ -80,6 +82,7 @@ interface Movie extends ModelBase { rootFolderPath: string; runtime: number; minimumAvailability: MovieAvailability; + downloadStatus: DownloadStatus; path: string; genres: string[]; keywords: string[]; diff --git a/frontend/src/Store/Actions/movieIndexActions.js b/frontend/src/Store/Actions/movieIndexActions.js index e36bee132a..3142c75bd3 100644 --- a/frontend/src/Store/Actions/movieIndexActions.js +++ b/frontend/src/Store/Actions/movieIndexActions.js @@ -440,6 +440,12 @@ export const defaultState = { type: filterBuilderTypes.DATE, valueType: filterBuilderValueTypes.DATE }, + { + name: 'downloadStatus', + label: () => translate('DownloadStatus'), + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.DOWNLOAD_STATUS + }, { name: 'physicalRelease', label: () => translate('PhysicalRelease'), diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 23a3b71ab8..807955787a 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -576,6 +576,7 @@ "DownloadPropersAndRepacksHelpTextCustomFormat": "Use 'Do not Prefer' to sort by custom format score over Propers/Repacks", "DownloadPropersAndRepacksHelpTextWarning": "Use custom formats for automatic upgrades to Propers/Repacks", "DownloadStationStatusExtracting": "Extracting: {progress}%", + "DownloadStatus": "Download Status", "DownloadWarning": "Download warning: {warningMessage}", "Downloaded": "Downloaded", "DownloadedAndMonitored": "Downloaded (Monitored)", diff --git a/src/Radarr.Api.V3/Movies/MovieController.cs b/src/Radarr.Api.V3/Movies/MovieController.cs index 7bc3ea3c12..36f684be2b 100644 --- a/src/Radarr.Api.V3/Movies/MovieController.cs +++ b/src/Radarr.Api.V3/Movies/MovieController.cs @@ -23,6 +23,7 @@ using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; using NzbDrone.SignalR; +using NzbDrone.Core.Queue; using Radarr.Http; using Radarr.Http.REST; using Radarr.Http.REST.Attributes; @@ -47,6 +48,7 @@ public class MovieController : RestControllerWithSignalR, private readonly IRootFolderService _rootFolderService; private readonly IUpgradableSpecification _qualityUpgradableSpecification; private readonly IConfigService _configService; + private readonly IQueueService _queueService; public MovieController(IBroadcastSignalRMessage signalRBroadcaster, IMovieService moviesService, @@ -58,6 +60,7 @@ public MovieController(IBroadcastSignalRMessage signalRBroadcaster, IRootFolderService rootFolderService, IUpgradableSpecification qualityUpgradableSpecification, IConfigService configService, + IQueueService queueService, RootFolderValidator rootFolderValidator, MappedNetworkDriveValidator mappedNetworkDriveValidator, MoviePathValidator moviesPathValidator, @@ -78,6 +81,7 @@ public MovieController(IBroadcastSignalRMessage signalRBroadcaster, _configService = configService; _coverMapper = coverMapper; _commandQueueManager = commandQueueManager; + _queueService = queueService; _rootFolderService = rootFolderService; SharedValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop) @@ -150,8 +154,8 @@ public List AllMovie(int? tmdbId, bool excludeLocalCovers = false foreach (var movie in movies) { - var translation = GetTranslationFromDict(tdict, movie.MovieMetadata, translationLanguage); - moviesResources.Add(movie.ToResource(availDelay, translation, _qualityUpgradableSpecification)); + var translation = GetTranslationFromDict(tdict, movie.MovieMetadata, translationLanguage); + moviesResources.Add(movie.ToResource(availDelay, translation, _qualityUpgradableSpecification, null, _queueService)); } if (!excludeLocalCovers) @@ -191,7 +195,7 @@ protected MovieResource MapToResource(Movie movie, Language translationLanguage var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(movie.MovieMetadataId); var translation = GetMovieTranslation(translations, movie.MovieMetadata, translationLanguage); - var resource = movie.ToResource(availDelay, translation, _qualityUpgradableSpecification); + var resource = movie.ToResource(availDelay, translation, _qualityUpgradableSpecification, null, _queueService); MapCoversToLocal(resource); FetchAndLinkMovieStatistics(resource); diff --git a/src/Radarr.Api.V3/Movies/MovieControllerWithSignalR.cs b/src/Radarr.Api.V3/Movies/MovieControllerWithSignalR.cs index 54c7e55c45..51920225b4 100644 --- a/src/Radarr.Api.V3/Movies/MovieControllerWithSignalR.cs +++ b/src/Radarr.Api.V3/Movies/MovieControllerWithSignalR.cs @@ -6,6 +6,7 @@ using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Download; +using NzbDrone.Core.Queue; using NzbDrone.Core.Languages; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaFiles.Events; @@ -30,6 +31,7 @@ public abstract class MovieControllerWithSignalR : RestControllerWithSignalR MapToResource(List movies) var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(movie.MovieMetadataId); var translation = GetMovieTranslation(translations, movie.MovieMetadata, language); - var resource = movie.ToResource(availDelay, translation, _upgradableSpecification, _formatCalculator); + var resource = movie.ToResource(availDelay, translation, _upgradableSpecification, _formatCalculator, _queueService); FetchAndLinkMovieStatistics(resource); resources.Add(resource); diff --git a/src/Radarr.Api.V3/Movies/MovieEditorController.cs b/src/Radarr.Api.V3/Movies/MovieEditorController.cs index 902c224519..3fe3fec48c 100644 --- a/src/Radarr.Api.V3/Movies/MovieEditorController.cs +++ b/src/Radarr.Api.V3/Movies/MovieEditorController.cs @@ -11,6 +11,7 @@ using NzbDrone.Core.Movies; using NzbDrone.Core.Movies.Commands; using NzbDrone.Core.Movies.Translations; +using NzbDrone.Core.Queue; using Radarr.Http; namespace Radarr.Api.V3.Movies @@ -25,6 +26,7 @@ public class MovieEditorController : Controller private readonly IManageCommandQueue _commandQueueManager; private readonly MovieEditorValidator _movieEditorValidator; private readonly IUpgradableSpecification _upgradableSpecification; + private readonly IQueueService _queueService; public MovieEditorController(IMovieService movieService, IMovieTranslationService movieTranslationService, @@ -32,7 +34,8 @@ public MovieEditorController(IMovieService movieService, IConfigService configService, IManageCommandQueue commandQueueManager, MovieEditorValidator movieEditorValidator, - IUpgradableSpecification upgradableSpecification) + IUpgradableSpecification upgradableSpecification, + IQueueService queueService) { _movieService = movieService; _movieTranslationService = movieTranslationService; @@ -41,6 +44,7 @@ public MovieEditorController(IMovieService movieService, _commandQueueManager = commandQueueManager; _movieEditorValidator = movieEditorValidator; _upgradableSpecification = upgradableSpecification; + _queueService = queueService; } [HttpPut] @@ -125,7 +129,7 @@ public IActionResult SaveAll([FromBody] MovieEditorResource resource) foreach (var movie in updatedMovies) { var translation = GetTranslationFromDict(tdict, movie.MovieMetadata, configLanguage); - var movieResource = movie.ToResource(availabilityDelay, translation, _upgradableSpecification); + var movieResource = movie.ToResource(availabilityDelay, translation, _upgradableSpecification, null, _queueService); MapCoversToLocal(movieResource); diff --git a/src/Radarr.Api.V3/Movies/MovieResource.cs b/src/Radarr.Api.V3/Movies/MovieResource.cs index 9a9829eadd..b34873ed05 100644 --- a/src/Radarr.Api.V3/Movies/MovieResource.cs +++ b/src/Radarr.Api.V3/Movies/MovieResource.cs @@ -9,6 +9,7 @@ using NzbDrone.Core.MediaCover; using NzbDrone.Core.Movies; using NzbDrone.Core.Movies.Translations; +using NzbDrone.Core.Queue; using Radarr.Api.V3.MovieFiles; using Radarr.Http.REST; using Swashbuckle.AspNetCore.Annotations; @@ -60,6 +61,9 @@ public MovieResource() public bool? HasFile { get; set; } public int MovieFileId { get; set; } + // Computed for frontend display/filtering + public string DownloadStatus { get; set; } + // Editing Only public bool Monitored { get; set; } public MovieStatusType MinimumAvailability { get; set; } @@ -99,7 +103,7 @@ public MovieResource() public static class MovieResourceMapper { - public static MovieResource ToResource(this Movie model, int availDelay, MovieTranslation movieTranslation = null, IUpgradableSpecification upgradableSpecification = null, ICustomFormatCalculationService formatCalculationService = null) + public static MovieResource ToResource(this Movie model, int availDelay, MovieTranslation movieTranslation = null, IUpgradableSpecification upgradableSpecification = null, ICustomFormatCalculationService formatCalculationService = null, IQueueService queueService = null) { if (model == null) { @@ -113,6 +117,35 @@ public static MovieResource ToResource(this Movie model, int availDelay, MovieTr var collection = model.MovieMetadata.Value.CollectionTmdbId > 0 ? new MovieCollectionResource { Title = model.MovieMetadata.Value.CollectionTitle, TmdbId = model.MovieMetadata.Value.CollectionTmdbId } : null; + var hasMovieFile = movieFile != null; + var isAvailable = model.IsAvailable(availDelay); + + // detect if this movie has an active queue entry + var isQueued = queueService != null && queueService.GetQueue().Any(q => q.Movie != null && q.Movie.Id == model.Id); + + string downloadStatus; + + if (isQueued) + { + downloadStatus = "queue"; + } + else if (hasMovieFile) + { + downloadStatus = model.Monitored ? "downloaded" : "unmonitored"; + } + else if (isAvailable && !model.Monitored) + { + downloadStatus = "missingUnmonitored"; + } + else if (isAvailable) + { + downloadStatus = "missingMonitored"; + } + else + { + downloadStatus = "continuing"; + } + return new MovieResource { Id = model.Id, @@ -160,6 +193,7 @@ public static MovieResource ToResource(this Movie model, int availDelay, MovieTr AlternateTitles = model.MovieMetadata.Value.AlternativeTitles.ToResource(), Ratings = model.MovieMetadata.Value.Ratings, MovieFile = movieFile, + DownloadStatus = downloadStatus, YouTubeTrailerId = model.MovieMetadata.Value.YouTubeTrailerId, Studio = model.MovieMetadata.Value.Studio, Collection = collection, @@ -225,9 +259,9 @@ public static Movie ToModel(this MovieResource resource, Movie movie) return movie; } - public static List ToResource(this IEnumerable movies, int availDelay, IUpgradableSpecification upgradableSpecification = null, ICustomFormatCalculationService formatCalculationService = null) + public static List ToResource(this IEnumerable movies, int availDelay, IUpgradableSpecification upgradableSpecification = null, ICustomFormatCalculationService formatCalculationService = null, IQueueService queueService = null) { - return movies.Select(x => ToResource(x, availDelay, null, upgradableSpecification, formatCalculationService)).ToList(); + return movies.Select(x => ToResource(x, availDelay, null, upgradableSpecification, formatCalculationService, queueService)).ToList(); } public static List ToModel(this IEnumerable resources)