From 9c61a5c28674c2d642f0d7b1f6a41952f5fb56eb Mon Sep 17 00:00:00 2001 From: Weblate Date: Thu, 12 Feb 2026 14:26:17 +0000 Subject: [PATCH 01/13] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: Klein Moretti Co-authored-by: ole brum Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/ Translation: Servarr/Sonarr --- src/NzbDrone.Core/Localization/Core/fr.json | 10 +++++++++- src/NzbDrone.Core/Localization/Core/nb_NO.json | 9 ++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index 6ae0db020..6ffeb2426 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -304,7 +304,7 @@ "CustomFormatsSpecificationReleaseGroup": "Groupe de versions", "CustomFormatsSpecificationResolution": "Résolution", "CustomFormatsSpecificationSource": "Source", - "Cutoff": "Seuil", + "Cutoff": "Limite", "CutoffNotMet": "Seuil non atteint", "CutoffUnmet": "Seuil non atteint", "CutoffUnmetLoadError": "Erreur lors du chargement des éléments dont le seuil n'est pas atteint", @@ -843,6 +843,8 @@ "Ignored": "Ignoré", "IgnoredAddresses": "Adresses ignorées", "ImageBanner": "bannière", + "ImageFanart": "fanart", + "ImagePoster": "affiche", "ImageSeason": "saison", "Images": "Images", "ImdbId": "IMDb ID", @@ -991,6 +993,7 @@ "IncludeCustomFormatWhenRenaming": "Inclure un format personnalisé lors du changement de nom", "IncludeCustomFormatWhenRenamingHelpText": "Inclure dans le format de renommage {Formats personnalisés}", "IncludeHealthWarnings": "Inclure les avertissements de santé", + "IncludeSpecials": "Inclure les offres spéciales", "IncludeUnmonitored": "Inclure les non surveillés", "Indexer": "Indexeur", "IndexerDownloadClientHealthCheckMessage": "Indexeurs avec des clients de téléchargement invalides : {indexerNames}.", @@ -1092,6 +1095,7 @@ "InstanceNameHelpText": "Nom de l'instance dans l'onglet et pour le nom de l'application Syslog", "InteractiveImport": "Importation interactive", "InteractiveImportLoadError": "Impossible de charger les éléments d'importation manuelle", + "InteractiveImportMultipleQueueItems": "Éléments de file d'attente multiples", "InteractiveImportNoEpisode": "Un ou plusieurs épisodes doivent être choisis pour chaque fichier sélectionné", "InteractiveImportNoFilesFound": "Aucun fichier vidéo n'a été trouvé dans le dossier sélectionné", "InteractiveImportNoImportMode": "Un mode d'importation doit être sélectionné", @@ -1194,9 +1198,11 @@ "MaximumSizeHelpText": "Taille maximale en Mo pour qu'une version soit récupérée. Réglez sur zéro pour une taille illimitée", "Mechanism": "Mécanisme", "MediaInfo": "Informations médias", + "MediaInfoAudioStreamHeader": "Flux audio #{number}", "MediaInfoFootNote": "MediaInfo Full/AudioLanguages/SubtitleLanguages supporte un suffixe `:EN+DE` vous permettant de filtrer les langues incluses dans le nom de fichier. Utilisez `-DE` pour exclure des langues spécifiques. En ajoutant `+` (par exemple `:EN+`), vous obtiendrez `[EN]`/`[EN+--]`/`[--]` en fonction des langues exclues. Par exemple `{MediaInfo Full:EN+DE}`.", "MediaInfoFootNote2": "MediaInfo AudioLanguages exclue l’anglais s’il s’agit de la seule langue. Utiliser MediaInfo AudioLanguagesAll pour inclure ceux seulement en anglais", "MediaInfoForced": "Forcé", + "MediaInfoHearingImpaired": "Malentendant", "MediaInfoSubtitlesHeader": "Sous-titres", "MediaManagement": "Gestion des médias", "MediaManagementSettings": "Paramètres de gestion des médias", @@ -2076,6 +2082,7 @@ "TheTvdb": "TheTVDB", "Theme": "Thème", "ThemeHelpText": "Modifiez le thème de l'interface utilisateur de l'application, le thème « Auto » utilisera le thème de votre système d'exploitation pour définir le mode clair ou sombre. Inspiré par Theme.Park", + "Threshold": "Seuil", "Time": "Heure", "TimeFormat": "Format de l'heure", "TimeLeft": "Temps restant", @@ -2134,6 +2141,7 @@ "Unknown": "Inconnu", "UnknownDownloadState": "État de téléchargement inconnu : {state}", "UnknownEventTooltip": "Événement inconnu", + "UnknownSeriesItems": "Éléments de la série inconnus", "Unlimited": "Illimité", "UnmappedFilesOnly": "Fichiers non mappés uniquement", "UnmappedFolders": "Dossiers non mappés", diff --git a/src/NzbDrone.Core/Localization/Core/nb_NO.json b/src/NzbDrone.Core/Localization/Core/nb_NO.json index 37d44653d..cc9a82136 100644 --- a/src/NzbDrone.Core/Localization/Core/nb_NO.json +++ b/src/NzbDrone.Core/Localization/Core/nb_NO.json @@ -13,7 +13,7 @@ "AddConditionError": "Ikke mulig å legge til ny betingelse, vennligst prøv igjen", "AddConditionImplementation": "Legg til betingelse - {implementationName}", "AddConnection": "Legg til tilkobling", - "AddConnectionImplementation": "Legg til tilkobling - {implementationName}", + "AddConnectionImplementation": "Legg til betingelse - {implementationName}", "AddCustomFilter": "Legg til eget filter", "AddCustomFormat": "Nytt Egendefinert format", "AddCustomFormatError": "Kunne ikke legge til nytt egendefinert format, vennligst prøv på nytt.", @@ -52,8 +52,15 @@ "Age": "Alder", "Agenda": "Agenda", "AllTitles": "Alle titler", + "AnalyseVideoFilesHelpText": "Trekke ut informasjon som oppløsning, kjøretid og kodek informasjon fra filer. Dette forutsetter att {appName}leser deler av filen. dette kan forutsake høy disk eller nettverks aktivitet når filer skannes.", "ApiKeyValidationHealthCheckMessage": "Vennligst oppdater din API-nøkkel til å være minst {length} tegn lang. Du kan gjøre dette via innstillinger eller konfigurasjonsfilen", + "AppDataDirectory": "AppData -katalog", + "AppUpdated": "{appName} Oppdatert", + "ApplicationUrlHelpText": "Denne applikasjonens eksterne URL inkludert http(s)://, port og URL base", "ApplyChanges": "Bekreft endringer", + "AudioLanguages": "Flerspråklig", + "AuthenticationMethodHelpTextWarning": "Vennligst velg en valid autentiserings metode.", + "AuthenticationRequired": "Verefisering påkrevd", "AutomaticAdd": "Legg til automatisk", "CalendarOptions": "Kalenderinnstillinger", "ClearBlocklistMessageText": "Er du sikker på at du vil fjerne alle elementer fra blokkeringslisten?", From 49f6117d549e34a61accf50c761c65e5fcf80a5f Mon Sep 17 00:00:00 2001 From: Sonarr Date: Mon, 16 Feb 2026 00:26:04 +0000 Subject: [PATCH 02/13] Automated API Docs update ignore-downstream --- src/Sonarr.Api.V5/openapi.json | 538 +++++++++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) diff --git a/src/Sonarr.Api.V5/openapi.json b/src/Sonarr.Api.V5/openapi.json index c421af9d5..c2e0b558c 100644 --- a/src/Sonarr.Api.V5/openapi.json +++ b/src/Sonarr.Api.V5/openapi.json @@ -2222,6 +2222,351 @@ } } }, + "/api/v5/settings/mediamanagement": { + "get": { + "tags": [ + "MediaManagementSettings" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaManagementSettingsResource" + } + } + } + } + } + } + }, + "/api/v5/settings/mediamanagement/{id}": { + "put": { + "tags": [ + "MediaManagementSettings" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaManagementSettingsResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MediaManagementSettingsResource" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaManagementSettingsResource" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MediaManagementSettingsResource" + } + } + } + } + } + }, + "get": { + "tags": [ + "MediaManagementSettings" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaManagementSettingsResource" + } + } + } + } + } + } + }, + "/api/v5/metadata": { + "get": { + "tags": [ + "Metadata" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetadataResource" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Metadata" + ], + "parameters": [ + { + "name": "forceSave", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataResource" + } + } + } + } + } + } + }, + "/api/v5/metadata/{id}": { + "put": { + "tags": [ + "Metadata" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "forceSave", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataResource" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Metadata" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "Metadata" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataResource" + } + } + } + } + } + } + }, + "/api/v5/metadata/schema": { + "get": { + "tags": [ + "Metadata" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetadataResource" + } + } + } + } + } + } + } + }, + "/api/v5/metadata/test": { + "post": { + "tags": [ + "Metadata" + ], + "parameters": [ + { + "name": "forceTest", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v5/metadata/testall": { + "post": { + "tags": [ + "Metadata" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v5/metadata/action/{name}": { + "post": { + "tags": [ + "Metadata" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/v5/wanted/missing": { "get": { "tags": [ @@ -6157,6 +6502,14 @@ ], "type": "string" }, + "EpisodeTitleRequiredType": { + "enum": [ + "always", + "bulkSeasonReleases", + "never" + ], + "type": "string" + }, "EpisodesMonitoredResource": { "required": [ "episodeIds" @@ -6250,6 +6603,14 @@ }, "additionalProperties": false }, + "FileDateType": { + "enum": [ + "none", + "localAirDate", + "utcAirDate" + ], + "type": "string" + }, "HealthCheckReason": { "enum": [ "appDataLocation", @@ -7000,6 +7361,153 @@ }, "additionalProperties": false }, + "MediaManagementSettingsResource": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "autoUnmonitorPreviouslyDownloadedEpisodes": { + "type": "boolean" + }, + "recycleBin": { + "type": "string", + "nullable": true + }, + "recycleBinCleanupDays": { + "type": "integer", + "format": "int32" + }, + "downloadPropersAndRepacks": { + "$ref": "#/components/schemas/ProperDownloadTypes" + }, + "createEmptySeriesFolders": { + "type": "boolean" + }, + "deleteEmptyFolders": { + "type": "boolean" + }, + "fileDate": { + "$ref": "#/components/schemas/FileDateType" + }, + "rescanAfterRefresh": { + "$ref": "#/components/schemas/RescanAfterRefreshType" + }, + "setPermissionsLinux": { + "type": "boolean" + }, + "chmodFolder": { + "type": "string", + "nullable": true + }, + "chownGroup": { + "type": "string", + "nullable": true + }, + "episodeTitleRequired": { + "$ref": "#/components/schemas/EpisodeTitleRequiredType" + }, + "skipFreeSpaceCheckWhenImporting": { + "type": "boolean" + }, + "minimumFreeSpaceWhenImporting": { + "type": "integer", + "format": "int32" + }, + "copyUsingHardlinks": { + "type": "boolean" + }, + "useScriptImport": { + "type": "boolean" + }, + "scriptImportPath": { + "type": "string", + "nullable": true + }, + "importExtraFiles": { + "type": "boolean" + }, + "extraFileExtensions": { + "type": "string", + "nullable": true + }, + "enableMediaInfo": { + "type": "boolean" + }, + "userRejectedExtensions": { + "type": "string", + "nullable": true + }, + "seasonPackUpgrade": { + "$ref": "#/components/schemas/SeasonPackUpgradeType" + }, + "seasonPackUpgradeThreshold": { + "type": "number", + "format": "double" + } + }, + "additionalProperties": false + }, + "MetadataResource": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Field" + }, + "nullable": true + }, + "implementationName": { + "type": "string", + "nullable": true + }, + "implementation": { + "type": "string", + "nullable": true + }, + "configContract": { + "type": "string", + "nullable": true + }, + "infoLink": { + "type": "string", + "nullable": true + }, + "message": { + "$ref": "#/components/schemas/ProviderMessage" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "nullable": true + }, + "presets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetadataResource" + }, + "nullable": true + }, + "enable": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "MissingSubresource": { "enum": [ "series", @@ -7394,6 +7902,14 @@ }, "additionalProperties": false }, + "ProperDownloadTypes": { + "enum": [ + "preferAndUpgrade", + "doNotUpgrade", + "doNotPrefer" + ], + "type": "string" + }, "ProviderMessage": { "type": "object", "properties": { @@ -8337,6 +8853,14 @@ }, "additionalProperties": false }, + "RescanAfterRefreshType": { + "enum": [ + "always", + "afterManual", + "never" + ], + "type": "string" + }, "Revision": { "type": "object", "properties": { @@ -8420,6 +8944,14 @@ }, "additionalProperties": false }, + "SeasonPackUpgradeType": { + "enum": [ + "all", + "threshold", + "any" + ], + "type": "string" + }, "SeasonPassResource": { "type": "object", "properties": { @@ -9493,6 +10025,12 @@ { "name": "ManualImport" }, + { + "name": "MediaManagementSettings" + }, + { + "name": "Metadata" + }, { "name": "Missing" }, From 06aba5fe1874e8cacdaa0f29a0dc9a3b5e292437 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 11 Feb 2026 18:05:09 +0200 Subject: [PATCH 03/13] Bump .NET to 10.0.3 --- global.json | 2 +- scripts/docs.sh | 2 +- src/NzbDrone.Common/Sonarr.Common.csproj | 10 +++++----- src/NzbDrone.Core/Sonarr.Core.csproj | 12 ++++++------ src/NzbDrone.Host/Sonarr.Host.csproj | 4 ++-- .../Sonarr.Integration.Test.csproj | 2 +- src/NzbDrone/Sonarr.csproj | 2 +- src/Sonarr.Api.V3/Sonarr.Api.V3.csproj | 2 +- src/Sonarr.Api.V5/Sonarr.Api.V5.csproj | 2 +- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/global.json b/global.json index c2af57a3f..058bafa62 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "10.0.102" + "version": "10.0.103" } } diff --git a/scripts/docs.sh b/scripts/docs.sh index 409a71f98..6cd3966fd 100755 --- a/scripts/docs.sh +++ b/scripts/docs.sh @@ -38,7 +38,7 @@ dotnet clean $slnFile -c Release dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids dotnet new tool-manifest -dotnet tool install --version 10.1.0 Swashbuckle.AspNetCore.Cli +dotnet tool install --version 10.1.2 Swashbuckle.AspNetCore.Cli # Remove the openapi.json file so we can check if it was created rm $outputFile diff --git a/src/NzbDrone.Common/Sonarr.Common.csproj b/src/NzbDrone.Common/Sonarr.Common.csproj index 147e5e497..c523822d7 100644 --- a/src/NzbDrone.Common/Sonarr.Common.csproj +++ b/src/NzbDrone.Common/Sonarr.Common.csproj @@ -6,20 +6,20 @@ - - + + - + - + - + diff --git a/src/NzbDrone.Core/Sonarr.Core.csproj b/src/NzbDrone.Core/Sonarr.Core.csproj index 63946075c..530b944a6 100644 --- a/src/NzbDrone.Core/Sonarr.Core.csproj +++ b/src/NzbDrone.Core/Sonarr.Core.csproj @@ -7,16 +7,16 @@ - - + + - - - - + + + + diff --git a/src/NzbDrone.Host/Sonarr.Host.csproj b/src/NzbDrone.Host/Sonarr.Host.csproj index a75169147..793fc2fdd 100644 --- a/src/NzbDrone.Host/Sonarr.Host.csproj +++ b/src/NzbDrone.Host/Sonarr.Host.csproj @@ -6,8 +6,8 @@ - - + + diff --git a/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj b/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj index eb5d56c27..e6e1abdce 100644 --- a/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj +++ b/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj @@ -4,7 +4,7 @@ Library - + diff --git a/src/NzbDrone/Sonarr.csproj b/src/NzbDrone/Sonarr.csproj index 113ebaaa7..bd7ae4e96 100644 --- a/src/NzbDrone/Sonarr.csproj +++ b/src/NzbDrone/Sonarr.csproj @@ -8,7 +8,7 @@ true - + diff --git a/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj b/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj index f702a94a1..18b446cbe 100644 --- a/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj +++ b/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj b/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj index 9e3ba07bf..0f624bb2b 100644 --- a/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj +++ b/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj @@ -9,7 +9,7 @@ - + From 944e33f24bb32fbc2822837394b4e0fbc4c426a5 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 7 Feb 2026 19:20:30 -0800 Subject: [PATCH 04/13] Convert getLanguageName to hook --- frontend/src/Episode/Summary/MediaInfo.tsx | 4 +- frontend/src/EpisodeFile/MediaInfo.tsx | 18 +++++-- frontend/src/Helpers/Hooks/useAppPage.ts | 2 + frontend/src/Language/useLanguageName.ts | 53 +++++++++++++++++++ .../src/Utilities/String/getLanguageName.ts | 41 -------------- 5 files changed, 72 insertions(+), 46 deletions(-) create mode 100644 frontend/src/Language/useLanguageName.ts delete mode 100644 frontend/src/Utilities/String/getLanguageName.ts diff --git a/frontend/src/Episode/Summary/MediaInfo.tsx b/frontend/src/Episode/Summary/MediaInfo.tsx index ef5eb499a..d9c80db69 100644 --- a/frontend/src/Episode/Summary/MediaInfo.tsx +++ b/frontend/src/Episode/Summary/MediaInfo.tsx @@ -1,13 +1,15 @@ import React from 'react'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import useLanguageName from 'Language/useLanguageName'; import MediaInfoProps from 'typings/MediaInfo'; import formatBitrate from 'Utilities/Number/formatBitrate'; import getEntries from 'Utilities/Object/getEntries'; -import getLanguageName from 'Utilities/String/getLanguageName'; import translate from 'Utilities/String/translate'; function MediaInfo(props: MediaInfoProps) { + const getLanguageName = useLanguageName(); + return ( {getEntries(props).map(([key, value]) => { diff --git a/frontend/src/EpisodeFile/MediaInfo.tsx b/frontend/src/EpisodeFile/MediaInfo.tsx index f737e6fb4..de137f0d2 100644 --- a/frontend/src/EpisodeFile/MediaInfo.tsx +++ b/frontend/src/EpisodeFile/MediaInfo.tsx @@ -1,9 +1,12 @@ import React from 'react'; -import getLanguageName from 'Utilities/String/getLanguageName'; +import useLanguageName from 'Language/useLanguageName'; import translate from 'Utilities/String/translate'; import { useEpisodeFile } from './EpisodeFileProvider'; -function formatLanguages(languages: string[] | undefined) { +function formatLanguages( + languages: string[] | undefined, + getLanguageName: (code: string) => string +) { if (!languages) { return null; } @@ -43,6 +46,7 @@ interface MediaInfoProps { } function MediaInfo({ episodeFileId, type }: MediaInfoProps) { + const getLanguageName = useLanguageName(); const episodeFile = useEpisodeFile(episodeFileId); if (!episodeFile?.mediaInfo) { @@ -76,11 +80,17 @@ function MediaInfo({ episodeFileId, type }: MediaInfoProps) { } if (type === 'audioLanguages') { - return formatLanguages(audioStreams.map(({ language }) => language)); + return formatLanguages( + audioStreams.map(({ language }) => language), + getLanguageName + ); } if (type === 'subtitles') { - return formatLanguages(subtitleStreams.map(({ language }) => language)); + return formatLanguages( + subtitleStreams.map(({ language }) => language), + getLanguageName + ); } if (type === 'video') { diff --git a/frontend/src/Helpers/Hooks/useAppPage.ts b/frontend/src/Helpers/Hooks/useAppPage.ts index cd554f188..dfd069bd1 100644 --- a/frontend/src/Helpers/Hooks/useAppPage.ts +++ b/frontend/src/Helpers/Hooks/useAppPage.ts @@ -5,6 +5,7 @@ import AppState from 'App/State/AppState'; import { useTranslations } from 'App/useTranslations'; import useCommands from 'Commands/useCommands'; import useCustomFilters from 'Filters/useCustomFilters'; +import { useInitializeLanguage } from 'Language/useLanguageName'; import useSeries from 'Series/useSeries'; import { useQualityProfiles } from 'Settings/Profiles/Quality/useQualityProfiles'; import { useUiSettings } from 'Settings/UI/useUiSettings'; @@ -76,6 +77,7 @@ const useAppPage = () => { const dispatch = useDispatch(); useCommands(); + useInitializeLanguage(); const { isFetched: isCustomFiltersFetched, error: customFiltersError } = useCustomFilters(); diff --git a/frontend/src/Language/useLanguageName.ts b/frontend/src/Language/useLanguageName.ts new file mode 100644 index 000000000..b451ccd68 --- /dev/null +++ b/frontend/src/Language/useLanguageName.ts @@ -0,0 +1,53 @@ +import { useCallback } from 'react'; +import useApiQuery from 'Helpers/Hooks/useApiQuery'; + +interface LanguageResponse { + identifier: string; +} + +function getDisplayName(code: string) { + return Intl.DisplayNames + ? new Intl.DisplayNames([code], { type: 'language' }) + : null; +} + +const useLanguage = () => { + return useApiQuery({ + path: '/localization/language', + queryOptions: { + staleTime: Infinity, + gcTime: Infinity, + }, + }); +}; + +export const useInitializeLanguage = () => { + useLanguage(); +}; + +const useLanguageName = () => { + const { data } = useLanguage(); + + const getLanguageName = useCallback( + (code: string): string => { + const languageNames = data?.identifier + ? getDisplayName(data.identifier) + : getDisplayName('en'); + + if (!languageNames) { + return code; + } + + try { + return languageNames.of(code) ?? code; + } catch { + return code; + } + }, + [data] + ); + + return getLanguageName; +}; + +export default useLanguageName; diff --git a/frontend/src/Utilities/String/getLanguageName.ts b/frontend/src/Utilities/String/getLanguageName.ts deleted file mode 100644 index 6bbaf3252..000000000 --- a/frontend/src/Utilities/String/getLanguageName.ts +++ /dev/null @@ -1,41 +0,0 @@ -import createAjaxRequest from 'Utilities/createAjaxRequest'; - -interface LanguageResponse { - identifier: string; -} - -function getLanguage() { - return createAjaxRequest({ - global: false, - dataType: 'json', - url: '/localization/language', - }).request; -} - -function getDisplayName(code: string) { - return Intl.DisplayNames - ? new Intl.DisplayNames([code], { type: 'language' }) - : null; -} - -let languageNames = getDisplayName('en'); - -getLanguage().then((data: LanguageResponse) => { - const names = getDisplayName(data.identifier); - - if (names) { - languageNames = names; - } -}); - -export default function getLanguageName(code: string) { - if (!languageNames) { - return code; - } - - try { - return languageNames.of(code) ?? code; - } catch { - return code; - } -} From 39573ea17b014ca359f74dfb71e77a8c6990e8f5 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 7 Feb 2026 19:21:40 -0800 Subject: [PATCH 05/13] New: Use translations for days of week Closes #8384 --- frontend/src/Language/useLanguageName.ts | 9 ++++++-- .../src/Utilities/Date/getRelativeDate.ts | 23 ++++++++++++++++++- src/NzbDrone.Core/Localization/Core/en.json | 5 ++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/frontend/src/Language/useLanguageName.ts b/frontend/src/Language/useLanguageName.ts index b451ccd68..7e880384f 100644 --- a/frontend/src/Language/useLanguageName.ts +++ b/frontend/src/Language/useLanguageName.ts @@ -1,4 +1,5 @@ -import { useCallback } from 'react'; +import moment from 'moment'; +import { useCallback, useEffect } from 'react'; import useApiQuery from 'Helpers/Hooks/useApiQuery'; interface LanguageResponse { @@ -22,7 +23,11 @@ const useLanguage = () => { }; export const useInitializeLanguage = () => { - useLanguage(); + const { data } = useLanguage(); + + useEffect(() => { + moment.locale(data?.identifier); + }, [data]); }; const useLanguageName = () => { diff --git a/frontend/src/Utilities/Date/getRelativeDate.ts b/frontend/src/Utilities/Date/getRelativeDate.ts index 1a3d56096..e5ef87bdb 100644 --- a/frontend/src/Utilities/Date/getRelativeDate.ts +++ b/frontend/src/Utilities/Date/getRelativeDate.ts @@ -74,7 +74,7 @@ function getRelativeDate({ if (isInNextWeek(date)) { const dateTime = convertToTimezone(date, timeZone); - const day = dateTime.format('dddd'); + const day = getDayOfWeek(dateTime.day()); return includeTime ? translate('DayOfWeekAt', { day, time }) : day; } @@ -88,3 +88,24 @@ function getRelativeDate({ } export default getRelativeDate; + +function getDayOfWeek(dayNumber: number) { + switch (dayNumber) { + case 0: + return translate('Sunday'); + case 1: + return translate('Monday'); + case 2: + return translate('Tuesday'); + case 3: + return translate('Wednesday'); + case 4: + return translate('Thursday'); + case 5: + return translate('Friday'); + case 6: + return translate('Saturday'); + default: + return ''; + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index cd49e021e..975ca5aed 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -786,6 +786,7 @@ "Formats": "Formats", "Forums": "Forums", "FreeSpace": "Free Space", + "Friday": "Friday", "From": "From", "FullColorEvents": "Full Color Events", "FullColorEventsHelpText": "Altered style to color the entire event with the status color, instead of just the left edge. Does not apply to Agenda", @@ -1862,6 +1863,7 @@ "RssSyncIntervalHelpText": "Interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)", "RssSyncIntervalHelpTextWarning": "This will apply to all indexers, please follow the rules set forth by them", "Runtime": "Runtime", + "Saturday": "Saturday", "Save": "Save", "SaveChanges": "Save Changes", "SaveSettings": "Save Settings", @@ -2089,6 +2091,7 @@ "TheTvdb": "TheTVDB", "Theme": "Theme", "ThemeHelpText": "Change Application UI Theme, 'Auto' Theme will use your OS Theme to set Light or Dark mode. Inspired by Theme.Park", + "Thursday": "Thursday", "Threshold": "Threshold", "Time": "Time", "TimeFormat": "Time Format", @@ -2120,6 +2123,7 @@ "TotalFileSize": "Total File Size", "TotalRecords": "Total records: {totalRecords}", "TotalSpace": "Total Space", + "Tuesday": "Tuesday", "Trace": "Trace", "True": "True", "TvdbId": "TVDB ID", @@ -2221,6 +2225,7 @@ "Wanted": "Wanted", "Warn": "Warn", "Warning": "Warning", + "Wednesday": "Wednesday", "Week": "Week", "WeekColumnHeader": "Week Column Header", "WeekColumnHeaderHelpText": "Shown above each column when week is the active view", From 93713c3827bf14b340b0ab51de97bc261fbbd2fa Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 8 Feb 2026 10:16:42 -0800 Subject: [PATCH 06/13] New: Additional logging for recycle bin cleanup Closes #8388 --- .../MediaFiles/RecycleBinProvider.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs b/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs index 89d9c2340..b7e6e7907 100644 --- a/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs +++ b/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using NLog; using NzbDrone.Common.Disk; @@ -176,20 +177,26 @@ public void Cleanup() _logger.Info("Removing items older than {0} days from the recycling bin", cleanupDays); + var removedFiles = new List(); + var skippedFiles = new List(); + foreach (var file in _diskProvider.GetFiles(_configService.RecycleBin, true)) { if (_diskProvider.FileGetLastWrite(file).AddDays(cleanupDays) > DateTime.UtcNow) { _logger.Debug("File hasn't expired yet, skipping: {0}", file); + skippedFiles.Add(file); continue; } + removedFiles.Add(file); + _logger.Debug("File expired, deleting: {0}", file); _diskProvider.DeleteFile(file); } _diskProvider.RemoveEmptySubfolders(_configService.RecycleBin); - _logger.Debug("Recycling Bin has been cleaned up."); + _logger.Debug("Recycling Bin has been cleaned up. Removed: {0}. Skipped: {1}", removedFiles.Count, skippedFiles.Count); } private void SetLastWriteTime(string file, DateTime dateTime) @@ -197,13 +204,16 @@ private void SetLastWriteTime(string file, DateTime dateTime) // Swallow any IOException that may be thrown due to "Invalid parameter" try { + _logger.Trace("Setting last write time for file: {0}", file); _diskProvider.FileSetLastWriteTime(file, dateTime); } - catch (IOException) + catch (IOException ex) { + _logger.Warn(ex, "Failed to set last write time for file: {0}", file); } - catch (UnauthorizedAccessException) + catch (UnauthorizedAccessException ex) { + _logger.Warn(ex, "Failed to set last write time for file: {0}", file); } } From 8fa16e35425eefe705af951d57c27e8db4d64803 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 8 Feb 2026 19:33:36 -0800 Subject: [PATCH 07/13] New: Add option to not download before air date to Release Profiles Closes #969 --- .../EditReleaseProfileModalContent.tsx | 40 +++- .../Profiles/Release/useReleaseProfiles.ts | 4 + .../AirDateSpecificationFixture.cs | 191 ++++++++++++++++++ ..._air_date_filtering_to_release_profiles.cs | 14 ++ .../DecisionEngine/DownloadRejectionReason.cs | 3 +- .../Specifications/AirDateSpecification.cs | 83 ++++++++ src/NzbDrone.Core/Localization/Core/en.json | 4 + .../Profiles/Releases/ReleaseProfile.cs | 2 + .../Release/ReleaseProfileController.cs | 2 +- .../Release/ReleaseProfileResource.cs | 6 + .../Release/ReleaseProfileController.cs | 2 +- .../Release/ReleaseProfileResource.cs | 6 + 12 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 src/NzbDrone.Core.Test/DecisionEngineTests/AirDateSpecificationFixture.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/226_add_air_date_filtering_to_release_profiles.cs create mode 100644 src/NzbDrone.Core/DecisionEngine/Specifications/AirDateSpecification.cs diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx index e1e35ef58..0c8fec237 100644 --- a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx @@ -39,8 +39,17 @@ function EditReleaseProfileModalContent({ saveProvider, } = useManageReleaseProfile(id ?? 0); - const { name, enabled, required, ignored, indexerIds, tags, excludedTags } = - item; + const { + name, + enabled, + required, + ignored, + airDateRestriction, + airDateGracePeriod, + indexerIds, + tags, + excludedTags, + } = item; const wasSaving = usePrevious(isSaving); @@ -131,6 +140,33 @@ function EditReleaseProfileModalContent({ /> + + {translate('AirDateRestriction')} + + + + + {airDateRestriction.value ? ( + + {translate('AirDateGracePeriod')} + + + + ) : null} + {translate('Indexer')} diff --git a/frontend/src/Settings/Profiles/Release/useReleaseProfiles.ts b/frontend/src/Settings/Profiles/Release/useReleaseProfiles.ts index 445aa9c88..2f5038858 100644 --- a/frontend/src/Settings/Profiles/Release/useReleaseProfiles.ts +++ b/frontend/src/Settings/Profiles/Release/useReleaseProfiles.ts @@ -10,6 +10,8 @@ export interface ReleaseProfileModel extends ModelBase { enabled: boolean; required: string[]; ignored: string[]; + airDateRestriction: boolean; + airDateGracePeriod: number; indexerIds: number[]; tags: number[]; excludedTags: number[]; @@ -23,6 +25,8 @@ const NEW_RELEASE_PROFILE: ReleaseProfileModel = { enabled: true, required: [], ignored: [], + airDateRestriction: false, + airDateGracePeriod: 0, indexerIds: [], tags: [], excludedTags: [], diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/AirDateSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/AirDateSpecificationFixture.cs new file mode 100644 index 000000000..8190ea853 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/AirDateSpecificationFixture.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + [TestFixture] + public class AirDateSpecificationFixture : CoreTest + { + private RemoteEpisode _remoteEpisode; + + [SetUp] + public void Setup() + { + _remoteEpisode = new RemoteEpisode + { + Series = new Series + { + Tags = new HashSet() + }, + Episodes = Builder.CreateListOfSize(1) + .All() + .With(e => e.AirDateUtc = DateTime.UtcNow) + .Build() + .ToList(), + Release = new ReleaseInfo + { + PublishDate = DateTime.UtcNow.AddDays(-1) + } + }; + } + + private void GivenSettings(bool airDateRestriction, int gracePeriod) + { + Mocker.GetMock() + .Setup(s => s.EnabledForTags(It.IsAny>(), It.IsAny())) + .Returns(new List + { + new() + { + AirDateRestriction = airDateRestriction, + AirDateGracePeriod = gracePeriod + } + }); + } + + [Test] + public void should_be_true_if_profile_does_not_enforce_air_date_restriction() + { + GivenSettings(false, 0); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_true_if_release_date_is_after_air_date() + { + _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow; + _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(1); + + GivenSettings(true, 0); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_true_if_release_date_with_grace_period_is_after_air_date() + { + _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow; + _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(1); + + GivenSettings(true, -2); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_true_if_release_date_is_the_same_as_air_date() + { + var airDate = DateTime.UtcNow; + _remoteEpisode.Episodes.First().AirDateUtc = airDate; + _remoteEpisode.Release.PublishDate = airDate; + + GivenSettings(true, 0); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_false_if_air_date_is_null() + { + _remoteEpisode.Episodes.First().AirDateUtc = null; + + GivenSettings(true, -2); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse(); + } + + [Test] + public void should_be_false_if_release_date_is_before_air_date() + { + _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow; + _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-1); + + GivenSettings(true, 0); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse(); + } + + [Test] + public void should_be_false_if_release_date_with_grace_period_is_before_air_date() + { + _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow; + _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-3); + + GivenSettings(true, -2); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse(); + } + + [Test] + public void should_be_false_if_release_date_is_after_air_date_and_grace_period_is_positive() + { + _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow; + _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(1); + + GivenSettings(true, 2); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse(); + } + + [Test] + public void should_be_false_if_release_date_with_highest_grace_period_is_before_air_date() + { + _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow; + _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-1); + + Mocker.GetMock() + .Setup(s => s.EnabledForTags(It.IsAny>(), It.IsAny())) + .Returns(new List + { + new() + { + AirDateRestriction = true, + AirDateGracePeriod = 0 + }, + new() + { + AirDateRestriction = true, + AirDateGracePeriod = -5 + } + }); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse(); + } + + [Test] + public void should_be_false_if_one_release_profile_does_not_allow_grabbing_before_air_date() + { + _remoteEpisode.Episodes.First().AirDateUtc = DateTime.UtcNow; + _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-1); + + Mocker.GetMock() + .Setup(s => s.EnabledForTags(It.IsAny>(), It.IsAny())) + .Returns(new List + { + new() + { + AirDateRestriction = true, + AirDateGracePeriod = 0 + }, + new() + { + AirDateRestriction = false, + AirDateGracePeriod = 0 + } + }); + + Subject.IsSatisfiedBy(_remoteEpisode, new()).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/226_add_air_date_filtering_to_release_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/226_add_air_date_filtering_to_release_profiles.cs new file mode 100644 index 000000000..b7889a3ec --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/226_add_air_date_filtering_to_release_profiles.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration; + +[Migration(226)] +public class add_air_date_filtering_to_release_profiles : NzbDroneMigrationBase +{ + protected override void MainDbUpgrade() + { + Alter.Table("ReleaseProfiles").AddColumn("AirDateRestriction").AsBoolean().WithDefaultValue(false); + Alter.Table("ReleaseProfiles").AddColumn("AirDateGracePeriod").AsInt32().WithDefaultValue(0); + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs b/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs index 5283f33ce..f4e36ed4f 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs @@ -75,5 +75,6 @@ public enum DownloadRejectionReason DiskCustomFormatScore, DiskCustomFormatScoreIncrement, DiskUpgradesNotAllowed, - DiskNotUpgrade + DiskNotUpgrade, + BeforeAirDate } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AirDateSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AirDateSpecification.cs new file mode 100644 index 000000000..cea857b84 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AirDateSpecification.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Releases; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class AirDateSpecification : IDownloadDecisionEngineSpecification + { + private readonly Logger _logger; + private readonly IReleaseProfileService _releaseProfileService; + private readonly ITermMatcherService _termMatcherService; + + public AirDateSpecification(ITermMatcherService termMatcherService, IReleaseProfileService releaseProfileService, Logger logger) + { + _logger = logger; + _releaseProfileService = releaseProfileService; + _termMatcherService = termMatcherService; + } + + public SpecificationPriority Priority => SpecificationPriority.Database; + public RejectionType Type => RejectionType.Permanent; + + public virtual DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, ReleaseDecisionInformation information) + { + _logger.Debug("Checking if release meets air date restrictions: {0}", subject); + + var releaseProfiles = _releaseProfileService.EnabledForTags(subject.Series.Tags, subject.Release.IndexerId); + + if (releaseProfiles.Empty()) + { + _logger.Debug("No Release Profile, accepting"); + return DownloadSpecDecision.Accept(); + } + + var bestProfile = releaseProfiles + .OrderByDescending(p => p.AirDateRestriction ? 1 : 0) + .ThenByDescending(p => p.AirDateGracePeriod) + .First(); + + if (!bestProfile.AirDateRestriction) + { + _logger.Debug("Release Profile does not prevent grabbing before release date, accepting"); + return DownloadSpecDecision.Accept(); + } + + var releaseDate = subject.Release.PublishDate; + var gracePeriod = bestProfile.AirDateGracePeriod; + + foreach (var episode in subject.Episodes) + { + var airDate = episode.AirDateUtc; + + if (!airDate.HasValue) + { + _logger.Debug("No air date available, rejecting"); + return DownloadSpecDecision.Reject(DownloadRejectionReason.BeforeAirDate, "No air date available"); + } + + var adjustedAirDate = airDate.Value.AddDays(gracePeriod); + + if (releaseDate < adjustedAirDate) + { + return DownloadSpecDecision.Reject(DownloadRejectionReason.BeforeAirDate, "Release date {0} is before adjusted air date of {1} (Air Date: {2}. Grace period {3} days)", releaseDate, adjustedAirDate, airDate, gracePeriod); + } + } + + _logger.Debug("All episodes within air date limitations, allowing"); + return DownloadSpecDecision.Accept(); + } + + private ReleaseProfile FindBestProfile(List releaseProfiles) + { + return releaseProfiles + .OrderBy(p => p.AirDateRestriction ? 0 : 1) + .ThenBy(p => p.AirDateGracePeriod) + .ThenBy(p => p.AirDateRestriction ? 0 : 1) + .FirstOrDefault(); + } + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 975ca5aed..e2ff3b88c 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -62,6 +62,10 @@ "AgeWhenGrabbed": "Age (when grabbed)", "Agenda": "Agenda", "AirDate": "Air Date", + "AirDateGracePeriod": "Air Date Grace Period", + "AirDateGracePeriodHelpText": "Negative values allow grabbing before the air date, positive values prevent grabbing after the air date.", + "AirDateRestriction": "Reject Unaired Releases", + "AirDateRestrictionHelpText": "Prevents {appName} from grabbing releases that contain episodes that have not yet aired.", "Airs": "Airs", "AirsDateAtTimeOn": "{date} at {time} on {networkLabel}", "AirsTbaOn": "TBA on {networkLabel}", diff --git a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs index c740dd48b..ee4b77447 100644 --- a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs +++ b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs @@ -9,6 +9,8 @@ public class ReleaseProfile : ModelBase public bool Enabled { get; set; } public List Required { get; set; } public List Ignored { get; set; } + public bool AirDateRestriction { get; set; } + public int AirDateGracePeriod { get; set; } public List IndexerIds { get; set; } public HashSet Tags { get; set; } public HashSet ExcludedTags { get; set; } diff --git a/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileController.cs b/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileController.cs index f1ffc58b9..af0b44b7d 100644 --- a/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileController.cs +++ b/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileController.cs @@ -23,7 +23,7 @@ public ReleaseProfileController(IReleaseProfileService releaseProfileService, II SharedValidator.RuleFor(d => d).Custom((restriction, context) => { - if (restriction.MapRequired().Empty() && restriction.MapIgnored().Empty()) + if (restriction.MapRequired().Empty() && restriction.MapIgnored().Empty() && !restriction.AirDateRestriction) { context.AddFailure(nameof(ReleaseProfileResource.Required), "'Must contain' or 'Must not contain' is required"); } diff --git a/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileResource.cs b/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileResource.cs index 127f1b265..254f1a20a 100644 --- a/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileResource.cs +++ b/src/Sonarr.Api.V3/Profiles/Release/ReleaseProfileResource.cs @@ -15,6 +15,8 @@ public class ReleaseProfileResource : RestResource // Is List, string or JArray, we accept 'string' with POST for backward compatibility public object Required { get; set; } public object Ignored { get; set; } + public bool AirDateRestriction { get; set; } + public int AirDateGracePeriod { get; set; } public int IndexerId { get; set; } public HashSet Tags { get; set; } public HashSet ExcludedTags { get; set; } @@ -42,6 +44,8 @@ public static ReleaseProfileResource ToResource(this ReleaseProfile model) Enabled = model.Enabled, Required = model.Required ?? new List(), Ignored = model.Ignored ?? new List(), + AirDateRestriction = model.AirDateRestriction, + AirDateGracePeriod = model.AirDateGracePeriod, IndexerId = model.IndexerIds.FirstOrDefault(0), Tags = new HashSet(model.Tags), ExcludedTags = new HashSet(model.ExcludedTags) @@ -62,6 +66,8 @@ public static ReleaseProfile ToModel(this ReleaseProfileResource resource) Enabled = resource.Enabled, Required = resource.MapRequired(), Ignored = resource.MapIgnored(), + AirDateRestriction = resource.AirDateRestriction, + AirDateGracePeriod = resource.AirDateGracePeriod, IndexerIds = new List { resource.IndexerId }, Tags = new HashSet(resource.Tags), ExcludedTags = new HashSet(resource.ExcludedTags) diff --git a/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs b/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs index cdbd144a1..2f51a9010 100644 --- a/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs +++ b/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs @@ -21,7 +21,7 @@ public ReleaseProfileController(IReleaseProfileService releaseProfileService, II SharedValidator.RuleFor(d => d).Custom((restriction, context) => { - if (restriction.Required.Empty() && restriction.Ignored.Empty()) + if (restriction.Required.Empty() && restriction.Ignored.Empty() && !restriction.AirDateRestriction) { context.AddFailure(nameof(ReleaseProfileResource.Required), "'Must contain' or 'Must not contain' is required"); } diff --git a/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileResource.cs b/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileResource.cs index 2a1448b8a..a04101137 100644 --- a/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileResource.cs +++ b/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileResource.cs @@ -9,6 +9,8 @@ public class ReleaseProfileResource : RestResource public bool Enabled { get; set; } public List Required { get; set; } = []; public List Ignored { get; set; } = []; + public bool AirDateRestriction { get; set; } + public int AirDateGracePeriod { get; set; } public List IndexerIds { get; set; } = []; public HashSet Tags { get; set; } = []; public HashSet ExcludedTags { get; set; } = []; @@ -25,6 +27,8 @@ public static ReleaseProfileResource ToResource(this ReleaseProfile model) Enabled = model.Enabled, Required = model.Required ?? [], Ignored = model.Ignored ?? [], + AirDateRestriction = model.AirDateRestriction, + AirDateGracePeriod = model.AirDateGracePeriod, IndexerIds = model.IndexerIds ?? [], Tags = model.Tags ?? [], ExcludedTags = model.ExcludedTags ?? [], @@ -40,6 +44,8 @@ public static ReleaseProfile ToModel(this ReleaseProfileResource resource) Enabled = resource.Enabled, Required = resource.Required, Ignored = resource.Ignored, + AirDateRestriction = resource.AirDateRestriction, + AirDateGracePeriod = resource.AirDateGracePeriod, IndexerIds = resource.IndexerIds, Tags = resource.Tags, ExcludedTags = resource.ExcludedTags From 2335657fe4c06583c12fa677ade195290bfcafe8 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 14 Feb 2026 19:14:48 -0800 Subject: [PATCH 08/13] New: Parse NCOP/NCED as specials --- .../ParserTests/AbsoluteEpisodeNumberParserFixture.cs | 2 ++ src/NzbDrone.Core/Parser/Parser.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs index f573270c2..149208a3e 100644 --- a/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs @@ -168,6 +168,8 @@ public void should_parse_absolute_specials(string postTitle, string title, int a } [TestCase("[Underwater] Another OVA - The Other -Karma- (BD 1080p) [3A561D0E].mkv", "Another", 0)] + [TestCase("[sam] Long Series - NCOP [BD 1080p FLAC] [BBC3BC68].mkv", "Long Series", 0)] + [TestCase("[sam] Long Series - NCED [BD 1080p FLAC] [BBC3BC68].mkv", "Long Series", 0)] public void should_parse_absolute_specials_without_absolute_number(string postTitle, string title, int absoluteEpisodeNumber) { var result = Parser.Parser.ParseTitle(postTitle); diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 79be10f59..0200887be 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -447,7 +447,7 @@ public static class Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime OVA special - new Regex(@"^\[(?.+?)\][-_. ]?(?.+?)(?:[-_. ]+(?<special>special|ova|ovd)).*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)", + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)(?:[-_. ]+(?<special>special|ova|ovd|ncop|nced)).*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled) }; From b94cf9b3b9835d2ade7948b32b07a05a25fdf740 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sun, 15 Feb 2026 14:22:44 -0800 Subject: [PATCH 09/13] New: Skip series search for seasons with files if profile doesn't allow upgrades --- .../ReleaseSearchServiceFixture.cs | 15 ++++++++++++ .../SeriesSearchServiceFixture.cs | 23 ++++++++++++++++++- .../IndexerSearch/ReleaseSearchService.cs | 6 +++++ .../IndexerSearch/SeriesSearchService.cs | 3 ++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs index 0da5fb02a..fbbe4502b 100644 --- a/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs @@ -331,6 +331,21 @@ public async Task scene_seasonsearch_should_use_seasonnumber_if_no_scene_number_ criteria[0].SeasonNumber.Should().Be(7); } + [Test] + public async Task scene_seasonsearch_should_skip_search_if_no_episodes_after_filtering() + { + WithEpisodes(); + _xemEpisodes.ForEach(e => e.EpisodeFileId = 1); + + var allCriteria = WatchForSearchCriteria(); + + await Subject.SeasonSearch(_xemSeries.Id, 1, true, false, true, false); + + var criteria = allCriteria.OfType<SeasonSearchCriteria>().ToList(); + + criteria.Count.Should().Be(0); + } + [Test] public async Task season_search_for_anime_should_search_for_each_monitored_episode() { diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/SeriesSearchServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/SeriesSearchServiceFixture.cs index ab6ab0755..774cc7d1f 100644 --- a/src/NzbDrone.Core.Test/IndexerSearchTests/SeriesSearchServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerSearchTests/SeriesSearchServiceFixture.cs @@ -1,13 +1,16 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using FizzWare.NBuilder; using FluentAssertions; using Moq; using NUnit.Framework; +using NzbDrone.Core.Datastore; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; @@ -25,7 +28,8 @@ public void Setup() { Id = 1, Title = "Title", - Seasons = new List<Season>() + Seasons = new List<Season>(), + QualityProfile = new LazyLoaded<QualityProfile>(Builder<QualityProfile>.CreateNew().With(q => q.UpgradeAllowed = true).Build()) }; Mocker.GetMock<ISeriesService>() @@ -56,6 +60,23 @@ public void should_only_include_monitored_seasons() .Verify(v => v.SeasonSearch(_series.Id, It.IsAny<int>(), false, true, true, false), Times.Exactly(_series.Seasons.Count(s => s.Monitored))); } + [Test] + public void should_only_search_missing_if_profile_does_not_allow_upgrades() + { + _series.Seasons = new List<Season> + { + new Season { SeasonNumber = 0, Monitored = false }, + new Season { SeasonNumber = 1, Monitored = true } + }; + + _series.QualityProfile.Value.UpgradeAllowed = false; + + Subject.Execute(new SeriesSearchCommand { SeriesId = _series.Id, Trigger = CommandTrigger.Manual }); + + Mocker.GetMock<ISearchForReleases>() + .Verify(v => v.SeasonSearch(_series.Id, It.IsAny<int>(), true, true, true, false), Times.Exactly(_series.Seasons.Count(s => s.Monitored))); + } + [Test] public void should_start_with_lower_seasons_first() { diff --git a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs index 6625015c7..3af963f78 100644 --- a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs @@ -102,6 +102,12 @@ public async Task<List<DownloadDecision>> SeasonSearch(int seriesId, int seasonN episodes = episodes.Where(e => !e.HasFile).ToList(); } + if (episodes.Count == 0) + { + _logger.Debug("No wanted episodes found for season {0}", seasonNumber); + return new List<DownloadDecision>(); + } + return await SeasonSearch(seriesId, seasonNumber, episodes, monitoredOnly, userInvokedSearch, interactiveSearch); } diff --git a/src/NzbDrone.Core/IndexerSearch/SeriesSearchService.cs b/src/NzbDrone.Core/IndexerSearch/SeriesSearchService.cs index 4f4f6b9ad..24dfdee26 100644 --- a/src/NzbDrone.Core/IndexerSearch/SeriesSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/SeriesSearchService.cs @@ -35,6 +35,7 @@ public void Execute(SeriesSearchCommand message) var series = _seriesService.GetSeries(message.SeriesId); var downloadedCount = 0; var userInvokedSearch = message.Trigger == CommandTrigger.Manual; + var profile = series.QualityProfile.Value; if (series.Seasons.None(s => s.Monitored)) { @@ -64,7 +65,7 @@ public void Execute(SeriesSearchCommand message) continue; } - var decisions = _releaseSearchService.SeasonSearch(message.SeriesId, season.SeasonNumber, false, true, userInvokedSearch, false).GetAwaiter().GetResult(); + var decisions = _releaseSearchService.SeasonSearch(message.SeriesId, season.SeasonNumber, !profile.UpgradeAllowed, true, userInvokedSearch, false).GetAwaiter().GetResult(); var processDecisions = _processDownloadDecisions.ProcessDecisions(decisions).GetAwaiter().GetResult(); downloadedCount += processDecisions.Grabbed.Count; } From d8698d1c28c393d4666af707bb806f687ae4c631 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 16 Feb 2026 09:18:21 -0800 Subject: [PATCH 10/13] Fixed: Ensure trailing slash is removed from drive letters when updating Plex Media Server Closes #8414 --- .../Notifications/Plex/Server/PlexServerService.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs index ceff60865..4d2a9f226 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs @@ -141,8 +141,9 @@ private void UpdateSectionPath(string seriesRelativePath, PlexSection section, P var separator = location.Path.Contains('\\') ? "\\" : "/"; var locationRelativePath = seriesRelativePath.Replace("\\", separator).Replace("/", separator); - // Plex location paths trim trailing extraneous separator characters, so it doesn't need to be trimmed - var pathToUpdate = $"{location.Path}{separator}{locationRelativePath}"; + // Plex location paths trim trailing extraneous separator characters, + // unless it's a Windows drive letter (S:\) that needs to be trimmed. + var pathToUpdate = $"{location.Path.TrimEnd(separator)}{separator}{locationRelativePath}"; _logger.Debug("Updating section location, {0}", location.Path); _plexServerProxy.Update(section.Id, pathToUpdate, settings); From 236978a9b13bf3f965cb15fe3a67019fed640cec Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sat, 14 Feb 2026 12:13:51 -0800 Subject: [PATCH 11/13] Add v5 Language endpoints --- .../Localization/LanguageController.cs | 35 +++++++++++++++++++ .../Localization/LanguageResource.cs | 12 +++++++ 2 files changed, 47 insertions(+) create mode 100644 src/Sonarr.Api.V5/Localization/LanguageController.cs create mode 100644 src/Sonarr.Api.V5/Localization/LanguageResource.cs diff --git a/src/Sonarr.Api.V5/Localization/LanguageController.cs b/src/Sonarr.Api.V5/Localization/LanguageController.cs new file mode 100644 index 000000000..ea010585b --- /dev/null +++ b/src/Sonarr.Api.V5/Localization/LanguageController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Languages; +using Sonarr.Http; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Localization; + +[V5ApiController] +public class LanguageController : RestController<LanguageResource> +{ + protected override LanguageResource GetResourceById(int id) + { + var language = (Language)id; + + return new LanguageResource + { + Id = (int)language, + Name = language.ToString() + }; + } + + [HttpGet] + public List<LanguageResource> GetAll() + { + var languageResources = Language.All.Select(l => new LanguageResource + { + Id = (int)l, + Name = l.ToString() + }) + .OrderBy(l => l.Id > 0).ThenBy(l => l.Name) + .ToList(); + + return languageResources; + } +} diff --git a/src/Sonarr.Api.V5/Localization/LanguageResource.cs b/src/Sonarr.Api.V5/Localization/LanguageResource.cs new file mode 100644 index 000000000..1b3719b76 --- /dev/null +++ b/src/Sonarr.Api.V5/Localization/LanguageResource.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Localization; + +public class LanguageResource : RestResource +{ + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public new int Id { get; set; } + public string? Name { get; set; } + public string? NameLower => Name?.ToLowerInvariant(); +} From 5bac016f0c8e7af3dd62a86f00ee1d7d3cc497e0 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sat, 14 Feb 2026 12:25:49 -0800 Subject: [PATCH 12/13] Use react-query for Languages --- frontend/src/App/State/SettingsAppState.ts | 3 - .../Builder/LanguageFilterBuilderRowValue.tsx | 5 +- .../Form/Select/LanguageSelectInput.tsx | 15 ++--- frontend/src/Helpers/Hooks/useAppPage.ts | 18 +++-- .../Language/SelectLanguageModalContent.tsx | 18 ++--- frontend/src/Language/useLanguages.ts | 65 +++++++++++++++++++ frontend/src/Settings/UI/UISettings.tsx | 23 +++---- .../src/Store/Actions/Settings/languages.js | 48 -------------- frontend/src/Store/Actions/settingsActions.js | 11 +--- .../Selectors/createLanguagesSelector.ts | 33 ---------- 10 files changed, 107 insertions(+), 132 deletions(-) create mode 100644 frontend/src/Language/useLanguages.ts delete mode 100644 frontend/src/Store/Actions/Settings/languages.js delete mode 100644 frontend/src/Store/Selectors/createLanguagesSelector.ts diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 162f03de4..61fc0cb52 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -6,7 +6,6 @@ import AppSectionState, { AppSectionSchemaState, PagedAppSectionState, } from 'App/State/AppSectionState'; -import Language from 'Language/Language'; import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging'; import CustomFormat from 'typings/CustomFormat'; import CustomFormatSpecification from 'typings/CustomFormatSpecification'; @@ -101,7 +100,6 @@ export interface ImportListExclusionsSettingsAppState } export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>; -export type LanguageSettingsAppState = AppSectionState<Language>; interface SettingsAppState { autoTaggings: AutoTaggingAppState; @@ -118,7 +116,6 @@ interface SettingsAppState { indexerFlags: IndexerFlagSettingsAppState; indexerOptions: IndexerOptionsAppState; indexers: IndexerAppState; - languages: LanguageSettingsAppState; } export default SettingsAppState; diff --git a/frontend/src/Components/Filter/Builder/LanguageFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/LanguageFilterBuilderRowValue.tsx index 89ef35869..123d59d25 100644 --- a/frontend/src/Components/Filter/Builder/LanguageFilterBuilderRowValue.tsx +++ b/frontend/src/Components/Filter/Builder/LanguageFilterBuilderRowValue.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { useSelector } from 'react-redux'; -import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector'; +import { useLanguages } from 'Language/useLanguages'; import FilterBuilderRowValue, { FilterBuilderRowValueProps, } from './FilterBuilderRowValue'; @@ -13,7 +12,7 @@ type LanguageFilterBuilderRowValueProps<T> = Omit< function LanguageFilterBuilderRowValue<T>( props: LanguageFilterBuilderRowValueProps<T> ) { - const { items } = useSelector(createLanguagesSelector()); + const { data: items = [] } = useLanguages(); return <FilterBuilderRowValue {...props} tagList={items} />; } diff --git a/frontend/src/Components/Form/Select/LanguageSelectInput.tsx b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx index 08142195f..a69aca46a 100644 --- a/frontend/src/Components/Form/Select/LanguageSelectInput.tsx +++ b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useMemo } from 'react'; -import { useSelector } from 'react-redux'; import Language from 'Language/Language'; -import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector'; +import { useFilteredLanguages } from 'Language/useLanguages'; import translate from 'Utilities/String/translate'; import EnhancedSelectInput, { EnhancedSelectInputValue, @@ -31,13 +30,11 @@ export default function LanguageSelectInput({ onChange, ...otherProps }: LanguageSelectInputProps) { - const { items } = useSelector( - createLanguagesSelector({ - Any: true, - Original: true, - Unknown: true, - }) - ); + const { data: items = [] } = useFilteredLanguages({ + includeAny: true, + includeOriginal: true, + includeUnknown: true, + }); const values = useMemo(() => { const result: EnhancedSelectInputValue<number | string>[] = items.map( diff --git a/frontend/src/Helpers/Hooks/useAppPage.ts b/frontend/src/Helpers/Hooks/useAppPage.ts index dfd069bd1..578c296f8 100644 --- a/frontend/src/Helpers/Hooks/useAppPage.ts +++ b/frontend/src/Helpers/Hooks/useAppPage.ts @@ -6,6 +6,7 @@ import { useTranslations } from 'App/useTranslations'; import useCommands from 'Commands/useCommands'; import useCustomFilters from 'Filters/useCustomFilters'; import { useInitializeLanguage } from 'Language/useLanguageName'; +import { useLanguages } from 'Language/useLanguages'; import useSeries from 'Series/useSeries'; import { useQualityProfiles } from 'Settings/Profiles/Quality/useQualityProfiles'; import { useUiSettings } from 'Settings/UI/useUiSettings'; @@ -13,7 +14,6 @@ import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; import { fetchImportLists, fetchIndexerFlags, - fetchLanguages, } from 'Store/Actions/settingsActions'; import useSystemStatus from 'System/Status/useSystemStatus'; import useTags from 'Tags/useTags'; @@ -27,6 +27,7 @@ const createErrorsSelector = ({ uiSettingsError, seriesError, qualityProfilesError, + languagesError, }: { customFiltersError: ApiError | null; systemStatusError: ApiError | null; @@ -35,12 +36,12 @@ const createErrorsSelector = ({ uiSettingsError: ApiError | null; seriesError: ApiError | null; qualityProfilesError: ApiError | null; + languagesError: ApiError | null; }) => createSelector( - (state: AppState) => state.settings.languages.error, (state: AppState) => state.settings.importLists.error, (state: AppState) => state.settings.indexerFlags.error, - (languagesError, importListsError, indexerFlagsError) => { + (importListsError, indexerFlagsError) => { const hasError = !!( customFiltersError || seriesError || @@ -82,7 +83,7 @@ const useAppPage = () => { const { isFetched: isCustomFiltersFetched, error: customFiltersError } = useCustomFilters(); - const { isSuccess: isSeriesFetched, error: seriesError } = useSeries(); + const { isFetched: isSeriesFetched, error: seriesError } = useSeries(); const { isFetched: isSystemStatusFetched, error: systemStatusError } = useSystemStatus(); @@ -98,9 +99,11 @@ const useAppPage = () => { const { isFetched: isQualityProfilesFetched, error: qualityProfilesError } = useQualityProfiles(); + const { isFetched: isLanguagesFetched, error: languagesError } = + useLanguages(); + const isAppStatePopulated = useSelector( (state: AppState) => - state.settings.languages.isPopulated && state.settings.importLists.isPopulated && state.settings.indexerFlags.isPopulated ); @@ -113,7 +116,8 @@ const useAppPage = () => { isTagsFetched && isTranslationsFetched && isUiSettingsFetched && - isQualityProfilesFetched; + isQualityProfilesFetched && + isLanguagesFetched; const { hasError, errors } = useSelector( createErrorsSelector({ @@ -124,6 +128,7 @@ const useAppPage = () => { translationsError, uiSettingsError, qualityProfilesError, + languagesError, }) ); @@ -142,7 +147,6 @@ const useAppPage = () => { useEffect(() => { dispatch(fetchCustomFilters()); - dispatch(fetchLanguages()); dispatch(fetchImportLists()); dispatch(fetchIndexerFlags()); }, [dispatch]); diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.tsx b/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.tsx index 0d1f668e6..9fc43a5cd 100644 --- a/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.tsx +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useState } from 'react'; -import { useSelector } from 'react-redux'; import Alert from 'Components/Alert'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; @@ -13,7 +12,7 @@ import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds, sizes } from 'Helpers/Props'; import Language from 'Language/Language'; -import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector'; +import { useFilteredLanguages } from 'Language/useLanguages'; import translate from 'Utilities/String/translate'; import styles from './SelectLanguageModalContent.css'; @@ -27,12 +26,15 @@ interface SelectLanguageModalContentProps { function SelectLanguageModalContent(props: SelectLanguageModalContentProps) { const { modalTitle, onLanguagesSelect, onModalClose } = props; - const { isFetching, isPopulated, error, items } = useSelector( - createLanguagesSelector({ - Any: true, - Original: true, - }) - ); + const { + data: items = [], + isFetching, + isFetched: isPopulated, + error, + } = useFilteredLanguages({ + includeAny: true, + includeOriginal: true, + }); const [languageIds, setLanguageIds] = useState(props.languageIds); diff --git a/frontend/src/Language/useLanguages.ts b/frontend/src/Language/useLanguages.ts new file mode 100644 index 000000000..f8b181be8 --- /dev/null +++ b/frontend/src/Language/useLanguages.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import useApiQuery from 'Helpers/Hooks/useApiQuery'; +import Language from 'Language/Language'; + +interface LanguageFilter { + [key: string]: boolean | undefined; + includeAny: boolean; + includeOriginal?: boolean; + includeUnknown?: boolean; +} + +const PATH = '/language'; + +export const useLanguages = () => { + return useApiQuery<Language[]>({ + path: PATH, + queryOptions: { + gcTime: Infinity, + staleTime: Infinity, + }, + }); +}; + +export const useFilteredLanguages = ( + excludeLanguages: LanguageFilter = { includeAny: true } +) => { + const { data, isFetching, isFetched, error } = useLanguages(); + + const filteredItems = useMemo(() => { + if (!data) return []; + + return data.filter((lang) => !excludeLanguages[lang.name]); + }, [data, excludeLanguages]); + + return { + data: filteredItems, + isFetching, + isFetched, + error, + }; +}; + +export const useLanguageById = (id: number | undefined) => { + const { data } = useLanguages(); + + return useMemo(() => { + if (id === undefined || !data) { + return undefined; + } + + return data.find((language) => language.id === id); + }, [data, id]); +}; + +export const useLanguageByName = (name: string | undefined) => { + const { data } = useLanguages(); + + return useMemo(() => { + if (!name || !data) { + return undefined; + } + + return data.find((language) => language.name === name); + }, [data, name]); +}; diff --git a/frontend/src/Settings/UI/UISettings.tsx b/frontend/src/Settings/UI/UISettings.tsx index 74e16c1db..8ac368702 100644 --- a/frontend/src/Settings/UI/UISettings.tsx +++ b/frontend/src/Settings/UI/UISettings.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useMemo } from 'react'; -import { useSelector } from 'react-redux'; import Alert from 'Components/Alert'; import FieldSet from 'Components/FieldSet'; import Form from 'Components/Form/Form'; @@ -11,8 +10,8 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import { inputTypes, kinds } from 'Helpers/Props'; +import { useFilteredLanguages } from 'Language/useLanguages'; import SettingsToolbar from 'Settings/SettingsToolbar'; -import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector'; import themes from 'Styles/Themes'; import { InputChanged } from 'typings/inputs'; import timeZoneOptions from 'Utilities/Date/timeZoneOptions'; @@ -63,17 +62,15 @@ export const timeFormatOptions: EnhancedSelectInputValue<string>[] = [ function UISettings() { const { - items, + data: languageItems = [], isFetching: isLanguagesFetching, - isPopulated: isLanguagesPopulated, + isFetched: isLanguagesPopulated, error: languagesError, - } = useSelector( - createLanguagesSelector({ - Any: true, - Original: true, - Unknown: true, - }) - ); + } = useFilteredLanguages({ + includeAny: true, + includeOriginal: true, + includeUnknown: true, + }); const { isFetching: isSettingsFetching, @@ -94,13 +91,13 @@ function UISettings() { const error = languagesError || settingsError; const languages = useMemo(() => { - return items.map((item) => { + return languageItems.map((item) => { return { key: item.id, value: item.name, }; }); - }, [items]); + }, [languageItems]); const themeOptions = Object.keys(themes).map((theme) => ({ key: theme, diff --git a/frontend/src/Store/Actions/Settings/languages.js b/frontend/src/Store/Actions/Settings/languages.js deleted file mode 100644 index a0b62fc49..000000000 --- a/frontend/src/Store/Actions/Settings/languages.js +++ /dev/null @@ -1,48 +0,0 @@ -import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; -import { createThunk } from 'Store/thunks'; - -// -// Variables - -const section = 'settings.languages'; - -// -// Actions Types - -export const FETCH_LANGUAGES = 'settings/languages/fetchLanguages'; - -// -// Action Creators - -export const fetchLanguages = createThunk(FETCH_LANGUAGES); - -// -// Details - -export default { - - // - // State - - defaultState: { - isFetching: false, - isPopulated: false, - error: null, - items: [] - }, - - // - // Action Handlers - - actionHandlers: { - [FETCH_LANGUAGES]: createFetchHandler(section, '/language') - }, - - // - // Reducers - - reducers: { - - } - -}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index d830f3124..ad369101b 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -14,7 +14,6 @@ import importLists from './Settings/importLists'; import indexerFlags from './Settings/indexerFlags'; import indexerOptions from './Settings/indexerOptions'; import indexers from './Settings/indexers'; -import languages from './Settings/languages'; export * from './Settings/autoTaggingSpecifications'; export * from './Settings/autoTaggings'; @@ -30,7 +29,6 @@ export * from './Settings/importListExclusions'; export * from './Settings/indexerFlags'; export * from './Settings/indexerOptions'; export * from './Settings/indexers'; -export * from './Settings/languages'; // // Variables @@ -55,8 +53,7 @@ export const defaultState = { importListOptions: importListOptions.defaultState, indexerFlags: indexerFlags.defaultState, indexerOptions: indexerOptions.defaultState, - indexers: indexers.defaultState, - languages: languages.defaultState + indexers: indexers.defaultState }; export const persistState = [ @@ -80,8 +77,7 @@ export const actionHandlers = handleThunks({ ...importListOptions.actionHandlers, ...indexerFlags.actionHandlers, ...indexerOptions.actionHandlers, - ...indexers.actionHandlers, - ...languages.actionHandlers + ...indexers.actionHandlers }); // @@ -101,7 +97,6 @@ export const reducers = createHandleActions({ ...importListOptions.reducers, ...indexerFlags.reducers, ...indexerOptions.reducers, - ...indexers.reducers, - ...languages.reducers + ...indexers.reducers }, defaultState, section); diff --git a/frontend/src/Store/Selectors/createLanguagesSelector.ts b/frontend/src/Store/Selectors/createLanguagesSelector.ts deleted file mode 100644 index 37a6c6135..000000000 --- a/frontend/src/Store/Selectors/createLanguagesSelector.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; - -interface LanguageFilter { - [key: string]: boolean | undefined; - Any: boolean; - Original?: boolean; - Unknown?: boolean; -} - -function createLanguagesSelector( - excludeLanguages: LanguageFilter = { Any: true } -) { - return createSelector( - (state: AppState) => state.settings.languages, - (languages) => { - const { isFetching, isPopulated, error, items } = languages; - - const filteredLanguages = items.filter( - (lang) => !excludeLanguages[lang.name] - ); - - return { - isFetching, - isPopulated, - error, - items: filteredLanguages, - }; - } - ); -} - -export default createLanguagesSelector; From fcfdac99d594f5e9b9b1dcaa4097a00a92c84ca7 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sat, 14 Feb 2026 19:32:35 -0800 Subject: [PATCH 13/13] New: Parse series if year is added to known alias Closes #8408 --- .../ParsingServiceTests/MapFixture.cs | 50 +++++++++++++++++++ src/NzbDrone.Core/Parser/ParsingService.cs | 25 ++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs index 5b26d0461..0f0da6768 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs @@ -293,5 +293,55 @@ public void should_not_use_scene_season_number_from_xem_mapping_if_alias_matches result.MappedSeasonNumber.Should().Be(sceneMapping.SceneSeasonNumber); } + + [Test] + public void should_use_tvdbid_matching_when_alias_without_year_is_found() + { + var alias = "Series Alias"; + + _parsedEpisodeInfo.SeriesTitle = $"{alias} {_series.Year}"; + _parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear = alias; + _parsedEpisodeInfo.SeriesTitleInfo.Year = _series.Year; + + Mocker.GetMock<ISceneMappingService>() + .Setup(s => s.FindTvdbId(alias, It.IsAny<string>(), It.IsAny<int>())) + .Returns(_series.TvdbId); + + Mocker.GetMock<ISeriesService>() + .Setup(s => s.FindByTvdbId(_series.Id)) + .Returns(_series); + + var result = Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, null); + + result.Series.Should().Be(_series); + + Mocker.GetMock<ISeriesService>() + .Verify(v => v.FindByTvdbId(It.IsAny<int>()), Times.Once()); + } + + [Test] + public void should_not_use_tvdbid_matching_when_alias_without_year_is_found_with_wrong_year() + { + var alias = "Series Alias"; + + _parsedEpisodeInfo.SeriesTitle = $"{alias} {_series.Year}"; + _parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear = alias; + _parsedEpisodeInfo.SeriesTitleInfo.Year = _series.Year + 1; + + Mocker.GetMock<ISceneMappingService>() + .Setup(s => s.FindTvdbId(alias, It.IsAny<string>(), It.IsAny<int>())) + .Returns(_series.TvdbId); + + Mocker.GetMock<ISeriesService>() + .Setup(s => s.FindByTvdbId(_series.Id)) + .Returns(_series); + + var result = Subject.Map(_parsedEpisodeInfo, 0, 0, "", null); + + result.Series.Should().BeNull(); + + Mocker.GetMock<ISeriesService>() + .Verify(v => v.FindByTvdbId(It.IsAny<int>()), Times.Once()); + } } } diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 30f5943e2..5b857c002 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -116,6 +116,25 @@ private Series GetSeriesByAllTitles(ParsedEpisodeInfo parsedEpisodeInfo) return foundSeries; } + private Series GetSeriesAliasTitleAndYear(ParsedEpisodeInfo parsedEpisodeInfo) + { + var year = parsedEpisodeInfo.SeriesTitleInfo.Year; + var titleWithoutyear = parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear; + var tvdbId = _sceneMappingService.FindTvdbId(titleWithoutyear, parsedEpisodeInfo.ReleaseTitle, parsedEpisodeInfo.SeasonNumber); + + if (tvdbId.HasValue) + { + var series = _seriesService.FindByTvdbId(tvdbId.Value); + + if (series.Year == year) + { + return series; + } + } + + return null; + } + public RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int tvdbId, int tvRageId, string imdbId, SearchCriteriaBase searchCriteria = null) { return Map(parsedEpisodeInfo, tvdbId, tvRageId, imdbId, null, searchCriteria); @@ -449,6 +468,12 @@ private FindSeriesResult FindSeries(ParsedEpisodeInfo parsedEpisodeInfo, int tvd { series = _seriesService.FindByTitle(parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear, parsedEpisodeInfo.SeriesTitleInfo.Year); matchType = SeriesMatchType.Title; + + if (series == null) + { + series = GetSeriesAliasTitleAndYear(parsedEpisodeInfo); + matchType = SeriesMatchType.Alias; + } } if (series == null && tvdbId > 0)