From 9c61a5c28674c2d642f0d7b1f6a41952f5fb56eb Mon Sep 17 00:00:00 2001 From: Weblate Date: Thu, 12 Feb 2026 14:26:17 +0000 Subject: [PATCH 001/110] 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 002/110] 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 003/110] 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 004/110] 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 005/110] 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 006/110] 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 007/110] 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 008/110] 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 009/110] 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 010/110] 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 011/110] 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 012/110] 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 013/110] 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) From c8f15ae1989cfb7d83cc580409a972e9fdd44a7c Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Fri, 20 Feb 2026 08:06:59 -0800 Subject: [PATCH 014/110] Close issues that don't follow issue templates --- .github/workflows/close_invalid_issues.yml | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/close_invalid_issues.yml diff --git a/.github/workflows/close_invalid_issues.yml b/.github/workflows/close_invalid_issues.yml new file mode 100644 index 000000000..601a61c29 --- /dev/null +++ b/.github/workflows/close_invalid_issues.yml @@ -0,0 +1,26 @@ +name: Close issues without labels + +on: + issues: + types: + - opened + - reopened + +jobs: + close-issue: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + sparse-checkout: | + .github + - name: Close issue if no labels found + if: join(github.event.issue.labels) == '' + run: | + gh issue comment ${{ github.event.issue.number }} --body ":wave: @${{ github.event.issue.user.login }}, this issue was closed automatically because it was created without following an issue template. Please update the issue following the correct template for this issue. Once updated please reply to this issue so we can review and re-open. In the future, use the [issue templates](https://github.com/${{ github.repository }}/issues/new/choose) instead of creating your own." + gh issue close ${{ github.event.issue.number }} --reason "not planned" + env: + GH_TOKEN: ${{ github.token }} From eda676f9a29565e1ed002d048dd60685fb8f5f87 Mon Sep 17 00:00:00 2001 From: Weblate <noreply@weblate.org> Date: Sat, 28 Feb 2026 14:26:18 +0000 Subject: [PATCH 015/110] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: Deleted User <noreply+5063@weblate.org> Co-authored-by: Havok Dan <havokdan@yahoo.com.br> Co-authored-by: Jonas <Sjokoladeergodt@gmail.com> Co-authored-by: Weblate <noreply@weblate.org> Co-authored-by: fordas <fordas15@gmail.com> Co-authored-by: jeff20001204 <jeff20001204@gmail.com> Co-authored-by: lalafei524 <kaba0524@qq.com> Co-authored-by: ugyes <ferenc.bodi@live.com> Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_TW/ Translation: Servarr/Sonarr --- src/NzbDrone.Core/Localization/Core/es.json | 15 ++++++++++++++- src/NzbDrone.Core/Localization/Core/hu.json | 13 +++++++++++++ src/NzbDrone.Core/Localization/Core/nb_NO.json | 8 +++++++- src/NzbDrone.Core/Localization/Core/pt_BR.json | 13 +++++++++++++ src/NzbDrone.Core/Localization/Core/zh_CN.json | 4 +++- src/NzbDrone.Core/Localization/Core/zh_TW.json | 4 ++++ 6 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/es.json b/src/NzbDrone.Core/Localization/Core/es.json index 7ec12946f..dfcd65f27 100644 --- a/src/NzbDrone.Core/Localization/Core/es.json +++ b/src/NzbDrone.Core/Localization/Core/es.json @@ -13,6 +13,7 @@ "AddConditionError": "No se ha podido añadir una nueva condición, prueba otra vez.", "AddConditionImplementation": "Añadir condición - {implementationName}", "AddConnection": "Añadir Conexión", + "AddConnectionError": "No se puede añadir una nueva conexión, por favor inténtalo de nuevo.", "AddConnectionImplementation": "Añadir Conexión - {implementationName}", "AddCustomFilter": "Añadir Filtro Personalizado", "AddCustomFormat": "Añadir Formato Personalizado", @@ -166,6 +167,8 @@ "BlocklistRelease": "Lista de bloqueos de lanzamiento", "BlocklistReleaseHelpText": "Bloquea este lanzamiento de volver a ser descargado por {appName} vía RSS o búsqueda automática", "BlocklistReleases": "Lista de bloqueos de lanzamientos", + "Blocklisted": "Bloqueado", + "BlocklistedAt": "Bloqueado el {date}", "Branch": "Rama", "BranchUpdate": "Rama a usar para actualizar {appName}", "BranchUpdateMechanism": "Rama usada por un mecanismo de actualización externo", @@ -556,6 +559,7 @@ "DownloadClientTriblerSettingsDirectoryHelpText": "Localización opcional en la que poner las descargas, dejar en blanco para usar la localización predeterminada de Tribler", "DownloadClientTriblerSettingsSafeSeeding": "Sembrado seguro", "DownloadClientTriblerSettingsSafeSeedingHelpText": "Cuando se habilita, solo se siembra a través de los proxies.", + "DownloadClientUTorrentProviderMessage": "uTorrent tiene un amplio historial de incluir criptomineros, malware y publicidad, por lo que recomendamos encarecidamente que elijas un cliente diferente.", "DownloadClientUTorrentTorrentStateError": "uTorrent está reportando un error", "DownloadClientUnavailable": "Cliente de descarga no disponible", "DownloadClientValidationApiKeyIncorrect": "Clave API incorrecta", @@ -782,6 +786,7 @@ "Formats": "Formatos", "Forums": "Foros", "FreeSpace": "Espacio libre", + "Friday": "Viernes", "From": "desde", "FullColorEvents": "Eventos a todo color", "FullColorEventsHelpText": "Estilo alterado para colorear todo el evento con el color de estado, en lugar de sólo el borde izquierdo. No se aplica a la Agenda", @@ -1858,6 +1863,7 @@ "RssSyncIntervalHelpText": "Intervalo en minutos. Configurar a cero para deshabilitar (esto detendrá todas las capturas automáticas de lanzamientos)", "RssSyncIntervalHelpTextWarning": "Esto se aplicará a todos los indexadores, por favor sigue las reglas establecidas por ellos", "Runtime": "Tiempo de duración", + "Saturday": "Sábado", "Save": "Guardar", "SaveChanges": "Guardar cambios", "SaveSettings": "Guardar ajustes", @@ -1906,6 +1912,8 @@ "SeasonPremiere": "Estreno de temporada", "SeasonPremieresOnly": "Solo estrenos de temporada", "Seasons": "Temporadas", + "SeasonsMonitoredAll": "Todo", + "SeasonsMonitoredNone": "Ninguno", "SeasonsMonitoredStatus": "Temporadas monitorizadas", "SecretToken": "Token secreto", "Security": "Seguridad", @@ -1929,7 +1937,7 @@ "SelectSeries": "Seleccionar Series", "SendAnonymousUsageData": "Enviar datos de uso anónimos", "Series": "Series", - "SeriesAndEpisodeInformationIsProvidedByTheTVDB": "La información de serie y episodio es proporcionada por TheTVDB.com. [Por favor considera apoyarlos]({url}) .", + "SeriesAndEpisodeInformationIsProvidedByTheTVDB": "La información de serie y episodio es proporcionada por TheTVDB.com. [Por favor considera apoyarlos]({url}).", "SeriesCannotBeFound": "Lo siento, esta serie no puede ser encontrada.", "SeriesDetailsCountEpisodeFiles": "{episodeFileCount} archivos de episodio", "SeriesDetailsGoTo": "Ir a {title}", @@ -2053,6 +2061,7 @@ "SupportedListsMoreInfo": "Para más información en las listas individuales, haz clic en los botones de más información.", "SupportedListsSeries": "{appName} soporta múltiples listas para importar series en la base de datos.", "System": "Sistema", + "SystemDefault": "Predeterminado del sistema", "SystemTimeHealthCheckMessage": "La hora del sistema está desfasada más de 1 día. Las tareas programadas pueden no ejecutarse correctamente hasta que la hora sea corregida", "Table": "Tabla", "TableColumns": "Columnas", @@ -2083,9 +2092,11 @@ "Theme": "Tema", "ThemeHelpText": "Cambia el tema de la interfaz de la aplicación, el tema 'Auto' usará el tema de tu sistema para establecer el modo luminoso u oscuro. Inspirado por Theme.Park", "Threshold": "Umbral", + "Thursday": "Jueves", "Time": "Tiempo", "TimeFormat": "Formato de hora", "TimeLeft": "Tiempo restante", + "TimeZone": "Zona horaria", "Title": "Título", "Titles": "Títulos", "Today": "Hoy", @@ -2114,6 +2125,7 @@ "TotalSpace": "Espacio Total", "Trace": "Traza", "True": "Verdadero", + "Tuesday": "Martes", "TvdbId": "TVDB ID", "TvdbIdExcludeHelpText": "La ID de TVDB de la serie a excluir", "Twitter": "Twitter", @@ -2213,6 +2225,7 @@ "Wanted": "Buscado", "Warn": "Advertencia", "Warning": "Aviso", + "Wednesday": "Miércoles", "Week": "Semana", "WeekColumnHeader": "Cabecera de columna de semana", "WeekColumnHeaderHelpText": "Mostrado sobre cada columna cuando la vista activa es semana", diff --git a/src/NzbDrone.Core/Localization/Core/hu.json b/src/NzbDrone.Core/Localization/Core/hu.json index fbb4f000e..c44918fd9 100644 --- a/src/NzbDrone.Core/Localization/Core/hu.json +++ b/src/NzbDrone.Core/Localization/Core/hu.json @@ -13,6 +13,7 @@ "AddConditionError": "Nem sikerült új feltételt hozzáadni, próbálkozzon újra.", "AddConditionImplementation": "Feltétel hozzáadása – {implementationName}", "AddConnection": "Kapcsolat hozzáadása", + "AddConnectionError": "Nem lehetséges új kapcsolatot hozzáadni, kérem próbálja meg ismét.", "AddConnectionImplementation": "Kapcsolat hozzáadása - {implementationName}", "AddCustomFilter": "Egyéni szűrő hozzáadása", "AddCustomFormat": "Egyéni formátum hozzáadása", @@ -166,6 +167,8 @@ "BlocklistRelease": "Tiltólista release", "BlocklistReleaseHelpText": "Letiltja ennek a release-nek a letöltését a {appName} által RSS-en vagy automatikus keresésen keresztül", "BlocklistReleases": "Tiltólista release-k", + "Blocklisted": "Tiltólistára téve", + "BlocklistedAt": "{date}-n tiltólistára téve", "Branch": "Kiadási csatorna", "BranchUpdate": "A {appName} frissítéséhez használt kiadási csatorna (ág)", "BranchUpdateMechanism": "Külső frissítési mechanizmus által használt kiadási csatorna (ág)", @@ -556,6 +559,7 @@ "DownloadClientTriblerSettingsDirectoryHelpText": "Választható hely a letöltések elhelyezéséhez, hagyja üresen az alapértelmezett Tribler hely használatához", "DownloadClientTriblerSettingsSafeSeeding": "Biztonságos seedelés", "DownloadClientTriblerSettingsSafeSeedingHelpText": "Engedélyezve csak proxykon keresztül seedel.", + "DownloadClientUTorrentProviderMessage": "A uTorrent múltjában előfordult, hogy kriptobányászót, kártevőket és reklámokat tartalmazott, ezért erősen javasoljuk, hogy válasszon egy másik klienst.", "DownloadClientUTorrentTorrentStateError": "Az uTorrent hibát jelez", "DownloadClientUnavailable": "Letöltőkliens nem elérhető", "DownloadClientValidationApiKeyIncorrect": "Az API kulcs helytelen", @@ -782,6 +786,7 @@ "Formats": "Formátumok", "Forums": "Fórumok", "FreeSpace": "Szabad hely", + "Friday": "Péntek", "From": "tól", "FullColorEvents": "Színes események", "FullColorEventsHelpText": "Módosított stílus: a teljes esemény a státusz színét kapja, nem csak a bal oldali sáv. A Teendők nézetre nem vonatkozik", @@ -1858,6 +1863,7 @@ "RssSyncIntervalHelpText": "Intervallum percekben. A letiltáshoz állítsa nullára (ez leállítja az összes automatikus feloldást)", "RssSyncIntervalHelpTextWarning": "Ez minden indexelőre vonatkozik, kérjük, kövesse az általuk meghatározott szabályokat", "Runtime": "Futási Idő", + "Saturday": "Szombat", "Save": "Mentés", "SaveChanges": "Változtatások mentése", "SaveSettings": "Beállítások mentése", @@ -1906,6 +1912,8 @@ "SeasonPremiere": "Évad Premier", "SeasonPremieresOnly": "Csak az évad premierjei", "Seasons": "Évad", + "SeasonsMonitoredAll": "Mind", + "SeasonsMonitoredNone": "Nincs", "SeasonsMonitoredStatus": "Figyelt évadok", "SecretToken": "Titkos token", "Security": "Biztonság", @@ -2053,6 +2061,7 @@ "SupportedListsMoreInfo": "Az egyes listákkal kapcsolatos további információkért kattintson a további információ gombokra.", "SupportedListsSeries": "A {appName} több listát is támogat a sorozatok adatbázisba történő importálásához.", "System": "Rendszer", + "SystemDefault": "Rendszer alapértelmezett", "SystemTimeHealthCheckMessage": "A rendszer idő több, mint 1 napot eltér az aktuális időtől. Előfordulhat, hogy az ütemezett feladatok nem futnak megfelelően, amíg az időt nem korrigálják", "Table": "Táblázat", "TableColumns": "Oszlopok", @@ -2083,9 +2092,11 @@ "Theme": "Téma", "ThemeHelpText": "Változtassa meg az alkalmazás felhasználói felület témáját, az \"Auto\" téma az operációs rendszer témáját használja a Világos vagy Sötét mód beállításához. A Theme.Park ihlette", "Threshold": "Küszöbérték", + "Thursday": "Csütörtök", "Time": "Idő", "TimeFormat": "Időformátum", "TimeLeft": "Hátralévő idő", + "TimeZone": "Időzóna", "Title": "Cím", "Titles": "Címek", "Today": "Ma", @@ -2114,6 +2125,7 @@ "TotalSpace": "Összes terület", "Trace": "Nyomon követés", "True": "Igaz", + "Tuesday": "Kedd", "TvdbId": "TVDB ID", "TvdbIdExcludeHelpText": "A kizárandó sorozat TVDB azonosítója", "Twitter": "Twitter", @@ -2213,6 +2225,7 @@ "Wanted": "Keresett", "Warn": "Figyelmeztetés", "Warning": "Figyelmeztetés", + "Wednesday": "Szerda", "Week": "Hét", "WeekColumnHeader": "Hét oszlopfejléc", "WeekColumnHeaderHelpText": "Minden oszlop felett jelenjen meg, hogy melyik hét az aktuális", diff --git a/src/NzbDrone.Core/Localization/Core/nb_NO.json b/src/NzbDrone.Core/Localization/Core/nb_NO.json index cc9a82136..72033ad40 100644 --- a/src/NzbDrone.Core/Localization/Core/nb_NO.json +++ b/src/NzbDrone.Core/Localization/Core/nb_NO.json @@ -53,14 +53,20 @@ "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.", + "Any": "Hvilken som helst", "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", + "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", + "AuthenticationRequiredHelpText": "Endre hvilke forespørsler som krever autentisering. Ikke endre dette med mindre du forstår risikoen.", + "AuthenticationRequiredPasswordConfirmationHelpTextWarning": "Gjenta nytt passord", + "AuthenticationRequiredPasswordHelpTextWarning": "Oppgi nytt passord", + "AuthenticationRequiredUsernameHelpTextWarning": "Oppgi nytt bruernavn", + "AuthenticationRequiredWarning": "For å forhindre ekstern tilgang uten pålogging, krever {appName} nå at autentisering er aktivert. Du kan velge å deaktivere autentisering for lokale adresser.", "AutomaticAdd": "Legg til automatisk", "CalendarOptions": "Kalenderinnstillinger", "ClearBlocklistMessageText": "Er du sikker på at du vil fjerne alle elementer fra blokkeringslisten?", diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index a4a29f7b8..ecbc14182 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -13,6 +13,7 @@ "AddConditionError": "Não foi possível adicionar uma nova condição, tente novamente.", "AddConditionImplementation": "Adicionar condição - {implementationName}", "AddConnection": "Adicionar conexão", + "AddConnectionError": "Não foi possível adicionar uma nova conexão. Tente novamente.", "AddConnectionImplementation": "Adicionar conexão - {implementationName}", "AddCustomFilter": "Adicionar filtro personalizado", "AddCustomFormat": "Adicionar formato personalizado", @@ -166,6 +167,8 @@ "BlocklistRelease": "Bloquear lançamento", "BlocklistReleaseHelpText": "Impede que este lançamento seja baixado novamente pelo {appName} via RSS ou Pesquisa automática", "BlocklistReleases": "Bloquear lançamentos", + "Blocklisted": "Bloqueados", + "BlocklistedAt": "Bloqueado em {date}", "Branch": "Ramificação", "BranchUpdate": "Ramificação para atualizar o {appName}", "BranchUpdateMechanism": "Ramificação usada pelo mecanismo externo de atualização", @@ -556,6 +559,7 @@ "DownloadClientTriblerSettingsDirectoryHelpText": "Local opcional para colocar downloads, deixe em branco para usar o local padrão do Tribler", "DownloadClientTriblerSettingsSafeSeeding": "Semeadura Segura", "DownloadClientTriblerSettingsSafeSeedingHelpText": "Quando ativado, apenas semeia por meio de proxies.", + "DownloadClientUTorrentProviderMessage": "O uTorrent tem um histórico de inclusão de criptomineradores, malware e anúncios. Recomendamos fortemente que você escolha um cliente diferente.", "DownloadClientUTorrentTorrentStateError": "O uTorrent está relatando um erro", "DownloadClientUnavailable": "Cliente de download indisponível", "DownloadClientValidationApiKeyIncorrect": "Chave da API incorreta", @@ -782,6 +786,7 @@ "Formats": "Formatos", "Forums": "Fóruns", "FreeSpace": "Espaço livre", + "Friday": "Sexta-feira", "From": "De", "FullColorEvents": "Eventos em cores", "FullColorEventsHelpText": "Estilo alterado para colorir todo o evento com a cor do status, em vez de apenas a borda esquerda. Não se aplica à Programação", @@ -1858,6 +1863,7 @@ "RssSyncIntervalHelpText": "Intervalo em minutos. Defina como zero para desativar (isso interromperá todas as capturas de lançamentos automáticas)", "RssSyncIntervalHelpTextWarning": "Isso se aplica a todos os indexadores, siga as regras estabelecidas por eles", "Runtime": "Duração", + "Saturday": "Sábado", "Save": "Salvar", "SaveChanges": "Salvar Mudanças", "SaveSettings": "Salvar configurações", @@ -1906,6 +1912,8 @@ "SeasonPremiere": "Estreia da Temporada", "SeasonPremieresOnly": "Somente Estreias da Temporada", "Seasons": "Temporadas", + "SeasonsMonitoredAll": "Todas", + "SeasonsMonitoredNone": "Nenhuma", "SeasonsMonitoredStatus": "Temporadas monitoradas", "SecretToken": "Token Secreto", "Security": "Segurança", @@ -2053,6 +2061,7 @@ "SupportedListsMoreInfo": "Para obter mais informações sobre as listas individuais, clique nos botões de mais informações.", "SupportedListsSeries": "O {appName} oferece suporte a várias listas para importar séries para o banco de dados.", "System": "Sistema", + "SystemDefault": "Padrão do sistema", "SystemTimeHealthCheckMessage": "A hora do sistema está desligada por mais de 1 dia. Tarefas agendadas podem não ser executadas corretamente até que o horário seja corrigido", "Table": "Tabela", "TableColumns": "Colunas", @@ -2083,9 +2092,11 @@ "Theme": "Tema", "ThemeHelpText": "Alterar o tema da interface do usuário do aplicativo, o tema 'Auto' usará o tema do sistema operacional para definir o modo Claro ou Escuro. Inspirado por Theme.Park", "Threshold": "Limite", + "Thursday": "Quinta-feira", "Time": "Horário", "TimeFormat": "Formato da Hora", "TimeLeft": "Tempo Restante", + "TimeZone": "Fuso Horário", "Title": "Título", "Titles": "Título", "Today": "Hoje", @@ -2114,6 +2125,7 @@ "TotalSpace": "Espaço Total", "Trace": "Traço", "True": "Verdadeiro", + "Tuesday": "Terça-feira", "TvdbId": "TVDB ID", "TvdbIdExcludeHelpText": "A ID TVDB da série a ser excluída", "Twitter": "Twitter", @@ -2213,6 +2225,7 @@ "Wanted": "Procurado", "Warn": "Alerta", "Warning": "Cuidado", + "Wednesday": "Quarta-feira", "Week": "Semana", "WeekColumnHeader": "Cabeçalho da Coluna da Semana", "WeekColumnHeaderHelpText": "Mostrado acima de cada coluna quando a semana é a exibição ativa", diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json index cb382378a..3cf9dd786 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_CN.json +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -3,7 +3,7 @@ "Absolute": "绝对", "AbsoluteEpisodeNumber": "准确的集数", "AbsoluteEpisodeNumbers": "准确的集数", - "Actions": "动作", + "Actions": "操作", "Activity": "活动", "Add": "添加", "AddANewPath": "添加一个新的目录", @@ -1278,6 +1278,8 @@ "NotificationsPushoverSettingsRetryHelpText": "紧急警报的重试间隔,最少 30 秒", "NotificationsPushoverSettingsSound": "声音", "NotificationsPushoverSettingsSoundHelpText": "通知声音,留空使用默认声音", + "NotificationsPushoverSettingsTtl": "有效期", + "NotificationsPushoverSettingsTtlHelpText": "消息过期前的秒数。设为 0 表示永久有效(永不过期)", "NotificationsPushoverSettingsUserKey": "用户密钥", "NotificationsSendGridSettingsApiKeyHelpText": "SendGrid 生成的 API 密钥", "NotificationsSettingsUpdateLibrary": "更新资源库", diff --git a/src/NzbDrone.Core/Localization/Core/zh_TW.json b/src/NzbDrone.Core/Localization/Core/zh_TW.json index f9a751450..d9d9e6ee5 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_TW.json +++ b/src/NzbDrone.Core/Localization/Core/zh_TW.json @@ -13,6 +13,7 @@ "AddConditionError": "無法加入新的條件,請重新嘗試。", "AddConditionImplementation": "新增條件 - {implementationName}", "AddConnection": "新增連接", + "AddConnectionError": "無法新增新的連線,請重試。", "AddConnectionImplementation": "新增連接 - {implementationName}", "AddCustomFilter": "新增自定義過濾器", "AddCustomFormat": "加入自訂格式", @@ -64,6 +65,9 @@ "BlocklistRelease": "封鎖清單版本", "BlocklistReleases": "封鎖清單版本", "NotificationsPushoverSettingsExpireHelpText": "緊急警報的最大重試時間,最長為 86400 秒 (24 小時)", + "NotificationsPushoverSettingsTtl": "有效期限", + "NotificationsPushoverSettingsTtlHelpText": "訊息過期前的秒數。設置為 0 表示無期限", + "NotificationsPushoverSettingsUserKey": "使用者金鑰", "UnselectAll": "取消全選", "UpdateAppDirectlyLoadError": "無法直接更新 {appName},", "UpdateAvailableHealthCheckMessage": "可用的新版本: {version}", From f93bc57426788915bde4bfd7408f9e414cb97b1a Mon Sep 17 00:00:00 2001 From: Sonarr <development@sonarr.tv> Date: Mon, 23 Feb 2026 00:25:52 +0000 Subject: [PATCH 016/110] Automated API Docs update ignore-downstream --- src/Sonarr.Api.V5/openapi.json | 100 ++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/src/Sonarr.Api.V5/openapi.json b/src/Sonarr.Api.V5/openapi.json index c2e0b558c..770de9c6b 100644 --- a/src/Sonarr.Api.V5/openapi.json +++ b/src/Sonarr.Api.V5/openapi.json @@ -1936,6 +1936,74 @@ } } }, + "/api/v5/language": { + "get": { + "tags": [ + "Language" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LanguageResource" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LanguageResource" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LanguageResource" + } + } + } + } + } + } + } + }, + "/api/v5/language/{id}": { + "get": { + "tags": [ + "Language" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LanguageResource" + } + } + } + } + } + } + }, "/api/v5/localization": { "get": { "tags": [ @@ -6226,7 +6294,8 @@ "diskCustomFormatScore", "diskCustomFormatScoreIncrement", "diskUpgradesNotAllowed", - "diskNotUpgrade" + "diskNotUpgrade", + "beforeAirDate" ], "type": "string" }, @@ -6900,6 +6969,25 @@ }, "additionalProperties": false }, + "LanguageResource": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "nameLower": { + "type": "string", + "nullable": true, + "readOnly": true + } + }, + "additionalProperties": false + }, "LocalizationLanguageResource": { "type": "object", "properties": { @@ -8566,6 +8654,13 @@ }, "nullable": true }, + "airDateRestriction": { + "type": "boolean" + }, + "airDateGracePeriod": { + "type": "integer", + "format": "int32" + }, "indexerIds": { "type": "array", "items": { @@ -10013,6 +10108,9 @@ { "name": "History" }, + { + "name": "Language" + }, { "name": "Localization" }, From 33fb0a4e880a5a5a3dc3d04e26d85b0a22c73552 Mon Sep 17 00:00:00 2001 From: felizk <felizk@gmail.com> Date: Sun, 1 Mar 2026 18:02:13 +0100 Subject: [PATCH 017/110] New: Calculate custom score using renamed filename before importing --- ...deCustomFormatCalculationServiceFixture.cs | 100 ++++++++++++++++++ .../UpgradeSpecificationFixture.cs | 44 ++++++++ .../CustomFormatCalculationService.cs | 8 +- .../EpisodeImport/ImportApprovedEpisodes.cs | 88 +++------------ .../EpisodeImport/ImportDecisionMaker.cs | 8 +- .../EpisodeImport/ImportRejectionReason.cs | 3 +- ...alEpisodeCustomFormatCalculationService.cs | 42 ++++++++ .../Manual/ManualImportService.cs | 12 +-- .../Specifications/UpgradeSpecification.cs | 14 +++ .../MediaInfo/UpdateMediaInfoService.cs | 7 +- .../Parser/Model/LocalEpisode.cs | 92 ++++++++++++++-- 11 files changed, 317 insertions(+), 101 deletions(-) create mode 100644 src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/LocalEpisodeCustomFormatCalculationServiceFixture.cs create mode 100644 src/NzbDrone.Core/MediaFiles/EpisodeImport/LocalEpisodeCustomFormatCalculationService.cs diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/LocalEpisodeCustomFormatCalculationServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/LocalEpisodeCustomFormatCalculationServiceFixture.cs new file mode 100644 index 000000000..3a5ac8ea2 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/LocalEpisodeCustomFormatCalculationServiceFixture.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Languages; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.EpisodeImport; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport +{ + [TestFixture] + public class LocalEpisodeCustomFormatCalculationServiceFixture : CoreTest<LocalEpisodeCustomFormatCalculationService> + { + private const int EnglishCustomFormatScore = 10; + private const int SpanishCustomFormatScore = 2; + private LocalEpisode _localEpisode; + private Series _series; + private QualityModel _quality; + private CustomFormat _englishCustomFormat; + private CustomFormat _spanishCustomFormat; + + [SetUp] + public void Setup() + { + _englishCustomFormat = new CustomFormat("HasEnglish") { Id = 1 }; + _spanishCustomFormat = new CustomFormat("HasSpanish") { Id = 2 }; + _series = Builder<Series>.CreateNew() + .With(e => e.Path = @"C:\Test\Series".AsOsAgnostic()) + .With(e => e.QualityProfile = new QualityProfile + { + Items = Qualities.QualityFixture.GetDefaultQualities(), + FormatItems = [ + new ProfileFormatItem { Format = _englishCustomFormat, Score = EnglishCustomFormatScore }, + new ProfileFormatItem { Format = _spanishCustomFormat, Score = SpanishCustomFormatScore } + ] + }) + .Build(); + + _quality = new QualityModel(Quality.DVD); + + _localEpisode = new LocalEpisode + { + Series = _series, + Quality = _quality, + Languages = new List<Language> { Language.Spanish }, + Episodes = new List<Episode> { new Episode() }, + Path = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.Spanish.XviD-OSiTV.avi" + }; + + Mocker.GetMock<ICustomFormatCalculationService>() + .Setup(s => s.ParseCustomFormat(It.IsAny<LocalEpisode>(), It.Is<string>(x => x.Contains("English")))) + .Returns([_englishCustomFormat]); + + Mocker.GetMock<ICustomFormatCalculationService>() + .Setup(s => s.ParseCustomFormat(It.IsAny<LocalEpisode>(), It.Is<string>(x => x.Contains("Spanish")))) + .Returns([_spanishCustomFormat]); + } + + [Test] + public void should_build_a_filename_and_use_it_to_calculate_custom_score() + { + var renamedFileName = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.English.XviD-OSiTV.avi"; + + Mocker.GetMock<IBuildFileNames>() + .Setup(s => s.BuildFileName(It.IsAny<List<Episode>>(), It.IsAny<Series>(), It.IsAny<EpisodeFile>(), "", null, null)) + .Returns(renamedFileName); + + Subject.ParseEpisodeCustomFormats(_localEpisode).Should().BeEquivalentTo([_englishCustomFormat]); + } + + [Test] + public void should_update_custom_formats_on_local_episode() + { + var renamedFileName = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.English.XviD-OSiTV.avi"; + + Mocker.GetMock<IBuildFileNames>() + .Setup(s => s.BuildFileName(It.IsAny<List<Episode>>(), It.IsAny<Series>(), It.IsAny<EpisodeFile>(), "", null, null)) + .Returns(renamedFileName); + + Subject.UpdateEpisodeCustomFormats(_localEpisode); + _localEpisode.FileNameUsedForCustomFormatCalculation.Should().Be(renamedFileName); + + _localEpisode.OriginalFileNameCustomFormats.Should().BeEquivalentTo([_spanishCustomFormat]); + _localEpisode.OriginalFileNameCustomFormatScore.Should().Be(SpanishCustomFormatScore); + + _localEpisode.CustomFormats.Should().BeEquivalentTo([_englishCustomFormat]); + _localEpisode.CustomFormatScore.Should().Be(EnglishCustomFormatScore); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/UpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/UpgradeSpecificationFixture.cs index fbad49de8..eaa888733 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/UpgradeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/UpgradeSpecificationFixture.cs @@ -9,6 +9,7 @@ using NzbDrone.Core.Datastore; using NzbDrone.Core.Languages; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles; @@ -565,5 +566,48 @@ public void should_return_false_if_not_upgrade_to_custom_format_score() Subject.IsSatisfiedBy(_localEpisode, null).Accepted.Should().BeFalse(); } + + [Test] + public void should_return_false_and_a_specific_reason_if_not_upgrade_to_custom_format_score_after_local_file_rename_but_was_before() + { + var episodeFileCustomFormats = Builder<CustomFormat>.CreateListOfSize(1).Build().ToList(); + + var episodeFile = new EpisodeFile + { + Quality = new QualityModel(Quality.Bluray1080p) + }; + + _series.QualityProfile.Value.FormatItems = episodeFileCustomFormats.Select(c => new ProfileFormatItem + { + Format = c, + Score = 50 + }) + .ToList(); + + Mocker.GetMock<IConfigService>() + .Setup(s => s.DownloadPropersAndRepacks) + .Returns(ProperDownloadTypes.DoNotPrefer); + + Mocker.GetMock<ICustomFormatCalculationService>() + .Setup(s => s.ParseCustomFormat(episodeFile)) + .Returns(episodeFileCustomFormats); + + _localEpisode.Quality = new QualityModel(Quality.Bluray1080p); + _localEpisode.CustomFormats = Builder<CustomFormat>.CreateListOfSize(1).Build().ToList(); + _localEpisode.CustomFormatScore = 20; + _localEpisode.OriginalFileNameCustomFormats = Builder<CustomFormat>.CreateListOfSize(1).Build().ToList(); + _localEpisode.OriginalFileNameCustomFormatScore = 60; + + _localEpisode.Episodes = Builder<Episode>.CreateListOfSize(1) + .All() + .With(e => e.EpisodeFileId = 1) + .With(e => e.EpisodeFile = new LazyLoaded<EpisodeFile>(episodeFile)) + .Build() + .ToList(); + + var result = Subject.IsSatisfiedBy(_localEpisode, null); + result.Accepted.Should().BeFalse(); + result.Reason.Should().Be(ImportRejectionReason.NotCustomFormatUpgradeAfterRename); + } } } diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs index 1f0cb296b..14f206609 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs @@ -19,7 +19,7 @@ public interface ICustomFormatCalculationService List<CustomFormat> ParseCustomFormat(EpisodeFile episodeFile); List<CustomFormat> ParseCustomFormat(Blocklist blocklist, Series series); List<CustomFormat> ParseCustomFormat(EpisodeHistory history, Series series); - List<CustomFormat> ParseCustomFormat(LocalEpisode localEpisode); + List<CustomFormat> ParseCustomFormat(LocalEpisode localEpisode, string fileName); } public class CustomFormatCalculationService : ICustomFormatCalculationService @@ -114,12 +114,12 @@ public List<CustomFormat> ParseCustomFormat(EpisodeHistory history, Series serie return ParseCustomFormat(input); } - public List<CustomFormat> ParseCustomFormat(LocalEpisode localEpisode) + public List<CustomFormat> ParseCustomFormat(LocalEpisode localEpisode, string fileName) { var episodeInfo = new ParsedEpisodeInfo { SeriesTitle = localEpisode.Series.Title, - ReleaseTitle = localEpisode.SceneName.IsNotNullOrWhiteSpace() ? localEpisode.SceneName : Path.GetFileName(localEpisode.Path), + ReleaseTitle = localEpisode.SceneName.IsNotNullOrWhiteSpace() ? localEpisode.SceneName : fileName, Quality = localEpisode.Quality, Languages = localEpisode.Languages, ReleaseGroup = localEpisode.ReleaseGroup @@ -133,7 +133,7 @@ public List<CustomFormat> ParseCustomFormat(LocalEpisode localEpisode) Languages = localEpisode.Languages, IndexerFlags = localEpisode.IndexerFlags, ReleaseType = localEpisode.ReleaseType, - Filename = Path.GetFileName(localEpisode.Path) + Filename = fileName }; return ParseCustomFormat(input); diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs index 90f15a348..0db1234e3 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Disk; @@ -85,24 +84,8 @@ public List<ImportResult> Import(List<ImportDecision> decisions, bool newDownloa continue; } - var episodeFile = new EpisodeFile(); - episodeFile.DateAdded = DateTime.UtcNow; - episodeFile.SeriesId = localEpisode.Series.Id; - episodeFile.Path = localEpisode.Path.CleanFilePath(); + var episodeFile = localEpisode.ToEpisodeFile(); episodeFile.Size = _diskProvider.GetFileSize(localEpisode.Path); - episodeFile.Quality = localEpisode.Quality; - episodeFile.MediaInfo = localEpisode.MediaInfo; - episodeFile.Series = localEpisode.Series; - episodeFile.SeasonNumber = localEpisode.SeasonNumber; - episodeFile.Episodes = localEpisode.Episodes; - episodeFile.ReleaseGroup = localEpisode.ReleaseGroup; - episodeFile.ReleaseHash = localEpisode.ReleaseHash; - episodeFile.Languages = localEpisode.Languages; - - // Prefer the release type from the download client, folder and finally the file so we have the most accurate information. - episodeFile.ReleaseType = localEpisode.DownloadClientEpisodeInfo?.ReleaseType ?? - localEpisode.FolderEpisodeInfo?.ReleaseType ?? - localEpisode.FileEpisodeInfo.ReleaseType; if (downloadClientItem?.DownloadId.IsNotNullOrWhiteSpace() == true) { @@ -118,23 +101,12 @@ public List<ImportResult> Import(List<ImportDecision> decisions, bool newDownloa // Prefer the release type from the grabbed history if (Enum.TryParse(grabHistory?.Data.GetValueOrDefault("releaseType"), true, out ReleaseType releaseType)) { - episodeFile.ReleaseType = releaseType; + if (releaseType != ReleaseType.Unknown) + { + episodeFile.ReleaseType = releaseType; + } } } - else - { - episodeFile.IndexerFlags = localEpisode.IndexerFlags; - episodeFile.ReleaseType = localEpisode.ReleaseType; - } - - // Fall back to parsed information if history is unavailable or missing - if (episodeFile.ReleaseType == ReleaseType.Unknown) - { - // Prefer the release type from the download client, folder and finally the file so we have the most accurate information. - episodeFile.ReleaseType = localEpisode.DownloadClientEpisodeInfo?.ReleaseType ?? - localEpisode.FolderEpisodeInfo?.ReleaseType ?? - localEpisode.FileEpisodeInfo.ReleaseType; - } bool copyOnly; switch (importMode) @@ -153,15 +125,20 @@ public List<ImportResult> Import(List<ImportDecision> decisions, bool newDownloa if (newDownload) { - episodeFile.SceneName = localEpisode.SceneName; - episodeFile.OriginalFilePath = GetOriginalFilePath(downloadClientItem, localEpisode); + if (downloadClientItem is { OutputPath.IsEmpty: false }) + { + var outputDirectory = downloadClientItem.OutputPath.Directory.ToString(); + + if (outputDirectory.IsParentPath(localEpisode.Path)) + { + episodeFile.OriginalFilePath = outputDirectory.GetRelativePath(localEpisode.Path); + } + } oldFiles = _episodeFileUpgrader.UpgradeEpisodeFile(episodeFile, localEpisode, copyOnly).OldFiles; } else { - episodeFile.RelativePath = localEpisode.Series.Path.GetRelativePath(episodeFile.Path); - // Delete existing files from the DB mapped to this path var previousFiles = _mediaFileService.GetFilesWithRelativePath(localEpisode.Series.Id, episodeFile.RelativePath); @@ -228,42 +205,5 @@ public List<ImportResult> Import(List<ImportDecision> decisions, bool newDownloa return importResults; } - - private string GetOriginalFilePath(DownloadClientItem downloadClientItem, LocalEpisode localEpisode) - { - var path = localEpisode.Path; - - if (downloadClientItem != null && !downloadClientItem.OutputPath.IsEmpty) - { - var outputDirectory = downloadClientItem.OutputPath.Directory.ToString(); - - if (outputDirectory.IsParentPath(path)) - { - return outputDirectory.GetRelativePath(path); - } - } - - var folderEpisodeInfo = localEpisode.FolderEpisodeInfo; - - if (folderEpisodeInfo != null) - { - var folderPath = path.GetAncestorPath(folderEpisodeInfo.ReleaseTitle); - - if (folderPath != null) - { - return folderPath.GetParentPath().GetRelativePath(path); - } - } - - var parentPath = path.GetParentPath(); - var grandparentPath = parentPath.GetParentPath(); - - if (grandparentPath != null) - { - return grandparentPath.GetRelativePath(path); - } - - return Path.GetFileName(path); - } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs index 762a5a4f3..64ee67750 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs @@ -4,7 +4,6 @@ using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Core.CustomFormats; using NzbDrone.Core.Download; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation; @@ -30,7 +29,7 @@ public class ImportDecisionMaker : IMakeImportDecision private readonly IDiskProvider _diskProvider; private readonly IDetectSample _detectSample; private readonly ITrackedDownloadService _trackedDownloadService; - private readonly ICustomFormatCalculationService _formatCalculator; + private readonly ILocalEpisodeCustomFormatCalculationService _formatCalculator; private readonly Logger _logger; public ImportDecisionMaker(IEnumerable<IImportDecisionEngineSpecification> specifications, @@ -39,7 +38,7 @@ public ImportDecisionMaker(IEnumerable<IImportDecisionEngineSpecification> speci IDiskProvider diskProvider, IDetectSample detectSample, ITrackedDownloadService trackedDownloadService, - ICustomFormatCalculationService formatCalculator, + ILocalEpisodeCustomFormatCalculationService formatCalculator, Logger logger) { _specifications = specifications; @@ -158,8 +157,7 @@ private ImportDecision GetDecision(LocalEpisode localEpisode, DownloadClientItem } } - localEpisode.CustomFormats = _formatCalculator.ParseCustomFormat(localEpisode); - localEpisode.CustomFormatScore = localEpisode.Series.QualityProfile?.Value.CalculateCustomFormatScore(localEpisode.CustomFormats) ?? 0; + _formatCalculator.UpdateEpisodeCustomFormats(localEpisode); decision = GetDecision(localEpisode, downloadClientItem); } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs index 7a0ce4170..20ca27b02 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs @@ -36,5 +36,6 @@ public enum ImportRejectionReason UnverifiedSceneMapping, NotQualityUpgrade, NotRevisionUpgrade, - NotCustomFormatUpgrade + NotCustomFormatUpgrade, + NotCustomFormatUpgradeAfterRename } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/LocalEpisodeCustomFormatCalculationService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/LocalEpisodeCustomFormatCalculationService.cs new file mode 100644 index 000000000..5ddf0e957 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/LocalEpisodeCustomFormatCalculationService.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.IO; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport; + +public interface ILocalEpisodeCustomFormatCalculationService +{ + public List<CustomFormat> ParseEpisodeCustomFormats(LocalEpisode localEpisode); + public void UpdateEpisodeCustomFormats(LocalEpisode localEpisode); +} + +public class LocalEpisodeCustomFormatCalculationService : ILocalEpisodeCustomFormatCalculationService +{ + private readonly IBuildFileNames _fileNameBuilder; + private readonly ICustomFormatCalculationService _formatCalculator; + + public LocalEpisodeCustomFormatCalculationService(IBuildFileNames fileNameBuilder, ICustomFormatCalculationService formatCalculator) + { + _fileNameBuilder = fileNameBuilder; + _formatCalculator = formatCalculator; + } + + public List<CustomFormat> ParseEpisodeCustomFormats(LocalEpisode localEpisode) + { + var fileNameUsedForCustomFormatCalculation = _fileNameBuilder.BuildFileName(localEpisode.Episodes, localEpisode.Series, localEpisode.ToEpisodeFile()); + return _formatCalculator.ParseCustomFormat(localEpisode, fileNameUsedForCustomFormatCalculation); + } + + public void UpdateEpisodeCustomFormats(LocalEpisode localEpisode) + { + var fileNameUsedForCustomFormatCalculation = _fileNameBuilder.BuildFileName(localEpisode.Episodes, localEpisode.Series, localEpisode.ToEpisodeFile()); + localEpisode.CustomFormats = _formatCalculator.ParseCustomFormat(localEpisode, fileNameUsedForCustomFormatCalculation); + localEpisode.FileNameUsedForCustomFormatCalculation = fileNameUsedForCustomFormatCalculation; + localEpisode.CustomFormatScore = localEpisode.Series.QualityProfile?.Value.CalculateCustomFormatScore(localEpisode.CustomFormats) ?? 0; + + localEpisode.OriginalFileNameCustomFormats = _formatCalculator.ParseCustomFormat(localEpisode, Path.GetFileName(localEpisode.Path)); + localEpisode.OriginalFileNameCustomFormatScore = localEpisode.Series.QualityProfile?.Value.CalculateCustomFormatScore(localEpisode.OriginalFileNameCustomFormats) ?? 0; + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs index 5adf30d8e..11c9d56b6 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs @@ -41,6 +41,7 @@ public class ManualImportService : IExecute<ManualImportCommand>, IManualImportS private readonly IDownloadedEpisodesImportService _downloadedEpisodesImportService; private readonly IMediaFileService _mediaFileService; private readonly ICustomFormatCalculationService _formatCalculator; + private readonly ILocalEpisodeCustomFormatCalculationService _localEpisodeFormatCalculator; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; @@ -55,6 +56,7 @@ public ManualImportService(IDiskProvider diskProvider, ITrackedDownloadService trackedDownloadService, IDownloadedEpisodesImportService downloadedEpisodesImportService, IMediaFileService mediaFileService, + ILocalEpisodeCustomFormatCalculationService localEpisodeFormatCalculator, ICustomFormatCalculationService formatCalculator, IEventAggregator eventAggregator, Logger logger) @@ -70,6 +72,7 @@ public ManualImportService(IDiskProvider diskProvider, _trackedDownloadService = trackedDownloadService; _downloadedEpisodesImportService = downloadedEpisodesImportService; _mediaFileService = mediaFileService; + _localEpisodeFormatCalculator = localEpisodeFormatCalculator; _formatCalculator = formatCalculator; _eventAggregator = eventAggregator; _logger = logger; @@ -180,8 +183,7 @@ public ManualImportItem ReprocessItem(string path, string downloadId, int series localEpisode.IndexerFlags = (IndexerFlags)indexerFlags; localEpisode.ReleaseType = releaseType; - localEpisode.CustomFormats = _formatCalculator.ParseCustomFormat(localEpisode); - localEpisode.CustomFormatScore = localEpisode.Series?.QualityProfile?.Value.CalculateCustomFormatScore(localEpisode.CustomFormats) ?? 0; + _localEpisodeFormatCalculator.UpdateEpisodeCustomFormats(localEpisode); // Augment episode file so imported files have all additional information an automatic import would localEpisode = _aggregationService.Augment(localEpisode, downloadClientItem); @@ -445,8 +447,7 @@ private ManualImportItem MapItem(ImportDecision decision, string rootFolder, str if (decision.LocalEpisode.Series != null) { item.Series = decision.LocalEpisode.Series; - - item.CustomFormats = _formatCalculator.ParseCustomFormat(decision.LocalEpisode); + item.CustomFormats = _localEpisodeFormatCalculator.ParseEpisodeCustomFormats(decision.LocalEpisode); item.CustomFormatScore = item.Series.QualityProfile?.Value.CalculateCustomFormatScore(item.CustomFormats) ?? 0; } @@ -537,8 +538,7 @@ public void Execute(ManualImportCommand message) localEpisode.IndexerFlags = (IndexerFlags)file.IndexerFlags; localEpisode.ReleaseType = file.ReleaseType; - localEpisode.CustomFormats = _formatCalculator.ParseCustomFormat(localEpisode); - localEpisode.CustomFormatScore = localEpisode.Series.QualityProfile?.Value.CalculateCustomFormatScore(localEpisode.CustomFormats) ?? 0; + _localEpisodeFormatCalculator.UpdateEpisodeCustomFormats(localEpisode); // TODO: Cleanup non-tracked downloads diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs index ae824ffb9..585213ccd 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs @@ -62,6 +62,8 @@ public ImportSpecDecision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClien var currentFormatScore = qualityProfile.CalculateCustomFormatScore(currentFormats); var newFormats = localEpisode.CustomFormats; var newFormatScore = localEpisode.CustomFormatScore; + var newFormatsBeforeRename = localEpisode.OriginalFileNameCustomFormats; + var newFormatScoreBeforeRename = localEpisode.OriginalFileNameCustomFormatScore; if (qualityCompare == 0 && newFormatScore < currentFormatScore) { @@ -71,6 +73,18 @@ public ImportSpecDecision IsSatisfiedBy(LocalEpisode localEpisode, DownloadClien currentFormats != null ? currentFormats.ConcatToString() : "", currentFormatScore); + if (newFormatScoreBeforeRename > currentFormatScore) + { + return ImportSpecDecision.Reject(ImportRejectionReason.NotCustomFormatUpgradeAfterRename, + "Not a Custom Format upgrade for existing episode file(s). AfterRename: [{0}] ({1}) do not improve on Existing: [{2}] ({3}) even though BeforeRename: [{4}] ({5}) did.", + newFormats != null ? newFormats.ConcatToString() : "", + newFormatScore, + currentFormats != null ? currentFormats.ConcatToString() : "", + currentFormatScore, + newFormatsBeforeRename != null ? newFormatsBeforeRename.ConcatToString() : "", + newFormatScoreBeforeRename); + } + return ImportSpecDecision.Reject(ImportRejectionReason.NotCustomFormatUpgrade, "Not a Custom Format upgrade for existing episode file(s). New: [{0}] ({1}) do not improve on Existing: [{2}] ({3})", newFormats != null ? newFormats.ConcatToString() : "", diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs index cde1db229..bf2a6bc1d 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs @@ -85,7 +85,12 @@ public bool UpdateMediaInfo(EpisodeFile episodeFile, Series series) } episodeFile.MediaInfo = updatedMediaInfo; - _mediaFileService.Update(episodeFile); + + if (episodeFile.Id != 0) + { + _mediaFileService.Update(episodeFile); + } + _logger.Debug("Updated MediaInfo for '{0}'", path); return true; diff --git a/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs b/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs index 0129c5d0c..f0e2ad0a3 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Extensions; @@ -13,13 +14,6 @@ namespace NzbDrone.Core.Parser.Model { public class LocalEpisode { - public LocalEpisode() - { - Episodes = new List<Episode>(); - Languages = new List<Language>(); - CustomFormats = new List<CustomFormat>(); - } - public string Path { get; set; } public long Size { get; set; } public ParsedEpisodeInfo FileEpisodeInfo { get; set; } @@ -27,10 +21,10 @@ public LocalEpisode() public DownloadClientItem DownloadItem { get; set; } public ParsedEpisodeInfo FolderEpisodeInfo { get; set; } public Series Series { get; set; } - public List<Episode> Episodes { get; set; } + public List<Episode> Episodes { get; set; } = new(); public List<DeletedEpisodeFile> OldFiles { get; set; } public QualityModel Quality { get; set; } - public List<Language> Languages { get; set; } + public List<Language> Languages { get; set; } = new(); public IndexerFlags IndexerFlags { get; set; } public ReleaseType ReleaseType { get; set; } public MediaInfoModel MediaInfo { get; set; } @@ -40,11 +34,14 @@ public LocalEpisode() public string ReleaseHash { get; set; } public string SceneName { get; set; } public bool OtherVideoFiles { get; set; } - public List<CustomFormat> CustomFormats { get; set; } + public List<CustomFormat> CustomFormats { get; set; } = new(); public int CustomFormatScore { get; set; } + public List<CustomFormat> OriginalFileNameCustomFormats { get; set; } = new(); + public int OriginalFileNameCustomFormatScore { get; set; } public GrabbedReleaseInfo Release { get; set; } public bool ScriptImported { get; set; } public string FileNameBeforeRename { get; set; } + public string FileNameUsedForCustomFormatCalculation { get; set; } public bool ShouldImportExtras { get; set; } public List<string> PossibleExtraFiles { get; set; } public SubtitleTitleInfo SubtitleInfo { get; set; } @@ -75,5 +72,80 @@ public override string ToString() { return Path; } + + public string GetSceneOrFileName() + { + if (SceneName.IsNotNullOrWhiteSpace()) + { + return SceneName; + } + + if (Path.IsNotNullOrWhiteSpace()) + { + return System.IO.Path.GetFileNameWithoutExtension(Path); + } + + return string.Empty; + } + + public EpisodeFile ToEpisodeFile() + { + var episodeFile = new EpisodeFile + { + DateAdded = DateTime.UtcNow, + SeriesId = Series.Id, + Path = Path.CleanFilePath(), + Quality = Quality, + MediaInfo = MediaInfo, + Series = Series, + SeasonNumber = SeasonNumber, + Episodes = Episodes, + ReleaseGroup = ReleaseGroup, + ReleaseHash = ReleaseHash, + Languages = Languages, + IndexerFlags = IndexerFlags, + ReleaseType = ReleaseType, + SceneName = SceneName, + OriginalFilePath = GetOriginalFilePath() + }; + + if (Series.Path.IsParentPath(episodeFile.Path)) + { + episodeFile.RelativePath = Series.Path.GetRelativePath(Path.CleanFilePath()); + } + + if (episodeFile.ReleaseType == ReleaseType.Unknown) + { + episodeFile.ReleaseType = DownloadClientEpisodeInfo?.ReleaseType ?? + FolderEpisodeInfo?.ReleaseType ?? + FileEpisodeInfo?.ReleaseType ?? + ReleaseType.Unknown; + } + + return episodeFile; + } + + private string GetOriginalFilePath() + { + if (FolderEpisodeInfo != null) + { + var folderPath = Path.GetAncestorPath(FolderEpisodeInfo.ReleaseTitle); + + if (folderPath != null) + { + return folderPath.GetParentPath().GetRelativePath(Path); + } + } + + var parentPath = Path.GetParentPath(); + var grandparentPath = parentPath.GetParentPath(); + + if (grandparentPath != null) + { + return grandparentPath.GetRelativePath(Path); + } + + return System.IO.Path.GetFileName(Path); + } } } From ac1c74105f4a739ec61636dc3817a4e942309434 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 16 Feb 2026 13:37:59 -0800 Subject: [PATCH 018/110] Cleanup settings controllers --- .../Configuration/ConfigFileProvider.cs | 7 +++- .../Configuration/ConfigService.cs | 7 +++- .../MediaManagementSettingsController.cs | 23 ++++++------ .../Settings/SettingsController.cs | 13 ++++--- .../Settings/UiSettingsController.cs | 25 ++----------- .../Settings/UpdateSettingsController.cs | 36 +++---------------- .../Settings/UpdateSettingsResource.cs | 15 ++++++++ 7 files changed, 55 insertions(+), 71 deletions(-) diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index 8cd48849b..1902a9773 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -137,6 +137,7 @@ public void SaveConfigDictionary(Dictionary<string, object> configValues) _cache.Clear(); var allWithDefaults = GetConfigDictionary(); + var hasUpdated = false; foreach (var configValue in configValues) { @@ -155,11 +156,15 @@ public void SaveConfigDictionary(Dictionary<string, object> configValues) if (!equal) { + hasUpdated = true; SetValue(configValue.Key.FirstCharToUpper(), configValue.Value.ToString()); } } - _eventAggregator.PublishEvent(new ConfigFileSavedEvent()); + if (hasUpdated) + { + _eventAggregator.PublishEvent(new ConfigFileSavedEvent()); + } } public string BindAddress diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index cf5a35529..72ae79891 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -55,6 +55,7 @@ private Dictionary<string, object> AllWithDefaults() public void SaveConfigDictionary(Dictionary<string, object> configValues) { var allWithDefaults = AllWithDefaults(); + var hasUpdated = false; foreach (var configValue in configValues) { @@ -68,11 +69,15 @@ public void SaveConfigDictionary(Dictionary<string, object> configValues) if (!equal) { + hasUpdated = true; SetValue(configValue.Key, configValue.Value.ToString()); } } - _eventAggregator.PublishEvent(new ConfigSavedEvent()); + if (hasUpdated) + { + _eventAggregator.PublishEvent(new ConfigSavedEvent()); + } } public bool IsDefined(string key) diff --git a/src/Sonarr.Api.V5/Settings/MediaManagementSettingsController.cs b/src/Sonarr.Api.V5/Settings/MediaManagementSettingsController.cs index 3d727bfa9..113db8ff4 100644 --- a/src/Sonarr.Api.V5/Settings/MediaManagementSettingsController.cs +++ b/src/Sonarr.Api.V5/Settings/MediaManagementSettingsController.cs @@ -11,16 +11,17 @@ namespace Sonarr.Api.V5.Settings; [V5ApiController("settings/mediamanagement")] public class MediaManagementSettingsController : SettingsController<MediaManagementSettingsResource> { - public MediaManagementSettingsController(IConfigService configService, - PathExistsValidator pathExistsValidator, - FolderChmodValidator folderChmodValidator, - FolderWritableValidator folderWritableValidator, - SeriesPathValidator seriesPathValidator, - StartupFolderValidator startupFolderValidator, - SystemFolderValidator systemFolderValidator, - RootFolderAncestorValidator rootFolderAncestorValidator, - RootFolderValidator rootFolderValidator) - : base(configService) + public MediaManagementSettingsController(IConfigFileProvider configFileProvider, + IConfigService configService, + PathExistsValidator pathExistsValidator, + FolderChmodValidator folderChmodValidator, + FolderWritableValidator folderWritableValidator, + SeriesPathValidator seriesPathValidator, + StartupFolderValidator startupFolderValidator, + SystemFolderValidator systemFolderValidator, + RootFolderAncestorValidator rootFolderAncestorValidator, + RootFolderValidator rootFolderValidator) + : base(configFileProvider, configService) { SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0); SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && (OsInfo.IsLinux || OsInfo.IsOsx)); @@ -62,7 +63,7 @@ public MediaManagementSettingsController(IConfigService configService, }); } - protected override MediaManagementSettingsResource ToResource(IConfigService model) + protected override MediaManagementSettingsResource ToResource(IConfigFileProvider configFile, IConfigService model) { return MediaManagementConfigResourceMapper.ToResource(model); } diff --git a/src/Sonarr.Api.V5/Settings/SettingsController.cs b/src/Sonarr.Api.V5/Settings/SettingsController.cs index 51e4f702f..cc515a6f4 100644 --- a/src/Sonarr.Api.V5/Settings/SettingsController.cs +++ b/src/Sonarr.Api.V5/Settings/SettingsController.cs @@ -9,10 +9,12 @@ namespace Sonarr.Api.V5.Settings public abstract class SettingsController<TResource> : RestController<TResource> where TResource : RestResource, new() { - protected readonly IConfigService _configService; + private readonly IConfigFileProvider _configFileProvider; + private readonly IConfigService _configService; - protected SettingsController(IConfigService configService) + protected SettingsController(IConfigFileProvider configFileProvider, IConfigService configService) { + _configFileProvider = configFileProvider; _configService = configService; } @@ -25,7 +27,7 @@ protected override TResource GetResourceById(int id) [Produces("application/json")] public TResource GetConfig() { - var resource = ToResource(_configService); + var resource = ToResource(_configFileProvider, _configService); resource.Id = 1; return resource; @@ -33,17 +35,18 @@ public TResource GetConfig() [RestPutById] [Consumes("application/json")] - public virtual ActionResult<TResource> SaveConfig([FromBody] TResource resource) + public virtual ActionResult<TResource> SaveSettings([FromBody] TResource resource) { var dictionary = resource.GetType() .GetProperties(BindingFlags.Instance | BindingFlags.Public) .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); + _configFileProvider.SaveConfigDictionary(dictionary); _configService.SaveConfigDictionary(dictionary); return Accepted(resource.Id); } - protected abstract TResource ToResource(IConfigService model); + protected abstract TResource ToResource(IConfigFileProvider configFile, IConfigService model); } } diff --git a/src/Sonarr.Api.V5/Settings/UiSettingsController.cs b/src/Sonarr.Api.V5/Settings/UiSettingsController.cs index 383010ab7..8172adf1a 100644 --- a/src/Sonarr.Api.V5/Settings/UiSettingsController.cs +++ b/src/Sonarr.Api.V5/Settings/UiSettingsController.cs @@ -1,22 +1,16 @@ -using System.Reflection; using FluentValidation; -using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Configuration; using NzbDrone.Core.Languages; using Sonarr.Http; -using Sonarr.Http.REST.Attributes; namespace Sonarr.Api.V5.Settings; [V5ApiController("settings/ui")] public class UiSettingsController : SettingsController<UiSettingsResource> { - private readonly IConfigFileProvider _configFileProvider; - public UiSettingsController(IConfigFileProvider configFileProvider, IConfigService configService) - : base(configService) + : base(configFileProvider, configService) { - _configFileProvider = configFileProvider; SharedValidator.RuleFor(c => c.UiLanguage).Custom((value, context) => { if (!Language.All.Any(o => o.Id == value)) @@ -30,21 +24,8 @@ public UiSettingsController(IConfigFileProvider configFileProvider, IConfigServi .WithMessage("The UI Language value cannot be less than 1"); } - [RestPutById] - public override ActionResult<UiSettingsResource> SaveConfig([FromBody] UiSettingsResource resource) + protected override UiSettingsResource ToResource(IConfigFileProvider configFile, IConfigService model) { - var dictionary = resource.GetType() - .GetProperties(BindingFlags.Instance | BindingFlags.Public) - .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); - - _configFileProvider.SaveConfigDictionary(dictionary); - _configService.SaveConfigDictionary(dictionary); - - return Accepted(resource.Id); - } - - protected override UiSettingsResource ToResource(IConfigService model) - { - return UiSettingsResourceMapper.ToResource(_configFileProvider, model); + return UiSettingsResourceMapper.ToResource(configFile, model); } } diff --git a/src/Sonarr.Api.V5/Settings/UpdateSettingsController.cs b/src/Sonarr.Api.V5/Settings/UpdateSettingsController.cs index b44942bba..fc6af58d3 100644 --- a/src/Sonarr.Api.V5/Settings/UpdateSettingsController.cs +++ b/src/Sonarr.Api.V5/Settings/UpdateSettingsController.cs @@ -1,50 +1,24 @@ -using System.Reflection; using FluentValidation; -using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Configuration; using NzbDrone.Core.Update; using NzbDrone.Core.Validation.Paths; using Sonarr.Http; -using Sonarr.Http.REST; namespace Sonarr.Api.V5.Settings; [V5ApiController("settings/update")] -public class UpdateSettingsController : RestController<UpdateSettingsResource> +public class UpdateSettingsController : SettingsController<UpdateSettingsResource> { - private readonly IConfigFileProvider _configFileProvider; - - public UpdateSettingsController(IConfigFileProvider configFileProvider) + public UpdateSettingsController(IConfigFileProvider configFileProvider, IConfigService configService) + : base(configFileProvider, configService) { - _configFileProvider = configFileProvider; SharedValidator.RuleFor(c => c.UpdateScriptPath) .IsValidPath() .When(c => c.UpdateMechanism == UpdateMechanism.Script); } - [HttpGet] - public UpdateSettingsResource GetUpdateSettings() + protected override UpdateSettingsResource ToResource(IConfigFileProvider configFile, IConfigService model) { - var resource = new UpdateSettingsResource - { - Branch = _configFileProvider.Branch, - UpdateAutomatically = _configFileProvider.UpdateAutomatically, - UpdateMechanism = _configFileProvider.UpdateMechanism, - UpdateScriptPath = _configFileProvider.UpdateScriptPath - }; - - return resource; - } - - [HttpPut] - public ActionResult<UpdateSettingsResource> SaveUpdateSettings([FromBody] UpdateSettingsResource resource) - { - var dictionary = resource.GetType() - .GetProperties(BindingFlags.Instance | BindingFlags.Public) - .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); - - _configFileProvider.SaveConfigDictionary(dictionary); - - return Accepted(resource); + return UpdateSettingsResourceMapper.ToResource(configFile); } } diff --git a/src/Sonarr.Api.V5/Settings/UpdateSettingsResource.cs b/src/Sonarr.Api.V5/Settings/UpdateSettingsResource.cs index d17f2f12e..c8498ae1e 100644 --- a/src/Sonarr.Api.V5/Settings/UpdateSettingsResource.cs +++ b/src/Sonarr.Api.V5/Settings/UpdateSettingsResource.cs @@ -1,3 +1,4 @@ +using NzbDrone.Core.Configuration; using NzbDrone.Core.Update; using Sonarr.Http.REST; @@ -10,3 +11,17 @@ public class UpdateSettingsResource : RestResource public UpdateMechanism UpdateMechanism { get; set; } public string? UpdateScriptPath { get; set; } } + +public static class UpdateSettingsResourceMapper +{ + public static UpdateSettingsResource ToResource(IConfigFileProvider config) + { + return new UpdateSettingsResource + { + Branch = config.Branch, + UpdateAutomatically = config.UpdateAutomatically, + UpdateMechanism = config.UpdateMechanism, + UpdateScriptPath = config.UpdateScriptPath + }; + } +} From dd6533c18a2aca1d7af48599b43f2d45462dbdb2 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 16 Feb 2026 14:24:08 -0800 Subject: [PATCH 019/110] Add v5 General settings endpoints --- .../Settings/CertificateValidator.cs | 75 ++++++++++++ .../Settings/GeneralSettingsController.cs | 114 ++++++++++++++++++ .../Settings/GeneralSettingsResource.cs | 93 ++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 src/Sonarr.Api.V5/Settings/CertificateValidator.cs create mode 100644 src/Sonarr.Api.V5/Settings/GeneralSettingsController.cs create mode 100644 src/Sonarr.Api.V5/Settings/GeneralSettingsResource.cs diff --git a/src/Sonarr.Api.V5/Settings/CertificateValidator.cs b/src/Sonarr.Api.V5/Settings/CertificateValidator.cs new file mode 100644 index 000000000..b5de3ab66 --- /dev/null +++ b/src/Sonarr.Api.V5/Settings/CertificateValidator.cs @@ -0,0 +1,75 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using FluentValidation; +using FluentValidation.Validators; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; + +namespace Sonarr.Api.V5.Settings +{ + public static class CertificateValidation + { + public static IRuleBuilderOptions<T, string> IsValidCertificate<T>(this IRuleBuilder<T, string> ruleBuilder) + { + return ruleBuilder.SetValidator(new CertificateValidator()); + } + } + + public class CertificateValidator : PropertyValidator + { + protected override string GetDefaultMessageTemplate() => "Invalid SSL certificate file or {passwordOrKey}. {message}"; + + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(CertificateValidator)); + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) + { + return false; + } + + if (context.InstanceToValidate is not GeneralSettingsResource resource) + { + return true; + } + + var certPath = resource.SslCertPath!; + var keyPath = resource.SslKeyPath; + var certPassword = resource.SslCertPassword; + var type = X509Certificate2.GetCertContentType(certPath); + + try + { + if (type == X509ContentType.Cert) + { + X509Certificate2.CreateFromPemFile(certPath, keyPath.IsNullOrWhiteSpace() ? null : keyPath); + } + else if (type == X509ContentType.Pkcs12) + { + X509CertificateLoader.LoadPkcs12FromFile(certPath, certPassword, X509KeyStorageFlags.DefaultKeySet); + } + else + { + Logger.Debug("Invalid SSL certificate file. Unexpected certificate type: {0}", type); + context.MessageFormatter.AppendArgument("passwordOrKey", "password"); + + return false; + } + + return true; + } + catch (CryptographicException ex) + { + var passwordOrKey = type == X509ContentType.Cert ? "key" : "password"; + + Logger.Debug(ex, "Invalid SSL certificate file or {0}. {1}", passwordOrKey, ex.Message); + + context.MessageFormatter.AppendArgument("passwordOrKey", passwordOrKey); + context.MessageFormatter.AppendArgument("message", ex.Message); + + return false; + } + } + } +} diff --git a/src/Sonarr.Api.V5/Settings/GeneralSettingsController.cs b/src/Sonarr.Api.V5/Settings/GeneralSettingsController.cs new file mode 100644 index 000000000..7b4ba433a --- /dev/null +++ b/src/Sonarr.Api.V5/Settings/GeneralSettingsController.cs @@ -0,0 +1,114 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Authentication; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Update; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Validation.Paths; +using Sonarr.Http; + +namespace Sonarr.Api.V5.Settings; + +[V5ApiController("settings/general")] +public class GeneralSettingsController : SettingsController<GeneralSettingsResource> +{ + private readonly IUserService _userService; + + public GeneralSettingsController(IConfigFileProvider configFileProvider, + IConfigService configService, + IUserService userService, + IDiskProvider diskProvider) + : base(configFileProvider, configService) + { + _userService = userService; + + SharedValidator.RuleFor(c => c.BindAddress) + .ValidIpAddress() + .When(c => c.BindAddress != "*" && c.BindAddress != "localhost"); + + SharedValidator.RuleFor(c => c.Port).ValidPort(); + + SharedValidator.RuleFor(c => c.UrlBase).ValidUrlBase(); + SharedValidator.RuleFor(c => c.InstanceName).StartsOrEndsWithSonarr(); + + SharedValidator.RuleFor(c => c.Username).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Forms); + SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => c.AuthenticationMethod == AuthenticationType.Forms); + + SharedValidator.RuleFor(c => c.AuthenticationMethod) +#pragma warning disable CS0618 // Type or member is obsolete + .NotEqual(AuthenticationType.Basic) +#pragma warning restore CS0618 // Type or member is obsolete + .WithMessage("'Basic' is no longer supported, switch to 'Forms' instead."); + + SharedValidator.RuleFor(c => c.PasswordConfirmation) + .Must((resource, p) => IsMatchingPassword(resource)).WithMessage("Must match Password"); + + SharedValidator.RuleFor(c => c.SslPort).ValidPort().When(c => c.EnableSsl); + SharedValidator.RuleFor(c => c.SslPort).NotEqual(c => c.Port).When(c => c.EnableSsl); + + SharedValidator.RuleFor(c => c.SslCertPath) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .IsValidPath() + .SetValidator(new FileExistsValidator(diskProvider)) + .IsValidCertificate() + .When(c => c.EnableSsl); + + SharedValidator.RuleFor(c => c.SslKeyPath) + .NotEmpty() + .IsValidPath() + .SetValidator(new FileExistsValidator(diskProvider)) + .When(c => c.SslKeyPath.IsNotNullOrWhiteSpace()); + + SharedValidator.RuleFor(c => c.LogSizeLimit).InclusiveBetween(1, 10); + + SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'main' is the default"); + SharedValidator.RuleFor(c => c.UpdateScriptPath).IsValidPath().When(c => c.UpdateMechanism == UpdateMechanism.Script); + + SharedValidator.RuleFor(c => c.BackupFolder).IsValidPath().When(c => Path.IsPathRooted(c.BackupFolder)); + SharedValidator.RuleFor(c => c.BackupInterval).InclusiveBetween(1, 7); + SharedValidator.RuleFor(c => c.BackupRetention).InclusiveBetween(1, 90); + } + + private bool IsMatchingPassword(GeneralSettingsResource resource) + { + var user = _userService.FindUser(); + + if (user != null && user.Password == resource.Password) + { + return true; + } + + if (resource.Password == resource.PasswordConfirmation) + { + return true; + } + + return false; + } + + protected override GeneralSettingsResource ToResource(IConfigFileProvider configFile, IConfigService model) + { + var resource = GeneralSettingsResourceMapper.ToResource(configFile, model); + + var user = _userService.FindUser(); + + resource.Username = user?.Username ?? string.Empty; + resource.Password = user?.Password ?? string.Empty; + resource.PasswordConfirmation = string.Empty; + + return resource; + } + + public override ActionResult<GeneralSettingsResource> SaveSettings(GeneralSettingsResource resource) + { + if (resource.Username.IsNotNullOrWhiteSpace() && resource.Password.IsNotNullOrWhiteSpace()) + { + _userService.Upsert(resource.Username, resource.Password); + } + + return base.SaveSettings(resource); + } +} diff --git a/src/Sonarr.Api.V5/Settings/GeneralSettingsResource.cs b/src/Sonarr.Api.V5/Settings/GeneralSettingsResource.cs new file mode 100644 index 000000000..b6c97d277 --- /dev/null +++ b/src/Sonarr.Api.V5/Settings/GeneralSettingsResource.cs @@ -0,0 +1,93 @@ +using NzbDrone.Common.Http.Proxy; +using NzbDrone.Core.Authentication; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Security; +using NzbDrone.Core.Update; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Settings; + +public class GeneralSettingsResource : RestResource +{ + public string? BindAddress { get; set; } + public int Port { get; set; } + public int SslPort { get; set; } + public bool EnableSsl { get; set; } + public bool LaunchBrowser { get; set; } + public AuthenticationType AuthenticationMethod { get; set; } + public AuthenticationRequiredType AuthenticationRequired { get; set; } + public bool AnalyticsEnabled { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } + public string? PasswordConfirmation { get; set; } + public string? LogLevel { get; set; } + public int LogSizeLimit { get; set; } + public string? ConsoleLogLevel { get; set; } + public string? Branch { get; set; } + public string? ApiKey { get; set; } + public string? SslCertPath { get; set; } + public string? SslKeyPath { get; set; } + public string? SslCertPassword { get; set; } + public string? UrlBase { get; set; } + public string? InstanceName { get; set; } + public string? ApplicationUrl { get; set; } + public bool UpdateAutomatically { get; set; } + public UpdateMechanism UpdateMechanism { get; set; } + public string? UpdateScriptPath { get; set; } + public bool ProxyEnabled { get; set; } + public ProxyType ProxyType { get; set; } + public string? ProxyHostname { get; set; } + public int ProxyPort { get; set; } + public string? ProxyUsername { get; set; } + public string? ProxyPassword { get; set; } + public string? ProxyBypassFilter { get; set; } + public bool ProxyBypassLocalAddresses { get; set; } + public CertificateValidationType CertificateValidation { get; set; } + public string? BackupFolder { get; set; } + public int BackupInterval { get; set; } + public int BackupRetention { get; set; } +} + +public static class GeneralSettingsResourceMapper +{ + public static GeneralSettingsResource ToResource(IConfigFileProvider model, IConfigService configService) + { + return new GeneralSettingsResource + { + BindAddress = model.BindAddress, + Port = model.Port, + SslPort = model.SslPort, + EnableSsl = model.EnableSsl, + LaunchBrowser = model.LaunchBrowser, + AuthenticationMethod = model.AuthenticationMethod, + AuthenticationRequired = model.AuthenticationRequired, + AnalyticsEnabled = model.AnalyticsEnabled, + LogLevel = model.LogLevel, + LogSizeLimit = model.LogSizeLimit, + ConsoleLogLevel = model.ConsoleLogLevel, + Branch = model.Branch, + ApiKey = model.ApiKey, + SslCertPath = model.SslCertPath, + SslKeyPath = model.SslKeyPath, + SslCertPassword = model.SslCertPassword, + UrlBase = model.UrlBase, + InstanceName = model.InstanceName, + UpdateAutomatically = model.UpdateAutomatically, + UpdateMechanism = model.UpdateMechanism, + UpdateScriptPath = model.UpdateScriptPath, + ProxyEnabled = configService.ProxyEnabled, + ProxyType = configService.ProxyType, + ProxyHostname = configService.ProxyHostname, + ProxyPort = configService.ProxyPort, + ProxyUsername = configService.ProxyUsername, + ProxyPassword = configService.ProxyPassword, + ProxyBypassFilter = configService.ProxyBypassFilter, + ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses, + CertificateValidation = configService.CertificateValidation, + BackupFolder = configService.BackupFolder, + BackupInterval = configService.BackupInterval, + BackupRetention = configService.BackupRetention, + ApplicationUrl = configService.ApplicationUrl + }; + } +} From 6764cf1c22645e933baecc4971e9912f5e9edd29 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 16 Feb 2026 14:24:54 -0800 Subject: [PATCH 020/110] Use react-query for General settings --- frontend/src/App/State/SettingsAppState.ts | 6 -- .../AuthenticationRequiredModalContent.tsx | 42 ++++-------- .../src/Settings/General/AnalyticSettings.tsx | 4 +- .../src/Settings/General/BackupSettings.tsx | 8 +-- .../src/Settings/General/GeneralSettings.tsx | 55 +++++----------- .../src/Settings/General/HostSettings.tsx | 24 +++---- .../src/Settings/General/LoggingSettings.tsx | 6 +- .../src/Settings/General/ProxySettings.tsx | 18 +++--- .../src/Settings/General/SecuritySettings.tsx | 16 ++--- .../src/Settings/General/UpdateSettings.tsx | 10 +-- .../General/useGeneralSettings.ts} | 22 ++++++- .../src/Settings/General/useUpdateSettings.ts | 2 +- .../src/Store/Actions/Settings/general.js | 64 ------------------- frontend/src/Store/Actions/settingsActions.js | 5 -- frontend/src/System/Updates/Updates.tsx | 14 ++-- 15 files changed, 99 insertions(+), 197 deletions(-) rename frontend/src/{typings/Settings/General.ts => Settings/General/useGeneralSettings.ts} (68%) delete mode 100644 frontend/src/Store/Actions/Settings/general.js diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 61fc0cb52..fc313b0bb 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -17,7 +17,6 @@ import ImportListOptionsSettings from 'typings/ImportListOptionsSettings'; import Indexer from 'typings/Indexer'; import IndexerFlag from 'typings/IndexerFlag'; import DownloadClientOptions from 'typings/Settings/DownloadClientOptions'; -import General from 'typings/Settings/General'; import IndexerOptions from 'typings/Settings/IndexerOptions'; type Presets<T> = T & { @@ -52,10 +51,6 @@ export interface DownloadClientOptionsAppState extends AppSectionItemState<DownloadClientOptions>, AppSectionSaveState {} -export interface GeneralAppState - extends AppSectionItemState<General>, - AppSectionSaveState {} - export interface ImportListAppState extends AppSectionState<ImportList>, AppSectionDeleteState, @@ -109,7 +104,6 @@ interface SettingsAppState { delayProfiles: DelayProfileAppState; downloadClients: DownloadClientAppState; downloadClientOptions: DownloadClientOptionsAppState; - general: GeneralAppState; importListExclusions: ImportListExclusionsSettingsAppState; importListOptions: ImportListOptionsSettingsAppState; importLists: ImportListAppState; diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.tsx b/frontend/src/FirstRun/AuthenticationRequiredModalContent.tsx index 747942851..c4021b82a 100644 --- a/frontend/src/FirstRun/AuthenticationRequiredModalContent.tsx +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import Alert from 'Components/Alert'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; @@ -16,31 +15,22 @@ import { authenticationMethodOptions, authenticationRequiredOptions, } from 'Settings/General/SecuritySettings'; -import { clearPendingChanges } from 'Store/Actions/baseActions'; -import { - fetchGeneralSettings, - saveGeneralSettings, - setGeneralSettingsValue, -} from 'Store/Actions/settingsActions'; -import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { useManageGeneralSettings } from 'Settings/General/useGeneralSettings'; import useSystemStatus from 'System/Status/useSystemStatus'; import { InputChanged } from 'typings/inputs'; import translate from 'Utilities/String/translate'; import styles from './AuthenticationRequiredModalContent.css'; -const SECTION = 'general'; - -const selector = createSettingsSectionSelector(SECTION); - function onModalClose() { // No-op } export default function AuthenticationRequiredModalContent() { - const { isPopulated, error, isSaving, settings } = useSelector(selector); - const dispatch = useDispatch(); const { refetch: refetchStatus } = useSystemStatus(); + const { settings, isFetched, error, isSaving, saveSettings, updateSetting } = + useManageGeneralSettings(); + const { authenticationMethod, authenticationRequired, @@ -51,20 +41,12 @@ export default function AuthenticationRequiredModalContent() { const wasSaving = usePrevious(isSaving); - useEffect(() => { - dispatch(fetchGeneralSettings()); - - return () => { - dispatch(clearPendingChanges({ section: `settings.${SECTION}` })); - }; - }, [dispatch]); - const onInputChange = useCallback( - (args: InputChanged) => { - // @ts-expect-error Actions aren't typed - dispatch(setGeneralSettingsValue(args)); + (change: InputChanged) => { + // @ts-expect-error input change events aren't typed + updateSetting(change.name, change.value); }, - [dispatch] + [updateSetting] ); const authenticationEnabled = @@ -79,8 +61,8 @@ export default function AuthenticationRequiredModalContent() { }, [isSaving, wasSaving, refetchStatus]); const onPress = useCallback(() => { - dispatch(saveGeneralSettings()); - }, [dispatch]); + saveSettings(); + }, [saveSettings]); return ( <ModalContent showCloseButton={false} onModalClose={onModalClose}> @@ -91,7 +73,7 @@ export default function AuthenticationRequiredModalContent() { {translate('AuthenticationRequiredWarning')} </Alert> - {isPopulated && !error ? ( + {isFetched && !error ? ( <div> <FormGroup> <FormLabel>{translate('AuthenticationMethod')}</FormLabel> @@ -177,7 +159,7 @@ export default function AuthenticationRequiredModalContent() { </div> ) : null} - {!isPopulated && !error ? <LoadingIndicator /> : null} + {!isFetched && !error ? <LoadingIndicator /> : null} </ModalBody> <ModalFooter> diff --git a/frontend/src/Settings/General/AnalyticSettings.tsx b/frontend/src/Settings/General/AnalyticSettings.tsx index 79892bb67..1defb9970 100644 --- a/frontend/src/Settings/General/AnalyticSettings.tsx +++ b/frontend/src/Settings/General/AnalyticSettings.tsx @@ -6,11 +6,11 @@ import FormLabel from 'Components/Form/FormLabel'; import { inputTypes, sizes } from 'Helpers/Props'; import { InputChanged } from 'typings/inputs'; import { PendingSection } from 'typings/pending'; -import General from 'typings/Settings/General'; import translate from 'Utilities/String/translate'; +import { GeneralSettingsModel } from './useGeneralSettings'; interface AnalyticSettingsProps { - analyticsEnabled: PendingSection<General>['analyticsEnabled']; + analyticsEnabled: PendingSection<GeneralSettingsModel>['analyticsEnabled']; onInputChange: (change: InputChanged) => void; } diff --git a/frontend/src/Settings/General/BackupSettings.tsx b/frontend/src/Settings/General/BackupSettings.tsx index 83d398373..9954a9d40 100644 --- a/frontend/src/Settings/General/BackupSettings.tsx +++ b/frontend/src/Settings/General/BackupSettings.tsx @@ -7,13 +7,13 @@ import { inputTypes } from 'Helpers/Props'; import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore'; import { InputChanged } from 'typings/inputs'; import { PendingSection } from 'typings/pending'; -import General from 'typings/Settings/General'; import translate from 'Utilities/String/translate'; +import { GeneralSettingsModel } from './useGeneralSettings'; interface BackupSettingsProps { - backupFolder: PendingSection<General>['backupFolder']; - backupInterval: PendingSection<General>['backupInterval']; - backupRetention: PendingSection<General>['backupRetention']; + backupFolder: PendingSection<GeneralSettingsModel>['backupFolder']; + backupInterval: PendingSection<GeneralSettingsModel>['backupInterval']; + backupRetention: PendingSection<GeneralSettingsModel>['backupRetention']; onInputChange: (change: InputChanged) => void; } diff --git a/frontend/src/Settings/General/GeneralSettings.tsx b/frontend/src/Settings/General/GeneralSettings.tsx index 8f8d6baae..7cab9a1b1 100644 --- a/frontend/src/Settings/General/GeneralSettings.tsx +++ b/frontend/src/Settings/General/GeneralSettings.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import CommandNames from 'Commands/CommandNames'; import { useCommandExecuting } from 'Commands/useCommands'; import Alert from 'Components/Alert'; @@ -11,13 +10,6 @@ import PageContentBody from 'Components/Page/PageContentBody'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { kinds } from 'Helpers/Props'; import SettingsToolbar from 'Settings/SettingsToolbar'; -import { clearPendingChanges } from 'Store/Actions/baseActions'; -import { - fetchGeneralSettings, - saveGeneralSettings, - setGeneralSettingsValue, -} from 'Store/Actions/settingsActions'; -import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; import { useIsWindowsService } from 'System/Status/useSystemStatus'; import { useRestart } from 'System/useSystem'; import { InputChanged } from 'typings/inputs'; @@ -29,8 +21,7 @@ import LoggingSettings from './LoggingSettings'; import ProxySettings from './ProxySettings'; import SecuritySettings from './SecuritySettings'; import UpdateSettings from './UpdateSettings'; - -const SECTION = 'general'; +import { useManageGeneralSettings } from './useGeneralSettings'; const requiresRestartKeys = [ 'bindAddress', @@ -44,24 +35,24 @@ const requiresRestartKeys = [ ]; function GeneralSettings() { - const dispatch = useDispatch(); const isWindowsService = useIsWindowsService(); const { mutate: restart } = useRestart(); const isResettingApiKey = useCommandExecuting(CommandNames.ResetApiKey); const { - isFetching, - isPopulated, - isSaving, - error, - saveError, settings, - hasSettings, + isFetching, + isFetched, + error, + updateSetting, + saveSettings, + isSaving, + saveError, hasPendingChanges, pendingChanges, validationErrors, validationWarnings, - } = useSelector(createSettingsSectionSelector(SECTION)); + } = useManageGeneralSettings(); const wasResettingApiKey = usePrevious(isResettingApiKey); const wasSaving = usePrevious(isSaving); @@ -72,15 +63,15 @@ function GeneralSettings() { const handleInputChange = useCallback( (change: InputChanged) => { - // @ts-expect-error - actions aren't typed - dispatch(setGeneralSettingsValue(change)); + // @ts-expect-error input change events aren't typed + updateSetting(change.name, change.value); }, - [dispatch] + [updateSetting] ); const handleSavePress = useCallback(() => { - dispatch(saveGeneralSettings()); - }, [dispatch]); + saveSettings(); + }, [saveSettings]); const handleConfirmRestart = useCallback(() => { setIsRestartRequiredModalOpen(false); @@ -91,20 +82,6 @@ function GeneralSettings() { setIsRestartRequiredModalOpen(false); }, []); - useEffect(() => { - dispatch(fetchGeneralSettings()); - - return () => { - dispatch(clearPendingChanges({ section: `settings.${SECTION}` })); - }; - }, [dispatch]); - - useEffect(() => { - if (!isResettingApiKey && wasResettingApiKey) { - dispatch(fetchGeneralSettings()); - } - }, [isResettingApiKey, wasResettingApiKey, dispatch]); - useEffect(() => { const isRestartedRequired = previousPendingChanges && @@ -132,7 +109,7 @@ function GeneralSettings() { /> <PageContentBody> - {isFetching && !isPopulated ? <LoadingIndicator /> : null} + {isFetching && !isFetched ? <LoadingIndicator /> : null} {!isFetching && error ? ( <Alert kind={kinds.DANGER}> @@ -140,7 +117,7 @@ function GeneralSettings() { </Alert> ) : null} - {hasSettings && isPopulated && !error ? ( + {settings && isFetched && !error ? ( <Form id="generalSettings" validationErrors={validationErrors} diff --git a/frontend/src/Settings/General/HostSettings.tsx b/frontend/src/Settings/General/HostSettings.tsx index b6d5012e4..f5e5b5b4e 100644 --- a/frontend/src/Settings/General/HostSettings.tsx +++ b/frontend/src/Settings/General/HostSettings.tsx @@ -8,21 +8,21 @@ import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore'; import { useIsWindowsService } from 'System/Status/useSystemStatus'; import { InputChanged } from 'typings/inputs'; import { PendingSection } from 'typings/pending'; -import General from 'typings/Settings/General'; import translate from 'Utilities/String/translate'; +import { GeneralSettingsModel } from './useGeneralSettings'; interface HostSettingsProps { - bindAddress: PendingSection<General>['bindAddress']; - port: PendingSection<General>['port']; - urlBase: PendingSection<General>['urlBase']; - instanceName: PendingSection<General>['instanceName']; - applicationUrl: PendingSection<General>['applicationUrl']; - enableSsl: PendingSection<General>['enableSsl']; - sslPort: PendingSection<General>['sslPort']; - sslKeyPath: PendingSection<General>['sslKeyPath']; - sslCertPath: PendingSection<General>['sslCertPath']; - sslCertPassword: PendingSection<General>['sslCertPassword']; - launchBrowser: PendingSection<General>['launchBrowser']; + bindAddress: PendingSection<GeneralSettingsModel>['bindAddress']; + port: PendingSection<GeneralSettingsModel>['port']; + urlBase: PendingSection<GeneralSettingsModel>['urlBase']; + instanceName: PendingSection<GeneralSettingsModel>['instanceName']; + applicationUrl: PendingSection<GeneralSettingsModel>['applicationUrl']; + enableSsl: PendingSection<GeneralSettingsModel>['enableSsl']; + sslPort: PendingSection<GeneralSettingsModel>['sslPort']; + sslKeyPath: PendingSection<GeneralSettingsModel>['sslKeyPath']; + sslCertPath: PendingSection<GeneralSettingsModel>['sslCertPath']; + sslCertPassword: PendingSection<GeneralSettingsModel>['sslCertPassword']; + launchBrowser: PendingSection<GeneralSettingsModel>['launchBrowser']; onInputChange: (change: InputChanged) => void; } diff --git a/frontend/src/Settings/General/LoggingSettings.tsx b/frontend/src/Settings/General/LoggingSettings.tsx index 78ddefb70..523717711 100644 --- a/frontend/src/Settings/General/LoggingSettings.tsx +++ b/frontend/src/Settings/General/LoggingSettings.tsx @@ -8,8 +8,8 @@ import { inputTypes } from 'Helpers/Props'; import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore'; import { InputChanged } from 'typings/inputs'; import { PendingSection } from 'typings/pending'; -import General from 'typings/Settings/General'; import translate from 'Utilities/String/translate'; +import { GeneralSettingsModel } from './useGeneralSettings'; const logLevelOptions: EnhancedSelectInputValue<string>[] = [ { @@ -33,8 +33,8 @@ const logLevelOptions: EnhancedSelectInputValue<string>[] = [ ]; interface LoggingSettingsProps { - logLevel: PendingSection<General>['logLevel']; - logSizeLimit: PendingSection<General>['logSizeLimit']; + logLevel: PendingSection<GeneralSettingsModel>['logLevel']; + logSizeLimit: PendingSection<GeneralSettingsModel>['logSizeLimit']; onInputChange: (change: InputChanged) => void; } diff --git a/frontend/src/Settings/General/ProxySettings.tsx b/frontend/src/Settings/General/ProxySettings.tsx index 2595c8dc2..848c337e8 100644 --- a/frontend/src/Settings/General/ProxySettings.tsx +++ b/frontend/src/Settings/General/ProxySettings.tsx @@ -7,18 +7,18 @@ import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectI import { inputTypes, sizes } from 'Helpers/Props'; import { InputChanged } from 'typings/inputs'; import { PendingSection } from 'typings/pending'; -import General from 'typings/Settings/General'; import translate from 'Utilities/String/translate'; +import { GeneralSettingsModel } from './useGeneralSettings'; interface ProxySettingsProps { - proxyEnabled: PendingSection<General>['proxyEnabled']; - proxyType: PendingSection<General>['proxyType']; - proxyHostname: PendingSection<General>['proxyHostname']; - proxyPort: PendingSection<General>['proxyPort']; - proxyUsername: PendingSection<General>['proxyUsername']; - proxyPassword: PendingSection<General>['proxyPassword']; - proxyBypassFilter: PendingSection<General>['proxyBypassFilter']; - proxyBypassLocalAddresses: PendingSection<General>['proxyBypassLocalAddresses']; + proxyEnabled: PendingSection<GeneralSettingsModel>['proxyEnabled']; + proxyType: PendingSection<GeneralSettingsModel>['proxyType']; + proxyHostname: PendingSection<GeneralSettingsModel>['proxyHostname']; + proxyPort: PendingSection<GeneralSettingsModel>['proxyPort']; + proxyUsername: PendingSection<GeneralSettingsModel>['proxyUsername']; + proxyPassword: PendingSection<GeneralSettingsModel>['proxyPassword']; + proxyBypassFilter: PendingSection<GeneralSettingsModel>['proxyBypassFilter']; + proxyBypassLocalAddresses: PendingSection<GeneralSettingsModel>['proxyBypassLocalAddresses']; onInputChange: (change: InputChanged) => void; } diff --git a/frontend/src/Settings/General/SecuritySettings.tsx b/frontend/src/Settings/General/SecuritySettings.tsx index 0f339e0a3..2bcd0ad0b 100644 --- a/frontend/src/Settings/General/SecuritySettings.tsx +++ b/frontend/src/Settings/General/SecuritySettings.tsx @@ -13,8 +13,8 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import { icons, inputTypes, kinds } from 'Helpers/Props'; import { InputChanged } from 'typings/inputs'; import { PendingSection } from 'typings/pending'; -import General from 'typings/Settings/General'; import translate from 'Utilities/String/translate'; +import { GeneralSettingsModel } from './useGeneralSettings'; export const authenticationMethodOptions: EnhancedSelectInputValue<string>[] = [ { @@ -85,13 +85,13 @@ const certificateValidationOptions: EnhancedSelectInputValue<string>[] = [ ]; interface SecuritySettingsProps { - authenticationMethod: PendingSection<General>['authenticationMethod']; - authenticationRequired: PendingSection<General>['authenticationRequired']; - username: PendingSection<General>['username']; - password: PendingSection<General>['password']; - passwordConfirmation: PendingSection<General>['passwordConfirmation']; - apiKey: PendingSection<General>['apiKey']; - certificateValidation: PendingSection<General>['certificateValidation']; + authenticationMethod: PendingSection<GeneralSettingsModel>['authenticationMethod']; + authenticationRequired: PendingSection<GeneralSettingsModel>['authenticationRequired']; + username: PendingSection<GeneralSettingsModel>['username']; + password: PendingSection<GeneralSettingsModel>['password']; + passwordConfirmation: PendingSection<GeneralSettingsModel>['passwordConfirmation']; + apiKey: PendingSection<GeneralSettingsModel>['apiKey']; + certificateValidation: PendingSection<GeneralSettingsModel>['certificateValidation']; isResettingApiKey: boolean; onInputChange: (change: InputChanged) => void; } diff --git a/frontend/src/Settings/General/UpdateSettings.tsx b/frontend/src/Settings/General/UpdateSettings.tsx index 0861ef92d..df8d7ffed 100644 --- a/frontend/src/Settings/General/UpdateSettings.tsx +++ b/frontend/src/Settings/General/UpdateSettings.tsx @@ -9,17 +9,17 @@ import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore'; import { useSystemStatusData } from 'System/Status/useSystemStatus'; import { InputChanged } from 'typings/inputs'; import { PendingSection } from 'typings/pending'; -import General from 'typings/Settings/General'; import titleCase from 'Utilities/String/titleCase'; import translate from 'Utilities/String/translate'; +import { GeneralSettingsModel } from './useGeneralSettings'; const branchValues = ['main', 'develop']; interface UpdateSettingsProps { - branch: PendingSection<General>['branch']; - updateAutomatically: PendingSection<General>['updateAutomatically']; - updateMechanism: PendingSection<General>['updateMechanism']; - updateScriptPath: PendingSection<General>['updateScriptPath']; + branch: PendingSection<GeneralSettingsModel>['branch']; + updateAutomatically: PendingSection<GeneralSettingsModel>['updateAutomatically']; + updateMechanism: PendingSection<GeneralSettingsModel>['updateMechanism']; + updateScriptPath: PendingSection<GeneralSettingsModel>['updateScriptPath']; onInputChange: (change: InputChanged) => void; } diff --git a/frontend/src/typings/Settings/General.ts b/frontend/src/Settings/General/useGeneralSettings.ts similarity index 68% rename from frontend/src/typings/Settings/General.ts rename to frontend/src/Settings/General/useGeneralSettings.ts index 339485f00..31cc2a039 100644 --- a/frontend/src/typings/Settings/General.ts +++ b/frontend/src/Settings/General/useGeneralSettings.ts @@ -1,3 +1,9 @@ +import { + useManageSettings, + useSaveSettings, + useSettings, +} from 'Settings/useSettings'; + export type UpdateMechanism = | 'builtIn' | 'script' @@ -5,7 +11,7 @@ export type UpdateMechanism = | 'apt' | 'docker'; -export default interface General { +export interface GeneralSettingsModel { bindAddress: string; port: number; sslPort: number; @@ -45,3 +51,17 @@ export default interface General { backupRetention: number; id: number; } + +const PATH = '/settings/general'; + +export const useGeneralSettings = () => { + return useSettings<GeneralSettingsModel>(PATH); +}; + +export const useManageGeneralSettings = () => { + return useManageSettings<GeneralSettingsModel>(PATH); +}; + +export const useSaveGeneralSettings = () => { + return useSaveSettings<GeneralSettingsModel>(PATH); +}; diff --git a/frontend/src/Settings/General/useUpdateSettings.ts b/frontend/src/Settings/General/useUpdateSettings.ts index d2e648e2c..765aa0ab7 100644 --- a/frontend/src/Settings/General/useUpdateSettings.ts +++ b/frontend/src/Settings/General/useUpdateSettings.ts @@ -1,5 +1,5 @@ import useApiQuery from 'Helpers/Hooks/useApiQuery'; -import { UpdateMechanism } from 'typings/Settings/General'; +import { UpdateMechanism } from './useGeneralSettings'; interface UpdateSettings { branch: string; diff --git a/frontend/src/Store/Actions/Settings/general.js b/frontend/src/Store/Actions/Settings/general.js deleted file mode 100644 index 98bb2703d..000000000 --- a/frontend/src/Store/Actions/Settings/general.js +++ /dev/null @@ -1,64 +0,0 @@ -import { createAction } from 'redux-actions'; -import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; -import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; -import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; -import { createThunk } from 'Store/thunks'; - -// -// Variables - -const section = 'settings.general'; - -// -// Actions Types - -export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings'; -export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue'; -export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings'; - -// -// Action Creators - -export const fetchGeneralSettings = createThunk(FETCH_GENERAL_SETTINGS); -export const saveGeneralSettings = createThunk(SAVE_GENERAL_SETTINGS); -export const setGeneralSettingsValue = createAction(SET_GENERAL_SETTINGS_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -// -// Details - -export default { - - // - // State - - defaultState: { - isFetching: false, - isPopulated: false, - error: null, - pendingChanges: {}, - isSaving: false, - saveError: null, - item: {} - }, - - // - // Action Handlers - - actionHandlers: { - [FETCH_GENERAL_SETTINGS]: createFetchHandler(section, '/config/host'), - [SAVE_GENERAL_SETTINGS]: createSaveHandler(section, '/config/host') - }, - - // - // Reducers - - reducers: { - [SET_GENERAL_SETTINGS_VALUE]: createSetSettingValueReducer(section) - } - -}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index ad369101b..7bd3794f8 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -7,7 +7,6 @@ import customFormatSpecifications from './Settings/customFormatSpecifications'; import delayProfiles from './Settings/delayProfiles'; import downloadClientOptions from './Settings/downloadClientOptions'; import downloadClients from './Settings/downloadClients'; -import general from './Settings/general'; import importListExclusions from './Settings/importListExclusions'; import importListOptions from './Settings/importListOptions'; import importLists from './Settings/importLists'; @@ -22,7 +21,6 @@ export * from './Settings/customFormats'; export * from './Settings/delayProfiles'; export * from './Settings/downloadClients'; export * from './Settings/downloadClientOptions'; -export * from './Settings/general'; export * from './Settings/importListOptions'; export * from './Settings/importLists'; export * from './Settings/importListExclusions'; @@ -47,7 +45,6 @@ export const defaultState = { delayProfiles: delayProfiles.defaultState, downloadClients: downloadClients.defaultState, downloadClientOptions: downloadClientOptions.defaultState, - general: general.defaultState, importLists: importLists.defaultState, importListExclusions: importListExclusions.defaultState, importListOptions: importListOptions.defaultState, @@ -71,7 +68,6 @@ export const actionHandlers = handleThunks({ ...delayProfiles.actionHandlers, ...downloadClients.actionHandlers, ...downloadClientOptions.actionHandlers, - ...general.actionHandlers, ...importLists.actionHandlers, ...importListExclusions.actionHandlers, ...importListOptions.actionHandlers, @@ -91,7 +87,6 @@ export const reducers = createHandleActions({ ...delayProfiles.reducers, ...downloadClients.reducers, ...downloadClientOptions.reducers, - ...general.reducers, ...importLists.reducers, ...importListExclusions.reducers, ...importListOptions.reducers, diff --git a/frontend/src/System/Updates/Updates.tsx b/frontend/src/System/Updates/Updates.tsx index 7e99007a8..1bdbe5748 100644 --- a/frontend/src/System/Updates/Updates.tsx +++ b/frontend/src/System/Updates/Updates.tsx @@ -1,5 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import React, { useCallback, useMemo, useState } from 'react'; import { useAppValue } from 'App/appStore'; import CommandNames from 'Commands/CommandNames'; import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; @@ -13,11 +12,13 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import { icons, kinds } from 'Helpers/Props'; +import { + UpdateMechanism, + useGeneralSettings, +} from 'Settings/General/useGeneralSettings'; import useUpdateSettings from 'Settings/General/useUpdateSettings'; import { useUiSettingsValues } from 'Settings/UI/useUiSettings'; -import { fetchGeneralSettings } from 'Store/Actions/settingsActions'; import { useSystemStatusData } from 'System/Status/useSystemStatus'; -import { UpdateMechanism } from 'typings/Settings/General'; import formatDate from 'Utilities/Date/formatDate'; import formatDateTime from 'Utilities/Date/formatDateTime'; import translate from 'Utilities/String/translate'; @@ -49,7 +50,6 @@ function Updates() { error: settingsError, } = useUpdateSettings(); - const dispatch = useDispatch(); const executeCommand = useExecuteCommand(); const [isMajorUpdateModalOpen, setIsMajorUpdateModalOpen] = useState(false); const isFetching = isLoadingUpdates || isLoadingSettings; @@ -107,9 +107,7 @@ function Updates() { setIsMajorUpdateModalOpen(false); }, [setIsMajorUpdateModalOpen]); - useEffect(() => { - dispatch(fetchGeneralSettings()); - }, [dispatch]); + useGeneralSettings(); return ( <PageContent title={translate('Updates')}> From bcceb22512b2a94bf8456425a80279d0a1aa45aa Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 16 Feb 2026 14:53:14 -0800 Subject: [PATCH 021/110] Add v5 Indexer endpoints --- .../Indexers/IndexerBulkResource.cs | 30 +++++++++++ .../Indexers/IndexerController.cs | 25 +++++++++ .../Indexers/IndexerFlagController.cs | 19 +++++++ .../Indexers/IndexerFlagResource.cs | 12 +++++ src/Sonarr.Api.V5/Indexers/IndexerResource.cs | 51 +++++++++++++++++++ 5 files changed, 137 insertions(+) create mode 100644 src/Sonarr.Api.V5/Indexers/IndexerBulkResource.cs create mode 100644 src/Sonarr.Api.V5/Indexers/IndexerController.cs create mode 100644 src/Sonarr.Api.V5/Indexers/IndexerFlagController.cs create mode 100644 src/Sonarr.Api.V5/Indexers/IndexerFlagResource.cs create mode 100644 src/Sonarr.Api.V5/Indexers/IndexerResource.cs diff --git a/src/Sonarr.Api.V5/Indexers/IndexerBulkResource.cs b/src/Sonarr.Api.V5/Indexers/IndexerBulkResource.cs new file mode 100644 index 000000000..236387d66 --- /dev/null +++ b/src/Sonarr.Api.V5/Indexers/IndexerBulkResource.cs @@ -0,0 +1,30 @@ +using NzbDrone.Core.Indexers; +using Sonarr.Api.V5.Provider; + +namespace Sonarr.Api.V5.Indexers; + +public class IndexerBulkResource : ProviderBulkResource<IndexerBulkResource> +{ + public bool? EnableRss { get; set; } + public bool? EnableAutomaticSearch { get; set; } + public bool? EnableInteractiveSearch { get; set; } + public int? Priority { get; set; } + public int? SeasonSearchMaximumSingleEpisodeAge { get; set; } +} + +public class IndexerBulkResourceMapper : ProviderBulkResourceMapper<IndexerBulkResource, IndexerDefinition> +{ + public override List<IndexerDefinition> UpdateModel(IndexerBulkResource resource, List<IndexerDefinition> existingDefinitions) + { + existingDefinitions.ForEach(existing => + { + existing.EnableRss = resource.EnableRss ?? existing.EnableRss; + existing.EnableAutomaticSearch = resource.EnableAutomaticSearch ?? existing.EnableAutomaticSearch; + existing.EnableInteractiveSearch = resource.EnableInteractiveSearch ?? existing.EnableInteractiveSearch; + existing.Priority = resource.Priority ?? existing.Priority; + existing.SeasonSearchMaximumSingleEpisodeAge = resource.SeasonSearchMaximumSingleEpisodeAge ?? existing.SeasonSearchMaximumSingleEpisodeAge; + }); + + return existingDefinitions; + } +} diff --git a/src/Sonarr.Api.V5/Indexers/IndexerController.cs b/src/Sonarr.Api.V5/Indexers/IndexerController.cs new file mode 100644 index 000000000..82527fc54 --- /dev/null +++ b/src/Sonarr.Api.V5/Indexers/IndexerController.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Validation; +using NzbDrone.SignalR; +using Sonarr.Api.V5.Provider; +using Sonarr.Http; + +namespace Sonarr.Api.V5.Indexers; + +[V5ApiController] +public class IndexerController : ProviderControllerBase<IndexerResource, IndexerBulkResource, IIndexer, IndexerDefinition> +{ + public static readonly IndexerResourceMapper ResourceMapper = new(); + public static readonly IndexerBulkResourceMapper BulkResourceMapper = new(); + + public IndexerController(IBroadcastSignalRMessage signalRBroadcaster, + IndexerFactory indexerFactory, + DownloadClientExistsValidator downloadClientExistsValidator) + : base(signalRBroadcaster, indexerFactory, "indexer", ResourceMapper, BulkResourceMapper) + { + SharedValidator.RuleFor(c => c.Priority).InclusiveBetween(1, 50); + SharedValidator.RuleFor(c => c.SeasonSearchMaximumSingleEpisodeAge).GreaterThanOrEqualTo(0); + SharedValidator.RuleFor(c => c.DownloadClientId).SetValidator(downloadClientExistsValidator); + } +} diff --git a/src/Sonarr.Api.V5/Indexers/IndexerFlagController.cs b/src/Sonarr.Api.V5/Indexers/IndexerFlagController.cs new file mode 100644 index 000000000..2424b98f4 --- /dev/null +++ b/src/Sonarr.Api.V5/Indexers/IndexerFlagController.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Parser.Model; +using Sonarr.Http; + +namespace Sonarr.Api.V5.Indexers; + +[V5ApiController] +public class IndexerFlagController : Controller +{ + [HttpGet] + public List<IndexerFlagResource> GetAll() + { + return Enum.GetValues(typeof(IndexerFlags)).Cast<IndexerFlags>().Select(f => new IndexerFlagResource + { + Id = (int)f, + Name = f.ToString() + }).ToList(); + } +} diff --git a/src/Sonarr.Api.V5/Indexers/IndexerFlagResource.cs b/src/Sonarr.Api.V5/Indexers/IndexerFlagResource.cs new file mode 100644 index 000000000..925f7be62 --- /dev/null +++ b/src/Sonarr.Api.V5/Indexers/IndexerFlagResource.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Indexers; + +public class IndexerFlagResource : RestResource +{ + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Include)] + public new int Id { get; set; } + public string? Name { get; set; } + public string? NameLower => Name?.ToLowerInvariant(); +} diff --git a/src/Sonarr.Api.V5/Indexers/IndexerResource.cs b/src/Sonarr.Api.V5/Indexers/IndexerResource.cs new file mode 100644 index 000000000..e97a69470 --- /dev/null +++ b/src/Sonarr.Api.V5/Indexers/IndexerResource.cs @@ -0,0 +1,51 @@ +using NzbDrone.Core.Indexers; +using Sonarr.Api.V5.Provider; + +namespace Sonarr.Api.V5.Indexers; + +public class IndexerResource : ProviderResource<IndexerResource> +{ + public bool EnableRss { get; set; } + public bool EnableAutomaticSearch { get; set; } + public bool EnableInteractiveSearch { get; set; } + public bool SupportsRss { get; set; } + public bool SupportsSearch { get; set; } + public DownloadProtocol Protocol { get; set; } + public int Priority { get; set; } + public int SeasonSearchMaximumSingleEpisodeAge { get; set; } + public int DownloadClientId { get; set; } +} + +public class IndexerResourceMapper : ProviderResourceMapper<IndexerResource, IndexerDefinition> +{ + public override IndexerResource ToResource(IndexerDefinition definition) + { + var resource = base.ToResource(definition); + + resource.EnableRss = definition.EnableRss; + resource.EnableAutomaticSearch = definition.EnableAutomaticSearch; + resource.EnableInteractiveSearch = definition.EnableInteractiveSearch; + resource.SupportsRss = definition.SupportsRss; + resource.SupportsSearch = definition.SupportsSearch; + resource.Protocol = definition.Protocol; + resource.Priority = definition.Priority; + resource.SeasonSearchMaximumSingleEpisodeAge = definition.SeasonSearchMaximumSingleEpisodeAge; + resource.DownloadClientId = definition.DownloadClientId; + + return resource; + } + + public override IndexerDefinition ToModel(IndexerResource resource, IndexerDefinition? existingDefinition) + { + var definition = base.ToModel(resource, existingDefinition); + + definition.EnableRss = resource.EnableRss; + definition.EnableAutomaticSearch = resource.EnableAutomaticSearch; + definition.EnableInteractiveSearch = resource.EnableInteractiveSearch; + definition.Priority = resource.Priority; + definition.SeasonSearchMaximumSingleEpisodeAge = resource.SeasonSearchMaximumSingleEpisodeAge; + definition.DownloadClientId = resource.DownloadClientId; + + return definition; + } +} From c4c0ec25acef7855cc25f38648bfc1649c322e87 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 16 Feb 2026 15:48:47 -0800 Subject: [PATCH 022/110] Use react-query for Indexers --- frontend/src/App/State/SettingsAppState.ts | 10 - .../Builder/IndexerFilterBuilderRowValue.tsx | 22 +- .../Form/Select/IndexerSelectInput.tsx | 61 +-- frontend/src/Components/SignalRListener.tsx | 54 ++- .../src/Settings/Indexers/IndexerSettings.tsx | 15 +- .../Indexers/Indexers/AddIndexerItem.tsx | 21 +- .../Indexers/Indexers/AddIndexerModal.tsx | 8 +- .../Indexers/AddIndexerModalContent.tsx | 28 +- .../Indexers/AddIndexerPresetMenuItem.tsx | 24 +- .../Indexers/Indexers/EditIndexerModal.tsx | 27 +- .../Indexers/EditIndexerModalContent.tsx | 347 ++++++++---------- .../Settings/Indexers/Indexers/Indexer.tsx | 10 +- .../Settings/Indexers/Indexers/Indexers.tsx | 52 ++- .../Manage/ManageIndexersModalContent.tsx | 85 ++--- .../Manage/ManageIndexersModalRow.tsx | 4 +- .../Indexers/Manage/Tags/TagsModalContent.tsx | 53 ++- frontend/src/Settings/Indexers/useIndexers.ts | 274 ++++++++++++++ .../Indexers/useManageIndexersOptionsStore.ts | 20 + .../Notifications/AddNotificationItem.tsx | 2 +- .../AddNotificationModalContent.tsx | 2 +- .../AddNotificationPresetMenuItem.tsx | 2 +- .../Notifications/EditNotificationModal.tsx | 17 +- .../EditNotificationModalContent.tsx | 9 +- .../Profiles/Release/ReleaseProfileItem.tsx | 4 +- .../Profiles/Release/ReleaseProfiles.tsx | 15 +- .../Tags/Details/TagDetailsModalContent.tsx | 9 +- frontend/src/Settings/Tags/Tags.tsx | 4 +- frontend/src/Settings/useProviderSettings.ts | 16 +- .../src/Store/Actions/Settings/indexers.js | 180 --------- frontend/src/Store/Actions/settingsActions.js | 11 +- frontend/src/System/Status/Health/Health.tsx | 15 +- frontend/src/typings/Indexer.ts | 17 - 32 files changed, 689 insertions(+), 729 deletions(-) create mode 100644 frontend/src/Settings/Indexers/useIndexers.ts create mode 100644 frontend/src/Settings/Indexers/useManageIndexersOptionsStore.ts delete mode 100644 frontend/src/Store/Actions/Settings/indexers.js delete mode 100644 frontend/src/typings/Indexer.ts diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index fc313b0bb..52b940692 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -14,7 +14,6 @@ import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; import ImportListExclusion from 'typings/ImportListExclusion'; import ImportListOptionsSettings from 'typings/ImportListOptionsSettings'; -import Indexer from 'typings/Indexer'; import IndexerFlag from 'typings/IndexerFlag'; import DownloadClientOptions from 'typings/Settings/DownloadClientOptions'; import IndexerOptions from 'typings/Settings/IndexerOptions'; @@ -63,14 +62,6 @@ export interface IndexerOptionsAppState extends AppSectionItemState<IndexerOptions>, AppSectionSaveState {} -export interface IndexerAppState - extends AppSectionState<Indexer>, - AppSectionDeleteState, - AppSectionSaveState, - AppSectionSchemaState<Presets<Indexer>> { - isTestingAll: boolean; -} - export interface CustomFormatAppState extends AppSectionState<CustomFormat>, AppSectionDeleteState, @@ -109,7 +100,6 @@ interface SettingsAppState { importLists: ImportListAppState; indexerFlags: IndexerFlagSettingsAppState; indexerOptions: IndexerOptionsAppState; - indexers: IndexerAppState; } export default SettingsAppState; diff --git a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValue.tsx index 3526081b0..3ff6f8bcf 100644 --- a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValue.tsx +++ b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValue.tsx @@ -1,7 +1,5 @@ -import React, { useEffect, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; -import { fetchIndexers } from 'Store/Actions/settingsActions'; +import React, { useMemo } from 'react'; +import { useSortedIndexers } from 'Settings/Indexers/useIndexers'; import FilterBuilderRowValue, { FilterBuilderRowValueProps, } from './FilterBuilderRowValue'; @@ -14,26 +12,16 @@ type IndexerFilterBuilderRowValueProps<T> = Omit< function IndexerFilterBuilderRowValue<T>( props: IndexerFilterBuilderRowValueProps<T> ) { - const dispatch = useDispatch(); - - const { isPopulated, items } = useSelector( - (state: AppState) => state.settings.indexers - ); + const { data } = useSortedIndexers(); const tagList = useMemo(() => { - return items.map((item) => { + return data.map((item) => { return { id: item.id, name: item.name, }; }); - }, [items]); - - useEffect(() => { - if (!isPopulated) { - dispatch(fetchIndexers()); - } - }, [isPopulated, dispatch]); + }, [data]); return <FilterBuilderRowValue {...props} tagList={tagList} />; } diff --git a/frontend/src/Components/Form/Select/IndexerSelectInput.tsx b/frontend/src/Components/Form/Select/IndexerSelectInput.tsx index 14a6df8ec..a66b423db 100644 --- a/frontend/src/Components/Form/Select/IndexerSelectInput.tsx +++ b/frontend/src/Components/Form/Select/IndexerSelectInput.tsx @@ -1,43 +1,9 @@ -import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; -import { fetchIndexers } from 'Store/Actions/settingsActions'; +import React, { useMemo } from 'react'; +import { useSortedIndexers } from 'Settings/Indexers/useIndexers'; import { EnhancedSelectInputChanged } from 'typings/inputs'; -import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import EnhancedSelectInput from './EnhancedSelectInput'; -function createIndexersSelector(includeAny: boolean) { - return createSelector( - (state: AppState) => state.settings.indexers, - (indexers) => { - const { isFetching, isPopulated, error, items } = indexers; - - const values = items.sort(sortByProp('name')).map((indexer) => { - return { - key: indexer.id, - value: indexer.name, - }; - }); - - if (includeAny) { - values.unshift({ - key: 0, - value: `(${translate('Any')})`, - }); - } - - return { - isFetching, - isPopulated, - error, - values, - }; - } - ); -} - export interface IndexerSelectInputProps { name: string; value: number | number[]; @@ -51,16 +17,23 @@ function IndexerSelectInput({ includeAny = false, onChange, }: IndexerSelectInputProps) { - const dispatch = useDispatch(); - const { isFetching, isPopulated, values } = useSelector( - createIndexersSelector(includeAny) - ); + const { isFetching, data } = useSortedIndexers(); - useEffect(() => { - if (!isPopulated) { - dispatch(fetchIndexers()); + const values = useMemo(() => { + const indexerOptions = data.map((indexer) => ({ + key: indexer.id, + value: indexer.name, + })); + + if (includeAny) { + indexerOptions.unshift({ + key: 0, + value: `(${translate('Any')})`, + }); } - }, [isPopulated, dispatch]); + + return indexerOptions; + }, [data, includeAny]); return ( <EnhancedSelectInput diff --git a/frontend/src/Components/SignalRListener.tsx b/frontend/src/Components/SignalRListener.tsx index ce6ca51bf..f9a7c8db1 100644 --- a/frontend/src/Components/SignalRListener.tsx +++ b/frontend/src/Components/SignalRListener.tsx @@ -14,6 +14,7 @@ import Episode from 'Episode/Episode'; import { EpisodeFile } from 'EpisodeFile/EpisodeFile'; import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery'; import Series from 'Series/Series'; +import { IndexerModel } from 'Settings/Indexers/useIndexers'; import { removeItem, updateItem } from 'Store/Actions/baseActions'; import { repopulatePage } from 'Utilities/pagePopulator'; import SignalRLogger from 'Utilities/SignalRLogger'; @@ -256,12 +257,12 @@ function SignalRListener() { } if (name === 'indexer') { - const section = 'settings.indexers'; + const updatedItem = body.resource as IndexerModel; if (body.action === 'created' || body.action === 'updated') { - dispatch(updateItem({ section, ...body.resource })); + updateQueryClientItem(queryClient, ['/indexer'], updatedItem, true); } else if (body.action === 'deleted') { - dispatch(removeItem({ section, id: body.resource.id })); + removeQueryClientItem(queryClient, ['/indexer'], body.resource.id); } return; @@ -521,3 +522,50 @@ const updatePagedItem = <T extends ModelBase>( } ); }; + +const updateQueryClientItem = <T extends ModelBase>( + queryClient: ReturnType<typeof useQueryClient>, + queryKey: QueryKey, + updatedItem: T, + addMissing: boolean +) => { + queryClient.setQueriesData({ queryKey }, (oldData: T[] | undefined) => { + if (!oldData) { + return oldData; + } + + const itemIndex = oldData.findIndex((item) => item.id === updatedItem.id); + + if (itemIndex === -1 && addMissing) { + return [...oldData, updatedItem]; + } + + return oldData.map((item) => { + if (item.id === updatedItem.id) { + return updatedItem; + } + + return item; + }); + }); +}; + +const removeQueryClientItem = <T extends ModelBase>( + queryClient: ReturnType<typeof useQueryClient>, + queryKey: QueryKey, + id: T['id'] +) => { + queryClient.setQueriesData({ queryKey }, (oldData: T[] | undefined) => { + if (!oldData) { + return oldData; + } + + const itemIndex = oldData.findIndex((item) => item.id === updatedItem.id); + + if (itemIndex === -1) { + return oldData; + } + + return oldData.filter((item) => item.id !== id); + }); +}; diff --git a/frontend/src/Settings/Indexers/IndexerSettings.tsx b/frontend/src/Settings/Indexers/IndexerSettings.tsx index c4878a8c0..908bbca95 100644 --- a/frontend/src/Settings/Indexers/IndexerSettings.tsx +++ b/frontend/src/Settings/Indexers/IndexerSettings.tsx @@ -1,13 +1,10 @@ import React, { useCallback, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import { icons } from 'Helpers/Props'; import SettingsToolbar from 'Settings/SettingsToolbar'; -import { testAllIndexers } from 'Store/Actions/settingsActions'; import { SaveCallback, SettingsStateChange, @@ -16,12 +13,10 @@ import translate from 'Utilities/String/translate'; import Indexers from './Indexers/Indexers'; import ManageIndexersModal from './Indexers/Manage/ManageIndexersModal'; import IndexerOptions from './Options/IndexerOptions'; +import { useTestAllIndexers } from './useIndexers'; function IndexerSettings() { - const dispatch = useDispatch(); - const isTestingAll = useSelector( - (state: AppState) => state.settings.indexers.isTestingAll - ); + const { isTestingAllIndexers, testAllIndexers } = useTestAllIndexers(); const saveOptions = useRef<() => void>(); @@ -55,8 +50,8 @@ function IndexerSettings() { }, []); const handleTestAllIndexersPress = useCallback(() => { - dispatch(testAllIndexers()); - }, [dispatch]); + testAllIndexers(); + }, [testAllIndexers]); return ( <PageContent title={translate('IndexerSettings')}> @@ -70,7 +65,7 @@ function IndexerSettings() { <PageToolbarButton label={translate('TestAllIndexers')} iconName={icons.TEST} - isSpinning={isTestingAll} + isSpinning={isTestingAllIndexers} onPress={handleTestAllIndexersPress} /> diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.tsx b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.tsx index f75623539..c415bc3d1 100644 --- a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.tsx +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.tsx @@ -1,13 +1,12 @@ import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; import Button from 'Components/Link/Button'; import Link from 'Components/Link/Link'; import Menu from 'Components/Menu/Menu'; import MenuContent from 'Components/Menu/MenuContent'; import { sizes } from 'Helpers/Props'; -import { selectIndexerSchema } from 'Store/Actions/settingsActions'; -import Indexer from 'typings/Indexer'; +import { SelectedSchema } from 'Settings/useProviderSchema'; import translate from 'Utilities/String/translate'; +import { IndexerModel } from '../useIndexers'; import AddIndexerPresetMenuItem from './AddIndexerPresetMenuItem'; import styles from './AddIndexerItem.css'; @@ -15,8 +14,8 @@ interface AddIndexerItemProps { implementation: string; implementationName: string; infoLink: string; - presets?: Indexer[]; - onIndexerSelect: () => void; + presets?: IndexerModel[]; + onIndexerSelect: (selectedSchema: SelectedSchema) => void; } function AddIndexerItem({ @@ -26,19 +25,11 @@ function AddIndexerItem({ presets, onIndexerSelect, }: AddIndexerItemProps) { - const dispatch = useDispatch(); const hasPresets = !!presets && !!presets.length; const handleIndexerSelect = useCallback(() => { - dispatch( - selectIndexerSchema({ - implementation, - implementationName, - }) - ); - - onIndexerSelect(); - }, [implementation, implementationName, dispatch, onIndexerSelect]); + onIndexerSelect({ implementation, implementationName }); + }, [implementation, implementationName, onIndexerSelect]); return ( <div className={styles.indexer}> diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.tsx b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.tsx index f834c30cb..f18857d4a 100644 --- a/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.tsx +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.tsx @@ -1,11 +1,11 @@ import React from 'react'; import Modal from 'Components/Modal/Modal'; -import AddIndexerModalContent from './AddIndexerModalContent'; +import AddIndexerModalContent, { + AddIndexerModalContentProps, +} from './AddIndexerModalContent'; -interface AddIndexerModalProps { +interface AddIndexerModalProps extends AddIndexerModalContentProps { isOpen: boolean; - onIndexerSelect: () => void; - onModalClose: () => void; } function AddIndexerModal({ diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.tsx index 78c4d7ceb..686023b93 100644 --- a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.tsx @@ -1,6 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; +import React, { useMemo } from 'react'; import Alert from 'Components/Alert'; import FieldSet from 'Components/FieldSet'; import Button from 'Components/Link/Button'; @@ -10,14 +8,14 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { kinds } from 'Helpers/Props'; -import { fetchIndexerSchema } from 'Store/Actions/settingsActions'; -import Indexer from 'typings/Indexer'; +import { SelectedSchema } from 'Settings/useProviderSchema'; import translate from 'Utilities/String/translate'; +import { IndexerModel, useIndexerSchema } from '../useIndexers'; import AddIndexerItem from './AddIndexerItem'; import styles from './AddIndexerModalContent.css'; -interface AddIndexerModalContentProps { - onIndexerSelect: () => void; +export interface AddIndexerModalContentProps { + onIndexerSelect: (selectedSchema: SelectedSchema) => void; onModalClose: () => void; } @@ -25,15 +23,13 @@ function AddIndexerModalContent({ onIndexerSelect, onModalClose, }: AddIndexerModalContentProps) { - const dispatch = useDispatch(); - - const { isSchemaFetching, isSchemaPopulated, schemaError, schema } = - useSelector((state: AppState) => state.settings.indexers); + const { isSchemaFetching, isSchemaFetched, schemaError, schema } = + useIndexerSchema(); const { usenetIndexers, torrentIndexers } = useMemo(() => { return schema.reduce<{ - usenetIndexers: Indexer[]; - torrentIndexers: Indexer[]; + usenetIndexers: IndexerModel[]; + torrentIndexers: IndexerModel[]; }>( (acc, item) => { if (item.protocol === 'usenet') { @@ -51,10 +47,6 @@ function AddIndexerModalContent({ ); }, [schema]); - useEffect(() => { - dispatch(fetchIndexerSchema()); - }, [dispatch]); - return ( <ModalContent onModalClose={onModalClose}> <ModalHeader>{translate('AddIndexer')}</ModalHeader> @@ -66,7 +58,7 @@ function AddIndexerModalContent({ <Alert kind={kinds.DANGER}>{translate('AddIndexerError')}</Alert> ) : null} - {isSchemaPopulated && !schemaError ? ( + {isSchemaFetched && !schemaError ? ( <div> <Alert kind={kinds.INFO}> <div>{translate('SupportedIndexers')}</div> diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.tsx b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.tsx index 70502a615..0c52ce69c 100644 --- a/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.tsx +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.tsx @@ -1,14 +1,12 @@ import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; -import MenuItem, { MenuItemProps } from 'Components/Menu/MenuItem'; -import { selectIndexerSchema } from 'Store/Actions/settingsActions'; +import MenuItem from 'Components/Menu/MenuItem'; +import { SelectedSchema } from 'Settings/useProviderSchema'; -interface AddIndexerPresetMenuItemProps - extends Omit<MenuItemProps, 'children'> { +interface AddIndexerPresetMenuItemProps { name: string; implementation: string; implementationName: string; - onPress: () => void; + onPress: (selectedSchema: SelectedSchema) => void; } function AddIndexerPresetMenuItem({ @@ -18,19 +16,9 @@ function AddIndexerPresetMenuItem({ onPress, ...otherProps }: AddIndexerPresetMenuItemProps) { - const dispatch = useDispatch(); - const handlePress = useCallback(() => { - dispatch( - selectIndexerSchema({ - implementation, - implementationName, - presetName: name, - }) - ); - - onPress(); - }, [name, implementation, implementationName, dispatch, onPress]); + onPress({ implementation, implementationName, presetName: name }); + }, [name, implementation, implementationName, onPress]); return ( <MenuItem {...otherProps} onPress={handlePress}> diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.tsx b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.tsx index 87175e197..04357de18 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.tsx +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.tsx @@ -1,18 +1,10 @@ -import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import React from 'react'; import Modal from 'Components/Modal/Modal'; import { sizes } from 'Helpers/Props'; -import { clearPendingChanges } from 'Store/Actions/baseActions'; -import { - cancelSaveIndexer, - cancelTestIndexer, -} from 'Store/Actions/settingsActions'; import EditIndexerModalContent, { EditIndexerModalContentProps, } from './EditIndexerModalContent'; -const section = 'settings.indexers'; - interface EditIndexerModalProps extends EditIndexerModalContentProps { isOpen: boolean; } @@ -22,22 +14,9 @@ function EditIndexerModal({ onModalClose, ...otherProps }: EditIndexerModalProps) { - const dispatch = useDispatch(); - - const handleModalClose = useCallback(() => { - dispatch(clearPendingChanges({ section })); - dispatch(cancelTestIndexer({ section })); - dispatch(cancelSaveIndexer({ section })); - - onModalClose(); - }, [dispatch, onModalClose]); - return ( - <Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}> - <EditIndexerModalContent - {...otherProps} - onModalClose={handleModalClose} - /> + <Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={onModalClose}> + <EditIndexerModalContent {...otherProps} onModalClose={onModalClose} /> </Modal> ); } diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.tsx index 460be11d5..854035556 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.tsx @@ -1,7 +1,4 @@ import React, { useCallback, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { IndexerAppState } from 'App/State/SettingsAppState'; -import Alert from 'Components/Alert'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; @@ -9,7 +6,6 @@ import FormLabel from 'Components/Form/FormLabel'; import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; import Button from 'Components/Link/Button'; import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; @@ -18,44 +14,41 @@ import usePrevious from 'Helpers/Hooks/usePrevious'; import { inputTypes, kinds } from 'Helpers/Props'; import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore'; -import { - saveIndexer, - setIndexerFieldValue, - setIndexerValue, - testIndexer, -} from 'Store/Actions/settingsActions'; -import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector'; -import Indexer from 'typings/Indexer'; -import { InputChanged } from 'typings/inputs'; +import { SelectedSchema } from 'Settings/useProviderSchema'; +import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs'; import translate from 'Utilities/String/translate'; +import { useManageIndexer } from '../useIndexers'; import styles from './EditIndexerModalContent.css'; export interface EditIndexerModalContentProps { id?: number; + cloneId?: number; + selectedSchema?: SelectedSchema; onModalClose: () => void; onDeleteIndexerPress?: () => void; } function EditIndexerModalContent({ id, + cloneId, + selectedSchema, onModalClose, onDeleteIndexerPress, }: EditIndexerModalContentProps) { - const dispatch = useDispatch(); const showAdvancedSettings = useShowAdvancedSettings(); const { - isFetching, - error, - isSaving, - isTesting = false, - saveError, item, + updateFieldValue, + updateValue, + saveProvider, + isSaving, + saveError, + testProvider, + isTesting, validationErrors, validationWarnings, - } = useSelector( - createProviderSettingsSelectorHook<Indexer, IndexerAppState>('indexers', id) - ); + } = useManageIndexer(id, cloneId, selectedSchema); const wasSaving = usePrevious(isSaving); @@ -77,27 +70,30 @@ function EditIndexerModalContent({ const handleInputChange = useCallback( (change: InputChanged) => { - // @ts-expect-error - actions are not typed - dispatch(setIndexerValue(change)); + // @ts-expect-error - InputChanged is not typed correctly + updateValue(change.name, change.value); }, - [dispatch] + [updateValue] ); const handleFieldChange = useCallback( - (change: InputChanged) => { - // @ts-expect-error - actions are not typed - dispatch(setIndexerFieldValue(change)); + ({ + name, + value, + additionalProperties, + }: EnhancedSelectInputChanged<unknown>) => { + updateFieldValue({ [name]: value, ...additionalProperties }); }, - [dispatch] + [updateFieldValue] ); const handleSavePress = useCallback(() => { - dispatch(saveIndexer({ id })); - }, [id, dispatch]); + saveProvider(); + }, [saveProvider]); const handleTestPress = useCallback(() => { - dispatch(testIndexer({ id })); - }, [id, dispatch]); + testProvider(); + }, [testProvider]); useEffect(() => { if (!isSaving && wasSaving && !saveError) { @@ -114,169 +110,152 @@ function EditIndexerModalContent({ </ModalHeader> <ModalBody> - {isFetching ? <LoadingIndicator /> : null} + <Form + validationErrors={validationErrors} + validationWarnings={validationWarnings} + > + <FormGroup> + <FormLabel>{translate('Name')}</FormLabel> - {!isFetching && error ? ( - <Alert kind={kinds.DANGER}>{translate('AddIndexerError')}</Alert> - ) : null} + <FormInputGroup + type={inputTypes.TEXT} + name="name" + {...name} + onChange={handleInputChange} + /> + </FormGroup> - {!isFetching && !error ? ( - <Form - validationErrors={validationErrors} - validationWarnings={validationWarnings} - > - <FormGroup> - <FormLabel>{translate('Name')}</FormLabel> + <FormGroup> + <FormLabel>{translate('EnableRss')}</FormLabel> - <FormInputGroup - type={inputTypes.TEXT} - name="name" - {...name} - onChange={handleInputChange} + <FormInputGroup + type={inputTypes.CHECK} + name="enableRss" + helpText={ + supportsRss.value ? translate('EnableRssHelpText') : undefined + } + helpTextWarning={ + supportsRss.value + ? undefined + : translate('RssIsNotSupportedWithThisIndexer') + } + isDisabled={!supportsRss.value} + {...enableRss} + onChange={handleInputChange} + /> + </FormGroup> + + <FormGroup> + <FormLabel>{translate('EnableAutomaticSearch')}</FormLabel> + + <FormInputGroup + type={inputTypes.CHECK} + name="enableAutomaticSearch" + helpText={ + supportsSearch.value + ? translate('EnableAutomaticSearchHelpText') + : undefined + } + helpTextWarning={ + supportsSearch.value + ? undefined + : translate('SearchIsNotSupportedWithThisIndexer') + } + isDisabled={!supportsSearch.value} + {...enableAutomaticSearch} + onChange={handleInputChange} + /> + </FormGroup> + + <FormGroup> + <FormLabel>{translate('EnableInteractiveSearch')}</FormLabel> + + <FormInputGroup + type={inputTypes.CHECK} + name="enableInteractiveSearch" + helpText={ + supportsSearch.value + ? translate('EnableInteractiveSearchHelpText') + : undefined + } + helpTextWarning={ + supportsSearch.value + ? undefined + : translate('SearchIsNotSupportedWithThisIndexer') + } + isDisabled={!supportsSearch.value} + {...enableInteractiveSearch} + onChange={handleInputChange} + /> + </FormGroup> + + {fields?.map((field) => { + return ( + <ProviderFieldFormGroup + key={field.name} + advancedSettings={showAdvancedSettings} + provider="indexer" + providerData={item} + {...field} + onChange={handleFieldChange} /> - </FormGroup> + ); + })} - <FormGroup> - <FormLabel>{translate('EnableRss')}</FormLabel> + <FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}> + <FormLabel>{translate('IndexerPriority')}</FormLabel> - <FormInputGroup - type={inputTypes.CHECK} - name="enableRss" - helpText={ - supportsRss.value ? translate('EnableRssHelpText') : undefined - } - helpTextWarning={ - supportsRss.value - ? undefined - : translate('RssIsNotSupportedWithThisIndexer') - } - isDisabled={!supportsRss.value} - {...enableRss} - onChange={handleInputChange} - /> - </FormGroup> + <FormInputGroup + type={inputTypes.NUMBER} + name="priority" + helpText={translate('IndexerPriorityHelpText')} + min={1} + max={50} + {...priority} + onChange={handleInputChange} + /> + </FormGroup> - <FormGroup> - <FormLabel>{translate('EnableAutomaticSearch')}</FormLabel> + <FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}> + <FormLabel>{translate('MaximumSingleEpisodeAge')}</FormLabel> - <FormInputGroup - type={inputTypes.CHECK} - name="enableAutomaticSearch" - helpText={ - supportsSearch.value - ? translate('EnableAutomaticSearchHelpText') - : undefined - } - helpTextWarning={ - supportsSearch.value - ? undefined - : translate('SearchIsNotSupportedWithThisIndexer') - } - isDisabled={!supportsSearch.value} - {...enableAutomaticSearch} - onChange={handleInputChange} - /> - </FormGroup> + <FormInputGroup + type={inputTypes.NUMBER} + name="seasonSearchMaximumSingleEpisodeAge" + helpText={translate('MaximumSingleEpisodeAgeHelpText')} + min={0} + unit="days" + {...seasonSearchMaximumSingleEpisodeAge} + onChange={handleInputChange} + /> + </FormGroup> - <FormGroup> - <FormLabel>{translate('EnableInteractiveSearch')}</FormLabel> + <FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}> + <FormLabel>{translate('DownloadClient')}</FormLabel> - <FormInputGroup - type={inputTypes.CHECK} - name="enableInteractiveSearch" - helpText={ - supportsSearch.value - ? translate('EnableInteractiveSearchHelpText') - : undefined - } - helpTextWarning={ - supportsSearch.value - ? undefined - : translate('SearchIsNotSupportedWithThisIndexer') - } - isDisabled={!supportsSearch.value} - {...enableInteractiveSearch} - onChange={handleInputChange} - /> - </FormGroup> + <FormInputGroup + type={inputTypes.DOWNLOAD_CLIENT_SELECT} + name="downloadClientId" + helpText={translate('IndexerDownloadClientHelpText')} + {...downloadClientId} + includeAny={true} + protocol={protocol.value} + onChange={handleInputChange} + /> + </FormGroup> - {fields?.map((field) => { - return ( - <ProviderFieldFormGroup - key={field.name} - advancedSettings={showAdvancedSettings} - provider="indexer" - providerData={item} - {...field} - onChange={handleFieldChange} - /> - ); - })} + <FormGroup> + <FormLabel>{translate('Tags')}</FormLabel> - <FormGroup - advancedSettings={showAdvancedSettings} - isAdvanced={true} - > - <FormLabel>{translate('IndexerPriority')}</FormLabel> - - <FormInputGroup - type={inputTypes.NUMBER} - name="priority" - helpText={translate('IndexerPriorityHelpText')} - min={1} - max={50} - {...priority} - onChange={handleInputChange} - /> - </FormGroup> - - <FormGroup - advancedSettings={showAdvancedSettings} - isAdvanced={true} - > - <FormLabel>{translate('MaximumSingleEpisodeAge')}</FormLabel> - - <FormInputGroup - type={inputTypes.NUMBER} - name="seasonSearchMaximumSingleEpisodeAge" - helpText={translate('MaximumSingleEpisodeAgeHelpText')} - min={0} - unit="days" - {...seasonSearchMaximumSingleEpisodeAge} - onChange={handleInputChange} - /> - </FormGroup> - - <FormGroup - advancedSettings={showAdvancedSettings} - isAdvanced={true} - > - <FormLabel>{translate('DownloadClient')}</FormLabel> - - <FormInputGroup - type={inputTypes.DOWNLOAD_CLIENT_SELECT} - name="downloadClientId" - helpText={translate('IndexerDownloadClientHelpText')} - {...downloadClientId} - includeAny={true} - protocol={protocol.value} - onChange={handleInputChange} - /> - </FormGroup> - - <FormGroup> - <FormLabel>{translate('Tags')}</FormLabel> - - <FormInputGroup - type={inputTypes.TAG} - name="tags" - helpText={translate('IndexerTagSeriesHelpText')} - {...tags} - onChange={handleInputChange} - /> - </FormGroup> - </Form> - ) : null} + <FormInputGroup + type={inputTypes.TAG} + name="tags" + helpText={translate('IndexerTagSeriesHelpText')} + {...tags} + onChange={handleInputChange} + /> + </FormGroup> + </Form> </ModalBody> <ModalFooter> diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.tsx b/frontend/src/Settings/Indexers/Indexers/Indexer.tsx index a9f068888..0a4b7f62b 100644 --- a/frontend/src/Settings/Indexers/Indexers/Indexer.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.tsx @@ -1,15 +1,13 @@ import React, { useCallback, useState } from 'react'; -import { useDispatch } from 'react-redux'; import Card from 'Components/Card'; import Label from 'Components/Label'; import IconButton from 'Components/Link/IconButton'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import TagList from 'Components/TagList'; import { icons, kinds } from 'Helpers/Props'; -import { deleteIndexer } from 'Store/Actions/settingsActions'; import { useTagList } from 'Tags/useTags'; -import IndexerModel from 'typings/Indexer'; import translate from 'Utilities/String/translate'; +import { IndexerModel, useDeleteIndexer } from '../useIndexers'; import EditIndexerModal from './EditIndexerModal'; import styles from './Indexer.css'; @@ -31,8 +29,8 @@ function Indexer({ showPriority, onCloneIndexerPress, }: IndexerProps) { - const dispatch = useDispatch(); const tagList = useTagList(); + const { deleteIndexer } = useDeleteIndexer(id); const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false); const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] = @@ -56,8 +54,8 @@ function Indexer({ }, []); const handleConfirmDeleteIndexer = useCallback(() => { - dispatch(deleteIndexer({ id })); - }, [id, dispatch]); + deleteIndexer(); + }, [deleteIndexer]); const handleCloneIndexerPress = useCallback(() => { onCloneIndexerPress(id); diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.tsx b/frontend/src/Settings/Indexers/Indexers/Indexers.tsx index 9483e2ff8..8c9cb4286 100644 --- a/frontend/src/Settings/Indexers/Indexers/Indexers.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Indexers.tsx @@ -1,49 +1,42 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { IndexerAppState } from 'App/State/SettingsAppState'; +import React, { useCallback, useState } from 'react'; import Card from 'Components/Card'; import FieldSet from 'Components/FieldSet'; import Icon from 'Components/Icon'; import PageSectionContent from 'Components/Page/PageSectionContent'; import { icons } from 'Helpers/Props'; -import { cloneIndexer, fetchIndexers } from 'Store/Actions/settingsActions'; -import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import IndexerModel from 'typings/Indexer'; -import sortByProp from 'Utilities/Array/sortByProp'; +import { SelectedSchema } from 'Settings/useProviderSchema'; import translate from 'Utilities/String/translate'; +import { useSortedIndexers } from '../useIndexers'; import AddIndexerModal from './AddIndexerModal'; import EditIndexerModal from './EditIndexerModal'; import Indexer from './Indexer'; import styles from './Indexers.css'; function Indexers() { - const dispatch = useDispatch(); - - const { isFetching, isPopulated, items, error } = useSelector( - createSortedSectionSelector<IndexerModel, IndexerAppState>( - 'settings.indexers', - sortByProp('name') - ) - ); + const { isFetching, isFetched, data, error } = useSortedIndexers(); const [isAddIndexerModalOpen, setIsAddIndexerModalOpen] = useState(false); const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false); + const [cloneIndexerId, setCloneIndexerId] = useState<number | null>(null); - const showPriority = items.some((index) => index.priority !== 25); + const showPriority = data.some((index) => index.priority !== 25); + + const [selectedSchema, setSelectedSchema] = useState< + SelectedSchema | undefined + >(undefined); const handleAddIndexerPress = useCallback(() => { + setCloneIndexerId(null); setIsAddIndexerModalOpen(true); }, []); - const handleCloneIndexerPress = useCallback( - (id: number) => { - dispatch(cloneIndexer({ id })); - setIsEditIndexerModalOpen(true); - }, - [dispatch] - ); + const handleCloneIndexerPress = useCallback((id: number) => { + setCloneIndexerId(id); + setIsEditIndexerModalOpen(true); + }, []); - const handleIndexerSelect = useCallback(() => { + const handleIndexerSelect = useCallback((selected: SelectedSchema) => { + setSelectedSchema(selected); setIsAddIndexerModalOpen(false); setIsEditIndexerModalOpen(true); }, []); @@ -53,23 +46,20 @@ function Indexers() { }, []); const handleEditIndexerModalClose = useCallback(() => { + setCloneIndexerId(null); setIsEditIndexerModalOpen(false); }, []); - useEffect(() => { - dispatch(fetchIndexers()); - }, [dispatch]); - return ( <FieldSet legend={translate('Indexers')}> <PageSectionContent errorMessage={translate('IndexersLoadError')} error={error} isFetching={isFetching} - isPopulated={isPopulated} + isPopulated={isFetched} > <div className={styles.indexers}> - {items.map((item) => { + {data.map((item) => { return ( <Indexer key={item.id} @@ -95,6 +85,8 @@ function Indexers() { <EditIndexerModal isOpen={isEditIndexerModalOpen} + cloneId={cloneIndexerId ?? undefined} + selectedSchema={selectedSchema} onModalClose={handleEditIndexerModalClose} /> </PageSectionContent> diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx index 24ea41a28..31a0b6785 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx @@ -1,7 +1,5 @@ import React, { useCallback, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { SelectProvider, useSelect } from 'App/Select/SelectContext'; -import { IndexerAppState } from 'App/State/SettingsAppState'; import Alert from 'Components/Alert'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; @@ -16,12 +14,16 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import { kinds } from 'Helpers/Props'; import { - bulkDeleteIndexers, - bulkEditIndexers, + IndexerModel, + useBulkDeleteIndexers, + useBulkEditIndexers, + useIndexersData, + useSortedIndexers, +} from 'Settings/Indexers/useIndexers'; +import { setManageIndexersSort, -} from 'Store/Actions/settingsActions'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import Indexer from 'typings/Indexer'; + useManageIndexersOptions, +} from 'Settings/Indexers/useManageIndexersOptionsStore'; import { CheckInputChanged } from 'typings/inputs'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; import translate from 'Utilities/String/translate'; @@ -94,19 +96,11 @@ function ManageIndexersModalContentInner( ) { const { onModalClose } = props; - const { - isFetching, - isPopulated, - isDeleting, - isSaving, - error, - items, - sortKey, - sortDirection, - }: IndexerAppState = useSelector( - createClientSideCollectionSelector('settings.indexers') - ); - const dispatch = useDispatch(); + const { sortKey, sortDirection } = useManageIndexersOptions(); + const { data, isFetching, isFetched, error } = useSortedIndexers(); + + const { isDeleting, bulkDeleteIndexers } = useBulkDeleteIndexers(); + const { isSaving, bulkEditIndexers } = useBulkEditIndexers(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); @@ -121,14 +115,11 @@ function ManageIndexersModalContentInner( selectAll, unselectAll, useSelectedIds, - } = useSelect<Indexer>(); + } = useSelect<IndexerModel>(); - const onSortPress = useCallback( - (value: string) => { - dispatch(setManageIndexersSort({ sortKey: value })); - }, - [dispatch] - ); + const onSortPress = useCallback((value: string) => { + setManageIndexersSort({ sortKey: value }); + }, []); const onDeletePress = useCallback(() => { setIsDeleteModalOpen(true); @@ -147,22 +138,20 @@ function ManageIndexersModalContentInner( }, [setIsEditModalOpen]); const onConfirmDelete = useCallback(() => { - dispatch(bulkDeleteIndexers({ ids: getSelectedIds() })); + bulkDeleteIndexers({ ids: getSelectedIds() }); setIsDeleteModalOpen(false); - }, [getSelectedIds, dispatch]); + }, [bulkDeleteIndexers, getSelectedIds]); const onSavePress = useCallback( (payload: object) => { setIsEditModalOpen(false); - dispatch( - bulkEditIndexers({ - ids: getSelectedIds(), - ...payload, - }) - ); + bulkEditIndexers({ + ids: getSelectedIds(), + ...payload, + }); }, - [getSelectedIds, dispatch] + [getSelectedIds, bulkEditIndexers] ); const onTagsPress = useCallback(() => { @@ -178,15 +167,13 @@ function ManageIndexersModalContentInner( setIsSavingTags(true); setIsTagsModalOpen(false); - dispatch( - bulkEditIndexers({ - ids: getSelectedIds(), - tags, - applyTags, - }) - ); + bulkEditIndexers({ + ids: getSelectedIds(), + tags, + applyTags, + }); }, - [getSelectedIds, dispatch] + [getSelectedIds, bulkEditIndexers] ); const onSelectAllChange = useCallback( @@ -211,11 +198,11 @@ function ManageIndexersModalContentInner( {error ? <div>{errorMessage}</div> : null} - {isPopulated && !error && !items.length ? ( + {isFetched && !error && !data.length ? ( <Alert kind={kinds.INFO}>{translate('NoIndexersFound')}</Alert> ) : null} - {isPopulated && !!items.length && !isFetching && !isFetching ? ( + {isFetched && !!data.length && !isFetching && !isFetching ? ( <Table columns={COLUMNS} horizontalScroll={true} @@ -228,7 +215,7 @@ function ManageIndexersModalContentInner( onSortPress={onSortPress} > <TableBody> - {items.map((item) => { + {data.map((item) => { return ( <ManageIndexersModalRow key={item.id} @@ -303,9 +290,7 @@ function ManageIndexersModalContentInner( } function ManageIndexersModalContent(props: ManageIndexersModalContentProps) { - const { items }: IndexerAppState = useSelector( - createClientSideCollectionSelector('settings.indexers', 'manageIndexers') - ); + const items = useIndexersData(); return ( <SelectProvider items={items}> diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx index 730b510cd..f87467044 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx @@ -7,7 +7,7 @@ import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import Column from 'Components/Table/Column'; import TableRow from 'Components/Table/TableRow'; import { kinds } from 'Helpers/Props'; -import Indexer from 'typings/Indexer'; +import { IndexerModel } from 'Settings/Indexers/useIndexers'; import { SelectStateInputProps } from 'typings/props'; import translate from 'Utilities/String/translate'; import styles from './ManageIndexersModalRow.css'; @@ -38,7 +38,7 @@ function ManageIndexersModalRow(props: ManageIndexersModalRowProps) { tags, } = props; - const { toggleSelected, useIsSelected } = useSelect<Indexer>(); + const { toggleSelected, useIsSelected } = useSelect<IndexerModel>(); const isSelected = useIsSelected(id); const onSelectedChangeWrapper = useCallback( diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx index a3657b524..96a7097ff 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx @@ -1,8 +1,5 @@ import { uniq } from 'lodash'; import React, { useCallback, useMemo, useState } from 'react'; -import { useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; -import { IndexerAppState } from 'App/State/SettingsAppState'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; @@ -15,8 +12,8 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import { useIndexersData } from 'Settings/Indexers/useIndexers'; import { Tag, useTagList } from 'Tags/useTags'; -import Indexer from 'typings/Indexer'; import translate from 'Utilities/String/translate'; import styles from './TagsModalContent.css'; @@ -26,12 +23,31 @@ interface TagsModalContentProps { onModalClose: () => void; } +const applyTagsOptions: EnhancedSelectInputValue<string>[] = [ + { + key: 'add', + get value() { + return translate('Add'); + }, + }, + { + key: 'remove', + get value() { + return translate('Remove'); + }, + }, + { + key: 'replace', + get value() { + return translate('Replace'); + }, + }, +]; + function TagsModalContent(props: TagsModalContentProps) { const { ids, onModalClose, onApplyTagsPress } = props; - const allIndexers: IndexerAppState = useSelector( - (state: AppState) => state.settings.indexers - ); + const allIndexers = useIndexersData(); const tagList: Tag[] = useTagList(); const [tags, setTags] = useState<number[]>([]); @@ -39,7 +55,7 @@ function TagsModalContent(props: TagsModalContentProps) { const indexersTags = useMemo(() => { const tags = ids.reduce((acc: number[], id) => { - const s = allIndexers.items.find((s: Indexer) => s.id === id); + const s = allIndexers.find((s) => s.id === id); if (s) { acc.push(...s.tags); @@ -69,27 +85,6 @@ function TagsModalContent(props: TagsModalContentProps) { onApplyTagsPress(tags, applyTags); }, [tags, applyTags, onApplyTagsPress]); - const applyTagsOptions: EnhancedSelectInputValue<string>[] = [ - { - key: 'add', - get value() { - return translate('Add'); - }, - }, - { - key: 'remove', - get value() { - return translate('Remove'); - }, - }, - { - key: 'replace', - get value() { - return translate('Replace'); - }, - }, - ]; - return ( <ModalContent onModalClose={onModalClose}> <ModalHeader>{translate('Tags')}</ModalHeader> diff --git a/frontend/src/Settings/Indexers/useIndexers.ts b/frontend/src/Settings/Indexers/useIndexers.ts new file mode 100644 index 000000000..b0bf26064 --- /dev/null +++ b/frontend/src/Settings/Indexers/useIndexers.ts @@ -0,0 +1,274 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import { + SelectedSchema, + useProviderSchema, + useSelectedSchema, +} from 'Settings/useProviderSchema'; +import { + useDeleteProvider, + useManageProviderSettings, + useProviderSettings, +} from 'Settings/useProviderSettings'; +import Provider from 'typings/Provider'; +import { sortByProp } from 'Utilities/Array/sortByProp'; +import { ApiError } from 'Utilities/Fetch/fetchJson'; +import translate from 'Utilities/String/translate'; + +export interface IndexerModel extends Provider { + enableRss: boolean; + enableAutomaticSearch: boolean; + enableInteractiveSearch: boolean; + supportsRss: boolean; + supportsSearch: boolean; + seasonSearchMaximumSingleEpisodeAge: number; + protocol: DownloadProtocol; + priority: number; + downloadClientId: number; + tags: number[]; +} + +interface BulkEditIndexersPayload { + ids: number[]; + [key: string]: unknown; +} + +interface BulkDeleteIndexersPayload { + ids: number[]; +} + +const PATH = '/indexer'; + +export const useIndexersWithIds = (ids: number[]) => { + const allIndexers = useIndexersData(); + + return allIndexers.filter((indexer) => ids.includes(indexer.id)); +}; + +export const useIndexer = (id: number | undefined) => { + const { data } = useIndexers(); + + if (id === undefined) { + return undefined; + } + + return data.find((indexer) => indexer.id === id); +}; + +export const useIndexersData = () => { + const { data } = useIndexers(); + + return data; +}; + +export const useSortedIndexers = () => { + const result = useIndexers(); + + const sortedData = useMemo( + () => result.data.sort(sortByProp('name')), + [result.data] + ); + + return { + ...result, + data: sortedData, + }; +}; + +export const useIndexers = () => { + return useProviderSettings<IndexerModel>({ + path: PATH, + }); +}; + +export const useManageIndexer = ( + id: number | undefined, + cloneId: number | undefined, + selectedSchema?: SelectedSchema +) => { + const schema = useSelectedSchema<IndexerModel>(PATH, selectedSchema); + const cloneIndexer = useIndexer(cloneId); + + if (cloneId && !cloneIndexer) { + throw new Error(`Indexer with ID ${cloneId} not found`); + } + + if (selectedSchema && !schema) { + throw new Error('A selected schema is required to manage metadata'); + } + + const defaultProvider = useMemo(() => { + if (cloneId && cloneIndexer) { + const clonedIndexer = { + ...cloneIndexer, + id: 0, + name: translate('DefaultNameCopiedProfile', { + name: cloneIndexer.name, + }), + }; + + clonedIndexer.fields = clonedIndexer.fields.map((field) => { + const newField = { ...field }; + + if (newField.privacy === 'apiKey' || newField.privacy === 'password') { + newField.value = ''; + } + + return newField; + }); + + return clonedIndexer; + } + + if (selectedSchema && schema) { + return { + ...schema, + name: schema.implementationName, + enableRss: schema.supportsRss, + enableAutomaticSearch: schema.supportsSearch, + enableInteractiveSearch: schema.supportsSearch, + }; + } + + return {} as IndexerModel; + }, [cloneId, cloneIndexer, schema, selectedSchema]); + + const manage = useManageProviderSettings<IndexerModel>( + id, + defaultProvider, + PATH + ); + + return manage; +}; + +export const useDeleteIndexer = (id: number) => { + const result = useDeleteProvider<IndexerModel>(id, PATH); + + return { + ...result, + deleteIndexer: result.deleteProvider, + }; +}; + +export const useIndexerSchema = (enabled: boolean = true) => { + return useProviderSchema<IndexerModel>(PATH, enabled); +}; + +export const useTestIndexer = ( + onSuccess?: () => void, + onError?: (error: ApiError) => void +) => { + const { mutate, isPending, error } = useApiMutation<void, IndexerModel>({ + path: `${PATH}/test`, + method: 'POST', + mutationOptions: { + onSuccess, + onError, + }, + }); + + return { + testIndexer: mutate, + isTesting: isPending, + testError: error, + }; +}; + +export const useTestAllIndexers = ( + onSuccess?: () => void, + onError?: (error: ApiError) => void +) => { + const { mutate, isPending, error } = useApiMutation<void, void>({ + path: `${PATH}/testall`, + method: 'POST', + mutationOptions: { + onSuccess, + onError, + }, + }); + + return { + testAllIndexers: mutate, + isTestingAllIndexers: isPending, + testAllError: error, + }; +}; + +export const useBulkEditIndexers = ( + onSuccess?: () => void, + onError?: (error: ApiError) => void +) => { + const queryClient = useQueryClient(); + + const { mutate, isPending, error } = useApiMutation< + IndexerModel[], + BulkEditIndexersPayload + >({ + path: `${PATH}/bulk`, + method: 'PUT', + mutationOptions: { + onSuccess: (updatedIndexers) => { + queryClient.setQueryData<IndexerModel[]>([PATH], (oldIndexers) => { + if (!oldIndexers) { + return oldIndexers; + } + + return oldIndexers.map((indexer) => { + const updatedIndexer = updatedIndexers.find( + (updated) => updated.id === indexer.id + ); + + return updatedIndexer ? { ...indexer, ...updatedIndexer } : indexer; + }); + }); + onSuccess?.(); + }, + onError, + }, + }); + + return { + bulkEditIndexers: mutate, + isSaving: isPending, + bulkError: error, + }; +}; + +export const useBulkDeleteIndexers = ( + onSuccess?: () => void, + onError?: (error: ApiError) => void +) => { + const queryClient = useQueryClient(); + + const { mutate, isPending, error } = useApiMutation< + void, + BulkDeleteIndexersPayload + >({ + path: `${PATH}/bulk`, + method: 'DELETE', + mutationOptions: { + onSuccess: (_, variables) => { + const deletedIds = new Set(variables.ids); + + queryClient.setQueryData<IndexerModel[]>([PATH], (oldIndexers) => { + if (!oldIndexers) { + return oldIndexers; + } + + return oldIndexers.filter((indexer) => !deletedIds.has(indexer.id)); + }); + onSuccess?.(); + }, + onError, + }, + }); + + return { + bulkDeleteIndexers: mutate, + isDeleting: isPending, + bulkDeleteError: error, + }; +}; diff --git a/frontend/src/Settings/Indexers/useManageIndexersOptionsStore.ts b/frontend/src/Settings/Indexers/useManageIndexersOptionsStore.ts new file mode 100644 index 000000000..6f95426d1 --- /dev/null +++ b/frontend/src/Settings/Indexers/useManageIndexersOptionsStore.ts @@ -0,0 +1,20 @@ +import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore'; +import { SortDirection } from 'Helpers/Props/sortDirections'; + +export interface ManageIndexersOptions { + sortKey: string; + sortDirection: SortDirection; +} + +const { useOptions, setSort } = createOptionsStore<ManageIndexersOptions>( + 'manage_indexers_options', + () => { + return { + sortKey: 'name', + sortDirection: 'ascending', + }; + } +); + +export const useManageIndexersOptions = useOptions; +export const setManageIndexersSort = setSort; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.tsx b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.tsx index e126a285e..ede25bbaf 100644 --- a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.tsx +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.tsx @@ -15,7 +15,7 @@ interface AddNotificationItemProps { implementationName: string; infoLink: string; presets?: NotificationModel[]; - onNotificationSelect: (selectedScehema: SelectedSchema) => void; + onNotificationSelect: (selectedSchema: SelectedSchema) => void; } function AddNotificationItem({ diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.tsx b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.tsx index 55e4dc6e8..7dca3e2c1 100644 --- a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.tsx +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.tsx @@ -14,7 +14,7 @@ import AddNotificationItem from './AddNotificationItem'; import styles from './AddNotificationModalContent.css'; export interface AddNotificationModalContentProps { - onNotificationSelect: (selectedScehema: SelectedSchema) => void; + onNotificationSelect: (selectedSchema: SelectedSchema) => void; onModalClose: () => void; } diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.tsx b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.tsx index a94b24247..2c5dc00d8 100644 --- a/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.tsx +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.tsx @@ -6,7 +6,7 @@ interface AddNotificationPresetMenuItemProps { name: string; implementation: string; implementationName: string; - onPress: (selectedScehema: SelectedSchema) => void; + onPress: (selectedSchema: SelectedSchema) => void; } function AddNotificationPresetMenuItem({ diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.tsx b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.tsx index 00752acad..4cf2d711f 100644 --- a/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.tsx +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.tsx @@ -1,14 +1,10 @@ -import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import React from 'react'; import Modal from 'Components/Modal/Modal'; import { sizes } from 'Helpers/Props'; -import { clearPendingChanges } from 'Store/Actions/baseActions'; import EditNotificationModalContent, { EditNotificationModalContentProps, } from './EditNotificationModalContent'; -const section = 'settings.notifications'; - interface EditNotificationModalProps extends EditNotificationModalContentProps { isOpen: boolean; } @@ -18,18 +14,11 @@ function EditNotificationModal({ onModalClose, ...otherProps }: EditNotificationModalProps) { - const dispatch = useDispatch(); - - const handleModalClose = useCallback(() => { - dispatch(clearPendingChanges({ section })); - onModalClose(); - }, [dispatch, onModalClose]); - return ( - <Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}> + <Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={onModalClose}> <EditNotificationModalContent {...otherProps} - onModalClose={handleModalClose} + onModalClose={onModalClose} /> </Modal> ); diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.tsx b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.tsx index 73bf7b756..c2c5eb85c 100644 --- a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.tsx +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.tsx @@ -37,9 +37,9 @@ function EditNotificationModalContent({ }: EditNotificationModalContentProps) { const showAdvancedSettings = useShowAdvancedSettings(); - const result = useManageConnection(id, selectedSchema); const { item, + updateFieldValue, updateValue, saveProvider, isSaving, @@ -48,12 +48,7 @@ function EditNotificationModalContent({ isTesting, validationErrors, validationWarnings, - } = result; - - // updateFieldValue is guaranteed to exist for NotificationModel since it extends Provider - const { updateFieldValue } = result as typeof result & { - updateFieldValue: (fieldProperties: Record<string, unknown>) => void; - }; + } = useManageConnection(id, selectedSchema); const wasSaving = usePrevious(isSaving); diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfileItem.tsx b/frontend/src/Settings/Profiles/Release/ReleaseProfileItem.tsx index ae718b272..efb14c546 100644 --- a/frontend/src/Settings/Profiles/Release/ReleaseProfileItem.tsx +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfileItem.tsx @@ -6,8 +6,8 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import TagList from 'Components/TagList'; import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; import { kinds } from 'Helpers/Props'; +import { IndexerModel } from 'Settings/Indexers/useIndexers'; import { Tag } from 'Tags/useTags'; -import Indexer from 'typings/Indexer'; import translate from 'Utilities/String/translate'; import EditReleaseProfileModal from './EditReleaseProfileModal'; import { @@ -18,7 +18,7 @@ import styles from './ReleaseProfileItem.css'; interface ReleaseProfileProps extends ReleaseProfileModel { tagList: Tag[]; - indexerList: Indexer[]; + indexerList: IndexerModel[]; } function ReleaseProfileItem(props: ReleaseProfileProps) { diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx index 877b09d3f..759394361 100644 --- a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx @@ -1,13 +1,11 @@ -import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; +import React from 'react'; import Card from 'Components/Card'; import FieldSet from 'Components/FieldSet'; import Icon from 'Components/Icon'; import PageSectionContent from 'Components/Page/PageSectionContent'; import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; import { icons } from 'Helpers/Props'; -import { fetchIndexers } from 'Store/Actions/settingsActions'; +import { useIndexersData } from 'Settings/Indexers/useIndexers'; import { useTagList } from 'Tags/useTags'; import translate from 'Utilities/String/translate'; import EditReleaseProfileModal from './EditReleaseProfileModal'; @@ -16,13 +14,10 @@ import { useReleaseProfiles } from './useReleaseProfiles'; import styles from './ReleaseProfiles.css'; function ReleaseProfiles() { - const dispatch = useDispatch(); const { data, isFetching, isFetched, error } = useReleaseProfiles(); const tagList = useTagList(); - const indexerList = useSelector( - (state: AppState) => state.settings.indexers.items - ); + const indexerList = useIndexersData(); const [ isAddReleaseProfileModalOpen, @@ -30,10 +25,6 @@ function ReleaseProfiles() { setAddReleaseProfileModalClosed, ] = useModalOpenState(false); - useEffect(() => { - dispatch(fetchIndexers()); - }, [dispatch]); - return ( <FieldSet legend={translate('ReleaseProfiles')}> <PageSectionContent diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx index 1bc9c2cb7..fd003ecb8 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx @@ -12,6 +12,7 @@ import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { kinds } from 'Helpers/Props'; import useSeries from 'Series/useSeries'; +import { useIndexersWithIds } from 'Settings/Indexers/useIndexers'; import { useConnectionsWithIds } from 'Settings/Notifications/useConnections'; import { useReleaseProfilesWithIds } from 'Settings/Profiles/Release/useReleaseProfiles'; import translate from 'Utilities/String/translate'; @@ -99,13 +100,7 @@ function TagDetailsModalContent({ const releaseProfiles = useReleaseProfilesWithIds(releaseProfileIds); const notifications = useConnectionsWithIds(notificationIds); - - const indexers = useSelector( - createMatchingItemSelector( - indexerIds, - (state: AppState) => state.settings.indexers.items - ) - ); + const indexers = useIndexersWithIds(indexerIds); const downloadClients = useSelector( createMatchingItemSelector( diff --git a/frontend/src/Settings/Tags/Tags.tsx b/frontend/src/Settings/Tags/Tags.tsx index 73c89bcc0..5cdc51faa 100644 --- a/frontend/src/Settings/Tags/Tags.tsx +++ b/frontend/src/Settings/Tags/Tags.tsx @@ -5,13 +5,13 @@ import Alert from 'Components/Alert'; import FieldSet from 'Components/FieldSet'; import PageSectionContent from 'Components/Page/PageSectionContent'; import { kinds } from 'Helpers/Props'; +import { useIndexers } from 'Settings/Indexers/useIndexers'; import { useConnections } from 'Settings/Notifications/useConnections'; import { useReleaseProfiles } from 'Settings/Profiles/Release/useReleaseProfiles'; import { fetchDelayProfiles, fetchDownloadClients, fetchImportLists, - fetchIndexers, } from 'Store/Actions/settingsActions'; import useTagDetails from 'Tags/useTagDetails'; import useTags, { useSortedTagList } from 'Tags/useTags'; @@ -33,11 +33,11 @@ function Tags() { useReleaseProfiles(); useConnections(); + useIndexers(); useEffect(() => { dispatch(fetchDelayProfiles()); dispatch(fetchImportLists()); - dispatch(fetchIndexers()); dispatch(fetchDownloadClients()); queryClient.invalidateQueries({ queryKey: ['releaseprofile'] }); diff --git a/frontend/src/Settings/useProviderSettings.ts b/frontend/src/Settings/useProviderSettings.ts index 0756f9300..06ef539eb 100644 --- a/frontend/src/Settings/useProviderSettings.ts +++ b/frontend/src/Settings/useProviderSettings.ts @@ -10,7 +10,7 @@ import { PendingSection } from 'typings/pending'; import Provider from 'typings/Provider'; import { ApiError } from 'Utilities/Fetch/fetchJson'; -interface ManageProviderSettings<T extends ModelBase> +interface BaseManageProviderSettings<T extends ModelBase> extends Omit<ReturnType<typeof selectSettings<T>>, 'settings'> { item: PendingSection<T>; updateValue: <K extends keyof T>(key: K, value: T[K]) => void; @@ -19,9 +19,17 @@ interface ManageProviderSettings<T extends ModelBase> saveError: ApiError | null; testProvider: () => void; isTesting: boolean; - updateFieldValue?: (fieldProperties: Record<string, unknown>) => void; } +interface ManageProviderSettingsWithFields<T extends ModelBase> + extends BaseManageProviderSettings<T> { + updateFieldValue: (fieldProperties: Record<string, unknown>) => void; +} + +type ManageProviderSettings<T extends ModelBase> = T extends Provider + ? ManageProviderSettingsWithFields<T> + : BaseManageProviderSettings<T>; + const isProviderWithFields = (provider: unknown): provider is Provider => { return ( typeof provider === 'object' && @@ -296,10 +304,10 @@ export const useManageProviderSettings = <T extends ModelBase>( return { ...baseReturn, updateFieldValue, - }; + } as ManageProviderSettings<T>; } - return baseReturn; + return baseReturn as ManageProviderSettings<T>; }; export const useDeleteProvider = <T extends ModelBase>( diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js deleted file mode 100644 index a277e013f..000000000 --- a/frontend/src/Store/Actions/Settings/indexers.js +++ /dev/null @@ -1,180 +0,0 @@ -import { createAction } from 'redux-actions'; -import { sortDirections } from 'Helpers/Props'; -import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; -import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; -import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; -import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; -import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; -import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; -import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; -import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; -import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer'; -import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; -import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; -import { createThunk } from 'Store/thunks'; -import getSectionState from 'Utilities/State/getSectionState'; -import selectProviderSchema from 'Utilities/State/selectProviderSchema'; -import updateSectionState from 'Utilities/State/updateSectionState'; -import translate from 'Utilities/String/translate'; - -// -// Variables - -const section = 'settings.indexers'; - -// -// Actions Types - -export const FETCH_INDEXERS = 'settings/indexers/fetchIndexers'; -export const FETCH_INDEXER_SCHEMA = 'settings/indexers/fetchIndexerSchema'; -export const SELECT_INDEXER_SCHEMA = 'settings/indexers/selectIndexerSchema'; -export const CLONE_INDEXER = 'settings/indexers/cloneIndexer'; -export const SET_INDEXER_VALUE = 'settings/indexers/setIndexerValue'; -export const SET_INDEXER_FIELD_VALUE = 'settings/indexers/setIndexerFieldValue'; -export const SAVE_INDEXER = 'settings/indexers/saveIndexer'; -export const CANCEL_SAVE_INDEXER = 'settings/indexers/cancelSaveIndexer'; -export const DELETE_INDEXER = 'settings/indexers/deleteIndexer'; -export const TEST_INDEXER = 'settings/indexers/testIndexer'; -export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; -export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; -export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers'; -export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers'; -export const SET_MANAGE_INDEXERS_SORT = 'settings/indexers/setManageIndexersSort'; - -// -// Action Creators - -export const fetchIndexers = createThunk(FETCH_INDEXERS); -export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA); -export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA); -export const cloneIndexer = createAction(CLONE_INDEXER); - -export const saveIndexer = createThunk(SAVE_INDEXER); -export const cancelSaveIndexer = createThunk(CANCEL_SAVE_INDEXER); -export const deleteIndexer = createThunk(DELETE_INDEXER); -export const testIndexer = createThunk(TEST_INDEXER); -export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); -export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); -export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS); -export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); -export const setManageIndexersSort = createAction(SET_MANAGE_INDEXERS_SORT); - -export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -export const setIndexerFieldValue = createAction(SET_INDEXER_FIELD_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -// -// Details - -export default { - - // - // State - - defaultState: { - isFetching: false, - isPopulated: false, - error: null, - isSchemaFetching: false, - isSchemaPopulated: false, - schemaError: null, - schema: [], - selectedSchema: {}, - isSaving: false, - saveError: null, - isDeleting: false, - deleteError: null, - isTesting: false, - isTestingAll: false, - items: [], - pendingChanges: {}, - sortKey: 'name', - sortDirection: sortDirections.ASCENDING, - sortPredicates: { - name: ({ name }) => { - return name.toLocaleLowerCase(); - } - } - }, - - // - // Action Handlers - - actionHandlers: { - [FETCH_INDEXERS]: createFetchHandler(section, '/indexer'), - [FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler(section, '/indexer/schema'), - - [SAVE_INDEXER]: createSaveProviderHandler(section, '/indexer'), - [CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler(section), - [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), - [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), - [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), - [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'), - [BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk'), - [BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk') - }, - - // - // Reducers - - reducers: { - [SET_INDEXER_VALUE]: createSetSettingValueReducer(section), - [SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer(section), - - [SELECT_INDEXER_SCHEMA]: (state, { payload }) => { - return selectProviderSchema(state, section, payload, (selectedSchema) => { - selectedSchema.name = payload.presetName ?? payload.implementationName; - selectedSchema.implementationName = payload.implementationName; - selectedSchema.enableRss = selectedSchema.supportsRss; - selectedSchema.enableAutomaticSearch = selectedSchema.supportsSearch; - selectedSchema.enableInteractiveSearch = selectedSchema.supportsSearch; - - return selectedSchema; - }); - }, - - [CLONE_INDEXER]: function(state, { payload }) { - const id = payload.id; - const newState = getSectionState(state, section); - const item = newState.items.find((i) => i.id === id); - - // Use selectedSchema so `createProviderSettingsSelector` works properly - const selectedSchema = { ...item }; - delete selectedSchema.id; - delete selectedSchema.name; - - selectedSchema.fields = selectedSchema.fields.map((field) => { - const newField = { ...field }; - - if (newField.privacy === 'apiKey' || newField.privacy === 'password') { - newField.value = ''; - } - - return newField; - }); - - newState.selectedSchema = selectedSchema; - - // Set the name in pendingChanges - newState.pendingChanges = { - name: translate('DefaultNameCopiedProfile', { name: item.name }) - }; - - return updateSectionState(state, section, newState); - }, - - [SET_MANAGE_INDEXERS_SORT]: createSetClientSideCollectionSortReducer(section) - - } - -}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 7bd3794f8..35d04611e 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -12,7 +12,6 @@ import importListOptions from './Settings/importListOptions'; import importLists from './Settings/importLists'; import indexerFlags from './Settings/indexerFlags'; import indexerOptions from './Settings/indexerOptions'; -import indexers from './Settings/indexers'; export * from './Settings/autoTaggingSpecifications'; export * from './Settings/autoTaggings'; @@ -26,7 +25,6 @@ export * from './Settings/importLists'; export * from './Settings/importListExclusions'; export * from './Settings/indexerFlags'; export * from './Settings/indexerOptions'; -export * from './Settings/indexers'; // // Variables @@ -49,8 +47,7 @@ export const defaultState = { importListExclusions: importListExclusions.defaultState, importListOptions: importListOptions.defaultState, indexerFlags: indexerFlags.defaultState, - indexerOptions: indexerOptions.defaultState, - indexers: indexers.defaultState + indexerOptions: indexerOptions.defaultState }; export const persistState = [ @@ -72,8 +69,7 @@ export const actionHandlers = handleThunks({ ...importListExclusions.actionHandlers, ...importListOptions.actionHandlers, ...indexerFlags.actionHandlers, - ...indexerOptions.actionHandlers, - ...indexers.actionHandlers + ...indexerOptions.actionHandlers }); // @@ -91,7 +87,6 @@ export const reducers = createHandleActions({ ...importListExclusions.reducers, ...importListOptions.reducers, ...indexerFlags.reducers, - ...indexerOptions.reducers, - ...indexers.reducers + ...indexerOptions.reducers }, defaultState, section); diff --git a/frontend/src/System/Status/Health/Health.tsx b/frontend/src/System/Status/Health/Health.tsx index f6743e664..fc334b542 100644 --- a/frontend/src/System/Status/Health/Health.tsx +++ b/frontend/src/System/Status/Health/Health.tsx @@ -14,10 +14,8 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import TableRow from 'Components/Table/TableRow'; import { icons, kinds } from 'Helpers/Props'; -import { - testAllDownloadClients, - testAllIndexers, -} from 'Store/Actions/settingsActions'; +import { useTestAllIndexers } from 'Settings/Indexers/useIndexers'; +import { testAllDownloadClients } from 'Store/Actions/settingsActions'; import titleCase from 'Utilities/String/titleCase'; import translate from 'Utilities/String/translate'; import HealthItemLink from './HealthItemLink'; @@ -49,9 +47,8 @@ function Health() { const isTestingAllDownloadClients = useSelector( (state: AppState) => state.settings.downloadClients.isTestingAll ); - const isTestingAllIndexers = useSelector( - (state: AppState) => state.settings.indexers.isTestingAll - ); + + const { testAllIndexers, isTestingAllIndexers } = useTestAllIndexers(); const healthIssues = !!data.length; @@ -60,8 +57,8 @@ function Health() { }, [dispatch]); const handleTestAllIndexersPress = useCallback(() => { - dispatch(testAllIndexers()); - }, [dispatch]); + testAllIndexers(); + }, [testAllIndexers]); return ( <FieldSet diff --git a/frontend/src/typings/Indexer.ts b/frontend/src/typings/Indexer.ts deleted file mode 100644 index db0be8531..000000000 --- a/frontend/src/typings/Indexer.ts +++ /dev/null @@ -1,17 +0,0 @@ -import DownloadProtocol from 'DownloadClient/DownloadProtocol'; -import Provider from './Provider'; - -interface Indexer extends Provider { - enableRss: boolean; - enableAutomaticSearch: boolean; - enableInteractiveSearch: boolean; - supportsRss: boolean; - supportsSearch: boolean; - seasonSearchMaximumSingleEpisodeAge: number; - protocol: DownloadProtocol; - priority: number; - downloadClientId: number; - tags: number[]; -} - -export default Indexer; From 1c805bded0edce2f34ec16075aed86ec7005b488 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 16 Feb 2026 16:29:38 -0800 Subject: [PATCH 023/110] Clean up SignalRListener --- frontend/src/Components/SignalRListener.tsx | 133 +++++--------------- 1 file changed, 29 insertions(+), 104 deletions(-) diff --git a/frontend/src/Components/SignalRListener.tsx b/frontend/src/Components/SignalRListener.tsx index f9a7c8db1..de6d8e442 100644 --- a/frontend/src/Components/SignalRListener.tsx +++ b/frontend/src/Components/SignalRListener.tsx @@ -15,6 +15,7 @@ import { EpisodeFile } from 'EpisodeFile/EpisodeFile'; import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery'; import Series from 'Series/Series'; import { IndexerModel } from 'Settings/Indexers/useIndexers'; +import { NotificationModel } from 'Settings/Notifications/useConnections'; import { removeItem, updateItem } from 'Store/Actions/baseActions'; import { repopulatePage } from 'Utilities/pagePopulator'; import SignalRLogger from 'Utilities/SignalRLogger'; @@ -142,30 +143,11 @@ function SignalRListener() { if (body.action === 'updated') { const updatedItem = body.resource as Episode; - queryClient.setQueriesData( - { queryKey: ['/episode'] }, - (oldData: Episode[] | undefined) => { - if (!oldData) { - return oldData; - } - - const itemIndex = oldData.findIndex( - (item) => item.id === updatedItem.id - ); - - // Don't add episode if not found - if (itemIndex === -1) { - return oldData; - } - - return oldData.map((item) => { - if (item.id === updatedItem.id) { - return updatedItem; - } - - return item; - }); - } + updateQueryClientItem( + queryClient, + ['/episode'], + updatedItem, + false // Don't add the episode to the list if it doesn't exist. Episodes should already be in the list since they are included in the series details. ); } @@ -180,30 +162,11 @@ function SignalRListener() { if (body.action === 'updated') { const updatedItem = body.resource as EpisodeFile; - queryClient.setQueriesData( - { queryKey: ['/episodeFile'] }, - (oldData: EpisodeFile[] | undefined) => { - if (!oldData) { - return oldData; - } - - const itemIndex = oldData.findIndex( - (item) => item.id === updatedItem.id - ); - - // Add episode file to the end - if (itemIndex === -1) { - return [...oldData, updatedItem]; - } - - return oldData.map((item) => { - if (item.id === updatedItem.id) { - return updatedItem; - } - - return item; - }); - } + updateQueryClientItem( + queryClient, + ['/episodeFile'], + updatedItem, + true // Add the episode file to the list if it doesn't exist. This can happen when an episode file is imported and wasn't previously in the list of episode files. ); // Repopulate the page to handle recently imported file @@ -211,24 +174,7 @@ function SignalRListener() { } else if (body.action === 'deleted') { const id = body.resource.id; - queryClient.setQueriesData( - { queryKey: ['/episodeFile'] }, - (oldData: EpisodeFile[] | undefined) => { - if (!oldData) { - return oldData; - } - - const itemIndex = oldData.findIndex((item) => item.id === id); - - // Add episode file to the end - if (itemIndex === -1) { - return oldData; - } - - return oldData.filter((item) => item.id !== id); - } - ); - + removeQueryClientItem(queryClient, ['/episodeFile'], id); repopulatePage('episodeFileDeleted'); } @@ -269,22 +215,27 @@ function SignalRListener() { } if (name === 'metadata') { - const section = 'settings.metadata'; + const updatedItem = body.resource as ModelBase; if (body.action === 'updated') { - dispatch(updateItem({ section, ...body.resource })); + updateQueryClientItem(queryClient, ['/metadata'], updatedItem, false); } return; } - if (name === 'notification') { - const section = 'settings.notifications'; + if (name === 'connection') { + const updatedItem = body.resource as NotificationModel; if (body.action === 'created' || body.action === 'updated') { - dispatch(updateItem({ section, ...body.resource })); + updateQueryClientItem( + queryClient, + ['/connection'], + updatedItem, + body.action === 'created' // Only add the connection to the list if it was created. If it was updated and it doesn't exist in the list, it likely means the connection is disabled and shouldn't be shown in the list. + ); } else if (body.action === 'deleted') { - dispatch(removeItem({ section, id: body.resource.id })); + removeQueryClientItem(queryClient, ['/connection'], body.resource.id); } return; @@ -351,42 +302,16 @@ function SignalRListener() { if (body.action === 'updated') { const updatedItem = body.resource as Series; - queryClient.setQueryData<Series[]>( + updateQueryClientItem( + queryClient, ['/series'], - (oldData: Series[] | undefined) => { - if (!oldData) { - return oldData; - } - - return oldData.map((item) => { - if (item.id === updatedItem.id) { - return { - ...item, - ...updatedItem, - }; - } - - return item; - }); - } + updatedItem, + false // Don't add the series to the list if it doesn't exist. Series should already be in the list since they are included in the calendar and series details. ); repopulatePage('seriesUpdated'); } else if (body.action === 'deleted') { - dispatch(removeItem({ section: 'series', id: body.resource.id })); - - queryClient.setQueriesData( - { queryKey: ['/series'] }, - (oldData: Series[] | undefined) => { - if (!oldData) { - return oldData; - } - - return oldData.filter((item) => { - return item.id !== body.resource.id; - }); - } - ); + removeQueryClientItem(queryClient, ['/series'], body.resource.id); } return; @@ -560,7 +485,7 @@ const removeQueryClientItem = <T extends ModelBase>( return oldData; } - const itemIndex = oldData.findIndex((item) => item.id === updatedItem.id); + const itemIndex = oldData.findIndex((item) => item.id === id); if (itemIndex === -1) { return oldData; From d5434270122442016886ae323af35b12fdf6dcd1 Mon Sep 17 00:00:00 2001 From: Stevie Robinson <stevie.robinson@gmail.com> Date: Sun, 1 Mar 2026 18:03:10 +0100 Subject: [PATCH 024/110] New: Improve acceptable size rejection messaging for multi-episode releases --- .../Specifications/AcceptableSizeSpecification.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs index e4e25b109..ff275a66c 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs @@ -89,7 +89,7 @@ public DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, ReleaseDecision // If the parsed size is smaller than minSize we don't want it if (subject.Release.Size < minSize) { - var runtimeMessage = subject.Episodes.Count == 1 ? $"{runtime}min" : $"{subject.Episodes.Count}x {runtime}min"; + var runtimeMessage = subject.Episodes.Count == 1 ? $"{runtime}min" : $"{subject.Episodes.Count} episodes totalling {runtime}min"; _logger.Debug("Item: {0}, Size: {1} is smaller than minimum allowed size ({2} bytes for {3}), rejecting.", subject, subject.Release.Size, minSize, runtimeMessage); return DownloadSpecDecision.Reject(DownloadRejectionReason.BelowMinimumSize, "{0} is smaller than minimum allowed {1} (for {2})", subject.Release.Size.SizeSuffix(), minSize.SizeSuffix(), runtimeMessage); @@ -110,7 +110,7 @@ public DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, ReleaseDecision // If the parsed size is greater than maxSize we don't want it if (subject.Release.Size > maxSize) { - var runtimeMessage = subject.Episodes.Count == 1 ? $"{runtime}min" : $"{subject.Episodes.Count}x {runtime}min"; + var runtimeMessage = subject.Episodes.Count == 1 ? $"{runtime}min" : $"{subject.Episodes.Count} episodes totalling {runtime}min"; _logger.Debug("Item: {0}, Size: {1} is greater than maximum allowed size ({2} for {3}), rejecting", subject, subject.Release.Size, maxSize, runtimeMessage); return DownloadSpecDecision.Reject(DownloadRejectionReason.AboveMaximumSize, "{0} is larger than maximum allowed {1} (for {2})", subject.Release.Size.SizeSuffix(), maxSize.SizeSuffix(), runtimeMessage); From 7add5aafad8314011ae9c3a60c8e9e4a96d6b3eb Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:34:55 +0200 Subject: [PATCH 025/110] Bump lodash, qs, rimraf, html-webpack-plugin and webpack --- package.json | 12 +- yarn.lock | 679 ++++++++++++++++++++++++++++++++------------------- 2 files changed, 440 insertions(+), 251 deletions(-) diff --git a/package.json b/package.json index e015d686a..37606dfd6 100644 --- a/package.json +++ b/package.json @@ -43,14 +43,14 @@ "history": "4.10.1", "jdu": "1.0.0", "jquery": "3.7.1", - "lodash": "4.17.21", + "lodash": "4.17.23", "mobile-detect": "1.4.5", "moment": "2.30.1", "moment-timezone": "0.6.0", "mousetrap": "1.6.5", "normalize.css": "8.0.1", "prop-types": "15.8.1", - "qs": "6.13.0", + "qs": "6.15.0", "rdndmb-html5-to-touch": "8.1.2", "react": "18.3.1", "react-addons-shallow-compare": "15.6.3", @@ -94,7 +94,7 @@ "@babel/preset-env": "7.28.5", "@babel/preset-react": "7.28.5", "@babel/preset-typescript": "7.28.5", - "@types/lodash": "4.14.195", + "@types/lodash": "4.14.202", "@types/moment-timezone": "0.5.30", "@types/mousetrap": "1.6.15", "@types/qs": "6.9.16", @@ -128,7 +128,7 @@ "file-loader": "6.2.0", "filemanager-webpack-plugin": "8.0.0", "fork-ts-checker-webpack-plugin": "8.0.0", - "html-webpack-plugin": "5.6.0", + "html-webpack-plugin": "5.6.6", "loader-utils": "^3.2.1", "mini-css-extract-plugin": "2.9.1", "postcss": "8.4.47", @@ -140,7 +140,7 @@ "postcss-url": "10.1.3", "prettier": "2.8.8", "require-nocache": "1.0.0", - "rimraf": "6.1.2", + "rimraf": "6.1.3", "style-loader": "3.3.2", "stylelint": "15.6.1", "stylelint-order": "6.0.4", @@ -148,7 +148,7 @@ "ts-loader": "9.5.1", "typescript-plugin-css-modules": "5.0.1", "url-loader": "4.1.1", - "webpack": "5.95.0", + "webpack": "5.105.2", "webpack-cli": "5.1.4", "webpack-livereload-plugin": "3.0.2", "worker-loader": "3.0.8" diff --git a/yarn.lock b/yarn.lock index bb00f7087..155f3c8cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1203,19 +1203,7 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== -"@isaacs/balanced-match@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" - integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== - -"@isaacs/brace-expansion@^5.0.0": - version "5.0.0" - resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" - integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== - dependencies: - "@isaacs/balanced-match" "^4.0.1" - -"@jridgewell/gen-mapping@^0.3.12": +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": version "0.3.13" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== @@ -1223,15 +1211,6 @@ "@jridgewell/sourcemap-codec" "^1.5.0" "@jridgewell/trace-mapping" "^0.3.24" -"@jridgewell/gen-mapping@^0.3.5": - version "0.3.5" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" - integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== - dependencies: - "@jridgewell/set-array" "^1.2.1" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping" "^0.3.24" - "@jridgewell/remapping@^2.3.5": version "2.3.5" resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" @@ -1245,30 +1224,20 @@ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== -"@jridgewell/set-array@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" - integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== - "@jridgewell/source-map@^0.3.3": - version "0.3.6" - resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.6.tgz#9d71ca886e32502eb9362c9a74a46787c36df81a" - integrity sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ== + version "0.3.11" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.11.tgz#b21835cbd36db656b857c2ad02ebd413cc13a9ba" + integrity sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA== dependencies: "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" - integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== - -"@jridgewell/sourcemap-codec@^1.5.0": +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": version "1.5.5" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": +"@jridgewell/trace-mapping@^0.3.20": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== @@ -1276,7 +1245,7 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@jridgewell/trace-mapping@^0.3.28": +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": version "0.3.31" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== @@ -1449,10 +1418,26 @@ dependencies: "@types/readdir-glob" "*" -"@types/estree@^1.0.5": - version "1.0.6" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" - integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== +"@types/eslint-scope@^3.7.7": + version "3.7.7" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" + integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg== + dependencies: + "@types/eslint" "*" + "@types/estree" "*" + +"@types/eslint@*": + version "9.6.1" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-9.6.1.tgz#d5795ad732ce81715f27f75da913004a56751584" + integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*", "@types/estree@^1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== "@types/history@^4.7.11": version "4.7.11" @@ -1472,7 +1457,7 @@ resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" integrity sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg== -"@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -1482,10 +1467,10 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== -"@types/lodash@4.14.195": - version "4.14.195" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632" - integrity sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg== +"@types/lodash@4.14.202": + version "4.14.202" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8" + integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ== "@types/minimist@^1.2.0": version "1.2.5" @@ -1505,11 +1490,11 @@ integrity sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw== "@types/node@*": - version "22.7.5" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.5.tgz#cfde981727a7ab3611a481510b473ae54442b92b" - integrity sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ== + version "25.3.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.3.0.tgz#749b1bd4058e51b72e22bd41e9eab6ebd0180470" + integrity sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A== dependencies: - undici-types "~6.19.2" + undici-types "~7.18.0" "@types/node@20.16.11": version "20.16.11" @@ -1786,125 +1771,125 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== -"@webassemblyjs/ast@1.12.1", "@webassemblyjs/ast@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.12.1.tgz#bb16a0e8b1914f979f45864c23819cc3e3f0d4bb" - integrity sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg== +"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" + integrity sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ== dependencies: - "@webassemblyjs/helper-numbers" "1.11.6" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/helper-numbers" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" -"@webassemblyjs/floating-point-hex-parser@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" - integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== +"@webassemblyjs/floating-point-hex-parser@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz#fcca1eeddb1cc4e7b6eed4fc7956d6813b21b9fb" + integrity sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA== -"@webassemblyjs/helper-api-error@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" - integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== +"@webassemblyjs/helper-api-error@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz#e0a16152248bc38daee76dd7e21f15c5ef3ab1e7" + integrity sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ== -"@webassemblyjs/helper-buffer@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz#6df20d272ea5439bf20ab3492b7fb70e9bfcb3f6" - integrity sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw== +"@webassemblyjs/helper-buffer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz#822a9bc603166531f7d5df84e67b5bf99b72b96b" + integrity sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA== -"@webassemblyjs/helper-numbers@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" - integrity sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g== +"@webassemblyjs/helper-numbers@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz#dbd932548e7119f4b8a7877fd5a8d20e63490b2d" + integrity sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA== dependencies: - "@webassemblyjs/floating-point-hex-parser" "1.11.6" - "@webassemblyjs/helper-api-error" "1.11.6" + "@webassemblyjs/floating-point-hex-parser" "1.13.2" + "@webassemblyjs/helper-api-error" "1.13.2" "@xtuc/long" "4.2.2" -"@webassemblyjs/helper-wasm-bytecode@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" - integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== +"@webassemblyjs/helper-wasm-bytecode@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz#e556108758f448aae84c850e593ce18a0eb31e0b" + integrity sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA== -"@webassemblyjs/helper-wasm-section@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz#3da623233ae1a60409b509a52ade9bc22a37f7bf" - integrity sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g== +"@webassemblyjs/helper-wasm-section@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz#9629dda9c4430eab54b591053d6dc6f3ba050348" + integrity sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw== dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/wasm-gen" "1.12.1" + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/wasm-gen" "1.14.1" -"@webassemblyjs/ieee754@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" - integrity sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg== +"@webassemblyjs/ieee754@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz#1c5eaace1d606ada2c7fd7045ea9356c59ee0dba" + integrity sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw== dependencies: "@xtuc/ieee754" "^1.2.0" -"@webassemblyjs/leb128@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" - integrity sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ== +"@webassemblyjs/leb128@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.13.2.tgz#57c5c3deb0105d02ce25fa3fd74f4ebc9fd0bbb0" + integrity sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw== dependencies: "@xtuc/long" "4.2.2" -"@webassemblyjs/utf8@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" - integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== +"@webassemblyjs/utf8@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.13.2.tgz#917a20e93f71ad5602966c2d685ae0c6c21f60f1" + integrity sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ== -"@webassemblyjs/wasm-edit@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz#9f9f3ff52a14c980939be0ef9d5df9ebc678ae3b" - integrity sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g== +"@webassemblyjs/wasm-edit@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz#ac6689f502219b59198ddec42dcd496b1004d597" + integrity sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ== dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/helper-wasm-section" "1.12.1" - "@webassemblyjs/wasm-gen" "1.12.1" - "@webassemblyjs/wasm-opt" "1.12.1" - "@webassemblyjs/wasm-parser" "1.12.1" - "@webassemblyjs/wast-printer" "1.12.1" + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/helper-wasm-section" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-opt" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + "@webassemblyjs/wast-printer" "1.14.1" -"@webassemblyjs/wasm-gen@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz#a6520601da1b5700448273666a71ad0a45d78547" - integrity sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w== +"@webassemblyjs/wasm-gen@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz#991e7f0c090cb0bb62bbac882076e3d219da9570" + integrity sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg== dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/ieee754" "1.11.6" - "@webassemblyjs/leb128" "1.11.6" - "@webassemblyjs/utf8" "1.11.6" + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" -"@webassemblyjs/wasm-opt@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz#9e6e81475dfcfb62dab574ac2dda38226c232bc5" - integrity sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg== +"@webassemblyjs/wasm-opt@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz#e6f71ed7ccae46781c206017d3c14c50efa8106b" + integrity sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw== dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/wasm-gen" "1.12.1" - "@webassemblyjs/wasm-parser" "1.12.1" + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" -"@webassemblyjs/wasm-parser@1.12.1", "@webassemblyjs/wasm-parser@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz#c47acb90e6f083391e3fa61d113650eea1e95937" - integrity sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ== +"@webassemblyjs/wasm-parser@1.14.1", "@webassemblyjs/wasm-parser@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz#b3e13f1893605ca78b52c68e54cf6a865f90b9fb" + integrity sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ== dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-api-error" "1.11.6" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/ieee754" "1.11.6" - "@webassemblyjs/leb128" "1.11.6" - "@webassemblyjs/utf8" "1.11.6" + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-api-error" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" -"@webassemblyjs/wast-printer@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz#bcecf661d7d1abdaf989d8341a4833e33e2b31ac" - integrity sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA== +"@webassemblyjs/wast-printer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz#3bb3e9638a8ae5fdaf9610e7a06b4d9f9aa6fe07" + integrity sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw== dependencies: - "@webassemblyjs/ast" "1.12.1" + "@webassemblyjs/ast" "1.14.1" "@xtuc/long" "4.2.2" "@webpack-cli/configtest@^2.1.1": @@ -1939,17 +1924,22 @@ abort-controller@^3.0.0: dependencies: event-target-shim "^5.0.0" -acorn-import-attributes@^1.9.5: - version "1.9.5" - resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" - integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== +acorn-import-phases@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7" + integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ== acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.15.0, acorn@^8.8.2: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + +acorn@^8.9.0: version "8.12.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== @@ -1996,7 +1986,17 @@ ajv@^6.12.4, ajv@^6.12.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.0, ajv@^8.0.1, ajv@^8.9.0: +ajv@^8.0.0, ajv@^8.9.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc" + integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +ajv@^8.0.1: version "8.17.1" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== @@ -2275,15 +2275,20 @@ balanced-match@^2.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9" integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA== +balanced-match@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.3.tgz#6337a2f23e0604a30481423432f99eac603599f9" + integrity sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g== + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== baseline-browser-mapping@^2.9.0: - version "2.9.11" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz#53724708c8db5f97206517ecfe362dbe5181deea" - integrity sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ== + version "2.10.0" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9" + integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA== big.js@^5.2.2: version "5.2.2" @@ -2334,6 +2339,13 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" +brace-expansion@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.2.tgz#b6c16d0791087af6c2bc463f52a8142046c06b6f" + integrity sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw== + dependencies: + balanced-match "^4.0.2" + braces@^3.0.3, braces@~3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" @@ -2341,7 +2353,7 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" -browserslist@^4.21.10, browserslist@^4.23.3, browserslist@^4.24.0: +browserslist@^4.23.3, browserslist@^4.24.0: version "4.24.0" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.0.tgz#a1325fe4bc80b64fda169629fc01b3d6cecd38d4" integrity sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A== @@ -2351,7 +2363,7 @@ browserslist@^4.21.10, browserslist@^4.23.3, browserslist@^4.24.0: node-releases "^2.0.18" update-browserslist-db "^1.1.0" -browserslist@^4.28.0: +browserslist@^4.28.0, browserslist@^4.28.1: version "4.28.1" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== @@ -2385,6 +2397,14 @@ bytes@1: resolved "https://registry.yarnpkg.com/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8" integrity sha512-/x68VkHLeTl3/Ll8IvxdwzhrT+IyKc52e/oyHhA2RwqPqswSnjVbSddfPRwAsJtbilMAPSRWwAlpxdYsSWOTKQ== +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" @@ -2396,6 +2416,14 @@ call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -2429,9 +2457,9 @@ camelcase@^5.3.1: integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001663, caniuse-lite@^1.0.30001759: - version "1.0.30001761" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz" - integrity sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g== + version "1.0.30001770" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz" + integrity sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw== chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" @@ -2750,9 +2778,9 @@ css-tree@^2.3.1: source-map-js "^1.0.1" css-what@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" - integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + version "6.2.2" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" + integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== cssesc@^3.0.0: version "3.0.0" @@ -2971,10 +2999,19 @@ dotenv@^16.0.3: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + electron-to-chromium@^1.5.263: - version "1.5.267" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz#5d84f2df8cdb6bfe7e873706bb21bd4bfb574dc7" - integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw== + version "1.5.302" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz#032a5802b31f7119269959c69fe2015d8dad5edb" + integrity sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg== electron-to-chromium@^1.5.28: version "1.5.35" @@ -3003,7 +3040,7 @@ end-of-stream@^1.4.1: dependencies: once "^1.4.0" -enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.1: +enhanced-resolve@^5.0.0: version "5.17.1" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== @@ -3011,6 +3048,14 @@ enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.1: graceful-fs "^4.2.4" tapable "^2.2.0" +enhanced-resolve@^5.19.0: + version "5.19.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz#6687446a15e969eaa63c2fa2694510e17ae6d97c" + integrity sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.3.0" + entities@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" @@ -3108,6 +3153,11 @@ es-define-property@^1.0.0: dependencies: get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.2.1, es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" @@ -3133,15 +3183,15 @@ es-iterator-helpers@^1.0.19: iterator.prototype "^1.1.3" safe-array-concat "^1.1.2" -es-module-lexer@^1.2.1: - version "1.5.4" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78" - integrity sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw== +es-module-lexer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.0.0.tgz#f657cd7a9448dcdda9c070a3cb75e5dc1e85f5b1" + integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw== -es-object-atoms@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" - integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw== +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== dependencies: es-errors "^1.3.0" @@ -3447,9 +3497,9 @@ fast-levenshtein@^2.0.6: integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== fast-uri@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.2.tgz#d78b298cf70fd3b752fd951175a3da6a7b48f024" - integrity sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row== + version "3.1.0" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" + integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== fastest-levenshtein@^1.0.12, fastest-levenshtein@^1.0.16: version "1.0.16" @@ -3677,11 +3727,35 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + get-node-dimensions@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/get-node-dimensions/-/get-node-dimensions-1.2.1.tgz#fb7b4bb57060fb4247dd51c9d690dfbec56b0823" integrity sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ== +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-symbol-description@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" @@ -3710,14 +3784,14 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^13.0.0: - version "13.0.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.0.tgz#9d9233a4a274fc28ef7adce5508b7ef6237a1be3" - integrity sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA== +glob@^13.0.3: + version "13.0.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d" + integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw== dependencies: - minimatch "^10.1.1" - minipass "^7.1.2" - path-scurry "^2.0.0" + minimatch "^10.2.2" + minipass "^7.1.3" + path-scurry "^2.0.2" glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.3: version "7.2.3" @@ -3791,6 +3865,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -3838,6 +3917,11 @@ has-symbols@^1.0.2, has-symbols@^1.0.3: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" @@ -3906,10 +3990,10 @@ html-tags@^3.3.1: resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== -html-webpack-plugin@5.6.0: - version "5.6.0" - resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz#50a8fa6709245608cb00e811eacecb8e0d7b7ea0" - integrity sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw== +html-webpack-plugin@5.6.6: + version "5.6.6" + resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.6.tgz#5321b9579f4a1949318550ced99c2a4a4e60cbaf" + integrity sha512-bLjW01UTrvoWTJQL5LsMRo1SypHW80FTm12OJRSnr3v6YHNhfe+1r0MYUZJMACxnCHURVnBWRwAsWs2yPU9Ezw== dependencies: "@types/html-minifier-terser" "^6.0.0" html-minifier-terser "^6.0.2" @@ -4486,10 +4570,10 @@ livereload-js@^2.3.0: resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.4.0.tgz#447c31cf1ea9ab52fc20db615c5ddf678f78009c" integrity sha512-XPQH8Z2GDP/Hwz2PCDrh2mth4yFejwA1OZ/81Ti3LgKyhDcEjsSsqFWZojHG0va/duGd+WyosY7eXLDoOyqcPw== -loader-runner@^4.2.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" - integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== +loader-runner@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.1.tgz#6c76ed29b0ccce9af379208299f07f876de737e3" + integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q== loader-utils@^1.2.3: version "1.4.2" @@ -4607,7 +4691,12 @@ lodash.upperfirst@4.3.1: resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg== -lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21: +lodash@4.17.23, lodash@^4.17.20, lodash@^4.17.21: + version "4.17.23" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" + integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== + +lodash@^4.17.14: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -4627,9 +4716,9 @@ lower-case@^2.0.2: tslib "^2.0.3" lru-cache@^11.0.0: - version "11.2.4" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.4.tgz#ecb523ebb0e6f4d837c807ad1abaea8e0619770d" - integrity sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg== + version "11.2.6" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.6.tgz#356bf8a29e88a7a2945507b31f6429a65a192c58" + integrity sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ== lru-cache@^5.1.1: version "5.1.1" @@ -4670,6 +4759,11 @@ map-obj@^4.0.0: resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a" integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ== +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + mathml-tag-names@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" @@ -4771,12 +4865,12 @@ mini-css-extract-plugin@2.9.1: schema-utils "^4.0.0" tapable "^2.2.1" -minimatch@^10.1.1: - version "10.1.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.1.1.tgz#e6e61b9b0c1dcab116b5a7d1458e8b6ae9e73a55" - integrity sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ== +minimatch@^10.2.2: + version "10.2.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.2.tgz#361603ee323cfb83496fea2ae17cc44ea4e1f99f" + integrity sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw== dependencies: - "@isaacs/brace-expansion" "^5.0.0" + brace-expansion "^5.0.2" minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" @@ -4820,10 +4914,10 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== -minipass@^7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" - integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== +minipass@^7.1.2, minipass@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== mkdirp@^0.5.6: version "0.5.6" @@ -4969,6 +5063,11 @@ object-inspect@^1.13.1: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== +object-inspect@^1.13.3: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -5162,10 +5261,10 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-scurry@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.1.tgz#4b6572376cfd8b811fca9cd1f5c24b3cbac0fe10" - integrity sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA== +path-scurry@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85" + integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg== dependencies: lru-cache "^11.0.0" minipass "^7.1.2" @@ -5452,7 +5551,14 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -qs@6.13.0, qs@^6.4.0: +qs@6.15.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.0.tgz#db8fd5d1b1d2d6b5b33adaf87429805f1909e7b3" + integrity sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ== + dependencies: + side-channel "^1.1.0" + +qs@^6.4.0: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== @@ -6032,12 +6138,12 @@ rgb@~0.1.0: resolved "https://registry.yarnpkg.com/rgb/-/rgb-0.1.0.tgz#be27b291e8feffeac1bd99729721bfa40fc037b5" integrity sha512-F49dXX73a92N09uQkfCp2QjwXpmJcn9/i9PvjmwsSIXUGqRLCf/yx5Q9gRxuLQTq248kakqQuc8GX/U/CxSqlA== -rimraf@6.1.2: - version "6.1.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-6.1.2.tgz#9a0f3cea2ab853e81291127422116ecf2a86ae89" - integrity sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g== +rimraf@6.1.3: + version "6.1.3" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-6.1.3.tgz#afbee236b3bd2be331d4e7ce4493bac1718981af" + integrity sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA== dependencies: - glob "^13.0.0" + glob "^13.0.3" package-json-from-dist "^1.0.1" rimraf@^3.0.2: @@ -6129,7 +6235,7 @@ schema-utils@>1.0.0, schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" -schema-utils@^3.0.0, schema-utils@^3.1.1, schema-utils@^3.2.0: +schema-utils@^3.0.0, schema-utils@^3.1.1: version "3.3.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== @@ -6138,6 +6244,16 @@ schema-utils@^3.0.0, schema-utils@^3.1.1, schema-utils@^3.2.0: ajv "^6.12.5" ajv-keywords "^3.5.2" +schema-utils@^4.3.0, schema-utils@^4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.3.tgz#5b1850912fa31df90716963d45d9121fdfc09f46" + integrity sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA== + dependencies: + "@types/json-schema" "^7.0.9" + ajv "^8.9.0" + ajv-formats "^2.1.1" + ajv-keywords "^5.1.0" + seamless-immutable@^7.1.3: version "7.1.4" resolved "https://registry.yarnpkg.com/seamless-immutable/-/seamless-immutable-7.1.4.tgz#6e9536def083ddc4dea0207d722e0e80d0f372f8" @@ -6163,7 +6279,7 @@ semver@^7.3.4, semver@^7.3.5, semver@^7.3.8, semver@^7.6.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== -serialize-javascript@^6.0.1: +serialize-javascript@^6.0.1, serialize-javascript@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== @@ -6226,6 +6342,35 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4, side-channel@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" @@ -6236,6 +6381,17 @@ side-channel@^1.0.4, side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + signal-exit@^4.0.1: version "4.1.0" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" @@ -6586,7 +6742,12 @@ table@^6.8.1: string-width "^4.2.3" strip-ansi "^6.0.1" -tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1: +tapable@^2.0.0, tapable@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" + integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== + +tapable@^2.2.0, tapable@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== @@ -6602,7 +6763,7 @@ tar-stream@^2.2.0: inherits "^2.0.3" readable-stream "^3.1.1" -terser-webpack-plugin@5.3.10, terser-webpack-plugin@^5.3.10: +terser-webpack-plugin@5.3.10: version "5.3.10" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz#904f4c9193c6fd2a03f693a2150c62a92f40d199" integrity sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w== @@ -6613,7 +6774,28 @@ terser-webpack-plugin@5.3.10, terser-webpack-plugin@^5.3.10: serialize-javascript "^6.0.1" terser "^5.26.0" -terser@^5.10.0, terser@^5.26.0: +terser-webpack-plugin@^5.3.16: + version "5.3.16" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz#741e448cc3f93d8026ebe4f7ef9e4afacfd56330" + integrity sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + jest-worker "^27.4.5" + schema-utils "^4.3.0" + serialize-javascript "^6.0.2" + terser "^5.31.1" + +terser@^5.10.0, terser@^5.31.1: + version "5.46.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.46.0.tgz#1b81e560d584bbdd74a8ede87b4d9477b0ff9695" + integrity sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg== + dependencies: + "@jridgewell/source-map" "^0.3.3" + acorn "^8.15.0" + commander "^2.20.0" + source-map-support "~0.5.20" + +terser@^5.26.0: version "5.34.1" resolved "https://registry.yarnpkg.com/terser/-/terser-5.34.1.tgz#af40386bdbe54af0d063e0670afd55c3105abeb6" integrity sha512-FsJZ7iZLd/BXkz+4xrRTGJ26o/6VTjQytUk8b8OxkwcD2I+79VPJlz7qss1+zE7h8GNIScFqXcDyJ/KqBYZFVA== @@ -6864,6 +7046,11 @@ undici-types@~6.19.2: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2" @@ -6985,10 +7172,10 @@ value-equal@^1.0.1: resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== -watchpack@^2.4.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.2.tgz#2feeaed67412e7c33184e5a79ca738fbd38564da" - integrity sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw== +watchpack@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.5.1.tgz#dd38b601f669e0cbf567cb802e75cead82cde102" + integrity sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -7036,39 +7223,41 @@ webpack-merge@^5.7.3: flat "^5.0.2" wildcard "^2.0.0" -webpack-sources@^3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" - integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== +webpack-sources@^3.3.3: + version "3.3.4" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891" + integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q== -webpack@5.95.0: - version "5.95.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.95.0.tgz#8fd8c454fa60dad186fbe36c400a55848307b4c0" - integrity sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q== +webpack@5.105.2: + version "5.105.2" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.105.2.tgz#f3b76f9fc36f1152e156e63ffda3bbb82e6739ea" + integrity sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw== dependencies: - "@types/estree" "^1.0.5" - "@webassemblyjs/ast" "^1.12.1" - "@webassemblyjs/wasm-edit" "^1.12.1" - "@webassemblyjs/wasm-parser" "^1.12.1" - acorn "^8.7.1" - acorn-import-attributes "^1.9.5" - browserslist "^4.21.10" + "@types/eslint-scope" "^3.7.7" + "@types/estree" "^1.0.8" + "@types/json-schema" "^7.0.15" + "@webassemblyjs/ast" "^1.14.1" + "@webassemblyjs/wasm-edit" "^1.14.1" + "@webassemblyjs/wasm-parser" "^1.14.1" + acorn "^8.15.0" + acorn-import-phases "^1.0.3" + browserslist "^4.28.1" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.17.1" - es-module-lexer "^1.2.1" + enhanced-resolve "^5.19.0" + es-module-lexer "^2.0.0" eslint-scope "5.1.1" events "^3.2.0" glob-to-regexp "^0.4.1" graceful-fs "^4.2.11" json-parse-even-better-errors "^2.3.1" - loader-runner "^4.2.0" + loader-runner "^4.3.1" mime-types "^2.1.27" neo-async "^2.6.2" - schema-utils "^3.2.0" - tapable "^2.1.1" - terser-webpack-plugin "^5.3.10" - watchpack "^2.4.1" - webpack-sources "^3.2.3" + schema-utils "^4.3.3" + tapable "^2.3.0" + terser-webpack-plugin "^5.3.16" + watchpack "^2.5.1" + webpack-sources "^3.3.3" websocket-driver@>=0.5.1: version "0.7.4" From 965b6144e303bc5e6d492187025897bc68ad2e72 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sat, 21 Feb 2026 12:34:12 -0800 Subject: [PATCH 026/110] New: Improve parsing of releases with contain multiple titles --- .../ParsingServiceTests/MapFixture.cs | 44 +++++++++++++++++++ src/NzbDrone.Core/Parser/ParsingService.cs | 20 ++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs index 0f0da6768..8fd47c8b0 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs @@ -343,5 +343,49 @@ public void should_not_use_tvdbid_matching_when_alias_without_year_is_found_with Mocker.GetMock<ISeriesService>() .Verify(v => v.FindByTvdbId(It.IsAny<int>()), Times.Once()); } + + [Test] + public void should_use_year_when_looking_up_by_all_titles_in_release_title() + { + var alias = "Series Alias"; + var title = "Series Title"; + + _parsedEpisodeInfo.SeriesTitle = $"Series Title AKA Series Alias {_series.Year}"; + _parsedEpisodeInfo.SeriesTitleInfo.AllTitles = [ + title, + alias + ]; + _parsedEpisodeInfo.SeriesTitleInfo.Year = _series.Year; + + Mocker.GetMock<ISeriesService>() + .Setup(s => s.FindByTitle(title, _series.Year)) + .Returns(_series); + + var result = Subject.Map(_parsedEpisodeInfo, 0, 0, "", null); + + result.Series.Should().Be(_series); + } + + [Test] + public void should_use_title_with_year_when_looking_up_by_all_titles_in_release_title() + { + var alias = "Series Alias"; + var title = "Series Title"; + + _parsedEpisodeInfo.SeriesTitle = $"Series Title AKA Series Alias {_series.Year}"; + _parsedEpisodeInfo.SeriesTitleInfo.AllTitles = [ + title, + alias + ]; + _parsedEpisodeInfo.SeriesTitleInfo.Year = _series.Year; + + Mocker.GetMock<ISeriesService>() + .Setup(s => s.FindByTitle($"{title} {_series.Year}")) + .Returns(_series); + + var result = Subject.Map(_parsedEpisodeInfo, 0, 0, "", null); + + result.Series.Should().Be(_series); + } } } diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 5b857c002..ac4b3add6 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -74,13 +74,31 @@ public Series GetSeries(string title) private Series GetSeriesByAllTitles(ParsedEpisodeInfo parsedEpisodeInfo) { + var year = parsedEpisodeInfo.SeriesTitleInfo.Year; Series foundSeries = null; int? foundTvdbId = null; // Match each title individually, they must all resolve to the same tvdbid foreach (var title in parsedEpisodeInfo.SeriesTitleInfo.AllTitles) { - var series = _seriesService.FindByTitle(title); + Series series = null; + + if (year > 0) + { + series = _seriesService.FindByTitle(title, year); + + // Fall back to title + year being part of the title, this will allow + // matching series with the same name that include the year in the title. + if (series == null) + { + series = _seriesService.FindByTitle($"{title} {year}"); + } + } + else + { + series = _seriesService.FindByTitle(title); + } + var tvdbId = series?.TvdbId; if (series == null) From da6340e421cd432d88c2eac73850b0b6eda55471 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sun, 22 Feb 2026 20:27:21 -0800 Subject: [PATCH 027/110] New: Add Original Country information Closes #5143 --- frontend/src/Helpers/Props/icons.ts | 2 + .../Internationalization/getCountryCode.ts | 262 ++++++++++++++++++ .../Internationalization/useCountryName.ts | 32 +++ frontend/src/Language/useLanguageName.ts | 2 +- frontend/src/Series/Details/SeriesDetails.css | 1 + .../src/Series/Details/SeriesDetails.css.d.ts | 1 + frontend/src/Series/Details/SeriesDetails.tsx | 18 ++ .../Index/Menus/SeriesIndexSortMenu.tsx | 9 + .../Index/Posters/SeriesIndexPoster.tsx | 2 + .../Index/Posters/SeriesIndexPosterInfo.tsx | 13 + .../src/Series/Index/Table/SeriesIndexRow.css | 1 + .../Index/Table/SeriesIndexRow.css.d.ts | 1 + .../src/Series/Index/Table/SeriesIndexRow.tsx | 10 + .../Index/Table/SeriesIndexTableHeader.css | 1 + .../Table/SeriesIndexTableHeader.css.d.ts | 1 + frontend/src/Series/Series.ts | 1 + frontend/src/Series/seriesOptionsStore.ts | 6 + .../OriginalCountrySpecification.cs | 47 ++++ .../Migration/227_original_country.cs | 15 + src/NzbDrone.Core/Localization/Core/en.json | 2 + .../SkyHook/Resource/ShowResource.cs | 1 + .../MetadataSource/SkyHook/SkyHookProxy.cs | 1 + .../CustomScript/CustomScript.cs | 191 ++++--------- .../Notifications/Webhook/WebhookSeries.cs | 2 + src/NzbDrone.Core/Tv/RefreshSeriesService.cs | 1 + src/NzbDrone.Core/Tv/Series.cs | 2 +- src/Sonarr.Api.V5/Series/SeriesResource.cs | 2 + 27 files changed, 487 insertions(+), 140 deletions(-) create mode 100644 frontend/src/Internationalization/getCountryCode.ts create mode 100644 frontend/src/Internationalization/useCountryName.ts create mode 100644 src/NzbDrone.Core/AutoTagging/Specifications/OriginalCountrySpecification.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/227_original_country.cs diff --git a/frontend/src/Helpers/Props/icons.ts b/frontend/src/Helpers/Props/icons.ts index 4f0f24f73..7550e1dd4 100644 --- a/frontend/src/Helpers/Props/icons.ts +++ b/frontend/src/Helpers/Props/icons.ts @@ -68,6 +68,7 @@ import { faFolderOpen as fasFolderOpen, faFolderTree as farFolderTree, faForward as fasForward, + faGlobe as fasGlobe, faHeart as fasHeart, faHistory as fasHistory, faHome as fasHome, @@ -166,6 +167,7 @@ export const FOOTNOTE = fasAsterisk; export const FOLDER = farFolder; export const FOLDER_OPEN = fasFolderOpen; export const GENRE = fasTheaterMasks; +export const GLOBE = fasGlobe; export const GROUP = farObjectGroup; export const HEALTH = fasMedkit; export const HEART = fasHeart; diff --git a/frontend/src/Internationalization/getCountryCode.ts b/frontend/src/Internationalization/getCountryCode.ts new file mode 100644 index 000000000..e412b9996 --- /dev/null +++ b/frontend/src/Internationalization/getCountryCode.ts @@ -0,0 +1,262 @@ +// Mapping from ISO 3166-1 alpha-3 (3-letter) to alpha-2 (2-letter) country codes +const alpha3ToAlpha2: Record<string, string> = { + AFG: 'AF', + ALB: 'AL', + DZA: 'DZ', + ASM: 'AS', + AND: 'AD', + AGO: 'AO', + AIA: 'AI', + ATA: 'AQ', + ATG: 'AG', + ARG: 'AR', + ARM: 'AM', + ABW: 'AW', + AUS: 'AU', + AUT: 'AT', + AZE: 'AZ', + BHS: 'BS', + BHR: 'BH', + BGD: 'BD', + BRB: 'BB', + BLR: 'BY', + BEL: 'BE', + BLZ: 'BZ', + BEN: 'BJ', + BMU: 'BM', + BTN: 'BT', + BOL: 'BO', + BES: 'BQ', + BIH: 'BA', + BWA: 'BW', + BVT: 'BV', + BRA: 'BR', + IOT: 'IO', + BRN: 'BN', + BGR: 'BG', + BFA: 'BF', + BDI: 'BI', + CPV: 'CV', + KHM: 'KH', + CMR: 'CM', + CAN: 'CA', + CYM: 'KY', + CAF: 'CF', + TCD: 'TD', + CHL: 'CL', + CHN: 'CN', + CXR: 'CX', + CCK: 'CC', + COL: 'CO', + COM: 'KM', + COG: 'CG', + COD: 'CD', + COK: 'CK', + CRI: 'CR', + CIV: 'CI', + HRV: 'HR', + CUB: 'CU', + CUW: 'CW', + CYP: 'CY', + CZE: 'CZ', + DNK: 'DK', + DJI: 'DJ', + DMA: 'DM', + DOM: 'DO', + ECU: 'EC', + EGY: 'EG', + SLV: 'SV', + GNQ: 'GQ', + ERI: 'ER', + EST: 'EE', + SWZ: 'SZ', + ETH: 'ET', + FLK: 'FK', + FRO: 'FO', + FJI: 'FJ', + FIN: 'FI', + FRA: 'FR', + GUF: 'GF', + PYF: 'PF', + ATF: 'TF', + GAB: 'GA', + GMB: 'GM', + GEO: 'GE', + DEU: 'DE', + GHA: 'GH', + GIB: 'GI', + GRC: 'GR', + GRL: 'GL', + GRD: 'GD', + GLP: 'GP', + GUM: 'GU', + GTM: 'GT', + GGY: 'GG', + GIN: 'GN', + GNB: 'GW', + GUY: 'GY', + HTI: 'HT', + HMD: 'HM', + VAT: 'VA', + HND: 'HN', + HKG: 'HK', + HUN: 'HU', + ISL: 'IS', + IND: 'IN', + IDN: 'ID', + IRN: 'IR', + IRQ: 'IQ', + IRL: 'IE', + IMN: 'IM', + ISR: 'IL', + ITA: 'IT', + JAM: 'JM', + JPN: 'JP', + JEY: 'JE', + JOR: 'JO', + KAZ: 'KZ', + KEN: 'KE', + KIR: 'KI', + PRK: 'KP', + KOR: 'KR', + KWT: 'KW', + KGZ: 'KG', + LAO: 'LA', + LVA: 'LV', + LBN: 'LB', + LSO: 'LS', + LBR: 'LR', + LBY: 'LY', + LIE: 'LI', + LTU: 'LT', + LUX: 'LU', + MAC: 'MO', + MDG: 'MG', + MWI: 'MW', + MYS: 'MY', + MDV: 'MV', + MLI: 'ML', + MLT: 'MT', + MHL: 'MH', + MTQ: 'MQ', + MRT: 'MR', + MUS: 'MU', + MYT: 'YT', + MEX: 'MX', + FSM: 'FM', + MDA: 'MD', + MCO: 'MC', + MNG: 'MN', + MNE: 'ME', + MSR: 'MS', + MAR: 'MA', + MOZ: 'MZ', + MMR: 'MM', + NAM: 'NA', + NRU: 'NR', + NPL: 'NP', + NLD: 'NL', + NCL: 'NC', + NZL: 'NZ', + NIC: 'NI', + NER: 'NE', + NGA: 'NG', + NIU: 'NU', + NFK: 'NF', + MKD: 'MK', + MNP: 'MP', + NOR: 'NO', + OMN: 'OM', + PAK: 'PK', + PLW: 'PW', + PSE: 'PS', + PAN: 'PA', + PNG: 'PG', + PRY: 'PY', + PER: 'PE', + PHL: 'PH', + PCN: 'PN', + POL: 'PL', + PRT: 'PT', + PRI: 'PR', + QAT: 'QA', + REU: 'RE', + ROU: 'RO', + RUS: 'RU', + RWA: 'RW', + BLM: 'BL', + SHN: 'SH', + KNA: 'KN', + LCA: 'LC', + MAF: 'MF', + SPM: 'PM', + VCT: 'VC', + WSM: 'WS', + SMR: 'SM', + STP: 'ST', + SAU: 'SA', + SEN: 'SN', + SRB: 'RS', + SYC: 'SC', + SLE: 'SL', + SGP: 'SG', + SXM: 'SX', + SVK: 'SK', + SVN: 'SI', + SLB: 'SB', + SOM: 'SO', + ZAF: 'ZA', + SGS: 'GS', + SSD: 'SS', + ESP: 'ES', + LKA: 'LK', + SDN: 'SD', + SUR: 'SR', + SJM: 'SJ', + SWE: 'SE', + CHE: 'CH', + SYR: 'SY', + TWN: 'TW', + TJK: 'TJ', + TZA: 'TZ', + THA: 'TH', + TLS: 'TL', + TGO: 'TG', + TKL: 'TK', + TON: 'TO', + TTO: 'TT', + TUN: 'TN', + TUR: 'TR', + TKM: 'TM', + TCA: 'TC', + TUV: 'TV', + UGA: 'UG', + UKR: 'UA', + ARE: 'AE', + GBR: 'GB', + USA: 'US', + UMI: 'UM', + URY: 'UY', + UZB: 'UZ', + VUT: 'VU', + VEN: 'VE', + VNM: 'VN', + VGB: 'VG', + VIR: 'VI', + WLF: 'WF', + ESH: 'EH', + YEM: 'YE', + ZMB: 'ZM', + ZWE: 'ZW', +}; + +const getCountryCode = (countryCode: string) => { + const normalizedCode = + countryCode.length === 3 + ? alpha3ToAlpha2[countryCode.toUpperCase()] + : countryCode; + + return normalizedCode; +}; + +export default getCountryCode; diff --git a/frontend/src/Internationalization/useCountryName.ts b/frontend/src/Internationalization/useCountryName.ts new file mode 100644 index 000000000..a5431a53a --- /dev/null +++ b/frontend/src/Internationalization/useCountryName.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { useLanguage } from 'Language/useLanguageName'; +import getCountryCode from './getCountryCode'; + +const useCountryName = (countryCode: string | undefined) => { + const { data } = useLanguage(); + + return useMemo(() => { + if (!countryCode) { + return ''; + } + + const locale = data?.identifier ?? 'en'; + + const getDisplayName = Intl.DisplayNames + ? new Intl.DisplayNames([locale], { type: 'region', fallback: 'code' }) + : null; + + if (!getDisplayName) { + return countryCode; + } + + try { + return getDisplayName.of(getCountryCode(countryCode)) ?? countryCode; + } catch (e) { + console.warn('Error getting country name for code:', countryCode, e); + return countryCode; + } + }, [countryCode, data]); +}; + +export default useCountryName; diff --git a/frontend/src/Language/useLanguageName.ts b/frontend/src/Language/useLanguageName.ts index 7e880384f..737d60c87 100644 --- a/frontend/src/Language/useLanguageName.ts +++ b/frontend/src/Language/useLanguageName.ts @@ -12,7 +12,7 @@ function getDisplayName(code: string) { : null; } -const useLanguage = () => { +export const useLanguage = () => { return useApiQuery<LanguageResponse>({ path: '/localization/language', queryOptions: { diff --git a/frontend/src/Series/Details/SeriesDetails.css b/frontend/src/Series/Details/SeriesDetails.css index ce05386ba..5b747a376 100644 --- a/frontend/src/Series/Details/SeriesDetails.css +++ b/frontend/src/Series/Details/SeriesDetails.css @@ -133,6 +133,7 @@ .path, .sizeOnDisk, .qualityProfileName, +.originalCountry, .originalLanguageName, .statusName, .network, diff --git a/frontend/src/Series/Details/SeriesDetails.css.d.ts b/frontend/src/Series/Details/SeriesDetails.css.d.ts index ad8d08312..efb070edc 100644 --- a/frontend/src/Series/Details/SeriesDetails.css.d.ts +++ b/frontend/src/Series/Details/SeriesDetails.css.d.ts @@ -16,6 +16,7 @@ interface CssExports { 'links': string; 'monitorToggleButton': string; 'network': string; + 'originalCountry': string; 'originalLanguageName': string; 'overview': string; 'path': string; diff --git a/frontend/src/Series/Details/SeriesDetails.tsx b/frontend/src/Series/Details/SeriesDetails.tsx index efda5a65a..605710b5f 100644 --- a/frontend/src/Series/Details/SeriesDetails.tsx +++ b/frontend/src/Series/Details/SeriesDetails.tsx @@ -30,6 +30,7 @@ import { tooltipPositions, } from 'Helpers/Props'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import useCountryName from 'Internationalization/useCountryName'; import OrganizePreviewModal from 'Organize/OrganizePreviewModal'; import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; import EditSeriesModal from 'Series/Edit/EditSeriesModal'; @@ -348,6 +349,8 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { refetchEpisodeFiles(); }, [refetchEpisodes, refetchEpisodeFiles]); + const originalCountryName = useCountryName(series?.originalCountry); + useEffect(() => { populate(); }, [populate]); @@ -694,6 +697,21 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { </Label> ) : null} + {originalCountryName ? ( + <Label + className={styles.detailsLabel} + title={translate('OriginalCountry')} + size={sizes.LARGE} + > + <div> + <Icon name={icons.GLOBE} size={17} /> + <span className={styles.originalCountry}> + {originalCountryName} + </span> + </div> + </Label> + ) : null} + {network ? ( <Label className={styles.detailsLabel} diff --git a/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx index b495944ac..29805a9bd 100644 --- a/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx +++ b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx @@ -46,6 +46,15 @@ function SeriesIndexSortMenu(props: SeriesIndexSortMenuProps) { {translate('Network')} </SortMenuItem> + <SortMenuItem + name="originalCountry" + sortKey={sortKey} + sortDirection={sortDirection} + onPress={onSortSelect} + > + {translate('OriginalCountry')} + </SortMenuItem> + <SortMenuItem name="originalLanguage" sortKey={sortKey} diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx b/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx index e378eab1d..bd2c33593 100644 --- a/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx +++ b/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx @@ -103,6 +103,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) { status, path, titleSlug, + originalCountry, originalLanguage, network, nextAiring, @@ -256,6 +257,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) { ) : null} <SeriesIndexPosterInfo + originalCountry={originalCountry} originalLanguage={originalLanguage} network={network} previousAiring={previousAiring} diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.tsx b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.tsx index a43dc538c..78e14b9f3 100644 --- a/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.tsx +++ b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.tsx @@ -1,6 +1,7 @@ import React from 'react'; import HeartRating from 'Components/HeartRating'; import SeriesTagList from 'Components/SeriesTagList'; +import useCountryName from 'Internationalization/useCountryName'; import Language from 'Language/Language'; import { Ratings } from 'Series/Series'; import { QualityProfileModel } from 'Settings/Profiles/Quality/useQualityProfiles'; @@ -11,6 +12,7 @@ import translate from 'Utilities/String/translate'; import styles from './SeriesIndexPosterInfo.css'; interface SeriesIndexPosterInfoProps { + originalCountry?: string; originalLanguage?: Language; network?: string; showQualityProfile: boolean; @@ -32,6 +34,7 @@ interface SeriesIndexPosterInfoProps { function SeriesIndexPosterInfo(props: SeriesIndexPosterInfoProps) { const { + originalCountry, originalLanguage, network, qualityProfile, @@ -51,6 +54,8 @@ function SeriesIndexPosterInfo(props: SeriesIndexPosterInfoProps) { showTags, } = props; + const originalCountryName = useCountryName(originalCountry); + if (sortKey === 'network' && network) { return ( <div className={styles.info} title={translate('Network')}> @@ -59,6 +64,14 @@ function SeriesIndexPosterInfo(props: SeriesIndexPosterInfoProps) { ); } + if (sortKey === 'originalCountry' && !!originalCountryName) { + return ( + <div className={styles.info} title={translate('OriginalCountry')}> + {originalCountryName} + </div> + ); + } + if (sortKey === 'originalLanguage' && !!originalLanguage?.name) { return ( <div className={styles.info} title={translate('OriginalLanguage')}> diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css b/frontend/src/Series/Index/Table/SeriesIndexRow.css index 9981dd354..d0d2b6db1 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css @@ -66,6 +66,7 @@ flex: 2 0 90px; } +.originalCountry, .originalLanguage, .qualityProfileId { composes: cell; diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts index e07bb3cac..dfa41ccae 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts @@ -17,6 +17,7 @@ interface CssExports { 'monitorNewItems': string; 'network': string; 'nextAiring': string; + 'originalCountry': string; 'originalLanguage': string; 'overlayTitle': string; 'path': string; diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx index 48d0392ca..afea5d1af 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx @@ -14,6 +14,7 @@ import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; import Column from 'Components/Table/Column'; import { icons } from 'Helpers/Props'; +import useCountryName from 'Internationalization/useCountryName'; import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; import EditSeriesModal from 'Series/Edit/EditSeriesModal'; import { Statistics } from 'Series/Series'; @@ -56,6 +57,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false); const [isDeleteSeriesModalOpen, setIsDeleteSeriesModalOpen] = useState(false); const { getIsSelected, toggleSelected } = useSelect(); + const originalCountryName = useCountryName(series?.originalCountry); const onRefreshPress = useCallback(() => { executeCommand({ @@ -230,6 +232,14 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { ); } + if (name === 'originalCountry') { + return ( + <VirtualTableRowCell key={name} className={styles[name]}> + {originalCountryName} + </VirtualTableRowCell> + ); + } + if (name === 'originalLanguage') { return ( <VirtualTableRowCell key={name} className={styles[name]}> diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css index 8e3b8f751..df574576e 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css @@ -30,6 +30,7 @@ flex: 2 0 90px; } +.originalCountry, .originalLanguage, .qualityProfileId { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts index 5cff4a8ec..f30a9e786 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts @@ -13,6 +13,7 @@ interface CssExports { 'monitorNewItems': string; 'network': string; 'nextAiring': string; + 'originalCountry': string; 'originalLanguage': string; 'path': string; 'previousAiring': string; diff --git a/frontend/src/Series/Series.ts b/frontend/src/Series/Series.ts index d3d933ac8..39d1ea1ff 100644 --- a/frontend/src/Series/Series.ts +++ b/frontend/src/Series/Series.ts @@ -78,6 +78,7 @@ interface Series extends ModelBase { monitored: boolean; monitorNewItems: MonitorNewItems; network: string; + originalCountry: string; originalLanguage: Language; overview: string; path: string; diff --git a/frontend/src/Series/seriesOptionsStore.ts b/frontend/src/Series/seriesOptionsStore.ts index ba1bb44a4..022bcb90d 100644 --- a/frontend/src/Series/seriesOptionsStore.ts +++ b/frontend/src/Series/seriesOptionsStore.ts @@ -125,6 +125,12 @@ const { useOptions, useOption, setOptions, setOption, setSort, getOptions } = isSortable: true, isVisible: false, }, + { + name: 'originalCountry', + label: () => translate('OriginalCountry'), + isSortable: true, + isVisible: false, + }, { name: 'originalLanguage', label: () => translate('OriginalLanguage'), diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/OriginalCountrySpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/OriginalCountrySpecification.cs new file mode 100644 index 000000000..b86076801 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/OriginalCountrySpecification.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.AutoTagging.Specifications +{ + public class OriginalCountrySpecificationValidator : AbstractValidator<OriginalCountrySpecification> + { + public OriginalCountrySpecificationValidator() + { + RuleFor(c => c.Value).NotEmpty(); + + RuleFor(c => c.Value).Custom((countries, context) => + { + if (countries.Any(c => c.Length != 3)) + { + context.AddFailure("Country code must be 3 characters long"); + } + }); + } + } + + public class OriginalCountrySpecification : AutoTaggingSpecificationBase + { + private static readonly OriginalCountrySpecificationValidator Validator = new(); + + public override int Order => 1; + public override string ImplementationName => "Original Country"; + + [FieldDefinition(1, Label = "AutoTaggingSpecificationOriginalCountry", Type = FieldType.Tag)] + public IEnumerable<string> Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(Series series) + { + return Value.Any(network => series.OriginalCountry.EqualsIgnoreCase(network)); + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/227_original_country.cs b/src/NzbDrone.Core/Datastore/Migration/227_original_country.cs new file mode 100644 index 000000000..95ee6436a --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/227_original_country.cs @@ -0,0 +1,15 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(227)] + public class original_country : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Series") + .AddColumn("OriginalCountry").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index e2ff3b88c..1b40c5138 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -136,6 +136,7 @@ "AutoTaggingSpecificationMaximumYear": "Maximum Year", "AutoTaggingSpecificationMinimumYear": "Minimum Year", "AutoTaggingSpecificationNetwork": "Network(s)", + "AutoTaggingSpecificationOriginalCountry": "Country", "AutoTaggingSpecificationOriginalLanguage": "Language", "AutoTaggingSpecificationQualityProfile": "Quality Profile", "AutoTaggingSpecificationRootFolder": "Root Folder", @@ -1616,6 +1617,7 @@ "OrganizeSelectedSeriesModalConfirmation": "Are you sure you want to organize all files in the {count} selected series?", "OrganizeSelectedSeriesModalHeader": "Organize Selected Series", "Original": "Original", + "OriginalCountry": "Original Country", "OriginalLanguage": "Original Language", "Other": "Other", "OutputPath": "Output Path", diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ShowResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ShowResource.cs index b7a9d7a42..6994f8559 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ShowResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ShowResource.cs @@ -33,6 +33,7 @@ public ShowResource() public string Network { get; set; } public string ImdbId { get; set; } public string OriginalLanguage { get; set; } + public string OriginalCountry { get; set; } public List<ActorResource> Actors { get; set; } public List<string> Genres { get; set; } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index 883a84c45..40b59ac78 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -239,6 +239,7 @@ private Series MapSeries(ShowResource show) series.Status = MapSeriesStatus(show.Status); series.Ratings = MapRatings(show.Rating); series.Genres = show.Genres; + series.OriginalCountry = show.OriginalCountry; if (show.ContentRating.IsNotNullOrWhiteSpace()) { diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs index 3bfa6037e..f949c25a0 100644 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs @@ -61,21 +61,9 @@ public override void OnGrab(GrabMessage message) var releaseGroup = remoteEpisode.ParsedEpisodeInfo.ReleaseGroup; var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "Grab"); - environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); - environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); - environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString()); - environmentVariables.Add("Sonarr_Series_Title", series.Title); - environmentVariables.Add("Sonarr_Series_TitleSlug", series.TitleSlug); - environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString()); - environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); - environmentVariables.Add("Sonarr_Series_TmdbId", series.TmdbId.ToString()); - environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); - environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); - environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); - environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); - environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); - environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series))); + AddInstanceVariables(environmentVariables, "Grab"); + AddSeriesVariables(environmentVariables, series); + environmentVariables.Add("Sonarr_Release_EpisodeCount", remoteEpisode.Episodes.Count.ToString()); environmentVariables.Add("Sonarr_Release_SeasonNumber", remoteEpisode.Episodes.First().SeasonNumber.ToString()); environmentVariables.Add("Sonarr_Release_EpisodeNumbers", string.Join(",", remoteEpisode.Episodes.Select(e => e.EpisodeNumber))); @@ -109,23 +97,10 @@ public override void OnDownload(DownloadMessage message) var sourcePath = message.SourcePath; var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "Download"); - environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); - environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); + AddInstanceVariables(environmentVariables, "Download"); + AddSeriesVariables(environmentVariables, series); + environmentVariables.Add("Sonarr_IsUpgrade", message.OldFiles.Any().ToString()); - environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString()); - environmentVariables.Add("Sonarr_Series_Title", series.Title); - environmentVariables.Add("Sonarr_Series_TitleSlug", series.TitleSlug); - environmentVariables.Add("Sonarr_Series_Path", series.Path); - environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString()); - environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); - environmentVariables.Add("Sonarr_Series_TmdbId", series.TmdbId.ToString()); - environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); - environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); - environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); - environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); - environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); - environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series))); environmentVariables.Add("Sonarr_EpisodeFile_Id", episodeFile.Id.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_EpisodeCount", episodeFile.Episodes.Value.Count.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_RelativePath", episodeFile.RelativePath); @@ -182,22 +157,9 @@ public override void OnImportComplete(ImportCompleteMessage message) var sourcePath = message.SourcePath; var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "Download"); - environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); - environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); - environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString()); - environmentVariables.Add("Sonarr_Series_Title", series.Title); - environmentVariables.Add("Sonarr_Series_TitleSlug", series.TitleSlug); - environmentVariables.Add("Sonarr_Series_Path", series.Path); - environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString()); - environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); - environmentVariables.Add("Sonarr_Series_TmdbId", series.TmdbId.ToString()); - environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); - environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); - environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); - environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); - environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); - environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series))); + AddInstanceVariables(environmentVariables, "Download"); + AddSeriesVariables(environmentVariables, series); + environmentVariables.Add("Sonarr_EpisodeFile_Ids", string.Join("|", episodeFiles.Select(f => f.Id))); environmentVariables.Add("Sonarr_EpisodeFile_Count", message.EpisodeFiles.Count.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_RelativePaths", string.Join("|", episodeFiles.Select(f => f.RelativePath))); @@ -238,22 +200,9 @@ public override void OnRename(Series series, List<RenamedEpisodeFile> renamedFil { var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "Rename"); - environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); - environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); - environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString()); - environmentVariables.Add("Sonarr_Series_Title", series.Title); - environmentVariables.Add("Sonarr_Series_TitleSlug", series.TitleSlug); - environmentVariables.Add("Sonarr_Series_Path", series.Path); - environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString()); - environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); - environmentVariables.Add("Sonarr_Series_TmdbId", series.TmdbId.ToString()); - environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); - environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); - environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); - environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); - environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); - environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series))); + AddInstanceVariables(environmentVariables, "Rename"); + AddSeriesVariables(environmentVariables, series); + environmentVariables.Add("Sonarr_EpisodeFile_Ids", string.Join(",", renamedFiles.Select(e => e.EpisodeFile.Id))); environmentVariables.Add("Sonarr_EpisodeFile_RelativePaths", string.Join("|", renamedFiles.Select(e => e.EpisodeFile.RelativePath))); environmentVariables.Add("Sonarr_EpisodeFile_Paths", string.Join("|", renamedFiles.Select(e => Path.Combine(series.Path, e.EpisodeFile.RelativePath)))); @@ -270,23 +219,9 @@ public override void OnEpisodeFileDelete(EpisodeDeleteMessage deleteMessage) var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "EpisodeFileDelete"); - environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); - environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); - environmentVariables.Add("Sonarr_EpisodeFile_DeleteReason", deleteMessage.Reason.ToString()); - environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString()); - environmentVariables.Add("Sonarr_Series_Title", series.Title); - environmentVariables.Add("Sonarr_Series_TitleSlug", series.TitleSlug); - environmentVariables.Add("Sonarr_Series_Path", series.Path); - environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString()); - environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); - environmentVariables.Add("Sonarr_Series_TmdbId", series.TmdbId.ToString()); - environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); - environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); - environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); - environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); - environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); - environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series))); + AddInstanceVariables(environmentVariables, "EpisodeFileDelete"); + AddSeriesVariables(environmentVariables, series); + environmentVariables.Add("Sonarr_EpisodeFile_Id", episodeFile.Id.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_EpisodeCount", episodeFile.Episodes.Value.Count.ToString()); environmentVariables.Add("Sonarr_EpisodeFile_RelativePath", episodeFile.RelativePath); @@ -311,22 +246,8 @@ public override void OnSeriesAdd(SeriesAddMessage message) var series = message.Series; var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "SeriesAdd"); - environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); - environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); - environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString()); - environmentVariables.Add("Sonarr_Series_Title", series.Title); - environmentVariables.Add("Sonarr_Series_TitleSlug", series.TitleSlug); - environmentVariables.Add("Sonarr_Series_Path", series.Path); - environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString()); - environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); - environmentVariables.Add("Sonarr_Series_TmdbId", series.TmdbId.ToString()); - environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); - environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); - environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); - environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); - environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); - environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series))); + AddInstanceVariables(environmentVariables, "SeriesAdd"); + AddSeriesVariables(environmentVariables, series); ExecuteScript(environmentVariables); } @@ -336,23 +257,8 @@ public override void OnSeriesDelete(SeriesDeleteMessage deleteMessage) var series = deleteMessage.Series; var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "SeriesDelete"); - environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); - environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); - environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString()); - environmentVariables.Add("Sonarr_Series_Title", series.Title); - environmentVariables.Add("Sonarr_Series_TitleSlug", series.TitleSlug); - environmentVariables.Add("Sonarr_Series_Path", series.Path); - environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString()); - environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); - environmentVariables.Add("Sonarr_Series_TmdbId", series.TmdbId.ToString()); - environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); - environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); - environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); - environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); - environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); - environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series))); - environmentVariables.Add("Sonarr_Series_DeletedFiles", deleteMessage.DeletedFiles.ToString()); + AddInstanceVariables(environmentVariables, "SeriesDelete"); + AddSeriesVariables(environmentVariables, series); ExecuteScript(environmentVariables); } @@ -361,9 +267,8 @@ public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) { var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "HealthIssue"); - environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); - environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); + AddInstanceVariables(environmentVariables, "HealthIssue"); + environmentVariables.Add("Sonarr_Health_Issue_Level", Enum.GetName(typeof(HealthCheckResult), healthCheck.Type)); environmentVariables.Add("Sonarr_Health_Issue_Message", healthCheck.Message); environmentVariables.Add("Sonarr_Health_Issue_Type", healthCheck.Source.Name); @@ -376,9 +281,8 @@ public override void OnHealthRestored(HealthCheck.HealthCheck previousCheck) { var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "HealthRestored"); - environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); - environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); + AddInstanceVariables(environmentVariables, "HealthRestored"); + environmentVariables.Add("Sonarr_Health_Restored_Level", Enum.GetName(typeof(HealthCheckResult), previousCheck.Type)); environmentVariables.Add("Sonarr_Health_Restored_Message", previousCheck.Message); environmentVariables.Add("Sonarr_Health_Restored_Type", previousCheck.Source.Name); @@ -391,9 +295,8 @@ public override void OnApplicationUpdate(ApplicationUpdateMessage updateMessage) { var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "ApplicationUpdate"); - environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); - environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); + AddInstanceVariables(environmentVariables, "ApplicationUpdate"); + environmentVariables.Add("Sonarr_Update_Message", updateMessage.Message); environmentVariables.Add("Sonarr_Update_NewVersion", updateMessage.NewVersion.ToString()); environmentVariables.Add("Sonarr_Update_PreviousVersion", updateMessage.PreviousVersion.ToString()); @@ -406,22 +309,9 @@ public override void OnManualInteractionRequired(ManualInteractionRequiredMessag var series = message.Series; var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "ManualInteractionRequired"); - environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); - environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); - environmentVariables.Add("Sonarr_Series_Id", series?.Id.ToString()); - environmentVariables.Add("Sonarr_Series_Title", series?.Title); - environmentVariables.Add("Sonarr_Series_TitleSlug", series?.TitleSlug); - environmentVariables.Add("Sonarr_Series_Path", series?.Path); - environmentVariables.Add("Sonarr_Series_TvdbId", series?.TvdbId.ToString()); - environmentVariables.Add("Sonarr_Series_TvMazeId", series?.TvMazeId.ToString()); - environmentVariables.Add("Sonarr_Series_TmdbId", series?.TmdbId.ToString()); - environmentVariables.Add("Sonarr_Series_ImdbId", series?.ImdbId ?? string.Empty); - environmentVariables.Add("Sonarr_Series_Type", series?.SeriesType.ToString()); - environmentVariables.Add("Sonarr_Series_Year", series?.Year.ToString()); - environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series?.OriginalLanguage)?.ThreeLetterCode); - environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series?.Genres ?? new List<string>())); - environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series))); + AddInstanceVariables(environmentVariables, "ManualInteractionRequired"); + AddSeriesVariables(environmentVariables, series); + environmentVariables.Add("Sonarr_Download_Client", message.DownloadClientInfo?.Name ?? string.Empty); environmentVariables.Add("Sonarr_Download_Client_Type", message.DownloadClientInfo?.Type ?? string.Empty); environmentVariables.Add("Sonarr_Download_Id", message.DownloadId ?? string.Empty); @@ -496,5 +386,30 @@ private List<string> GetTagLabels(Series series) .OrderBy(l => l) .ToList(); } + + private void AddInstanceVariables(StringDictionary environmentVariables, string eventType) + { + environmentVariables.Add("Sonarr_EventType", eventType); + environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName); + environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl); + } + + private void AddSeriesVariables(StringDictionary environmentVariables, Series series) + { + environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString()); + environmentVariables.Add("Sonarr_Series_Title", series.Title); + environmentVariables.Add("Sonarr_Series_TitleSlug", series.TitleSlug); + environmentVariables.Add("Sonarr_Series_Path", series.Path); + environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString()); + environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString()); + environmentVariables.Add("Sonarr_Series_TmdbId", series.TmdbId.ToString()); + environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty); + environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString()); + environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString()); + environmentVariables.Add("Sonarr_Series_OriginalCountry", series.OriginalCountry); + environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode); + environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres)); + environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series))); + } } } diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookSeries.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookSeries.cs index 6ad1ae14f..a0ca26e7e 100644 --- a/src/NzbDrone.Core/Notifications/Webhook/WebhookSeries.cs +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookSeries.cs @@ -23,6 +23,7 @@ public class WebhookSeries public List<WebhookImage> Images { get; set; } public List<string> Tags { get; set; } public Language OriginalLanguage { get; set; } + public string OriginalCountry { get; set; } public WebhookSeries() { @@ -46,6 +47,7 @@ public WebhookSeries(Series series, List<string> tags) Images = series.Images.Select(i => new WebhookImage(i)).ToList(); Tags = tags; OriginalLanguage = series.OriginalLanguage; + OriginalCountry = series.OriginalCountry; } } } diff --git a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs index 8499ef261..63cbf7257 100644 --- a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs +++ b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs @@ -111,6 +111,7 @@ private Series RefreshSeriesInfo(int seriesId) series.Actors = seriesInfo.Actors; series.Genres = seriesInfo.Genres; series.Certification = seriesInfo.Certification; + series.OriginalCountry = seriesInfo.OriginalCountry; try { diff --git a/src/NzbDrone.Core/Tv/Series.cs b/src/NzbDrone.Core/Tv/Series.cs index 2464742b7..8c2017684 100644 --- a/src/NzbDrone.Core/Tv/Series.cs +++ b/src/NzbDrone.Core/Tv/Series.cs @@ -57,7 +57,7 @@ public Series() public DateTime? LastAired { get; set; } public LazyLoaded<QualityProfile> QualityProfile { get; set; } public Language OriginalLanguage { get; set; } - + public string OriginalCountry { get; set; } public List<Season> Seasons { get; set; } public HashSet<int> Tags { get; set; } public AddSeriesOptions AddOptions { get; set; } diff --git a/src/Sonarr.Api.V5/Series/SeriesResource.cs b/src/Sonarr.Api.V5/Series/SeriesResource.cs index 2d3e67e04..4de388146 100644 --- a/src/Sonarr.Api.V5/Series/SeriesResource.cs +++ b/src/Sonarr.Api.V5/Series/SeriesResource.cs @@ -47,6 +47,7 @@ public class SeriesResource : RestResource public string? Folder { get; set; } public string? Certification { get; set; } public List<string>? Genres { get; set; } + public string? OriginalCountry { get; set; } public HashSet<int>? Tags { get; set; } public DateTime Added { get; set; } public AddSeriesOptions? AddOptions { get; set; } @@ -71,6 +72,7 @@ public static SeriesResource ToResource(this NzbDrone.Core.Tv.Series model, bool Images = model.Images.JsonClone(), Seasons = model.Seasons.ToResource(includeSeasonImages), Year = model.Year, + OriginalCountry = model.OriginalCountry, OriginalLanguage = model.OriginalLanguage, Path = model.Path, QualityProfileId = model.QualityProfileId, From 2a5667e634b35b0794aea6b61c1c7719975d3425 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sat, 21 Feb 2026 12:15:55 -0800 Subject: [PATCH 028/110] Fixed: Consistent diacrital removal from clean titles Closes #8424 --- .../Extensions/StringExtensions.cs | 26 +++++++------------ .../AdditionalDiacriticsProvider.cs | 17 ++++++++++++ src/NzbDrone.Common/Sonarr.Common.csproj | 1 + .../NormalizeSeriesTitleFixture.cs | 1 + .../Migration/163_mediainfo_to_ffmpeg.cs | 2 +- .../Definitions/SearchCriteriaBase.cs | 2 +- .../Organizer/FileNameBuilder.cs | 1 - src/NzbDrone.Core/Parser/Parser.cs | 2 +- .../AuthenticationBuilderExtensions.cs | 2 +- 9 files changed, 33 insertions(+), 21 deletions(-) create mode 100644 src/NzbDrone.Common/Globalization/AdditionalDiacriticsProvider.cs diff --git a/src/NzbDrone.Common/Extensions/StringExtensions.cs b/src/NzbDrone.Common/Extensions/StringExtensions.cs index 8a4d140f7..1ec592515 100644 --- a/src/NzbDrone.Common/Extensions/StringExtensions.cs +++ b/src/NzbDrone.Common/Extensions/StringExtensions.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; using System.Text.RegularExpressions; +using Diacritical; +using NzbDrone.Common.Globalization; namespace NzbDrone.Common.Extensions { @@ -13,6 +14,11 @@ public static class StringExtensions { private static readonly Regex CamelCaseRegex = new Regex("(?<!^)[A-Z]", RegexOptions.Compiled); + static StringExtensions() + { + DiacriticMap.AddProviders(new AdditionalDiacriticsProvider()); + } + public static string NullSafe(this string target) { return ((object)target).NullSafe().ToString(); @@ -62,21 +68,9 @@ public static string Replace(this string text, int index, int length, string rep return text; } - public static string RemoveAccent(this string text) + public static string RemoveDiacritics(this string text) { - var normalizedString = text.Normalize(NormalizationForm.FormD); - var stringBuilder = new StringBuilder(); - - foreach (var c in normalizedString) - { - var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); - if (unicodeCategory != UnicodeCategory.NonSpacingMark) - { - stringBuilder.Append(c); - } - } - - return stringBuilder.ToString().Normalize(NormalizationForm.FormC); + return Diacritical.StringExtensions.RemoveDiacritics(text); } public static string TrimEnd(this string text, string postfix) @@ -180,7 +174,7 @@ public static string ToUrlSlug(this string value) value = value.ToLowerInvariant(); // Remove all accents - value = value.RemoveAccent(); + value = value.RemoveDiacritics(); // Replace spaces value = Regex.Replace(value, @"\s", "-", RegexOptions.Compiled); diff --git a/src/NzbDrone.Common/Globalization/AdditionalDiacriticsProvider.cs b/src/NzbDrone.Common/Globalization/AdditionalDiacriticsProvider.cs new file mode 100644 index 000000000..cae49ccd5 --- /dev/null +++ b/src/NzbDrone.Common/Globalization/AdditionalDiacriticsProvider.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Diacritical; + +namespace NzbDrone.Common.Globalization; + +public class AdditionalDiacriticsProvider : IDiacriticProvider +{ + public IDictionary<char, string> Provide() + { + return new Dictionary<char, string> + { + { 'ð', "d" }, + { 'Ð', "D" }, + { 'þ', "th" }, + }; + } +} diff --git a/src/NzbDrone.Common/Sonarr.Common.csproj b/src/NzbDrone.Common/Sonarr.Common.csproj index c523822d7..b733fdb28 100644 --- a/src/NzbDrone.Common/Sonarr.Common.csproj +++ b/src/NzbDrone.Common/Sonarr.Common.csproj @@ -4,6 +4,7 @@ <DefineConstants Condition="'$(RuntimeIdentifier)' == 'linux-musl-x64' or '$(RuntimeIdentifier)' == 'linux-musl-arm64'">ISMUSL</DefineConstants> </PropertyGroup> <ItemGroup> + <PackageReference Include="Diacritical.Net" Version="1.0.5" /> <PackageReference Include="DryIoc.dll" Version="5.4.3" /> <PackageReference Include="IPAddressRange" Version="6.3.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" /> diff --git a/src/NzbDrone.Core.Test/ParserTests/NormalizeSeriesTitleFixture.cs b/src/NzbDrone.Core.Test/ParserTests/NormalizeSeriesTitleFixture.cs index 1c47b4fe8..86ce520eb 100644 --- a/src/NzbDrone.Core.Test/ParserTests/NormalizeSeriesTitleFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/NormalizeSeriesTitleFixture.cs @@ -26,6 +26,7 @@ public void should_normalize_series_title(string parsedSeriesName, string series [TestCase("24", "24")] [TestCase("Test: Something à Deux", "testsomethingdeux")] [TestCase("Parler à", "parlera")] + [TestCase("Ríkið", "rikid")] public void should_remove_special_characters_and_casing(string dirty, string clean) { var result = dirty.CleanSeriesTitle(); diff --git a/src/NzbDrone.Core/Datastore/Migration/163_mediainfo_to_ffmpeg.cs b/src/NzbDrone.Core/Datastore/Migration/163_mediainfo_to_ffmpeg.cs index bb8801a44..7f5bc7ff4 100644 --- a/src/NzbDrone.Core/Datastore/Migration/163_mediainfo_to_ffmpeg.cs +++ b/src/NzbDrone.Core/Datastore/Migration/163_mediainfo_to_ffmpeg.cs @@ -773,7 +773,7 @@ private List<string> MigrateLanguages(string mediaInfoLanguages) try { - var cultureInfo = cultures.FirstOrDefault(p => p.EnglishName.RemoveAccent() == tokens[i]); + var cultureInfo = cultures.FirstOrDefault(p => p.EnglishName.RemoveDiacritics() == tokens[i]); if (cultureInfo != null) { diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs index 5c5c1a3fb..f7e46bdd9 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs @@ -37,7 +37,7 @@ public static string GetCleanSceneTitle(string title) // remove any repeating +s cleanTitle = Regex.Replace(cleanTitle, @"\+{2,}", "+"); - cleanTitle = cleanTitle.RemoveAccent(); + cleanTitle = cleanTitle.RemoveDiacritics(); return cleanTitle.Trim('+', ' '); } } diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 754c24a91..8f3908022 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -5,7 +5,6 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; -using Diacritical; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Disk; diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 0200887be..02d0aa82e 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -827,7 +827,7 @@ public static string CleanSeriesTitle(this string title) // Replace `%` with `percent` to deal with the 3% case title = PercentRegex.Replace(title, "percent"); - return NormalizeRegex.Replace(title).ToLowerInvariant().RemoveAccent(); + return NormalizeRegex.Replace(title).ToLowerInvariant().RemoveDiacritics(); } public static string NormalizeEpisodeTitle(string title) diff --git a/src/Sonarr.Http/Authentication/AuthenticationBuilderExtensions.cs b/src/Sonarr.Http/Authentication/AuthenticationBuilderExtensions.cs index 16ff47cf3..0dfc5cb28 100644 --- a/src/Sonarr.Http/Authentication/AuthenticationBuilderExtensions.cs +++ b/src/Sonarr.Http/Authentication/AuthenticationBuilderExtensions.cs @@ -1,11 +1,11 @@ using System; using System.Text.RegularExpressions; using System.Threading.Tasks; -using Diacritical; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; From 6c329e8a6fa4f9df23dd562da953d427ae754c17 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sat, 21 Feb 2026 12:16:56 -0800 Subject: [PATCH 029/110] Remove English limitation for aliases --- .../DataAugmentation/Scene/SceneMappingService.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs index b9e568163..ee4c331de 100644 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs +++ b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs @@ -62,7 +62,6 @@ public List<string> GetSceneNames(int tvdbId, List<int> seasonNumbers, List<int> var names = mappings.Where(n => seasonNumbers.Contains(n.SeasonNumber ?? -1) || sceneSeasonNumbers.Contains(n.SceneSeasonNumber ?? -1) || ((n.SeasonNumber ?? -1) == -1 && (n.SceneSeasonNumber ?? -1) == -1 && n.SceneOrigin != "tvdb")) - .Where(n => IsEnglish(n.SearchTerm)) .Select(n => n.SearchTerm) .Distinct(StringComparer.InvariantCultureIgnoreCase) .ToList(); @@ -274,11 +273,6 @@ private List<SceneMapping> FilterSceneMappings(List<SceneMapping> candidates, in return normalCandidates; } - private bool IsEnglish(string title) - { - return title.All(c => c <= 255); - } - public void Handle(SeriesRefreshStartingEvent message) { if (message.ManualTrigger && (_findByTvdbIdCache.IsExpired(TimeSpan.FromMinutes(1)) || !_updatedAfterStartup)) From d99f8b5685b78868b7c70f054ada19951112dd6b Mon Sep 17 00:00:00 2001 From: Stevie Robinson <stevie.robinson@gmail.com> Date: Sun, 1 Mar 2026 18:05:01 +0100 Subject: [PATCH 030/110] New: Warning on add series list for Import list exclusions Closes #8433 --- .../AddNewSeries/AddNewSeriesSearchResult.css | 6 ++++++ .../AddNewSeries/AddNewSeriesSearchResult.css.d.ts | 1 + .../AddNewSeries/AddNewSeriesSearchResult.tsx | 10 ++++++++++ frontend/src/AddSeries/AddSeries.ts | 1 + src/NzbDrone.Core/Localization/Core/en.json | 5 +++-- src/Sonarr.Api.V5/Series/SeriesLookupController.cs | 6 +++++- src/Sonarr.Api.V5/Series/SeriesResource.cs | 5 +++++ 7 files changed, 31 insertions(+), 3 deletions(-) diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css index dcf3f6de3..dd4f13925 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css @@ -97,6 +97,12 @@ pointer-events: all; } +.excludedIcon { + margin-left: 10px; + color: var(--dangerColor); + pointer-events: all; +} + .overview { margin-top: 20px; } diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css.d.ts b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css.d.ts index b6fcfe361..67bb853d4 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css.d.ts +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css.d.ts @@ -3,6 +3,7 @@ interface CssExports { 'alreadyExistsIcon': string; 'content': string; + 'excludedIcon': string; 'genres': string; 'icons': string; 'network': string; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx index 8fc9386b6..76c8669fe 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx @@ -34,6 +34,7 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) { overview, seriesType, images, + isExcluded, } = series; const isExistingSeries = useExistingSeries(tvdbId); @@ -100,6 +101,15 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) { /> ) : null} + {isExcluded ? ( + <Icon + className={styles.excludedIcon} + name={icons.DANGER} + size={36} + title={translate('SeriesInImportListExclusions')} + /> + ) : null} + <Link className={styles.tvdbLink} to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`} diff --git a/frontend/src/AddSeries/AddSeries.ts b/frontend/src/AddSeries/AddSeries.ts index 984edc74a..5daec31e8 100644 --- a/frontend/src/AddSeries/AddSeries.ts +++ b/frontend/src/AddSeries/AddSeries.ts @@ -2,6 +2,7 @@ import Series from 'Series/Series'; interface AddSeries extends Series { folder: string; + isExcluded: boolean; } export default AddSeries; diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 1b40c5138..d5e2d43a1 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1958,6 +1958,7 @@ "SeriesFolderImportedTooltip": "Episode imported from series folder", "SeriesFootNote": "Optionally control truncation to a maximum number of bytes including ellipsis (`...`). Truncating from the end (e.g. `{Series Title:30}`) or the beginning (e.g. `{Series Title:-30}`) are both supported.", "SeriesID": "Series ID", + "SeriesInImportListExclusions": "Series is in Import List Exclusions", "SeriesIndexFooterContinuing": "Continuing (All episodes downloaded)", "SeriesIndexFooterDownloading": "Downloading (One or more episodes)", "SeriesIndexFooterEnded": "Ended (All episodes downloaded)", @@ -2097,8 +2098,8 @@ "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", + "Thursday": "Thursday", "Time": "Time", "TimeFormat": "Time Format", "TimeLeft": "Time Left", @@ -2129,9 +2130,9 @@ "TotalFileSize": "Total File Size", "TotalRecords": "Total records: {totalRecords}", "TotalSpace": "Total Space", - "Tuesday": "Tuesday", "Trace": "Trace", "True": "True", + "Tuesday": "Tuesday", "TvdbId": "TVDB ID", "TvdbIdExcludeHelpText": "The TVDB ID of the series to exclude", "Twitter": "Twitter", diff --git a/src/Sonarr.Api.V5/Series/SeriesLookupController.cs b/src/Sonarr.Api.V5/Series/SeriesLookupController.cs index d504cca1f..776fc0484 100644 --- a/src/Sonarr.Api.V5/Series/SeriesLookupController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesLookupController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.ImportLists.Exclusions; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource; using NzbDrone.Core.Organizer; @@ -13,12 +14,14 @@ public class SeriesLookupController : Controller private readonly ISearchForNewSeries _searchProxy; private readonly IBuildFileNames _fileNameBuilder; private readonly IMapCoversToLocal _coverMapper; + private readonly IImportListExclusionService _importListExclusionService; - public SeriesLookupController(ISearchForNewSeries searchProxy, IBuildFileNames fileNameBuilder, IMapCoversToLocal coverMapper) + public SeriesLookupController(ISearchForNewSeries searchProxy, IBuildFileNames fileNameBuilder, IMapCoversToLocal coverMapper, IImportListExclusionService importListExclusionService) { _searchProxy = searchProxy; _fileNameBuilder = fileNameBuilder; _coverMapper = coverMapper; + _importListExclusionService = importListExclusionService; } [HttpGet] @@ -45,6 +48,7 @@ private IEnumerable<SeriesResource> MapToResource(IEnumerable<NzbDrone.Core.Tv.S resource.Folder = _fileNameBuilder.GetSeriesFolder(currentSeries); resource.Statistics = new SeriesStatistics().ToResource(resource.Seasons); + resource.IsExcluded = _importListExclusionService.FindByTvdbId(currentSeries.TvdbId) is not null; yield return resource; } diff --git a/src/Sonarr.Api.V5/Series/SeriesResource.cs b/src/Sonarr.Api.V5/Series/SeriesResource.cs index 4de388146..c955c328f 100644 --- a/src/Sonarr.Api.V5/Series/SeriesResource.cs +++ b/src/Sonarr.Api.V5/Series/SeriesResource.cs @@ -1,8 +1,10 @@ +using System.Text.Json.Serialization; using NzbDrone.Common.Extensions; using NzbDrone.Core.Languages; using NzbDrone.Core.MediaCover; using NzbDrone.Core.Tv; using Sonarr.Http.REST; +using Swashbuckle.AspNetCore.Annotations; namespace Sonarr.Api.V5.Series; @@ -54,6 +56,9 @@ public class SeriesResource : RestResource public Ratings? Ratings { get; set; } public SeriesStatisticsResource? Statistics { get; set; } public bool? EpisodesChanged { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [SwaggerIgnore] + public bool? IsExcluded { get; set; } } public static class SeriesResourceMapper From f91ebd4c072d8027c474ae5208a3480e34d7713e Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 16 Feb 2026 08:20:51 -0800 Subject: [PATCH 031/110] New: Do not automatically import multi-season releases Closes #8133 --- .../DownloadedEpisodesImportServiceFixture.cs | 47 +++++++++++++++---- .../ImportDecisionMakerFixture.cs | 2 +- .../Download/CompletedDownloadService.cs | 7 +++ .../DownloadedEpisodesImportService.cs | 16 ++++++- .../EpisodeImport/ImportDecisionMaker.cs | 19 +++----- .../EpisodeImport/ImportRejectionReason.cs | 3 +- .../Manual/ManualImportService.cs | 6 ++- 7 files changed, 72 insertions(+), 28 deletions(-) diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs index 0dce58278..82e07a190 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs @@ -80,7 +80,7 @@ private void GivenSuccessfulImport() imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock<IMakeImportDecision>() - .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), null, true, true)) .Returns(imported); Mocker.GetMock<IImportApprovedEpisodes>() @@ -124,7 +124,7 @@ public void should_skip_if_no_series_found() Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); Mocker.GetMock<IMakeImportDecision>() - .Verify(c => c.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), It.IsAny<bool>(), true), + .Verify(c => c.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), It.IsAny<ParsedEpisodeInfo>(), It.IsAny<bool>(), true), Times.Never()); VerifyNoImport(); @@ -175,7 +175,7 @@ public void should_not_delete_folder_if_files_were_imported_and_video_files_rema imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock<IMakeImportDecision>() - .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), null, true, true)) .Returns(imported); Mocker.GetMock<IImportApprovedEpisodes>() @@ -201,7 +201,7 @@ public void should_delete_folder_if_files_were_imported_and_only_sample_files_re imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock<IMakeImportDecision>() - .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), null, true, true)) .Returns(imported); Mocker.GetMock<IImportApprovedEpisodes>() @@ -271,7 +271,7 @@ public void should_not_delete_if_there_is_large_rar_file() imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock<IMakeImportDecision>() - .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), null, true, true)) .Returns(imported); Mocker.GetMock<IImportApprovedEpisodes>() @@ -322,7 +322,7 @@ public void should_use_folder_if_folder_import() Subject.ProcessPath(fileName); Mocker.GetMock<IMakeImportDecision>() - .Verify(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.Is<ParsedEpisodeInfo>(v => v.AbsoluteEpisodeNumbers.First() == 9), true), Times.Once()); + .Verify(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), It.Is<ParsedEpisodeInfo>(v => v.AbsoluteEpisodeNumbers.First() == 9), true), Times.Once()); } [Test] @@ -346,7 +346,7 @@ public void should_not_use_folder_if_file_import() var result = Subject.ProcessPath(fileName); Mocker.GetMock<IMakeImportDecision>() - .Verify(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), null, true), Times.Once()); + .Verify(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), null, true), Times.Once()); } [Test] @@ -379,7 +379,7 @@ public void should_not_delete_if_no_files_were_imported() imported.Add(new ImportDecision(localEpisode)); Mocker.GetMock<IMakeImportDecision>() - .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), null, true, true)) .Returns(imported); Mocker.GetMock<IImportApprovedEpisodes>() @@ -456,7 +456,7 @@ public void should_return_rejection_if_nothing_imported_and_contains_rar_file() var imported = new List<ImportDecision>(); Mocker.GetMock<IMakeImportDecision>() - .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), null, true, true)) .Returns(imported); Mocker.GetMock<IImportApprovedEpisodes>() @@ -482,7 +482,7 @@ public void should_return_rejection_if_nothing_imported_and_contains_executable_ var imported = new List<ImportDecision>(); Mocker.GetMock<IMakeImportDecision>() - .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), null, true, true)) + .Setup(s => s.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), null, true, true)) .Returns(imported); Mocker.GetMock<IImportApprovedEpisodes>() @@ -499,6 +499,33 @@ public void should_return_rejection_if_nothing_imported_and_contains_executable_ result.First().Result.Should().Be(ImportResultType.Rejected); } + [Test] + public void should_reject_if_download_is_multi_season() + { + GivenValidSeries(); + + _trackedDownload.DownloadItem.Title = "Series Title S01-S11"; + + var folderName = @"C:\media\ba09030e-1234-1234-1234-123456789abc\[HorribleSubs] Maria the Virgin Witch - 09 [720p]".AsOsAgnostic(); + + Mocker.GetMock<IDiskProvider>().Setup(c => c.FolderExists(folderName)) + .Returns(true); + + var result = Subject.ProcessPath(folderName, ImportMode.Auto, _trackedDownload.RemoteEpisode.Series, _trackedDownload.DownloadItem); + + result.Count.Should().Be(1); + result.First().Result.Should().Be(ImportResultType.Rejected); + result.First().ImportDecision.Rejections.First().Reason.Should().Be(ImportRejectionReason.MultiSeason); + + Mocker.GetMock<IParsingService>().Setup(c => c.GetSeries("foldername")).Returns((Series)null); + + Mocker.GetMock<IMakeImportDecision>() + .Verify(c => c.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Series>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedEpisodeInfo>(), It.IsAny<ParsedEpisodeInfo>(), It.IsAny<bool>(), true), + Times.Never()); + + VerifyNoImport(); + } + private void VerifyNoImport() { Mocker.GetMock<IImportApprovedEpisodes>().Verify(c => c.Import(It.IsAny<List<ImportDecision>>(), true, null, ImportMode.Auto), diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs index 51d181abe..783afd001 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs @@ -103,7 +103,7 @@ public void should_call_all_specifications() GivenAugmentationSuccess(); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - Subject.GetImportDecisions(_videoFiles, _series, downloadClientItem, null, false, true); + Subject.GetImportDecisions(_videoFiles, _series, downloadClientItem, null, null, false, true); _fail1.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), downloadClientItem), Times.Once()); _fail2.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), downloadClientItem), Times.Once()); diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index ffb7b60be..292009e39 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -172,6 +172,13 @@ public void Import(TrackedDownload trackedDownload) { return; } + + if (firstResult.ImportDecision.Rejections.FirstOrDefault()?.Reason == ImportRejectionReason.MultiSeason) + { + trackedDownload.Warn(new TrackedDownloadStatusMessage(trackedDownload.DownloadItem.Title, firstResult.Errors)); + SetStateToImportBlocked(trackedDownload); + return; + } } var statusMessages = new List<TrackedDownloadStatusMessage> diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs index 3584851ab..be003b5e4 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs @@ -187,6 +187,7 @@ private List<ImportResult> ProcessFolder(DirectoryInfo directoryInfo, ImportMode var folderInfo = Parser.Parser.ParseTitle(directoryInfo.Name); var videoFiles = _diskScanService.FilterPaths(directoryInfo.FullName, _diskScanService.GetVideoFiles(directoryInfo.FullName)); + var downloadClientItemInfo = downloadClientItem == null ? null : Parser.Parser.ParseTitle(downloadClientItem.Title); if (downloadClientItem == null) { @@ -202,7 +203,17 @@ private List<ImportResult> ProcessFolder(DirectoryInfo directoryInfo, ImportMode } } - var decisions = _importDecisionMaker.GetImportDecisions(videoFiles.ToList(), series, downloadClientItem, folderInfo, true); + if (downloadClientItemInfo is { IsMultiSeason: true }) + { + _logger.Debug("Download client item is marked as multi-season, not processing automatically to avoid importing incorrect files"); + + return new List<ImportResult> + { + RejectionResult(ImportRejectionReason.MultiSeason, "Multi-season download, unable to import automatically") + }; + } + + var decisions = _importDecisionMaker.GetImportDecisions(videoFiles.ToList(), series, downloadClientItem, downloadClientItemInfo, folderInfo, true); var importResults = _importApprovedEpisodes.Import(decisions, true, downloadClientItem, importMode); if (importMode == ImportMode.Auto) @@ -328,7 +339,8 @@ private List<ImportResult> ProcessFile(FileInfo fileInfo, ImportMode importMode, } } - var decisions = _importDecisionMaker.GetImportDecisions(new List<string>() { fileInfo.FullName }, series, downloadClientItem, null, true); + var downloadClientItemInfo = downloadClientItem == null ? null : Parser.Parser.ParseTitle(downloadClientItem.Title); + var decisions = _importDecisionMaker.GetImportDecisions(new List<string>() { fileInfo.FullName }, series, downloadClientItem, downloadClientItemInfo, null, true); return _importApprovedEpisodes.Import(decisions, true, downloadClientItem, importMode); } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs index 64ee67750..18288cd96 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs @@ -16,8 +16,8 @@ public interface IMakeImportDecision { List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series series); List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series series, bool filterExistingFiles); - List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource); - List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource, bool filterExistingFiles); + List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo downloadClientItemInfo, ParsedEpisodeInfo folderInfo, bool sceneSource); + List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo downloadClientItemInfo, ParsedEpisodeInfo folderInfo, bool sceneSource, bool filterExistingFiles); ImportDecision GetDecision(LocalEpisode localEpisode, DownloadClientItem downloadClientItem); } @@ -58,27 +58,20 @@ public List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series s public List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series series, bool filterExistingFiles) { - return GetImportDecisions(videoFiles, series, null, null, false, filterExistingFiles); + return GetImportDecisions(videoFiles, series, null, null, null, false, filterExistingFiles); } - public List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource) + public List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo downloadClientItemInfo, ParsedEpisodeInfo folderInfo, bool sceneSource) { - return GetImportDecisions(videoFiles, series, downloadClientItem, folderInfo, sceneSource, true); + return GetImportDecisions(videoFiles, series, downloadClientItem, downloadClientItemInfo, folderInfo, sceneSource, true); } - public List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo folderInfo, bool sceneSource, bool filterExistingFiles) + public List<ImportDecision> GetImportDecisions(List<string> videoFiles, Series series, DownloadClientItem downloadClientItem, ParsedEpisodeInfo downloadClientItemInfo, ParsedEpisodeInfo folderInfo, bool sceneSource, bool filterExistingFiles) { var newFiles = filterExistingFiles ? _mediaFileService.FilterExistingFiles(videoFiles.ToList(), series) : videoFiles.ToList(); _logger.Debug("Analyzing {0}/{1} files.", newFiles.Count, videoFiles.Count); - ParsedEpisodeInfo downloadClientItemInfo = null; - - if (downloadClientItem != null) - { - downloadClientItemInfo = Parser.Parser.ParseTitle(downloadClientItem.Title); - } - // If not importing from a scene source (series folder for example), then assume all files are not samples // to avoid using media info on every file needlessly (especially if Analyse Media Files is disabled). var nonSampleVideoFileCount = sceneSource ? GetNonSampleVideoFileCount(newFiles, series, downloadClientItemInfo, folderInfo) : videoFiles.Count; diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs index 20ca27b02..3a8f49d4c 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportRejectionReason.cs @@ -37,5 +37,6 @@ public enum ImportRejectionReason NotQualityUpgrade, NotRevisionUpgrade, NotCustomFormatUpgrade, - NotCustomFormatUpgradeAfterRename + NotCustomFormatUpgradeAfterRename, + MultiSeason } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs index 11c9d56b6..9b80a82ff 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs @@ -290,9 +290,10 @@ private List<ManualImportItem> ProcessFolder(string rootFolder, string baseFolde return processedFiles.Concat(processedFolders).Where(i => i != null).ToList(); } + var downloadClientItemInfo = downloadClientItem == null ? null : Parser.Parser.ParseTitle(downloadClientItem.Title); var folderInfo = Parser.Parser.ParseTitle(directoryInfo.Name); var seriesFiles = _diskScanService.FilterPaths(rootFolder, _diskScanService.GetVideoFiles(baseFolder).ToList()); - var decisions = _importDecisionMaker.GetImportDecisions(seriesFiles, series, downloadClientItem, folderInfo, SceneSource(series, baseFolder), filterExistingFiles); + var decisions = _importDecisionMaker.GetImportDecisions(seriesFiles, series, downloadClientItem, downloadClientItemInfo, folderInfo, SceneSource(series, baseFolder), filterExistingFiles); return decisions.Select(decision => MapItem(decision, rootFolder, downloadId, directoryInfo.Name)).ToList(); } @@ -345,9 +346,12 @@ private ManualImportItem ProcessFile(string rootFolder, string baseFolder, strin null); } + var downloadClientItemInfo = trackedDownload?.DownloadItem == null ? null : Parser.Parser.ParseTitle(trackedDownload.DownloadItem.Title); + var importDecisions = _importDecisionMaker.GetImportDecisions(new List<string> { file }, series, trackedDownload?.DownloadItem, + downloadClientItemInfo, null, SceneSource(series, baseFolder)); From 147f11dece5010b8bd1cbe01fab9ac20fe5752aa Mon Sep 17 00:00:00 2001 From: Weblate <noreply@weblate.org> Date: Sun, 1 Mar 2026 17:04:24 +0000 Subject: [PATCH 032/110] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: Andreu Punsola Soler <andreu4ps@gmail.com> Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/ Translation: Servarr/Sonarr --- src/NzbDrone.Core/Localization/Core/ca.json | 29 ++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Localization/Core/ca.json b/src/NzbDrone.Core/Localization/Core/ca.json index 380454a2a..85c0a19f9 100644 --- a/src/NzbDrone.Core/Localization/Core/ca.json +++ b/src/NzbDrone.Core/Localization/Core/ca.json @@ -13,6 +13,7 @@ "AddConditionError": "No es pot afegir una condició nova, torneu-ho a provar.", "AddConditionImplementation": "Afegeix una condició - {implementationName}", "AddConnection": "Afegeix una connexió", + "AddConnectionError": "Incapaç d'afegir una nova connexió, si us plau torneu-ho a intentar.", "AddConnectionImplementation": "Afegeix una connexió - {implementationName}", "AddCustomFilter": "Afegeix un filtre personalitzat", "AddCustomFormat": "Afegeix un format personalitzat", @@ -166,6 +167,8 @@ "BlocklistRelease": "Publicació de la llista de bloqueig", "BlocklistReleaseHelpText": "Impedeix que {appName} baixi aquesta versió mitjançant RSS o cerca automàtica", "BlocklistReleases": "Llista de llançaments bloquejats", + "Blocklisted": "Bloquejat per llista", + "BlocklistedAt": "Bloquejat per llista el {date}", "Branch": "Branca", "BranchUpdate": "Branca que s'utilitza per a actualitzar {appName}", "BranchUpdateMechanism": "Branca utilitzada pel mecanisme d'actualització extern", @@ -253,6 +256,7 @@ "ConnectionLostToBackend": "{appName} ha perdut la connexió amb el backend i s'haurà de tornar a carregar per a restaurar la funcionalitat.", "ConnectionSettingsUrlBaseHelpText": "Afegeix un prefix a l'URL {connectionName}, com ara {url}", "Connections": "Connexions", + "ConnectionsLoadError": "Incapaç de carregar Connexions", "Continuing": "Continua", "ContinuingOnly": "Només en emissió", "ContinuingSeriesDescription": "S'esperen més episodis o altra temporada", @@ -555,12 +559,13 @@ "DownloadClientTriblerSettingsDirectoryHelpText": "Ubicació opcional per a desar les baixades, deixeu-ho en blanc per utilitzar la ubicació predeterminada del Tribler", "DownloadClientTriblerSettingsSafeSeeding": "Compartició segura", "DownloadClientTriblerSettingsSafeSeedingHelpText": "Quan està activat, només es comparteix a través de proxies.", + "DownloadClientUTorrentProviderMessage": "uTorrent té historial d'incloure criptominers, malware i anuncis, suggerim fortament que escolleixis un client diferent.", "DownloadClientUTorrentTorrentStateError": "uTorrent està informant d'un error", "DownloadClientUnavailable": "Client de baixada no disponible", "DownloadClientValidationApiKeyIncorrect": "Clau API incorrecta", "DownloadClientValidationApiKeyRequired": "Clau API requerida", "DownloadClientValidationAuthenticationFailure": "Error d'autenticació", - "DownloadClientValidationAuthenticationFailureDetail": "Verifiqueu el vostre nom d'usuari i contrasenya. Verifiqueu també si el servidor que executa {appName} no està bloquejat per accedir a {clientName} per les limitacions de WhiteList a la configuració {clientName}.", + "DownloadClientValidationAuthenticationFailureDetail": "Verifiqueu el vostre nom d'usuari i contrasenya. Verifiqueu també si el servidor que executa {appName} no està bloquejat d'accedir a {clientName} per les limitacions de WhiteList a la configuració de {clientName}.", "DownloadClientValidationCategoryMissing": "La categoria no existeix", "DownloadClientValidationCategoryMissingDetail": "La categoria que heu introduït no existeix a {clientName}. Primer creeu-lo a {clientName}.", "DownloadClientValidationErrorVersion": "La versió de {clientName} hauria de ser com a mínim {requiredVersion}. La versió informada és {reportedVersion}", @@ -781,6 +786,7 @@ "Formats": "Formats", "Forums": "Fòrums", "FreeSpace": "Espai lliure", + "Friday": "Divendres", "From": "Des de", "FullColorEvents": "Esdeveniments a tot color", "FullColorEventsHelpText": "Estil alterat per a pintar tot l'esdeveniment amb el color d'estat, en lloc de només la vora esquerra. No s'aplica a l'Agenda", @@ -803,6 +809,8 @@ "HasMissingSeason": "Té temporades que falten", "HasUnmonitoredSeason": "Té una temporada no monitorada", "Health": "Salut", + "HealthIssue": "1 problema de salut", + "HealthIssues": "{count} problemes de salut", "HealthMessagesInfoBox": "Podeu trobar més informació sobre la causa d'aquests missatges de comprovació de salut fent clic a l'enllaç wiki (icona del llibre) al final de la fila o consultant els vostres [registres]({link}). Si teniu problemes per a interpretar aquests missatges, podeu posar-vos en contacte amb el nostre suport als enllaços següents.", "Here": "aquí", "HiddenClickToShow": "Amagat, feu clic per a mostrar", @@ -839,6 +847,10 @@ "IgnoreDownloadsHint": "Atura {appName} de processar aquestes baixades més", "Ignored": "Ignorat", "IgnoredAddresses": "Adreces ignorades", + "ImageBanner": "bàner", + "ImageFanart": "art de fans", + "ImagePoster": "pòster", + "ImageSeason": "temporada", "Images": "Imatges", "ImdbId": "ID d'IMDb", "Implementation": "Implementació", @@ -1191,8 +1203,12 @@ "MaximumSizeHelpText": "Mida màxima per a una versió que es pot capturar en MB. Establiu a zero per a establir-lo en il·limitat", "Mechanism": "Mecanisme", "MediaInfo": "Informació multimèdia", + "MediaInfoAudioStreamHeader": "Transmissió d'àudio #{number}", "MediaInfoFootNote": "MediaInfo Full/AudioLanguages/SubtitleLanguages suporta un sufix `EN+DE` que permet filtrar les llengües incloses en el nom de fitxer. Utilitzeu `-DE` per excloure llengües específiques. Afegint `+` (ex. `:EN+`) sortirà `[EN]`/`[EN+--]`/`[--]` depenent de les llengües excloses. Per exemple `{MediaInfo Full:EN+DE}`.", "MediaInfoFootNote2": "MediaInfo AudioLanguages exclou l'anglès si és l'únic idioma. Usa MediaInfo AudioLanguagesAll per incloure només l'anglès", + "MediaInfoForced": "Forçat", + "MediaInfoHearingImpaired": "Dificultats auditives", + "MediaInfoSubtitlesHeader": "Subtítols", "MediaManagement": "Gestió multimèdia", "MediaManagementSettings": "Configuració de la gestió multimèdia", "MediaManagementSettingsLoadError": "No s'han pogut carregar la configuració de gestió multimèdia", @@ -1684,6 +1700,8 @@ "Queue": "Cua", "QueueFilterHasNoItems": "El filtre de cua seleccionat no té elements", "QueueIsEmpty": "La cua és buida", + "QueueItem": "1 element a la cua", + "QueueItems": "{count} elements a la cua", "QueueLoadError": "No s'ha pogut carregar la cua", "Queued": "En cua", "QuickSearch": "Cerca ràpida", @@ -1845,6 +1863,7 @@ "RssSyncIntervalHelpText": "Interval en minuts. Establiu a zero per a desactivar (això aturarà tota la captura automàtica de versions)", "RssSyncIntervalHelpTextWarning": "Això s'aplicarà a tots els indexadors, seguiu les regles establertes per ells", "Runtime": "Temps d'execució", + "Saturday": "Dissabte", "Save": "Desa", "SaveChanges": "Desa els canvis", "SaveSettings": "Desa la configuració", @@ -1893,6 +1912,8 @@ "SeasonPremiere": "Estrena de temporada", "SeasonPremieresOnly": "Només estrenes de temporada", "Seasons": "Temporades", + "SeasonsMonitoredAll": "Tot", + "SeasonsMonitoredNone": "Cap", "SeasonsMonitoredStatus": "Temporades monitorades", "SecretToken": "Testimoni secret", "Security": "Seguretat", @@ -2040,6 +2061,7 @@ "SupportedListsMoreInfo": "Per a més informació sobre les llistes individuals, feu clic als botons de més informació.", "SupportedListsSeries": "{appName} admet diverses llistes per importar sèries a la base de dades.", "System": "Sistema", + "SystemDefault": "Per defecte del sistema", "SystemTimeHealthCheckMessage": "L'hora del sistema està desfasada més d'1 dia. Les tasques planificades no es poden executar correctament fins que no es corregeixi el temps", "Table": "Taula", "TableColumns": "Columnes", @@ -2069,9 +2091,12 @@ "TheTvdb": "TheTVDB", "Theme": "Tema", "ThemeHelpText": "Canvia el tema de la interfície d'usuari de l'aplicació, el tema 'Automàtic' utilitzarà el tema del sistema per establir el mode clar o fosc. Inspirat per Theme.Park", + "Threshold": "Llindar", + "Thursday": "Dijous", "Time": "Temps", "TimeFormat": "Format de l'hora", "TimeLeft": "Temps a l'esquerra", + "TimeZone": "Zona horària", "Title": "Títol", "Titles": "Títols", "Today": "Avui", @@ -2100,6 +2125,7 @@ "TotalSpace": "Espai total", "Trace": "Rastreig", "True": "Vertader", + "Tuesday": "Dimarts", "TvdbId": "ID de TVDB", "TvdbIdExcludeHelpText": "L'ID de TVDB de la sèrie a excloure", "Twitter": "Twitter", @@ -2199,6 +2225,7 @@ "Wanted": "Demanat", "Warn": "Avís", "Warning": "Avís", + "Wednesday": "Dimecres", "Week": "Setmana", "WeekColumnHeader": "Capçalera de la columna de la setmana", "WeekColumnHeaderHelpText": "Es mostra per sobre de cada columna quan la setmana és la vista activa", From db9ef92a80f2f3c27ecfad72437182aa6223c1d8 Mon Sep 17 00:00:00 2001 From: Sonarr <development@sonarr.tv> Date: Sun, 1 Mar 2026 17:08:05 +0000 Subject: [PATCH 033/110] Automated API Docs update ignore-downstream --- src/Sonarr.Api.V5/openapi.json | 844 +++++++++++++++++++++++++++++++-- 1 file changed, 800 insertions(+), 44 deletions(-) diff --git a/src/Sonarr.Api.V5/openapi.json b/src/Sonarr.Api.V5/openapi.json index 770de9c6b..d94c5d6a0 100644 --- a/src/Sonarr.Api.V5/openapi.json +++ b/src/Sonarr.Api.V5/openapi.json @@ -1565,6 +1565,101 @@ } } }, + "/api/v5/settings/general/{id}": { + "put": { + "tags": [ + "GeneralSettings" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GeneralSettingsResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/GeneralSettingsResource" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/GeneralSettingsResource" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/GeneralSettingsResource" + } + } + } + } + } + }, + "get": { + "tags": [ + "GeneralSettings" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GeneralSettingsResource" + } + } + } + } + } + } + }, + "/api/v5/settings/general": { + "get": { + "tags": [ + "GeneralSettings" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GeneralSettingsResource" + } + } + } + } + } + } + }, "/api/v5/health": { "get": { "tags": [ @@ -1936,6 +2031,341 @@ } } }, + "/api/v5/indexer": { + "get": { + "tags": [ + "Indexer" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IndexerResource" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Indexer" + ], + "parameters": [ + { + "name": "forceSave", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerResource" + } + } + } + } + } + } + }, + "/api/v5/indexer/{id}": { + "put": { + "tags": [ + "Indexer" + ], + "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/IndexerResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerResource" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Indexer" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "Indexer" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerResource" + } + } + } + } + } + } + }, + "/api/v5/indexer/bulk": { + "put": { + "tags": [ + "Indexer" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerBulkResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerResource" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Indexer" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerBulkResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v5/indexer/schema": { + "get": { + "tags": [ + "Indexer" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IndexerResource" + } + } + } + } + } + } + } + }, + "/api/v5/indexer/test": { + "post": { + "tags": [ + "Indexer" + ], + "parameters": [ + { + "name": "forceTest", + "in": "query", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v5/indexer/testall": { + "post": { + "tags": [ + "Indexer" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v5/indexer/action/{name}": { + "post": { + "tags": [ + "Indexer" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v5/indexerflag": { + "get": { + "tags": [ + "IndexerFlag" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IndexerFlagResource" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IndexerFlagResource" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IndexerFlagResource" + } + } + } + } + } + } + } + }, "/api/v5/language": { "get": { "tags": [ @@ -5235,6 +5665,25 @@ } } }, + "/api/v5/settings/ui": { + "get": { + "tags": [ + "UiSettings" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UiSettingsResource" + } + } + } + } + } + } + }, "/api/v5/settings/ui/{id}": { "put": { "tags": [ @@ -5311,25 +5760,6 @@ } } }, - "/api/v5/settings/ui": { - "get": { - "tags": [ - "UiSettings" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UiSettingsResource" - } - } - } - } - } - } - }, "/api/v5/update": { "get": { "tags": [ @@ -5406,45 +5836,37 @@ "200": { "description": "OK", "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/UpdateSettingsResource" - } - }, "application/json": { "schema": { "$ref": "#/components/schemas/UpdateSettingsResource" } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSettingsResource" - } } } } } - }, + } + }, + "/api/v5/settings/update/{id}": { "put": { "tags": [ "UpdateSettings" ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateSettingsResource" } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSettingsResource" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateSettingsResource" - } } } }, @@ -5470,9 +5892,7 @@ } } } - } - }, - "/api/v5/settings/update/{id}": { + }, "get": { "tags": [ "UpdateSettings" @@ -5562,6 +5982,13 @@ ], "type": "string" }, + "AuthenticationRequiredType": { + "enum": [ + "enabled", + "disabledForLocalAddresses" + ], + "type": "string" + }, "AuthenticationType": { "enum": [ "none", @@ -5742,6 +6169,14 @@ ], "type": "string" }, + "CertificateValidationType": { + "enum": [ + "enabled", + "disabledForLocalAddresses", + "disabled" + ], + "type": "string" + }, "Command": { "type": "object", "properties": { @@ -6680,6 +7115,153 @@ ], "type": "string" }, + "GeneralSettingsResource": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "bindAddress": { + "type": "string", + "nullable": true + }, + "port": { + "type": "integer", + "format": "int32" + }, + "sslPort": { + "type": "integer", + "format": "int32" + }, + "enableSsl": { + "type": "boolean" + }, + "launchBrowser": { + "type": "boolean" + }, + "authenticationMethod": { + "$ref": "#/components/schemas/AuthenticationType" + }, + "authenticationRequired": { + "$ref": "#/components/schemas/AuthenticationRequiredType" + }, + "analyticsEnabled": { + "type": "boolean" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "passwordConfirmation": { + "type": "string", + "nullable": true + }, + "logLevel": { + "type": "string", + "nullable": true + }, + "logSizeLimit": { + "type": "integer", + "format": "int32" + }, + "consoleLogLevel": { + "type": "string", + "nullable": true + }, + "branch": { + "type": "string", + "nullable": true + }, + "apiKey": { + "type": "string", + "nullable": true + }, + "sslCertPath": { + "type": "string", + "nullable": true + }, + "sslKeyPath": { + "type": "string", + "nullable": true + }, + "sslCertPassword": { + "type": "string", + "nullable": true + }, + "urlBase": { + "type": "string", + "nullable": true + }, + "instanceName": { + "type": "string", + "nullable": true + }, + "applicationUrl": { + "type": "string", + "nullable": true + }, + "updateAutomatically": { + "type": "boolean" + }, + "updateMechanism": { + "$ref": "#/components/schemas/UpdateMechanism" + }, + "updateScriptPath": { + "type": "string", + "nullable": true + }, + "proxyEnabled": { + "type": "boolean" + }, + "proxyType": { + "$ref": "#/components/schemas/ProxyType" + }, + "proxyHostname": { + "type": "string", + "nullable": true + }, + "proxyPort": { + "type": "integer", + "format": "int32" + }, + "proxyUsername": { + "type": "string", + "nullable": true + }, + "proxyPassword": { + "type": "string", + "nullable": true + }, + "proxyBypassFilter": { + "type": "string", + "nullable": true + }, + "proxyBypassLocalAddresses": { + "type": "boolean" + }, + "certificateValidation": { + "$ref": "#/components/schemas/CertificateValidationType" + }, + "backupFolder": { + "type": "string", + "nullable": true + }, + "backupInterval": { + "type": "integer", + "format": "int32" + }, + "backupRetention": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, "HealthCheckReason": { "enum": [ "appDataLocation", @@ -6935,7 +7517,8 @@ "unverifiedSceneMapping", "notQualityUpgrade", "notRevisionUpgrade", - "notCustomFormatUpgrade" + "notCustomFormatUpgrade", + "notCustomFormatUpgradeAfterRename" ], "type": "string" }, @@ -6955,6 +7538,158 @@ }, "additionalProperties": false }, + "IndexerBulkResource": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "nullable": true + }, + "applyTags": { + "$ref": "#/components/schemas/ApplyTags" + }, + "enableRss": { + "type": "boolean", + "nullable": true + }, + "enableAutomaticSearch": { + "type": "boolean", + "nullable": true + }, + "enableInteractiveSearch": { + "type": "boolean", + "nullable": true + }, + "priority": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "seasonSearchMaximumSingleEpisodeAge": { + "type": "integer", + "format": "int32", + "nullable": true + } + }, + "additionalProperties": false + }, + "IndexerFlagResource": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "nameLower": { + "type": "string", + "nullable": true, + "readOnly": true + } + }, + "additionalProperties": false + }, + "IndexerResource": { + "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/IndexerResource" + }, + "nullable": true + }, + "enableRss": { + "type": "boolean" + }, + "enableAutomaticSearch": { + "type": "boolean" + }, + "enableInteractiveSearch": { + "type": "boolean" + }, + "supportsRss": { + "type": "boolean" + }, + "supportsSearch": { + "type": "boolean" + }, + "protocol": { + "$ref": "#/components/schemas/DownloadProtocol" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "seasonSearchMaximumSingleEpisodeAge": { + "type": "integer", + "format": "int32" + }, + "downloadClientId": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, "Language": { "type": "object", "properties": { @@ -8019,6 +8754,14 @@ ], "type": "string" }, + "ProxyType": { + "enum": [ + "http", + "socks4", + "socks5" + ], + "type": "string" + }, "Quality": { "type": "object", "properties": { @@ -9416,6 +10159,10 @@ }, "nullable": true }, + "originalCountry": { + "type": "string", + "nullable": true + }, "tags": { "uniqueItems": true, "type": "array", @@ -10102,12 +10849,21 @@ { "name": "FileSystem" }, + { + "name": "GeneralSettings" + }, { "name": "Health" }, { "name": "History" }, + { + "name": "Indexer" + }, + { + "name": "IndexerFlag" + }, { "name": "Language" }, From 7f971d47ac032c8f5731303b3a29dac5cf90c9d3 Mon Sep 17 00:00:00 2001 From: Yashizzle <16638776+Yashizzle@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:48:15 +1100 Subject: [PATCH 034/110] New: Include External IDs for series with links Closes #7927 --- .../src/Components/Link/ClipboardButton.css | 4 + .../Components/Link/ClipboardButton.css.d.ts | 1 + .../src/Components/Link/ClipboardButton.tsx | 3 + .../src/Series/Details/SeriesDetailsLinks.css | 16 +- .../Details/SeriesDetailsLinks.css.d.ts | 1 + .../src/Series/Details/SeriesDetailsLinks.tsx | 152 +++++++++--------- 6 files changed, 97 insertions(+), 80 deletions(-) diff --git a/frontend/src/Components/Link/ClipboardButton.css b/frontend/src/Components/Link/ClipboardButton.css index 438489155..a524dec26 100644 --- a/frontend/src/Components/Link/ClipboardButton.css +++ b/frontend/src/Components/Link/ClipboardButton.css @@ -4,6 +4,10 @@ position: relative; } +.buttonText { + margin: 0 5px; +} + .stateIconContainer { position: absolute; top: 50%; diff --git a/frontend/src/Components/Link/ClipboardButton.css.d.ts b/frontend/src/Components/Link/ClipboardButton.css.d.ts index c1ad078d8..8a5347352 100644 --- a/frontend/src/Components/Link/ClipboardButton.css.d.ts +++ b/frontend/src/Components/Link/ClipboardButton.css.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'button': string; + 'buttonText': string; 'clipboardIconContainer': string; 'showStateIcon': string; 'stateIconContainer': string; diff --git a/frontend/src/Components/Link/ClipboardButton.tsx b/frontend/src/Components/Link/ClipboardButton.tsx index dfce115ac..3b9e1beba 100644 --- a/frontend/src/Components/Link/ClipboardButton.tsx +++ b/frontend/src/Components/Link/ClipboardButton.tsx @@ -8,6 +8,7 @@ import styles from './ClipboardButton.css'; export interface ClipboardButtonProps extends Omit<ButtonProps, 'children'> { value: string; + label?: string | number; } export type ClipboardState = 'success' | 'error' | null; @@ -15,6 +16,7 @@ export type ClipboardState = 'success' | 'error' | null; export default function ClipboardButton({ id, value, + label, className = styles.button, ...otherProps }: ClipboardButtonProps) { @@ -68,6 +70,7 @@ export default function ClipboardButton({ ) : null} <span className={styles.clipboardIconContainer}> + {label ? <span className={styles.buttonText}>{label}</span> : null} <Icon name={icons.CLIPBOARD} /> </span> </span> diff --git a/frontend/src/Series/Details/SeriesDetailsLinks.css b/frontend/src/Series/Details/SeriesDetailsLinks.css index 4ccb0e70e..c364ea98e 100644 --- a/frontend/src/Series/Details/SeriesDetailsLinks.css +++ b/frontend/src/Series/Details/SeriesDetailsLinks.css @@ -1,4 +1,6 @@ .links { + display: flex; + flex-wrap: wrap; margin: 0; } @@ -9,12 +11,22 @@ .linkLabel { composes: label from '~Components/Label.css'; - cursor: pointer; + margin: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + &:hover { + cursor: pointer; + } +} + +.linkBlock { + display: flex; + margin: 3px; } @media only screen and (max-width: $breakpointExtraSmall) { .links { - display: flex; flex-flow: column wrap; } } diff --git a/frontend/src/Series/Details/SeriesDetailsLinks.css.d.ts b/frontend/src/Series/Details/SeriesDetailsLinks.css.d.ts index 9f91f93a4..c8855831b 100644 --- a/frontend/src/Series/Details/SeriesDetailsLinks.css.d.ts +++ b/frontend/src/Series/Details/SeriesDetailsLinks.css.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'link': string; + 'linkBlock': string; 'linkLabel': string; 'links': string; } diff --git a/frontend/src/Series/Details/SeriesDetailsLinks.tsx b/frontend/src/Series/Details/SeriesDetailsLinks.tsx index b2a725a68..7bad2f91b 100644 --- a/frontend/src/Series/Details/SeriesDetailsLinks.tsx +++ b/frontend/src/Series/Details/SeriesDetailsLinks.tsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import Label from 'Components/Label'; +import ClipboardButton from 'Components/Link/ClipboardButton'; import Link from 'Components/Link/Link'; import { kinds, sizes } from 'Helpers/Props'; import Series from 'Series/Series'; +import translate from 'Utilities/String/translate'; import styles from './SeriesDetailsLinks.css'; type SeriesDetailsLinksProps = Pick< @@ -10,96 +12,90 @@ type SeriesDetailsLinksProps = Pick< 'tvdbId' | 'tvMazeId' | 'imdbId' | 'tmdbId' >; +interface SeriesDetailsLink { + externalId: string | number; + name: string; + url: string; +} + function SeriesDetailsLinks(props: SeriesDetailsLinksProps) { const { tvdbId, tvMazeId, imdbId, tmdbId } = props; + const links = useMemo(() => { + const validLinks: SeriesDetailsLink[] = []; + + if (tvdbId) { + validLinks.push( + { + externalId: tvdbId, + name: 'The TVDB', + url: `https://www.thetvdb.com/?tab=series&id=${tvdbId}`, + }, + { + externalId: tvdbId, + name: 'Trakt', + url: `https://trakt.tv/search/tvdb/${tvdbId}?id_type=show`, + } + ); + } + + if (tvMazeId) { + validLinks.push({ + externalId: tvMazeId, + name: 'TV Maze', + url: `https://www.tvmaze.com/shows/${tvMazeId}/_`, + }); + } + + if (imdbId) { + validLinks.push( + { + externalId: imdbId, + name: 'IMDB', + url: `https://imdb.com/title/${imdbId}/`, + }, + { + externalId: imdbId, + name: 'MDBList', + url: `https://mdblist.com/show/${imdbId}`, + } + ); + } + + if (tmdbId) { + validLinks.push({ + externalId: tmdbId, + name: 'TMDB', + url: `https://www.themoviedb.org/tv/${tmdbId}`, + }); + } + + return validLinks; + }, [tvdbId, tvMazeId, imdbId, tmdbId]); + return ( <div className={styles.links}> - <Link - className={styles.link} - to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`} - > - <Label - className={styles.linkLabel} - kind={kinds.INFO} - size={sizes.LARGE} - > - The TVDB - </Label> - </Link> - - <Link - className={styles.link} - to={`https://trakt.tv/search/tvdb/${tvdbId}?id_type=show`} - > - <Label - className={styles.linkLabel} - kind={kinds.INFO} - size={sizes.LARGE} - > - Trakt - </Label> - </Link> - - {tvMazeId ? ( - <Link - className={styles.link} - to={`https://www.tvmaze.com/shows/${tvMazeId}/_`} - > - <Label - className={styles.linkLabel} - kind={kinds.INFO} - size={sizes.LARGE} - > - TV Maze - </Label> - </Link> - ) : null} - - {imdbId ? ( - <> - <Link - className={styles.link} - to={`https://imdb.com/title/${imdbId}/`} - > + {links.map((link) => ( + <div key={link.name} className={styles.linkBlock}> + <Link className={styles.link} to={link.url}> <Label className={styles.linkLabel} kind={kinds.INFO} size={sizes.LARGE} > - IMDB + {link.name} </Label> </Link> - <Link - className={styles.link} - to={`http://mdblist.com/show/${imdbId}`} - > - <Label - className={styles.linkLabel} - kind={kinds.INFO} - size={sizes.LARGE} - > - MDBList - </Label> - </Link> - </> - ) : null} - - {tmdbId ? ( - <Link - className={styles.link} - to={`https://www.themoviedb.org/tv/${tmdbId}`} - > - <Label - className={styles.linkLabel} - kind={kinds.INFO} - size={sizes.LARGE} - > - TMDB - </Label> - </Link> - ) : null} + <ClipboardButton + value={`${link.externalId}`} + title={translate('CopyToClipboard')} + kind={kinds.DEFAULT} + size={sizes.SMALL} + label={link.externalId} + /> + </div> + ))} </div> ); } From 0723459122aad919b14186748fa63bfc2c241708 Mon Sep 17 00:00:00 2001 From: Weblate <noreply@weblate.org> Date: Wed, 4 Mar 2026 18:26:18 +0000 Subject: [PATCH 035/110] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: FASTAWFUL <laochuang666@gmail.com> Co-authored-by: Havok Dan <havokdan@yahoo.com.br> Co-authored-by: Rodion <rodyon009@gmail.com> Co-authored-by: Weblate <noreply@weblate.org> Co-authored-by: fordas <fordas15@gmail.com> Co-authored-by: ugyes <ferenc.bodi@live.com> Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/uk/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/ Translation: Servarr/Sonarr --- src/NzbDrone.Core/Localization/Core/es.json | 6 ++++++ src/NzbDrone.Core/Localization/Core/hu.json | 18 ++++++++++++------ src/NzbDrone.Core/Localization/Core/pt_BR.json | 6 ++++++ src/NzbDrone.Core/Localization/Core/uk.json | 6 ++++++ src/NzbDrone.Core/Localization/Core/zh_CN.json | 1 + 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/es.json b/src/NzbDrone.Core/Localization/Core/es.json index dfcd65f27..29fa79e85 100644 --- a/src/NzbDrone.Core/Localization/Core/es.json +++ b/src/NzbDrone.Core/Localization/Core/es.json @@ -62,6 +62,10 @@ "AgeWhenGrabbed": "Antigüedad (cuando se añadió)", "Agenda": "Agenda", "AirDate": "Fecha de Emisión", + "AirDateGracePeriod": "Período de gracia de la fecha de emisión", + "AirDateGracePeriodHelpText": "Valores negativos permiten capturar antes de la fecha de emisión, valores positivos evitan capturar después de la fecha de emisión.", + "AirDateRestriction": "Rechazar lanzamientos sin emitir", + "AirDateRestrictionHelpText": "Evita que {appName} capture lanzamientos que contienen episodios que aún no se han emitido.", "Airs": "Emision", "AirsDateAtTimeOn": "{date} en {time} en{networkLabel}", "AirsTbaOn": "A anunciar en {networkLabel}", @@ -132,6 +136,7 @@ "AutoTaggingSpecificationMaximumYear": "Año máximo", "AutoTaggingSpecificationMinimumYear": "Año mínimo", "AutoTaggingSpecificationNetwork": "Red(es)", + "AutoTaggingSpecificationOriginalCountry": "País", "AutoTaggingSpecificationOriginalLanguage": "Idioma", "AutoTaggingSpecificationQualityProfile": "Perfil de calidad", "AutoTaggingSpecificationRootFolder": "Carpeta raíz", @@ -1612,6 +1617,7 @@ "OrganizeSelectedSeriesModalConfirmation": "¿Estás seguro que quieres organizar todos los archivos en las {count} series seleccionadas?", "OrganizeSelectedSeriesModalHeader": "Organizar series seleccionadas", "Original": "Original", + "OriginalCountry": "País original", "OriginalLanguage": "Idioma original", "Other": "Otro", "OutputPath": "Ruta de salida", diff --git a/src/NzbDrone.Core/Localization/Core/hu.json b/src/NzbDrone.Core/Localization/Core/hu.json index c44918fd9..0a16429ea 100644 --- a/src/NzbDrone.Core/Localization/Core/hu.json +++ b/src/NzbDrone.Core/Localization/Core/hu.json @@ -62,6 +62,10 @@ "AgeWhenGrabbed": "Kor (amikor megragadták)", "Agenda": "Teendők", "AirDate": "Adásba kerülés dátuma", + "AirDateGracePeriod": "Sugárzási türelmi idő", + "AirDateGracePeriodHelpText": "A negatív értékek lehetővé teszik a letöltést a sugárzási dátum előtt, a pozitív értékek pedig megakadályozzák a letöltést a sugárzási dátum után.", + "AirDateRestriction": "Adásba nem került epizódok elutasítása", + "AirDateRestrictionHelpText": "Megakadályozza, hogy a {appName} olyan kiadásokat töltsön le, amelyek adásba még nem került epizódokat tartalmaznak.", "Airs": "Adásban", "AirsDateAtTimeOn": "{date} {time}-kor a(z) {networkLabel}-on/en/ön", "AirsTbaOn": "Bejelentés alatt a(z) {networkLabel}-on/en/ön", @@ -132,6 +136,7 @@ "AutoTaggingSpecificationMaximumYear": "Maximum Év", "AutoTaggingSpecificationMinimumYear": "Minimum Év", "AutoTaggingSpecificationNetwork": "Hálózat", + "AutoTaggingSpecificationOriginalCountry": "Ország", "AutoTaggingSpecificationOriginalLanguage": "Nyelv", "AutoTaggingSpecificationQualityProfile": "Minőségi Profil", "AutoTaggingSpecificationRootFolder": "Gyökérmappa", @@ -185,9 +190,9 @@ "CalendarFeed": "{appName} Naptár Feed", "CalendarLegendEpisodeDownloadedTooltip": "Az epizódot letöltötték és rendezték", "CalendarLegendEpisodeDownloadingTooltip": "Epizód letöltés alatt", - "CalendarLegendEpisodeMissingTooltip": "Az epizódot leadták, és hiányzik a lemezről", + "CalendarLegendEpisodeMissingTooltip": "Az epizód adásba került, és hiányzik a lemezről", "CalendarLegendEpisodeOnAirTooltip": "Az epizód jelenleg adásban van", - "CalendarLegendEpisodeUnairedTooltip": "Az epizódot még nem adták le", + "CalendarLegendEpisodeUnairedTooltip": "Az epizód még nem került adásba", "CalendarLegendEpisodeUnmonitoredTooltip": "Az epizód nem figyelhető", "CalendarLegendSeriesFinaleTooltip": "Sorozat vagy évad finálé", "CalendarLegendSeriesPremiereTooltip": "Sorozat vagy évad premierje", @@ -882,7 +887,7 @@ "ImportListsAniListSettingsImportDropped": "Importálás elvetve", "ImportListsAniListSettingsImportDroppedHelpText": "Lista: Elvetve", "ImportListsAniListSettingsImportFinished": "Az importálás befejeződött", - "ImportListsAniListSettingsImportFinishedHelpText": "Média: Minden epizódot leadtak", + "ImportListsAniListSettingsImportFinishedHelpText": "Média: Minden epizód adásba került", "ImportListsAniListSettingsImportHiatus": "Importálás szünet", "ImportListsAniListSettingsImportHiatusHelpText": "Média: Szünetelő sorozatok", "ImportListsAniListSettingsImportNotYetReleased": "Az importálás még nem jelent meg", @@ -1273,15 +1278,15 @@ "MonitorEpisodes": "Epizódok figyelése", "MonitorEpisodesModalInfo": "Ez a beállítás csak azt módosítja, hogy egy sorozaton belül mely epizódok vagy évadok legyenek figyelve. A ‘Nincs’ kiválasztása a sorozat figyelésének leállítását eredményezi", "MonitorExistingEpisodes": "Meglévő epizódok", - "MonitorExistingEpisodesDescription": "Figyelje meg azokat az epizódokat, amelyekben vannak fájlok, vagy amelyek még nem kerültek adásba", + "MonitorExistingEpisodesDescription": "Figyelje azokat az epizódokat, amelyeknek vannak fájljai, vagy amelyek még nem kerültek adásba", "MonitorFirstSeason": "Első évad", "MonitorFirstSeasonDescription": "Kövesse nyomon az első évad összes epizódját. Az összes többi évadot figyelmen kívül hagyjuk", "MonitorFutureEpisodes": "Jövőbeni epizódok", - "MonitorFutureEpisodesDescription": "Figyelje meg az adásba még nem került epizódokat", + "MonitorFutureEpisodesDescription": "Figyelje az adásba még nem került epizódokat", "MonitorLastSeason": "Utolsó évad", "MonitorLastSeasonDescription": "Kövesse nyomon az elmúlt évad összes epizódját", "MonitorMissingEpisodes": "Hiányzó epizódok", - "MonitorMissingEpisodesDescription": "Figyelje meg azokat az epizódokat, amelyekhez nem tartoznak fájlok, vagy amelyeket még nem kerültek adásba", + "MonitorMissingEpisodesDescription": "Figyelje azokat az epizódokat, amelyekhez nem tartoznak fájlok, vagy amelyek még nem kerültek adásba", "MonitorNewItems": "Új elemek figyelése", "MonitorNewSeasons": "Kövesse az új évadokat", "MonitorNewSeasonsHelpText": "Mely új évadokat kell automatikusan figyelni", @@ -1612,6 +1617,7 @@ "OrganizeSelectedSeriesModalConfirmation": "Biztos benne hogy rendszerezni kívánja a kiválaszott {count} sorozat összes fájlját?", "OrganizeSelectedSeriesModalHeader": "Kiválasztott sorozatok rendszerezése", "Original": "Eredeti", + "OriginalCountry": "Gyártási ország", "OriginalLanguage": "Eredeti nyelv", "Other": "Egyéb", "OutputPath": "Kimeneti útvonal", diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index ecbc14182..4e28f69ee 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -62,6 +62,10 @@ "AgeWhenGrabbed": "Tempo de vida (quando obtido)", "Agenda": "Programação", "AirDate": "Data de exibição", + "AirDateGracePeriod": "Período de Carência para Data de Exibição", + "AirDateGracePeriodHelpText": "Valores negativos permitem baixar antes da data de exibição, valores positivos evitam baixar após a data de exibição.", + "AirDateRestriction": "Rejeitar Lançamentos Não Exibidos", + "AirDateRestrictionHelpText": "Impede {appName} baixe lançamentos que contenham episódios que ainda não foram exibidos.", "Airs": "Vai ao ar em", "AirsDateAtTimeOn": "{date} às {time} em {networkLabel}", "AirsTbaOn": "A ser anunciado em {networkLabel}", @@ -132,6 +136,7 @@ "AutoTaggingSpecificationMaximumYear": "Ano máximo", "AutoTaggingSpecificationMinimumYear": "Ano mínimo", "AutoTaggingSpecificationNetwork": "Rede(s)", + "AutoTaggingSpecificationOriginalCountry": "País", "AutoTaggingSpecificationOriginalLanguage": "Idioma", "AutoTaggingSpecificationQualityProfile": "Perfil de qualidade", "AutoTaggingSpecificationRootFolder": "Pasta raiz", @@ -1612,6 +1617,7 @@ "OrganizeSelectedSeriesModalConfirmation": "Tem certeza de que deseja organizar todos os arquivos da {count} série selecionada?", "OrganizeSelectedSeriesModalHeader": "Organizar Séries Selecionadas", "Original": "Original", + "OriginalCountry": "País Original", "OriginalLanguage": "Idioma Original", "Other": "Outro", "OutputPath": "Caminho de saída", diff --git a/src/NzbDrone.Core/Localization/Core/uk.json b/src/NzbDrone.Core/Localization/Core/uk.json index abb8e9223..c0837363b 100644 --- a/src/NzbDrone.Core/Localization/Core/uk.json +++ b/src/NzbDrone.Core/Localization/Core/uk.json @@ -61,6 +61,10 @@ "AgeWhenGrabbed": "Вік (коли схоплено)", "Agenda": "План", "AirDate": "Дата виходу в ефір", + "AirDateGracePeriod": "Допустимий період відносно дати виходу в ефір", + "AirDateGracePeriodHelpText": "Від’ємні значення дозволяють завантаження до дати виходу в ефір, додатні — забороняють завантаження після дати виходу в ефір.", + "AirDateRestriction": "Відхиляти релізи з невипущеними епізодами", + "AirDateRestrictionHelpText": "Запобігти тому, щоб {appName} завантажував релізи, які містять епізоди, що ще не вийшли в ефір.", "Airs": "Ефіри", "AirsDateAtTimeOn": "{date} о {time} на {networkLabel}", "AirsTbaOn": "Будь ласка, оголосіть пізніше на {networkLabel}", @@ -131,6 +135,7 @@ "AutoTaggingSpecificationMaximumYear": "Максимальний рік", "AutoTaggingSpecificationMinimumYear": "Мінімальний рік", "AutoTaggingSpecificationNetwork": "Телеканал", + "AutoTaggingSpecificationOriginalCountry": "Країна", "AutoTaggingSpecificationOriginalLanguage": "Мова", "AutoTaggingSpecificationQualityProfile": "Профіль якості", "AutoTaggingSpecificationRootFolder": "Коренева тека", @@ -1588,6 +1593,7 @@ "OrganizeSelectedSeriesModalConfirmation": "Ви впевнені, що хочете упорядкувати всі файли у вибраному серіалі: {count}?", "OrganizeSelectedSeriesModalHeader": "Упорядкувати вибрані серіали", "Original": "Оригінал", + "OriginalCountry": "Країна оригіналу", "OriginalLanguage": "Мова оригіналу", "Other": "Інше", "OutputPath": "Вихідний шлях", diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json index 3cf9dd786..f2222dc32 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_CN.json +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -467,6 +467,7 @@ "DownloadClientFreeboxSettingsPortHelpText": "用于访问 Freebox 接口的端口,默认为 '{port}'", "DownloadClientFreeboxUnableToReachFreebox": "无法访问 Freebox API。请检查 “主机名”、“端口” 或 “使用 SSL” 的设置(错误: {exceptionMessage})", "DownloadClientFreeboxUnableToReachFreeboxApi": "无法访问 Freebox API。 请检查 “API 地址” 的基础地址和版本。", + "DownloadClientItemErrorMessage": "{clientName}回報錯誤:{message}", "DownloadClientNzbVortexMultipleFilesMessage": "下载包含多个文件且不在作业文件夹中:{outputPath}", "DownloadClientNzbgetSettingsAddPausedHelpText": "此选项至少需要 NzbGet 版本 16.0", "DownloadClientNzbgetValidationKeepHistoryOverMax": "NzbGet 设置 KeepHistory 应小于 25000", From f9f71d3f2a6c071c1c37dfb5e6f6e53674f97161 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:36:16 +0200 Subject: [PATCH 036/110] Bump terser-webpack-plugin --- package.json | 2 +- yarn.lock | 561 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 328 insertions(+), 235 deletions(-) diff --git a/package.json b/package.json index 37606dfd6..ff21e0f02 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "style-loader": "3.3.2", "stylelint": "15.6.1", "stylelint-order": "6.0.4", - "terser-webpack-plugin": "5.3.10", + "terser-webpack-plugin": "5.3.17", "ts-loader": "9.5.1", "typescript-plugin-css-modules": "5.0.1", "url-loader": "4.1.1", diff --git a/yarn.lock b/yarn.lock index 155f3c8cb..95b021b11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,17 +3,18 @@ "@adobe/css-tools@^4.0.1": - version "4.4.0" - resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.0.tgz#728c484f4e10df03d5a3acd0d8adcbbebff8ad63" - integrity sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ== + version "4.4.4" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.4.tgz#2856c55443d3d461693f32d2b96fb6ea92e1ffa9" + integrity sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg== "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.16.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.25.7.tgz#438f2c524071531d643c6f0188e1e28f130cebc7" - integrity sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g== + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== dependencies: - "@babel/highlight" "^7.25.7" - picocolors "^1.0.0" + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" "@babel/code-frame@^7.27.1": version "7.27.1" @@ -273,21 +274,16 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== -"@babel/helper-validator-identifier@^7.25.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz#77b7f60c40b15c97df735b38a66ba1d7c3e93da5" - integrity sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg== +"@babel/helper-validator-identifier@^7.25.7", "@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== "@babel/helper-validator-identifier@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== -"@babel/helper-validator-identifier@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" - integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== - "@babel/helper-validator-option@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" @@ -310,16 +306,6 @@ "@babel/template" "^7.27.2" "@babel/types" "^7.28.4" -"@babel/highlight@^7.25.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.25.7.tgz#20383b5f442aa606e7b5e3043b0b1aafe9f37de5" - integrity sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw== - dependencies: - "@babel/helper-validator-identifier" "^7.25.7" - chalk "^2.4.2" - js-tokens "^4.0.0" - picocolors "^1.0.0" - "@babel/parser@^7.27.1", "@babel/parser@^7.27.2": version "7.27.2" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.2.tgz#577518bedb17a2ce4212afd052e01f7df0941127" @@ -1076,21 +1062,16 @@ integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": - version "4.4.0" - resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" - integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== dependencies: - eslint-visitor-keys "^3.3.0" + eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.10.0": - version "4.12.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" - integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== - -"@eslint-community/regexpp@^4.6.1": - version "4.11.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.11.1.tgz#a547badfc719eb3e5f4b556325e542fbe9d7a18f" - integrity sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q== +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== "@eslint/eslintrc@^2.1.4": version "2.1.4" @@ -1237,14 +1218,6 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@^0.3.20": - version "0.3.25" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" - integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": version "0.3.31" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" @@ -1297,6 +1270,95 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@parcel/watcher-android-arm64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz#5f32e0dba356f4ac9a11068d2a5c134ca3ba6564" + integrity sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A== + +"@parcel/watcher-darwin-arm64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz#88d3e720b59b1eceffce98dac46d7c40e8be5e8e" + integrity sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA== + +"@parcel/watcher-darwin-x64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz#bf05d76a78bc15974f15ec3671848698b0838063" + integrity sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg== + +"@parcel/watcher-freebsd-x64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz#8bc26e9848e7303ac82922a5ae1b1ef1bdb48a53" + integrity sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng== + +"@parcel/watcher-linux-arm-glibc@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz#1328fee1deb0c2d7865079ef53a2ba4cc2f8b40a" + integrity sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ== + +"@parcel/watcher-linux-arm-musl@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz#bad0f45cb3e2157746db8b9d22db6a125711f152" + integrity sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg== + +"@parcel/watcher-linux-arm64-glibc@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz#b75913fbd501d9523c5f35d420957bf7d0204809" + integrity sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA== + +"@parcel/watcher-linux-arm64-musl@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz#da5621a6a576070c8c0de60dea8b46dc9c3827d4" + integrity sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA== + +"@parcel/watcher-linux-x64-glibc@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz#ce437accdc4b30f93a090b4a221fd95cd9b89639" + integrity sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ== + +"@parcel/watcher-linux-x64-musl@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz#02400c54b4a67efcc7e2327b249711920ac969e2" + integrity sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg== + +"@parcel/watcher-win32-arm64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz#caae3d3c7583ca0a7171e6bd142c34d20ea1691e" + integrity sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q== + +"@parcel/watcher-win32-ia32@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz#9ac922550896dfe47bfc5ae3be4f1bcaf8155d6d" + integrity sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g== + +"@parcel/watcher-win32-x64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz#73fdafba2e21c448f0e456bbe13178d8fe11739d" + integrity sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw== + +"@parcel/watcher@^2.4.1": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.6.tgz#3f932828c894f06d0ad9cfefade1756ecc6ef1f1" + integrity sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ== + dependencies: + detect-libc "^2.0.3" + is-glob "^4.0.3" + node-addon-api "^7.0.0" + picomatch "^4.0.3" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.5.6" + "@parcel/watcher-darwin-arm64" "2.5.6" + "@parcel/watcher-darwin-x64" "2.5.6" + "@parcel/watcher-freebsd-x64" "2.5.6" + "@parcel/watcher-linux-arm-glibc" "2.5.6" + "@parcel/watcher-linux-arm-musl" "2.5.6" + "@parcel/watcher-linux-arm64-glibc" "2.5.6" + "@parcel/watcher-linux-arm64-musl" "2.5.6" + "@parcel/watcher-linux-x64-glibc" "2.5.6" + "@parcel/watcher-linux-x64-musl" "2.5.6" + "@parcel/watcher-win32-arm64" "2.5.6" + "@parcel/watcher-win32-ia32" "2.5.6" + "@parcel/watcher-win32-x64" "2.5.6" + "@react-dnd/asap@^5.0.1": version "5.0.2" resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488" @@ -1490,9 +1552,9 @@ integrity sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw== "@types/node@*": - version "25.3.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.3.0.tgz#749b1bd4058e51b72e22bd41e9eab6ebd0180470" - integrity sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A== + version "25.3.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.3.3.tgz#605862544ee7ffd7a936bcbf0135a14012f1e549" + integrity sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ== dependencies: undici-types "~7.18.0" @@ -1767,9 +1829,9 @@ eslint-visitor-keys "^4.2.0" "@ungap/structured-clone@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" - integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + version "1.3.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" + integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": version "1.14.1" @@ -1934,16 +1996,11 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.15.0, acorn@^8.8.2: +acorn@^8.15.0, acorn@^8.9.0: version "8.16.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== -acorn@^8.9.0: - version "8.12.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" - integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== - add-px-to-style@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/add-px-to-style/-/add-px-to-style-1.0.0.tgz#d0c135441fa8014a8137904531096f67f28f263a" @@ -1977,9 +2034,9 @@ ajv-keywords@^5.1.0: fast-deep-equal "^3.1.3" ajv@^6.12.4, ajv@^6.12.5: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -2276,9 +2333,9 @@ balanced-match@^2.0.0: integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA== balanced-match@^4.0.2: - version "4.0.3" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.3.tgz#6337a2f23e0604a30481423432f99eac603599f9" - integrity sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g== + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== base64-js@^1.3.1: version "1.5.1" @@ -2325,24 +2382,24 @@ boolbase@^1.0.0: integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" -brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== +brace-expansion@^2.0.1, brace-expansion@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== dependencies: balanced-match "^1.0.0" brace-expansion@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.2.tgz#b6c16d0791087af6c2bc463f52a8142046c06b6f" - integrity sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw== + version "5.0.4" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.4.tgz#614daaecd0a688f660bbbc909a8748c3d80d4336" + integrity sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg== dependencies: balanced-match "^4.0.2" @@ -2457,11 +2514,11 @@ camelcase@^5.3.1: integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001663, caniuse-lite@^1.0.30001759: - version "1.0.30001770" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz" - integrity sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw== + version "1.0.30001776" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz" + integrity sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw== -chalk@^2.4.1, chalk@^2.4.2: +chalk@^2.4.1: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -2494,9 +2551,9 @@ chokidar@^3.5.3: fsevents "~2.3.2" chokidar@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.1.tgz#4a6dff66798fb0f72a94f616abbd7e1a19f31d41" - integrity sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA== + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== dependencies: readdirp "^4.0.1" @@ -2712,7 +2769,16 @@ crc32-stream@^4.0.2: crc-32 "^1.2.0" readable-stream "^3.4.0" -cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.2: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -2831,14 +2897,14 @@ debug@^3.1.0, debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: +debug@^4.1.0: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== dependencies: ms "^2.1.3" -debug@^4.4.1: +debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -2900,6 +2966,11 @@ del@^6.1.1: rimraf "^3.0.2" slash "^3.0.0" +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + detect-node-es@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" @@ -2995,9 +3066,9 @@ dot-case@^3.0.4: tslib "^2.0.3" dotenv@^16.0.3: - version "16.4.5" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" - integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + version "16.6.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020" + integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== dunder-proto@^1.0.1: version "1.0.1" @@ -3009,9 +3080,9 @@ dunder-proto@^1.0.1: gopd "^1.2.0" electron-to-chromium@^1.5.263: - version "1.5.302" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz#032a5802b31f7119269959c69fe2015d8dad5edb" - integrity sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg== + version "1.5.307" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz#09f8973100c39fb0d003b890393cd1d58932b1c8" + integrity sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg== electron-to-chromium@^1.5.28: version "1.5.35" @@ -3034,9 +3105,9 @@ emojis-list@^3.0.0: integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== end-of-stream@^1.4.1: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + version "1.4.5" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c" + integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== dependencies: once "^1.4.0" @@ -3049,9 +3120,9 @@ enhanced-resolve@^5.0.0: tapable "^2.2.0" enhanced-resolve@^5.19.0: - version "5.19.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz#6687446a15e969eaa63c2fa2694510e17ae6d97c" - integrity sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg== + version "5.20.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz#323c2a70d2aa7fb4bdfd6d3c24dfc705c581295d" + integrity sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ== dependencies: graceful-fs "^4.2.4" tapable "^2.3.0" @@ -3074,9 +3145,9 @@ errno@^0.1.1: prr "~1.0.1" error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + version "1.3.4" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414" + integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== dependencies: is-arrayish "^0.2.1" @@ -3358,15 +3429,15 @@ eslint-visitor-keys@^2.1.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: +eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== eslint-visitor-keys@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz#687bacb2af884fcdda8a6e7d65c606f46a14cd45" - integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== eslint@8.57.1: version "8.57.1" @@ -3422,9 +3493,9 @@ espree@^9.6.0, espree@^9.6.1: eslint-visitor-keys "^3.4.1" esquery@^1.4.2: - version "1.6.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" - integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== dependencies: estraverse "^5.1.0" @@ -3475,7 +3546,7 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== -fast-glob@^3.2.11, fast-glob@^3.2.12, fast-glob@^3.2.9, fast-glob@^3.3.2: +fast-glob@^3.2.11: version "3.3.2" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== @@ -3486,6 +3557,17 @@ fast-glob@^3.2.11, fast-glob@^3.2.12, fast-glob@^3.2.9, fast-glob@^3.3.2: merge2 "^1.3.0" micromatch "^4.0.4" +fast-glob@^3.2.12, fast-glob@^3.2.9, fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" @@ -3507,9 +3589,9 @@ fastest-levenshtein@^1.0.12, fastest-levenshtein@^1.0.16: integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== fastq@^1.6.0: - version "1.17.1" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47" - integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w== + version "1.20.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675" + integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw== dependencies: reusify "^1.0.4" @@ -3616,9 +3698,9 @@ flat@^5.0.2: integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== flatted@^3.2.9: - version "3.3.1" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" - integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== + version "3.3.4" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.4.tgz#0986e681008f0f13f58e18656c47967682db5ff6" + integrity sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA== focus-lock@^0.11.6: version "0.11.6" @@ -3672,9 +3754,9 @@ fs-extra@^10.0.0, fs-extra@^10.1.0: universalify "^2.0.0" fs-monkey@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.6.tgz#8ead082953e88d992cf3ff844faa907b26756da2" - integrity sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg== + version "1.1.0" + resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.1.0.tgz#632aa15a20e71828ed56b24303363fb1414e5997" + integrity sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw== fs.realpath@^1.0.0: version "1.0.0" @@ -4048,12 +4130,25 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== -"immutable@^3.8.1 || ^4.0.0", immutable@^4.0.0: - version "4.3.7" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381" - integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw== +"immutable@^3.8.1 || ^4.0.0": + version "4.3.8" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.8.tgz#02d183c7727fb2bb1d5d0380da0d779dce9296a7" + integrity sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw== -import-fresh@^3.2.1, import-fresh@^3.3.0: +immutable@^5.0.2: + version "5.1.5" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165" + integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A== + +import-fresh@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-fresh@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== @@ -4410,9 +4505,9 @@ jquery@3.7.1: integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== dependencies: argparse "^2.0.1" @@ -4459,9 +4554,9 @@ json5@^2.1.2, json5@^2.2.2, json5@^2.2.3: integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + version "6.2.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.2.0.tgz#7c265bd1b65de6977478300087c99f1c84383f62" + integrity sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg== dependencies: universalify "^2.0.0" optionalDependencies: @@ -4512,9 +4607,9 @@ lazystream@^1.0.0: readable-stream "^2.0.5" less@^4.1.3: - version "4.2.0" - resolved "https://registry.yarnpkg.com/less/-/less-4.2.0.tgz#cbefbfaa14a4cd388e2099b2b51f956e1465c450" - integrity sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA== + version "4.5.1" + resolved "https://registry.yarnpkg.com/less/-/less-4.5.1.tgz#739266532249a3de232e8b60ffb1b27ad5ec6ad8" + integrity sha512-UKgI3/KON4u6ngSsnDADsUERqhZknsVZbnuzlRZXLQCmfC/MDld42fTydUE9B+Mla1AL6SJ/Pp6SlEFi/AVGfw== dependencies: copy-anything "^2.0.1" parse-node-version "^1.0.1" @@ -4814,7 +4909,7 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -micromatch@^4.0.0, micromatch@^4.0.4, micromatch@^4.0.5: +micromatch@^4.0.0, micromatch@^4.0.4, micromatch@^4.0.5, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -4866,32 +4961,32 @@ mini-css-extract-plugin@2.9.1: tapable "^2.2.1" minimatch@^10.2.2: - version "10.2.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.2.tgz#361603ee323cfb83496fea2ae17cc44ea4e1f99f" - integrity sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw== + version "10.2.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" + integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg== dependencies: brace-expansion "^5.0.2" minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== dependencies: brace-expansion "^1.1.7" minimatch@^5.1.0: - version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + version "5.1.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.9.tgz#1293ef15db0098b394540e8f9f744f9fda8dee4b" + integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== dependencies: brace-expansion "^2.0.1" minimatch@^9.0.4: - version "9.0.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" - integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + version "9.0.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== dependencies: - brace-expansion "^2.0.1" + brace-expansion "^2.0.2" minimatch@~3.0.4: version "3.0.8" @@ -4953,10 +5048,10 @@ ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nanoid@^3.3.7: - version "3.3.7" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" - integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== +nanoid@^3.3.11, nanoid@^3.3.7: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== natural-compare@^1.4.0: version "1.4.0" @@ -4989,6 +5084,11 @@ node-abort-controller@^3.0.1: resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" integrity sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ== +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" @@ -5002,9 +5102,9 @@ node-releases@^2.0.18: integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g== node-releases@^2.0.27: - version "2.0.27" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" - integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== + version "2.0.36" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.36.tgz#99fd6552aaeda9e17c4713b57a63964a2e325e9d" + integrity sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA== normalize-package-data@^2.5.0: version "2.5.0" @@ -5286,21 +5386,26 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== -picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.0.tgz#5358b76a78cde483ba5cef6a9dc9671440b27d59" - integrity sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw== - -picocolors@^1.1.1: +picocolors@^1.0.0, picocolors@^1.1.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== +picocolors@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.0.tgz#5358b76a78cde483ba5cef6a9dc9671440b27d59" + integrity sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + pify@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" @@ -5395,20 +5500,20 @@ postcss-modules-extract-imports@^3.0.0: integrity sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q== postcss-modules-local-by-default@^4.0.0: - version "4.0.5" - resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.5.tgz#f1b9bd757a8edf4d8556e8d0f4f894260e3df78f" - integrity sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw== + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz#d150f43837831dae25e4085596e84f6f5d6ec368" + integrity sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw== dependencies: icss-utils "^5.0.0" - postcss-selector-parser "^6.0.2" + postcss-selector-parser "^7.0.0" postcss-value-parser "^4.1.0" postcss-modules-scope@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.2.0.tgz#a43d28289a169ce2c15c00c4e64c0858e43457d5" - integrity sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ== + version "3.2.1" + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz#1bbccddcb398f1d7a511e0a2d1d047718af4078c" + integrity sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA== dependencies: - postcss-selector-parser "^6.0.4" + postcss-selector-parser "^7.0.0" postcss-modules-values@^4.0.0: version "4.0.0" @@ -5434,7 +5539,7 @@ postcss-safe-parser@^6.0.0: resolved "https://registry.yarnpkg.com/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz#bb4c29894171a94bc5c996b9a30317ef402adaa1" integrity sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ== -postcss-selector-parser@^6.0.12, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.1.1: +postcss-selector-parser@^6.0.12, postcss-selector-parser@^6.1.1: version "6.1.2" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de" integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== @@ -5442,6 +5547,14 @@ postcss-selector-parser@^6.0.12, postcss-selector-parser@^6.0.2, postcss-selecto cssesc "^3.0.0" util-deprecate "^1.0.2" +postcss-selector-parser@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz#e75d2e0d843f620e5df69076166f4e16f891cb9f" + integrity sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + postcss-simple-vars@7.0.1, postcss-simple-vars@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-7.0.1.tgz#836b3097a54dcd13dbd3c36a5dbdd512fad2954c" @@ -5472,7 +5585,7 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.4.47, postcss@^8.0.0, postcss@^8.4.19, postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.32: +postcss@8.4.47, postcss@^8.4.19, postcss@^8.4.23, postcss@^8.4.32: version "8.4.47" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.47.tgz#5bf6c9a010f3e724c503bf03ef7947dcb0fea365" integrity sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ== @@ -5490,6 +5603,15 @@ postcss@^6.0.23: source-map "^0.6.1" supports-color "^5.4.0" +postcss@^8.0.0, postcss@^8.4.21: + version "8.5.8" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.8.tgz#6230ecc8fb02e7a0f6982e53990937857e13f399" + integrity sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + prefix-style@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/prefix-style/-/prefix-style-2.0.1.tgz#66bba9a870cfda308a5dc20e85e9120932c95a06" @@ -5587,13 +5709,6 @@ raf@^3.1.0: dependencies: performance-now "^2.1.0" -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - raw-body@~1.1.0: version "1.1.7" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425" @@ -5891,9 +6006,9 @@ readdir-glob@^1.1.2: minimatch "^5.1.0" readdirp@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.0.2.tgz#388fccb8b75665da3abffe2d8f8ed59fe74c230a" - integrity sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA== + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== readdirp@~3.6.0: version "3.6.0" @@ -6129,9 +6244,9 @@ resolve@^2.0.0-next.5: supports-preserve-symlinks-flag "^1.0.0" reusify@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" - integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + version "1.1.0" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== rgb@~0.1.0: version "0.1.0" @@ -6170,7 +6285,7 @@ safe-array-concat@^1.1.2: has-symbols "^1.0.3" isarray "^2.0.5" -safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@>=5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -6200,18 +6315,20 @@ safe-regex-test@^1.0.3: integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== sass@^1.58.3: - version "1.79.4" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.79.4.tgz#f9c45af35fbeb53d2c386850ec842098d9935267" - integrity sha512-K0QDSNPXgyqO4GZq2HO5Q70TLxTH6cIT59RdoCHMivrC8rqzaTw5ab9prjz9KUN1El4FLXrBXJhik61JR4HcGg== + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.3.tgz#9cb59339514fa7e2aec592b9700953ac6e331ab2" + integrity sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg== dependencies: chokidar "^4.0.0" - immutable "^4.0.0" + immutable "^5.0.2" source-map-js ">=0.6.2 <2.0.0" + optionalDependencies: + "@parcel/watcher" "^2.4.1" sax@^1.2.4: - version "1.4.1" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f" - integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg== + version "1.5.0" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.5.0.tgz#b5549b671069b7aa392df55ec7574cf411179eb8" + integrity sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA== sax@~1.2.4: version "1.2.4" @@ -6225,7 +6342,7 @@ scheduler@^0.23.2: dependencies: loose-envify "^1.1.0" -schema-utils@>1.0.0, schema-utils@^4.0.0: +schema-utils@>1.0.0: version "4.2.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.2.0.tgz#70d7c93e153a273a805801882ebd3bff20d89c8b" integrity sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw== @@ -6244,7 +6361,7 @@ schema-utils@^3.0.0, schema-utils@^3.1.1: ajv "^6.12.5" ajv-keywords "^3.5.2" -schema-utils@^4.3.0, schema-utils@^4.3.3: +schema-utils@^4.0.0, schema-utils@^4.3.0, schema-utils@^4.3.3: version "4.3.3" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.3.tgz#5b1850912fa31df90716963d45d9121fdfc09f46" integrity sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA== @@ -6274,17 +6391,15 @@ semver@^6.0.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.4, semver@^7.3.5, semver@^7.3.8, semver@^7.6.0: +semver@^7.3.4, semver@^7.3.8: version "7.6.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== -serialize-javascript@^6.0.1, serialize-javascript@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" - integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== - dependencies: - randombytes "^2.1.0" +semver@^7.3.5, semver@^7.6.0: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== set-cookie-parser@^2.4.8: version "2.7.2" @@ -6434,7 +6549,12 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@^0.7.3, source-map@^0.7.4: +source-map@^0.7.3: + version "0.7.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02" + integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== + +source-map@^0.7.4: version "0.7.4" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== @@ -6742,12 +6862,12 @@ table@^6.8.1: string-width "^4.2.3" strip-ansi "^6.0.1" -tapable@^2.0.0, tapable@^2.3.0: +tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== -tapable@^2.2.0, tapable@^2.2.1: +tapable@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== @@ -6763,26 +6883,14 @@ tar-stream@^2.2.0: inherits "^2.0.3" readable-stream "^3.1.1" -terser-webpack-plugin@5.3.10: - version "5.3.10" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz#904f4c9193c6fd2a03f693a2150c62a92f40d199" - integrity sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w== - dependencies: - "@jridgewell/trace-mapping" "^0.3.20" - jest-worker "^27.4.5" - schema-utils "^3.1.1" - serialize-javascript "^6.0.1" - terser "^5.26.0" - -terser-webpack-plugin@^5.3.16: - version "5.3.16" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz#741e448cc3f93d8026ebe4f7ef9e4afacfd56330" - integrity sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q== +terser-webpack-plugin@5.3.17, terser-webpack-plugin@^5.3.16: + version "5.3.17" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz#75ea98876297fbb190d2fbb395e982582b859a67" + integrity sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw== dependencies: "@jridgewell/trace-mapping" "^0.3.25" jest-worker "^27.4.5" schema-utils "^4.3.0" - serialize-javascript "^6.0.2" terser "^5.31.1" terser@^5.10.0, terser@^5.31.1: @@ -6795,16 +6903,6 @@ terser@^5.10.0, terser@^5.31.1: commander "^2.20.0" source-map-support "~0.5.20" -terser@^5.26.0: - version "5.34.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.34.1.tgz#af40386bdbe54af0d063e0670afd55c3105abeb6" - integrity sha512-FsJZ7iZLd/BXkz+4xrRTGJ26o/6VTjQytUk8b8OxkwcD2I+79VPJlz7qss1+zE7h8GNIScFqXcDyJ/KqBYZFVA== - dependencies: - "@jridgewell/source-map" "^0.3.3" - acorn "^8.8.2" - commander "^2.20.0" - source-map-support "~0.5.20" - text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -6923,16 +7021,11 @@ tsconfig-paths@^4.1.2: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.0.0, tslib@^2.0.3: +tslib@^2.0.0, tslib@^2.0.3, tslib@^2.3.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== -tslib@^2.3.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" - integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== - type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" From f7c7f8db8c72e51a211655671a0040bfb71b3899 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:26:53 +0200 Subject: [PATCH 037/110] Remove postcss-url --- package.json | 1 - yarn.lock | 43 +------------------------------------------ 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/package.json b/package.json index ff21e0f02..4eff18a48 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,6 @@ "postcss-mixins": "9.0.4", "postcss-nested": "6.2.0", "postcss-simple-vars": "7.0.1", - "postcss-url": "10.1.3", "prettier": "2.8.8", "require-nocache": "1.0.0", "rimraf": "6.1.3", diff --git a/yarn.lock b/yarn.lock index 95b021b11..16c3ef5a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2858,11 +2858,6 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== -cuint@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" - integrity sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw== - data-view-buffer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" @@ -4837,13 +4832,6 @@ make-dir@^2.1.0: pify "^4.0.1" semver "^5.6.0" -make-dir@~3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - map-obj@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" @@ -4934,11 +4922,6 @@ mime@^1.4.1: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -mime@~2.5.2: - version "2.5.2" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" - integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== - min-indent@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" @@ -4988,13 +4971,6 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.2" -minimatch@~3.0.4: - version "3.0.8" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.8.tgz#5e6a59bd11e2ab0de1cfb843eb2d82e546c321c1" - integrity sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q== - dependencies: - brace-expansion "^1.1.7" - minimist-options@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619" @@ -5565,16 +5541,6 @@ postcss-sorting@^8.0.2: resolved "https://registry.yarnpkg.com/postcss-sorting/-/postcss-sorting-8.0.2.tgz#6393385ece272baf74bee9820fb1b58098e4eeca" integrity sha512-M9dkSrmU00t/jK7rF6BZSZauA5MAaBW4i5EnJXspMwt4iqTh/L9j6fgMnbElEOfyRyfLfVbIHj/R52zHzAPe1Q== -postcss-url@10.1.3: - version "10.1.3" - resolved "https://registry.yarnpkg.com/postcss-url/-/postcss-url-10.1.3.tgz#54120cc910309e2475ec05c2cfa8f8a2deafdf1e" - integrity sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw== - dependencies: - make-dir "~3.1.0" - mime "~2.5.2" - minimatch "~3.0.4" - xxhashjs "~0.2.2" - postcss-value-parser@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" @@ -6386,7 +6352,7 @@ section-iterator@^2.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@^6.0.0, semver@^6.3.1: +semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -7474,13 +7440,6 @@ ws@^7.5.10: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== -xxhashjs@~0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/xxhashjs/-/xxhashjs-0.2.2.tgz#8a6251567621a1c46a5ae204da0249c7f8caa9d8" - integrity sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw== - dependencies: - cuint "^0.2.2" - yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" From 01c7d06da4fc08f1e7fb7d990416a436c5109364 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:51:59 +0200 Subject: [PATCH 038/110] Bump Dapper, MailKit, Polly, Swashbuckle and NLog.Layouts.ClefJsonLayout --- scripts/docs.sh | 2 +- src/NzbDrone.Common/Sonarr.Common.csproj | 2 +- src/NzbDrone.Core.Test/Sonarr.Core.Test.csproj | 2 +- src/NzbDrone.Core/Sonarr.Core.csproj | 6 +++--- src/NzbDrone.Host/Sonarr.Host.csproj | 2 +- src/Sonarr.Api.V3/Sonarr.Api.V3.csproj | 2 +- src/Sonarr.Api.V5/Sonarr.Api.V5.csproj | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/docs.sh b/scripts/docs.sh index 6cd3966fd..78ee2cfb8 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.2 Swashbuckle.AspNetCore.Cli +dotnet tool install --version 10.1.4 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 b733fdb28..9ac4fad55 100644 --- a/src/NzbDrone.Common/Sonarr.Common.csproj +++ b/src/NzbDrone.Common/Sonarr.Common.csproj @@ -11,7 +11,7 @@ <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="NLog" Version="5.5.1" /> - <PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.4" /> + <PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.5" /> <PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" /> <PackageReference Include="NLog.Extensions.Logging" Version="5.5.0" /> <PackageReference Include="Sentry" Version="5.16.3" /> diff --git a/src/NzbDrone.Core.Test/Sonarr.Core.Test.csproj b/src/NzbDrone.Core.Test/Sonarr.Core.Test.csproj index 56177aa94..1bf36c807 100644 --- a/src/NzbDrone.Core.Test/Sonarr.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/Sonarr.Core.Test.csproj @@ -3,7 +3,7 @@ <TargetFrameworks>net10.0</TargetFrameworks> </PropertyGroup> <ItemGroup> - <PackageReference Include="Dapper" Version="2.1.66" /> + <PackageReference Include="Dapper" Version="2.1.72" /> <PackageReference Include="NBuilder" Version="6.1.0" /> </ItemGroup> <ItemGroup> diff --git a/src/NzbDrone.Core/Sonarr.Core.csproj b/src/NzbDrone.Core/Sonarr.Core.csproj index 530b944a6..3944a804e 100644 --- a/src/NzbDrone.Core/Sonarr.Core.csproj +++ b/src/NzbDrone.Core/Sonarr.Core.csproj @@ -3,16 +3,16 @@ <TargetFrameworks>net10.0</TargetFrameworks> </PropertyGroup> <ItemGroup> - <PackageReference Include="Dapper" Version="2.1.66" /> + <PackageReference Include="Dapper" Version="2.1.72" /> <PackageReference Include="Diacritical.Net" Version="1.0.5" /> <PackageReference Include="Equ" Version="2.3.0" /> - <PackageReference Include="MailKit" Version="4.14.1" /> + <PackageReference Include="MailKit" Version="4.15.1" /> <PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.3" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" /> <PackageReference Include="MiniProfiler.AspNetCore" Version="4.5.4" /> <PackageReference Include="Openur.FFMpegCore" Version="5.4.0.31" /> <PackageReference Include="Openur.FFprobeStatic" Version="8.0.1.302" /> - <PackageReference Include="Polly" Version="8.6.5" /> + <PackageReference Include="Polly" Version="8.6.6" /> <PackageReference Include="System.Drawing.Common" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" /> diff --git a/src/NzbDrone.Host/Sonarr.Host.csproj b/src/NzbDrone.Host/Sonarr.Host.csproj index 793fc2fdd..31fd24857 100644 --- a/src/NzbDrone.Host/Sonarr.Host.csproj +++ b/src/NzbDrone.Host/Sonarr.Host.csproj @@ -6,7 +6,7 @@ <ItemGroup> <PackageReference Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" /> <PackageReference Include="NLog.Extensions.Logging" Version="5.5.0" /> - <PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.2" /> + <PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.4" /> <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.3" /> <PackageReference Include="DryIoc.dll" Version="5.4.3" /> <PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" /> diff --git a/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj b/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj index 18b446cbe..77e047e5f 100644 --- a/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj +++ b/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj @@ -6,7 +6,7 @@ <PackageReference Include="FluentValidation" Version="9.5.4" /> <PackageReference Include="Ical.Net" Version="4.3.1" /> <PackageReference Include="NLog" Version="5.5.1" /> - <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.2" /> + <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.4" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\NzbDrone.Core\Sonarr.Core.csproj" /> diff --git a/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj b/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj index 0f624bb2b..0fbf4b3e4 100644 --- a/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj +++ b/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj @@ -9,7 +9,7 @@ <PackageReference Include="FluentValidation" Version="9.5.4" /> <PackageReference Include="Ical.Net" Version="4.3.1" /> <PackageReference Include="NLog" Version="5.5.1" /> - <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.2" /> + <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.4" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\NzbDrone.Core\Sonarr.Core.csproj" /> From 0e5ccbebc732c14e24466841af59229b3110caa7 Mon Sep 17 00:00:00 2001 From: Weblate <noreply@weblate.org> Date: Sun, 15 Mar 2026 15:27:28 +0000 Subject: [PATCH 039/110] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: EpIcNuGeTs <boutchich.nabil@gmail.com> Co-authored-by: Jurrendel van Delden <wieiscool@hotmail.com> Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com> Co-authored-by: Weblate <noreply@weblate.org> Co-authored-by: Whoman <whoman0981@proton.me> Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nl/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ro/ Translation: Servarr/Sonarr --- src/NzbDrone.Core/Localization/Core/fr.json | 2 +- src/NzbDrone.Core/Localization/Core/nl.json | 4 + .../Localization/Core/pt_BR.json | 78 +++++++++---------- src/NzbDrone.Core/Localization/Core/ro.json | 61 ++++++++++++++- 4 files changed, 104 insertions(+), 41 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json index 6ffeb2426..011a2df78 100644 --- a/src/NzbDrone.Core/Localization/Core/fr.json +++ b/src/NzbDrone.Core/Localization/Core/fr.json @@ -24,7 +24,7 @@ "AddDownloadClientImplementation": "Ajouter un client de téléchargement - {implementationName}", "AddExclusion": "Ajouter une exclusion", "AddImportList": "Ajouter une liste d'importation", - "AddImportListExclusion": "Ajouter une exclusion à la liste des importations", + "AddImportListExclusion": "Ajouter une liste d'exclusion", "AddImportListExclusionError": "Impossible d'ajouter une nouvelle exclusion de liste d'importation, veuillez réessayer.", "AddImportListImplementation": "Ajouter une liste d'importation - {implementationName}", "AddIndexer": "Ajouter un indexeur", diff --git a/src/NzbDrone.Core/Localization/Core/nl.json b/src/NzbDrone.Core/Localization/Core/nl.json index 1a6821dc5..687370de1 100644 --- a/src/NzbDrone.Core/Localization/Core/nl.json +++ b/src/NzbDrone.Core/Localization/Core/nl.json @@ -212,6 +212,10 @@ "ClearBlocklist": "Blokkeerlijst wissen", "ClearBlocklistMessageText": "Weet je zeker dat je de blokkeerlijst wil legen?", "ClickToChangeIndexerFlags": "Klik om indexeringsvlaggen te wijzigen", + "ClickToChangeQuality": "Klik om de kwaliteit aan te passen", + "ClickToChangeSeason": "Klik om seizoen te veranderen", + "ClickToChangeSeries": "Klik om de serie te veranderen", + "ClientPriority": "Client prioriteit", "Clone": "Kloon", "CloneAutoTag": "Kopieer Automatische Tag", "CloneCondition": "Kloon Conditie", diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index 4e28f69ee..e20cf51f3 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -141,7 +141,7 @@ "AutoTaggingSpecificationQualityProfile": "Perfil de qualidade", "AutoTaggingSpecificationRootFolder": "Pasta raiz", "AutoTaggingSpecificationSeriesType": "Tipo de série", - "AutoTaggingSpecificationStatus": "Status", + "AutoTaggingSpecificationStatus": "Estado", "AutoTaggingSpecificationTag": "Etiqueta", "Automatic": "Automático", "AutomaticAdd": "Adição automática", @@ -178,7 +178,7 @@ "BranchUpdate": "Ramificação para atualizar o {appName}", "BranchUpdateMechanism": "Ramificação usada pelo mecanismo externo de atualização", "BrowserReloadRequired": "É necessário recarregar o navegador", - "BuiltIn": "Integrado", + "BuiltIn": "Incorporado", "BypassDelayIfAboveCustomFormatScore": "Ignorar se estiver acima da pontuação do formato personalizado", "BypassDelayIfAboveCustomFormatScoreHelpText": "Ignorar quando o lançamento tiver uma pontuação mais alta que a pontuação mínima configurada do formato personalizado", "BypassDelayIfAboveCustomFormatScoreMinimumScore": "Pontuação mínima do formato personalizado", @@ -236,7 +236,7 @@ "CloneAutoTag": "Clonar etiqueta automática", "CloneCondition": "Clonar condição", "CloneCustomFormat": "Clonar formato personalizado", - "CloneImportList": "Clonar Lista de Importação", + "CloneImportList": "Clonar lista de importação", "CloneIndexer": "Clonar indexador", "CloneProfile": "Clonar perfil", "Close": "Fechar", @@ -257,8 +257,8 @@ "ConnectSettingsSummary": "Notificações, conexões com servidores/reprodutores de mídia e scripts personalizados", "Connection": "Conexão", "ConnectionLost": "Conexão perdida", - "ConnectionLostReconnect": "O {appName} tentará se conectar automaticamente, ou você pode clicar em Recarregar abaixo.", - "ConnectionLostToBackend": "O {appName} perdeu a conexão com o backend e precisa ser recarregado para restaurar a funcionalidade.", + "ConnectionLostReconnect": "O {appName} tentará se conectar automaticamente ou você pode clicar em Recarregar abaixo.", + "ConnectionLostToBackend": "O {appName} perdeu a conexão com o backend e precisará ser recarregado para restaurar a funcionalidade.", "ConnectionSettingsUrlBaseHelpText": "Adiciona um prefixo ao URL {connectionName}, como {url}", "Connections": "Conexões", "ConnectionsLoadError": "Não foi possível carregar Conexões", @@ -378,14 +378,14 @@ "DeleteReleaseProfileMessageText": "Tem certeza de que deseja excluir o perfil de lançamento \"{name}\"?", "DeleteRemotePathMapping": "Excluir mapeamento de caminho remoto", "DeleteRemotePathMappingMessageText": "Tem certeza de que deseja excluir este mapeamento de caminho remoto?", - "DeleteSelected": "Excluir selecionado(s)", + "DeleteSelected": "Excluir selecionado", "DeleteSelectedCustomFormats": "Excluir formato(s) personalizado(s)", "DeleteSelectedCustomFormatsMessageText": "Tem certeza que deseja excluir o(s) {count} formato(s) personalizado(s) selecionado(s)?", "DeleteSelectedDownloadClients": "Excluir cliente(s) de download", "DeleteSelectedDownloadClientsMessageText": "Tem certeza de que deseja excluir o(s) {count} cliente(s) de download selecionado(s)?", "DeleteSelectedEpisodeFiles": "Excluir arquivos de episódios selecionados", "DeleteSelectedEpisodeFilesHelpText": "Tem certeza de que deseja excluir os arquivos de episódios selecionados?", - "DeleteSelectedImportListExclusionsMessageText": "Tem certeza de que deseja remover as exclusões de lista de importação selecionadas?", + "DeleteSelectedImportListExclusionsMessageText": "Tem certeza de que deseja remover as exclusões selecionadas da lista de importação?", "DeleteSelectedImportLists": "Excluir lista(s) de importação", "DeleteSelectedImportListsMessageText": "Tem certeza de que deseja excluir a(s) {count} lista(s) de importação selecionada(s)?", "DeleteSelectedIndexers": "Excluir indexador(es)", @@ -476,7 +476,7 @@ "DownloadClientFreeboxSettingsPortHelpText": "Porta usada para acessar a interface do Freebox, o padrão é \"{port}\"", "DownloadClientFreeboxUnableToReachFreebox": "Não foi possível acessar a API do Freebox. Verifique as configurações \"Host\", \"Port\" (Porta) ou \"Use SSL\" (Usar SSL). (Erro: {exceptionMessage})", "DownloadClientFreeboxUnableToReachFreeboxApi": "Não foi possível acessar a API do Freebox. Verifique o URL base e a versão na configuração \"URL da API\".", - "DownloadClientItemErrorMessage": "{clientName} está relatando um erro: {message}", + "DownloadClientItemErrorMessage": "O {clientName} está relatando um erro: {message}", "DownloadClientNzbVortexMultipleFilesMessage": "O download contém vários arquivos e não está em uma pasta de trabalho: {outputPath}", "DownloadClientNzbgetSettingsAddPausedHelpText": "Esta opção requer pelo menos a versão 16.0 do NzbGet", "DownloadClientNzbgetValidationKeepHistoryOverMax": "A configuração KeepHistory do NzbGet deve ser menor que 25.000", @@ -719,7 +719,7 @@ "Failed": "Falhou", "FailedAt": "Falha em: {date}", "FailedToFetchSettings": "Falha ao obter configurações", - "FailedToFetchUpdates": "Falha ao obter atualizações", + "FailedToFetchUpdates": "Falha ao buscar atualizações", "FailedToLoadCustomFiltersFromApi": "Falha ao carregar filtros personalizados da API", "FailedToLoadQualityProfilesFromApi": "Falha ao carregar perfis de qualidade da API", "FailedToLoadSeriesFromApi": "Falha ao carregar a série da API", @@ -740,7 +740,7 @@ "FileManagement": "Gerenciamento de arquivos", "FileNameTokens": "Tokens de nome de arquivo", "FileNames": "Nomes de arquivos", - "FileSize": "Tamanho do Arquivo", + "FileSize": "Tamanho do arquivo", "Filename": "Nome do arquivo", "Files": "Arquivos", "Filter": "Filtro", @@ -816,7 +816,7 @@ "Health": "Integridade", "HealthIssue": "1 problema de saúde", "HealthIssues": "{count} problemas de saúde", - "HealthMessagesInfoBox": "Para saber mais sobre a causa dessas mensagens de verificação de integridade, clique no link da wiki (ícone de livro) no final da linha ou verifique os [logs]({link}). Se tiver dificuldade em interpretar essas mensagens, entre em contato com nosso suporte nos links abaixo.", + "HealthMessagesInfoBox": "Para saber mais sobre a causa dessas mensagens de verificação de integridade, clique no link da wiki (ícone de livro) no final da linha, ou verifique os [logs]({link}). Se tiver dificuldade em interpretar essas mensagens, entre em contato com nosso suporte nos links abaixo.", "Here": "aqui", "HiddenClickToShow": "Oculto, clique para mostrar", "HideAdvanced": "Ocultar opções avançadas", @@ -1041,7 +1041,7 @@ "IndexerSettingsApiPath": "Caminho da API", "IndexerSettingsApiPathHelpText": "Caminho para a API, geralmente {url}", "IndexerSettingsApiUrl": "URL da API", - "IndexerSettingsApiUrlHelpText": "Não mude isso a menos que você saiba o que está fazendo. Já que sua chave da API será enviada para esse host.", + "IndexerSettingsApiUrlHelpText": "Não mude isso a menos que você saiba o que está fazendo, já que sua chave da API será enviada para esse host.", "IndexerSettingsCategories": "Categorias", "IndexerSettingsCategoriesHelpText": "Lista suspensa, deixe em branco para desativar séries padrão/diárias", "IndexerSettingsCookie": "Cookie", @@ -1386,7 +1386,7 @@ "NotificationTriggersHelpText": "Selecione quais eventos devem acionar esta notificação", "NotificationsAppriseSettingsConfigurationKey": "Chave de configuração do Apprise", "NotificationsAppriseSettingsConfigurationKeyHelpText": "Chave de configuração para a solução de armazenamento persistente. Deixe em branco se usar URLs sem estado.", - "NotificationsAppriseSettingsIncludePoster": "Incluir Pôster", + "NotificationsAppriseSettingsIncludePoster": "Incluir pôster", "NotificationsAppriseSettingsIncludePosterHelpText": "Incluir pôster na mensagem", "NotificationsAppriseSettingsNotificationType": "Tipo de notificação do Apprise", "NotificationsAppriseSettingsPasswordHelpText": "Senha de autenticação HTTP básica", @@ -1487,9 +1487,9 @@ "NotificationsPushBulletSettingsDeviceIdsHelpText": "Lista de IDs de dispositivos (deixe em branco para enviar para todos os dispositivos)", "NotificationsPushcutSettingsApiKeyHelpText": "As chaves da API podem ser gerenciadas na visualização da conta do aplicativo Pushcut", "NotificationsPushcutSettingsIncludePoster": "Incluir pôster", - "NotificationsPushcutSettingsIncludePosterHelpText": "Incluir pôster com notificação", + "NotificationsPushcutSettingsIncludePosterHelpText": "Incluir pôster na notificação", "NotificationsPushcutSettingsMetadataLinks": "Links de metadados", - "NotificationsPushcutSettingsMetadataLinksHelpText": "Adicionar links para os metadados da série ao enviar notificações", + "NotificationsPushcutSettingsMetadataLinksHelpText": "Adicionar links aos metadados da série ao enviar notificações", "NotificationsPushcutSettingsNotificationName": "Nome da Notificação", "NotificationsPushcutSettingsNotificationNameHelpText": "Nome da notificação na aba Notificações do aplicativo Pushcut", "NotificationsPushcutSettingsTimeSensitive": "Urgente", @@ -1502,7 +1502,7 @@ "NotificationsPushoverSettingsRetryHelpText": "Intervalo para repetir o envio de alertas de emergência, mínimo de 30 segundos", "NotificationsPushoverSettingsSound": "Som", "NotificationsPushoverSettingsSoundHelpText": "Som da notificação, deixe em branco para usar o padrão", - "NotificationsPushoverSettingsTtl": "Tempo para Viver", + "NotificationsPushoverSettingsTtl": "Tempo de vida", "NotificationsPushoverSettingsTtlHelpText": "Tempo em segundos antes da mensagem expirar. Defina como 0 para duração ilimitada", "NotificationsPushoverSettingsUserKey": "Chave do usuário", "NotificationsSendGridSettingsApiKeyHelpText": "A chave da API gerada pelo SendGrid", @@ -1583,8 +1583,8 @@ "OnApplicationUpdate": "Na Atualização do Aplicativo", "OnEpisodeFileDelete": "Ao Excluir o Arquivo do Episódio", "OnEpisodeFileDeleteForUpgrade": "No Arquivo do Episódio Excluir para Atualização", - "OnFileImport": "Ao Importar o Arquivo", - "OnFileUpgrade": "Ao Atualizar o Arquivo", + "OnFileImport": "Ao importar o arquivo", + "OnFileUpgrade": "Ao atualizar o arquivo", "OnGrab": "Ao obter", "OnHealthIssue": "Ao Problema de Saúde", "OnHealthRestored": "Com a Saúde Restaurada", @@ -1651,7 +1651,7 @@ "Permissions": "Permissões", "Port": "Porta", "PortNumber": "Número da Porta", - "PostImportCategory": "Categoria Pós-Importação", + "PostImportCategory": "Categoria pós-importação", "PosterOptions": "Opções do pôster", "PosterSize": "Tamanho do Pôster", "Posters": "Pôsteres", @@ -1688,7 +1688,7 @@ "ProxyResolveIpHealthCheckMessage": "Falha ao resolver o endereço IP do host de proxy configurado {proxyHostName}", "ProxyType": "Tipo de Proxy", "ProxyUsernameHelpText": "Você só precisa digitar um nome de usuário e senha se for necessário. Caso contrário, deixe-os em branco.", - "PublishedDate": "Data de Publicação", + "PublishedDate": "Data de publicação", "Qualities": "Qualidades", "QualitiesHelpText": "As qualidades mais altas na lista são mais preferidas (mesmo quando não são marcadas). As qualidades dentro do mesmo grupo são iguais. Somente qualidades marcadas são desejadas", "QualitiesLoadError": "Não foi possível carregar qualidades", @@ -1717,7 +1717,7 @@ "ReadTheWikiForMoreInformation": "Leia o Wiki para mais informações", "Real": "Real", "Reason": "Razão", - "RecentChanges": "Mudanças Recentes", + "RecentChanges": "Mudanças recentes", "RecentFolders": "Pastas Recentes", "RecycleBinUnableToWriteHealthCheckMessage": "Não é possível gravar na pasta da lixeira configurada: {path}. Certifique-se de que este caminho exista e seja gravável pelo usuário executando o {appName}", "RecyclingBin": "Lixeira", @@ -1740,14 +1740,14 @@ "ReleaseGroupFootNote": "Opcionalmente, controle o truncamento para um número máximo de bytes, incluindo reticências (`...`). Truncar do final (por exemplo, `{Release Group:30}`) ou do início (por exemplo, `{Release Group:-30}`) é suportado.`).", "ReleaseGroups": "Grupos do Lançamento", "ReleaseHash": "Hash do Lançamento", - "ReleaseProfile": "Perfil de Lançamento", + "ReleaseProfile": "Perfil de lançamento", "ReleaseProfileExcludedTagSeriesHelpText": "Os perfis de lançamento não se aplicarão a séries com pelo menos uma etiqueta correspondente.", "ReleaseProfileIndexerHelpText": "Especifique a qual indexador o perfil se aplica", "ReleaseProfileIndexerHelpTextWarning": "Definir um indexador específico em um perfil de lançamento fará com que esse perfil seja aplicado apenas a lançamentos desse indexador.", "ReleaseProfileTagSeriesHelpText": "Os perfis de lançamento serão aplicados a séries com pelo menos uma tag correspondente. Deixe em branco para aplicar a todas as séries", "ReleaseProfiles": "Perfis de Lançamentos", "ReleaseProfilesLoadError": "Não foi possível carregar perfis de lançamentos", - "ReleasePush": "Impulsionar Lançamento", + "ReleasePush": "Impulsionar lançamento", "ReleaseRejected": "Lançamento Rejeitado", "ReleaseSceneIndicatorAssumingScene": "Assumindo a Numeração da Scene.", "ReleaseSceneIndicatorAssumingTvdb": "Assumindo a numeração TVDB.", @@ -1755,7 +1755,7 @@ "ReleaseSceneIndicatorSourceMessage": "Existem lançamentos de {message} com numeração ambígua, incapaz de identificar o episódio de forma confiável.", "ReleaseSceneIndicatorUnknownMessage": "A numeração varia para este episódio e o lançamento não corresponde a nenhum mapeamento conhecido.", "ReleaseSceneIndicatorUnknownSeries": "Episódio ou série desconhecida.", - "ReleaseSource": "Fonte do Lançamento", + "ReleaseSource": "Origem do lançamento", "ReleaseTitle": "Título do Lançamento", "ReleaseType": "Tipo de Lançamento", "Reload": "Recarregar", @@ -1799,7 +1799,7 @@ "RemoveQueueItemRemovalMethod": "Método de Remoção", "RemoveQueueItemRemovalMethodHelpTextWarning": "'Remover do cliente de download' removerá o download e os arquivos do cliente de download.", "RemoveQueueItemsRemovalMethodHelpTextWarning": "'Remover do Cliente de Download' removerá os downloads e os arquivos do cliente de download.", - "RemoveRootFolder": "Remover Pasta Raiz", + "RemoveRootFolder": "Remover pasta raiz", "RemoveRootFolderWithSeriesMessageText": "Tem certeza de que deseja remover a pasta raiz '{path}'? Arquivos e pastas não serão excluídos do disco e as séries nesta pasta raiz não serão removidas de {appName}.", "RemoveSelected": "Remover Selecionado", "RemoveSelectedBlocklistMessageText": "Tem certeza de que deseja remover os itens selecionados da lista de bloqueio?", @@ -1833,7 +1833,7 @@ "RescanSeriesFolderAfterRefresh": "Verificar novamente a pasta da série após a atualização", "Reset": "Redefinir", "ResetAPIKey": "Redefinir chave de API", - "ResetAPIKeyMessageText": "Tem certeza de que deseja redefinir sua chave de API?", + "ResetAPIKeyMessageText": "Tem certeza de que deseja redefinir sua chave da API?", "ResetDefinitionTitlesHelpText": "Redefinir títulos de definição e valores", "ResetDefinitions": "Redefinir definições", "ResetQualityDefinitions": "Redefinir Definições de Qualidade", @@ -1841,7 +1841,7 @@ "Restart": "Reiniciar", "RestartLater": "Reiniciarei mais tarde", "RestartNow": "Reiniciar Agora", - "RestartReloadNote": "Observação: o {appName} reiniciará automaticamente e recarregará a IU durante o processo de restauração.", + "RestartReloadNote": "Observação: o {appName} reiniciará automaticamente e recarregará a interface durante o processo de restauração.", "RestartRequiredHelpTextWarning": "Requer reinicialização para entrar em vigor", "RestartRequiredToApplyChanges": "{appName} requer reinicialização para aplicar as alterações. Deseja reiniciar agora?", "RestartRequiredWindowsService": "Dependendo de qual usuário está executando o serviço {appName}, pode ser necessário reiniciar {appName} como administrador uma vez antes que o serviço seja iniciado automaticamente.", @@ -1859,7 +1859,7 @@ "RootFolderMultipleEmptyHealthCheckMessage": "Múltiplas pastas raiz estão vazias: {rootFolderPaths}", "RootFolderMultipleMissingHealthCheckMessage": "Faltam várias pastas raiz: {rootFolderPaths}", "RootFolderPath": "Caminho da Pasta Raiz", - "RootFolderSelectFreeSpace": "{freeSpace} Livre", + "RootFolderSelectFreeSpace": "{freeSpace} livre(s)", "RootFolders": "Pastas Raiz", "RootFoldersLoadError": "Não foi possível carregar as pastas raiz", "Rss": "RSS", @@ -2017,7 +2017,7 @@ "SizeLimit": "Limite de Tamanho", "SizeOnDisk": "Tamanho no disco", "SkipFreeSpaceCheck": "Ignorar verificação de espaço livre", - "SkipFreeSpaceCheckHelpText": "Usar quando {appName} não consegue detectar espaço livre em sua pasta raiz", + "SkipFreeSpaceCheckHelpText": "Usar quando o {appName} não conseguir detectar espaço livre em sua pasta raiz", "SkipRedownload": "Ignorar o Redownload", "SkipRedownloadHelpText": "Impede que o {appName} tente baixar uma versão alternativa para este item", "Small": "Pequeno", @@ -2144,15 +2144,15 @@ "UiSettingsLoadError": "Não foi possível carregar as configurações da UI", "UiSettingsSummary": "Opções de calendário, data e cores para daltônicos", "Umask": "Desmascarar", - "Umask750Description": "{octal} - gravação do proprietário, leitura do grupo", - "Umask755Description": "{octal} - Escrita do proprietário, todos os outros lêem", - "Umask770Description": "{octal} - proprietário e gravação do grupo", - "Umask775Description": "{octal} - gravação do proprietário e do grupo, leitura de outros", - "Umask777Description": "{octal} - Todo mundo escreve", + "Umask750Description": "{octal} - Proprietário pode gravar, grupo pode ler", + "Umask755Description": "{octal} - Proprietário pode gravar, todos os outros podem ler", + "Umask770Description": "{octal} - Proprietário e grupo podem gravar", + "Umask775Description": "{octal} - Proprietário e grupo podem gravar, outros podem ler", + "Umask777Description": "{octal} - Todos podem gravar", "UnableToImportAutomatically": "Não foi possível importar automaticamente", "UnableToLoadAutoTagging": "Não foi possível carregar as etiquetas automáticas", "UnableToLoadBackups": "Não foi possível carregar os backups", - "UnableToUpdateSonarrDirectly": "Incapaz de atualizar o {appName} diretamente,", + "UnableToUpdateSonarrDirectly": "Não foi possível atualizar o {appName} diretamente,", "Unavailable": "Indisponível", "Underscore": "Sublinhar", "Ungroup": "Desagrupar", @@ -2175,13 +2175,13 @@ "Upcoming": "Por vir", "UpcomingSeriesDescription": "A série foi anunciada, mas ainda não há data exata para ir ao ar", "UpdateAll": "Atualizar Tudo", - "UpdateAppDirectlyLoadError": "Incapaz de atualizar o {appName} diretamente,", + "UpdateAppDirectlyLoadError": "Não foi possível atualizar o {appName} diretamente,", "UpdateAutomaticallyHelpText": "Baixe e instale atualizações automaticamente. Você ainda poderá instalar a partir do Sistema: Atualizações", "UpdateAvailableHealthCheckMessage": "Nova atualização está disponível: {version}", "UpdateFiltered": "Atualização Filtrada", "UpdateMechanismHelpText": "Usar o atualizador integrado do {appName} ou um script", "UpdateMonitoring": "Atualizar Monitoramento", - "UpdatePath": "Caminho da Atualização", + "UpdatePath": "Caminho da atualização", "UpdateScriptPathHelpText": "Caminho para um script personalizado que usa um pacote de atualização extraído e lida com o restante do processo de atualização", "UpdateSelected": "Atualizar Selecionado", "UpdateSeriesPath": "Atualizar Caminho da Série", @@ -2213,7 +2213,7 @@ "UsenetDelayHelpText": "Atraso em minutos para esperar antes de pegar um lançamento da Usenet", "UsenetDelayTime": "Atraso da Usenet: {usenetDelay}", "UsenetDisabled": "Usenet Desabilitada", - "UserInvokedSearch": "Pesquisa Invocada pelo Usuário", + "UserInvokedSearch": "Pesquisa iniciada pelo usuário", "UserRejectedExtensions": "Extensões de Arquivos Rejeitadas Adicionais", "UserRejectedExtensionsHelpText": "Lista separada por vírgulas de extensões de arquivos para falhar (Falha em downloads também precisa ser habilitado por indexador)", "UserRejectedExtensionsTextsExamples": "Exemplos: '.ext, .xyz' or 'ext,xyz'", @@ -2230,7 +2230,7 @@ "WantMoreControlAddACustomFormat": "Quer mais controle sobre quais downloads são preferidos? Adicione um [Formato Personalizado](/settings/customformats)", "Wanted": "Procurado", "Warn": "Alerta", - "Warning": "Cuidado", + "Warning": "Aviso", "Wednesday": "Quarta-feira", "Week": "Semana", "WeekColumnHeader": "Cabeçalho da Coluna da Semana", diff --git a/src/NzbDrone.Core/Localization/Core/ro.json b/src/NzbDrone.Core/Localization/Core/ro.json index 9d2846cce..95fccbad9 100644 --- a/src/NzbDrone.Core/Localization/Core/ro.json +++ b/src/NzbDrone.Core/Localization/Core/ro.json @@ -24,6 +24,7 @@ "AddIndexerImplementation": "Adăugați Indexator - {implementationName}", "AddList": "Adaugă listă", "AddListError": "Nu se poate adăuga o nouă listă, vă rugăm să încercați din nou.", + "AddListExclusion": "Adăugați excluderea listei", "AddListExclusionError": "Imposibil de adăugat o nouă listă de excludere, încercați din nou.", "AddNew": "Adaugă nou", "AddNewRestriction": "Adăugați o restricție nouă", @@ -39,6 +40,7 @@ "AfterManualRefresh": "După reîmprospătarea manuală", "Age": "Vechime", "AirDate": "Data de difuzare", + "AirDateRestriction": "Respinge lansările ne difuzate", "All": "Toate", "AllResultsAreHiddenByTheAppliedFilter": "Toate rezultatele sunt ascunse de filtrul aplicat", "AllTitles": "Toate titlurile", @@ -49,6 +51,7 @@ "AnalyticsEnabledHelpText": "Trimiteți informații anonime privind utilizarea și erorile către serverele {appName}. Aceasta include informații despre browserul dvs., ce pagini WebUI {appName} utilizați, raportarea erorilor, precum și sistemul de operare și versiunea de execuție. Vom folosi aceste informații pentru a acorda prioritate caracteristicilor și remedierilor de erori.", "Any": "Oricare", "ApiKey": "Cheie API", + "ApiKeyValidationHealthCheckMessage": "Te rugăm să actualizezi cheia API astfel încât să aibă cel puțin {length} caractere. Poți face acest lucru din setări sau din fișierul de configurare", "AppDataDirectory": "Directorul AppData", "AppDataLocationHealthCheckMessage": "Pentru a preveni ștergerea AppData, update-ul nu este posibil", "AppUpdated": "{appName} actualizat", @@ -71,14 +74,17 @@ "AuthenticationRequired": "Autentificare necesara", "AuthenticationRequiredPasswordHelpTextWarning": "Introduceți o parolă nouă", "AuthenticationRequiredUsernameHelpTextWarning": "Introduceți un nou nume de utilizator", + "AutoTaggingSpecificationOriginalCountry": "Țară", "AutomaticAdd": "Adăugare automată", "Backup": "Copie de rezervă", "BackupNow": "Fă o copie de rezervă", "Backups": "Copii de rezervă", "BeforeUpdate": "Înainte de actualizare", "BlocklistLoadError": "Imposibil de încărcat lista neagră", + "Blocklisted": "Blocat", + "BlocklistedAt": "Blocată la {date}", "Calendar": "Calendar", - "CalendarOptions": "Setări Calendar", + "CalendarOptions": "Opțiuni calendar", "Cancel": "Anulează", "CancelPendingTask": "Sigur doriți să anulați această sarcină în așteptare?", "CertificateValidationHelpText": "Modificați cât de strictă este validarea certificării HTTPS. Nu schimbați dacă nu înțelegeți riscurile.", @@ -135,6 +141,7 @@ "DownloadClientsLoadError": "Nu se pot încărca clienții de descărcare", "DownloadIgnored": "Descărcarea ignorată", "Edit": "Editează", + "Empty": "Gol", "EnableAutomaticSearch": "Activați căutarea automată", "EnableInteractiveSearch": "Activați căutarea interactivă", "Enabled": "Activat", @@ -148,8 +155,10 @@ "Events": "Evenimente", "Exception": "Excepție", "ExistingTag": "Etichetă existentă", + "ExpandAll": "Extinde tot", "ExportCustomFormat": "Exportați formatul personalizat", "Failed": "Eșuat", + "FailedAt": "Eșuat la: {date}", "False": "Fals", "FileNameTokens": "Jetoane pentru nume de fișier", "Filename": "Nume fișier", @@ -162,15 +171,21 @@ "FormatAgeMinutes": "minute", "Formats": "Formate", "FreeSpace": "Spațiu Liber", + "Friday": "Vineri", "FullColorEvents": "Evenimente pline de culoare", + "FullSeason": "Sezon full", "General": "General", "GeneralSettings": "Setări generale", "Genres": "Genuri", + "GrabbedAt": "Preluat la: {date}", + "HasMissingSeason": "Are sezon lipsă", + "HasUnmonitoredSeason": "Are sezon nemonitorizat", "Health": "Sănătate", "HiddenClickToShow": "Ascuns, faceți clic pentru afișare", "HideAdvanced": "Ascunde Avansat", "History": "Istoric", "HistoryLoadError": "Istoricul nu poate fi încărcat", + "HistoryModalHeaderSeason": "Istoric {season}", "HomePage": "Pagina principală", "Ignored": "Ignorat", "Implementation": "Implementarea", @@ -180,43 +195,87 @@ "InteractiveImportNoFilesFound": "Nu au fost găsite fișiere video în folderul selectat", "InteractiveImportNoImportMode": "Un mod de import trebuie selectat", "InteractiveImportNoQuality": "Calitatea trebuie aleasă pentru fiecare fișier selectat", + "InteractiveSearchGrabError": "NU s-a putut adăuga în coada de descărcare", "LanguagesLoadError": "Nu se pot încărca limbile", "LastDuration": "Ultima durată", + "Links": "Linkuri", "LongDateFormat": "Format de dată lungă", + "ManageEpisodes": "Gestionează episoadele", + "ManageEpisodesSeason": "Gestionează fișierele episoadelor din acest sezon", + "MonitorFirstSeason": "Primul sezon", + "MonitorLastSeason": "Ultimul sezon", + "MonitorNoNewSeasons": "Nu există sezoane noi", + "Monitored": "Monitorizat", "MoreInfo": "Mai multe informații", + "NoEpisodesInThisSeason": "Nu exista episoade în acest sezon", "NoHistoryFound": "Nu s-a găsit istoric", + "NoSeasons": "Fără sezoane", "NotificationStatusSingleClientHealthCheckMessage": "Notificări indisponibile datorită erorilor: {notificationNames}", "OnFileImport": "La import fișier", "OnFileUpgrade": "La actualizare fișier", + "OneSeason": "1 sezon", "Or": "sau", + "OriginalCountry": "Țară originală", "Parse": "Analiza", "ParseModalErrorParsing": "Eroare la analizare, încercați din nou.", "ParseModalUnableToParse": "Nu se poate analiza titlul furnizat, vă rugăm să încercați din nou.", + "PartialSeason": "Sezon parțial", "Pending": "În așteptare", "PendingDownloadClientUnavailable": "În așteptare - Clientul de descărcare nu este disponibil", + "PreviewRenameSeason": "Previzualizează redenumirea pentru acest sezon", "QualitiesLoadError": "Nu se pot încărca calitățile", "QueueLoadError": "Nu s-a putut încărca coada de așteptare", "ReleaseProfilesLoadError": "Nu se pot încărca profilurile", "RemoveSelectedBlocklistMessageText": "Sigur doriți să eliminați elementele selectate din lista neagră?", + "RootFolderEmptyHealthCheckMessage": "Folder root gol: {rootFolderPath}", + "RootFolderMultipleEmptyHealthCheckMessage": "Mai multe foldere root sunt goale: {rootFolderPaths}", + "Saturday": "Sâmbătă", + "SearchForQuery": "Caută {query}", + "Season": "Sezon", + "SeasonCount": "Număr de sezoane", + "SeasonDetails": "Detalii sezon", + "SeasonFinale": "Finalul sezonului", + "SeasonFolder": "Folder sezon", + "SeasonFolderFormat": "Format folder sezon", + "SeasonInformation": "Informații sezon", + "SeasonNumber": "Număr sezon", + "SeasonNumberToken": "Sezon {seasonNumber}", + "SeasonPack": "Pachet sezon", + "SeasonPremiere": "Premiera sezonului", + "SeasonPremieresOnly": "Doar premierele sezonului", + "Seasons": "Sezoane", + "SeasonsMonitoredAll": "Toate", + "SeasonsMonitoredNone": "Niciunul", + "SeasonsMonitoredStatus": "Sezoane monitorizate", "SelectDownloadClientModalTitle": "{modalTitle} - Selectați clientul de descărcare", "SelectDropdown": "Selectați...", "SelectFolderModalTitle": "{modalTitle} - Selectați folder", "SelectLanguageModalTitle": "{modalTitle} - Selectează limba", + "SelectSeason": "Selectați sezonul", + "SelectSeasonModalTitle": "{modalTitle} - Selectați sezonul", "SetReleaseGroupModalTitle": "{modalTitle} - Setați grupul de lansare", "ShortDateFormat": "Format scurt de dată", "ShowAdvanced": "Arată setări avansate", "ShowRelativeDates": "Afișați datele relative", "ShowRelativeDatesHelpText": "Afișați datele relative (Azi / Ieri / etc) sau absolute", + "ShowSeasonCount": "Afișează numărul de sezoane", "ShownClickToHide": "Afișat, faceți clic pentru a ascunde", + "StandardEpisodeTypeFormat": "Numerele sezonului si episodului ({format})", "TablePageSize": "Mărimea Paginii", "TablePageSizeHelpText": "Numărul de articole de afișat pe fiecare pagină", + "Thursday": "Joi", "TimeFormat": "Format ora", + "TimeZone": "Fus orar", "True": "Adevărat", + "Tuesday": "Marți", "Umask": "Umask", "Unknown": "Necunoscut", "UnknownEventTooltip": "Eveniment necunoscut", "UpdateAvailableHealthCheckMessage": "O nouă versiune este disponibilă: {version}", + "UseSeasonFolder": "Folosește folderul sezonului", + "UseSeasonFolderHelpText": "Sortează episoadele în foldere de sezon", "Warning": "Avertisment", + "Wednesday": "Miercuri", "Week": "Săptămână", "WeekColumnHeader": "Antetul coloanei săptămânii", "WhatsNew": "Ce-i nou?", From e56dd15928253552584e6e170828cbddaec5bb6d Mon Sep 17 00:00:00 2001 From: Sonarr <development@sonarr.tv> Date: Mon, 16 Mar 2026 00:29:43 +0000 Subject: [PATCH 040/110] Automated API Docs update ignore-downstream --- src/Sonarr.Api.V5/openapi.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Sonarr.Api.V5/openapi.json b/src/Sonarr.Api.V5/openapi.json index d94c5d6a0..ee9cf2509 100644 --- a/src/Sonarr.Api.V5/openapi.json +++ b/src/Sonarr.Api.V5/openapi.json @@ -7518,7 +7518,8 @@ "notQualityUpgrade", "notRevisionUpgrade", "notCustomFormatUpgrade", - "notCustomFormatUpgradeAfterRename" + "notCustomFormatUpgradeAfterRename", + "multiSeason" ], "type": "string" }, From c64f4adfc435347da071e2fbd69847c9bb3e64ff Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sun, 1 Mar 2026 09:05:56 -0800 Subject: [PATCH 041/110] New: Delete files for Select Series Closes #5110 --- frontend/src/Commands/CommandNames.ts | 1 + .../Delete/DeleteSeriesModalContent.tsx | 86 +++--------------- .../Delete/Files/DeleteSeriesFilesModal.tsx | 22 +++++ .../Files/DeleteSeriesFilesModalContent.css | 23 +++++ .../DeleteSeriesFilesModalContent.css.d.ts | 11 +++ .../Files/DeleteSeriesFilesModalContent.tsx | 66 ++++++++++++++ .../Index/Select/Delete/SeriesDeleteList.tsx | 73 +++++++++++++++ .../Select/Delete/useSelectedSeriesStats.ts | 45 ++++++++++ .../Index/Select/SeriesIndexSelectFooter.tsx | 27 ++++++ .../DeleteEpisodeFileFixture.cs | 5 ++ src/NzbDrone.Core/Localization/Core/en.json | 3 + .../Commands/DeleteSeriesFilesCommand.cs | 18 ++++ .../MediaFiles/MediaFileDeletionService.cs | 88 ++++++++++++++++++- src/NzbDrone.Core/Tv/RefreshSeriesService.cs | 2 +- .../EpisodeFiles/EpisodeFileController.cs | 2 +- .../Queue/QueueActionController.cs | 4 +- .../Series/SeriesEditorController.cs | 2 +- 17 files changed, 399 insertions(+), 79 deletions(-) create mode 100644 frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModal.tsx create mode 100644 frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.css create mode 100644 frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.css.d.ts create mode 100644 frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.tsx create mode 100644 frontend/src/Series/Index/Select/Delete/SeriesDeleteList.tsx create mode 100644 frontend/src/Series/Index/Select/Delete/useSelectedSeriesStats.ts create mode 100644 src/NzbDrone.Core/MediaFiles/Commands/DeleteSeriesFilesCommand.cs diff --git a/frontend/src/Commands/CommandNames.ts b/frontend/src/Commands/CommandNames.ts index 8cee7d38c..301a72d1f 100644 --- a/frontend/src/Commands/CommandNames.ts +++ b/frontend/src/Commands/CommandNames.ts @@ -5,6 +5,7 @@ enum CommandNames { ClearLog = 'ClearLog', CutoffUnmetEpisodeSearch = 'CutoffUnmetEpisodeSearch', DeleteLogFiles = 'DeleteLogFiles', + DeleteSeriesFiles = 'DeleteSeriesFiles', DeleteUpdateLogFiles = 'DeleteUpdateLogFiles', DownloadedEpisodesScan = 'DownloadedEpisodesScan', EpisodeSearch = 'EpisodeSearch', diff --git a/frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.tsx b/frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.tsx index ae270078a..03c58ebeb 100644 --- a/frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.tsx +++ b/frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.tsx @@ -1,6 +1,4 @@ -import { orderBy } from 'lodash'; -import React, { useCallback, useMemo, useState } from 'react'; -import { useSelect } from 'App/Select/SelectContext'; +import React, { useCallback, useState } from 'react'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; @@ -10,15 +8,15 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds } from 'Helpers/Props'; -import Series from 'Series/Series'; import { setSeriesDeleteOptions, useSeriesDeleteOptions, } from 'Series/seriesOptionsStore'; -import useSeries, { useBulkDeleteSeries } from 'Series/useSeries'; +import { useBulkDeleteSeries } from 'Series/useSeries'; import { InputChanged } from 'typings/inputs'; -import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; +import SeriesDeleteList from './SeriesDeleteList'; +import useSelectedSeriesStats from './useSelectedSeriesStats'; import styles from './DeleteSeriesModalContent.css'; export interface DeleteSeriesModalContentProps { @@ -29,19 +27,10 @@ function DeleteSeriesModalContent({ onModalClose, }: DeleteSeriesModalContentProps) { const { addImportListExclusion } = useSeriesDeleteOptions(); - const { data: allSeries } = useSeries(); const { bulkDeleteSeries } = useBulkDeleteSeries(); const [deleteFiles, setDeleteFiles] = useState(false); - const { useSelectedIds } = useSelect<Series>(); - const seriesIds = useSelectedIds(); - - const series = useMemo((): Series[] => { - const seriesList = seriesIds.map((id) => { - return allSeries.find((s) => s.id === id); - }) as Series[]; - - return orderBy(seriesList, ['sortTitle']); - }, [allSeries, seriesIds]); + const { series, seriesIds, totalEpisodeFileCount, totalSizeOnDisk } = + useSelectedSeriesStats(); const onDeleteFilesChange = useCallback( ({ value }: InputChanged<boolean>) => { @@ -78,23 +67,6 @@ function DeleteSeriesModalContent({ onModalClose, ]); - const { totalEpisodeFileCount, totalSizeOnDisk } = useMemo(() => { - return series.reduce( - (acc, { statistics = {} }) => { - const { episodeFileCount = 0, sizeOnDisk = 0 } = statistics; - - acc.totalEpisodeFileCount += episodeFileCount; - acc.totalSizeOnDisk += sizeOnDisk; - - return acc; - }, - { - totalEpisodeFileCount: 0, - totalSizeOnDisk: 0, - } - ); - }, [series]); - return ( <ModalContent onModalClose={onModalClose}> <ModalHeader>{translate('DeleteSelectedSeries')}</ModalHeader> @@ -145,45 +117,13 @@ function DeleteSeriesModalContent({ })} </div> - <ul> - {series.map(({ title, path, statistics = {} }) => { - const { episodeFileCount = 0, sizeOnDisk = 0 } = statistics; - - return ( - <li key={title}> - <span>{title}</span> - - {deleteFiles && ( - <span> - <span className={styles.pathContainer}> - -<span className={styles.path}>{path}</span> - </span> - - {!!episodeFileCount && ( - <span className={styles.statistics}> - ( - {translate('DeleteSeriesFolderEpisodeCount', { - episodeFileCount, - size: formatBytes(sizeOnDisk), - })} - ) - </span> - )} - </span> - )} - </li> - ); - })} - </ul> - - {deleteFiles && !!totalEpisodeFileCount ? ( - <div className={styles.deleteFilesMessage}> - {translate('DeleteSeriesFolderEpisodeCount', { - episodeFileCount: totalEpisodeFileCount, - size: formatBytes(totalSizeOnDisk), - })} - </div> - ) : null} + <SeriesDeleteList + series={series} + showFileDetails={deleteFiles} + totalEpisodeFileCount={totalEpisodeFileCount} + totalSizeOnDisk={totalSizeOnDisk} + styles={styles} + /> </ModalBody> <ModalFooter> diff --git a/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModal.tsx b/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModal.tsx new file mode 100644 index 000000000..2cac5b3bd --- /dev/null +++ b/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DeleteSeriesModalContent, { + DeleteSeriesFilesModalContentProps, +} from './DeleteSeriesFilesModalContent'; + +interface DeleteSeriesFilesModalProps + extends DeleteSeriesFilesModalContentProps { + isOpen: boolean; +} + +function DeleteSeriesFilesModal(props: DeleteSeriesFilesModalProps) { + const { isOpen, onModalClose } = props; + + return ( + <Modal isOpen={isOpen} onModalClose={onModalClose}> + <DeleteSeriesModalContent onModalClose={onModalClose} /> + </Modal> + ); +} + +export default DeleteSeriesFilesModal; diff --git a/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.css b/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.css new file mode 100644 index 000000000..7e9ce40cb --- /dev/null +++ b/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.css @@ -0,0 +1,23 @@ +.message { + margin-bottom: 10px; +} + +.pathContainer { + margin-left: 5px; +} + +.path { + margin-left: 5px; + color: var(--dangerColor); + font-weight: bold; +} + +.statistics { + margin-left: 5px; + color: var(--warningColor); +} + +.deleteFilesMessage { + margin-top: 20px; + color: var(--warningColor); +} diff --git a/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.css.d.ts b/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.css.d.ts new file mode 100644 index 000000000..ca4650422 --- /dev/null +++ b/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteFilesMessage': string; + 'message': string; + 'path': string; + 'pathContainer': string; + 'statistics': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.tsx b/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.tsx new file mode 100644 index 000000000..a6c227fe3 --- /dev/null +++ b/frontend/src/Series/Index/Select/Delete/Files/DeleteSeriesFilesModalContent.tsx @@ -0,0 +1,66 @@ +import React, { useCallback } from 'react'; +import CommandNames from 'Commands/CommandNames'; +import { useExecuteCommand } from 'Commands/useCommands'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import SeriesDeleteList from '../SeriesDeleteList'; +import useSelectedSeriesStats from '../useSelectedSeriesStats'; +import styles from './DeleteSeriesFilesModalContent.css'; + +export interface DeleteSeriesFilesModalContentProps { + onModalClose(): void; +} + +function DeleteSeriesFilesModalContent({ + onModalClose, +}: DeleteSeriesFilesModalContentProps) { + const { series, seriesIds, totalEpisodeFileCount, totalSizeOnDisk } = + useSelectedSeriesStats(); + const executeCommand = useExecuteCommand(); + + const onDeleteSeriesConfirmed = useCallback(() => { + executeCommand({ + name: CommandNames.DeleteSeriesFiles, + seriesIds, + }); + + onModalClose(); + }, [seriesIds, executeCommand, onModalClose]); + + return ( + <ModalContent onModalClose={onModalClose}> + <ModalHeader>{translate('DeleteSelectedSeriesFiles')}</ModalHeader> + + <ModalBody> + <div className={styles.message}> + {translate('DeleteSeriesFilesConfirmation', { + count: series.length, + })} + </div> + + <SeriesDeleteList + series={series} + showFileDetails={true} + totalEpisodeFileCount={totalEpisodeFileCount} + totalSizeOnDisk={totalSizeOnDisk} + styles={styles} + /> + </ModalBody> + + <ModalFooter> + <Button onPress={onModalClose}>{translate('Cancel')}</Button> + + <Button kind={kinds.DANGER} onPress={onDeleteSeriesConfirmed}> + {translate('Delete')} + </Button> + </ModalFooter> + </ModalContent> + ); +} + +export default DeleteSeriesFilesModalContent; diff --git a/frontend/src/Series/Index/Select/Delete/SeriesDeleteList.tsx b/frontend/src/Series/Index/Select/Delete/SeriesDeleteList.tsx new file mode 100644 index 000000000..15c8cca47 --- /dev/null +++ b/frontend/src/Series/Index/Select/Delete/SeriesDeleteList.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import Series from 'Series/Series'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; + +interface SeriesDeleteListStyles { + pathContainer: string; + path: string; + statistics: string; + deleteFilesMessage: string; +} + +interface SeriesDeleteListProps { + series: Series[]; + showFileDetails: boolean; + totalEpisodeFileCount: number; + totalSizeOnDisk: number; + styles: SeriesDeleteListStyles; +} + +function SeriesDeleteList({ + series, + showFileDetails, + totalEpisodeFileCount, + totalSizeOnDisk, + styles, +}: SeriesDeleteListProps) { + return ( + <> + <ul> + {series.map(({ title, path, statistics = {} }) => { + const { episodeFileCount = 0, sizeOnDisk = 0 } = statistics; + + return ( + <li key={title}> + <span>{title}</span> + + {showFileDetails ? ( + <span> + <span className={styles.pathContainer}> + -<span className={styles.path}>{path}</span> + </span> + + {episodeFileCount ? ( + <span className={styles.statistics}> + ( + {translate('DeleteSeriesFolderEpisodeCount', { + episodeFileCount, + size: formatBytes(sizeOnDisk), + })} + ) + </span> + ) : null} + </span> + ) : null} + </li> + ); + })} + </ul> + + {showFileDetails && totalEpisodeFileCount ? ( + <div className={styles.deleteFilesMessage}> + {translate('DeleteSeriesFolderEpisodeCount', { + episodeFileCount: totalEpisodeFileCount, + size: formatBytes(totalSizeOnDisk), + })} + </div> + ) : null} + </> + ); +} + +export default SeriesDeleteList; diff --git a/frontend/src/Series/Index/Select/Delete/useSelectedSeriesStats.ts b/frontend/src/Series/Index/Select/Delete/useSelectedSeriesStats.ts new file mode 100644 index 000000000..0748cbdcd --- /dev/null +++ b/frontend/src/Series/Index/Select/Delete/useSelectedSeriesStats.ts @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; +import { useSelect } from 'App/Select/SelectContext'; +import Series from 'Series/Series'; +import useSeries from 'Series/useSeries'; +import sortByProp from 'Utilities/Array/sortByProp'; + +function useSelectedSeriesStats() { + const { data: allSeries } = useSeries(); + const { useSelectedIds } = useSelect<Series>(); + const seriesIds = useSelectedIds(); + + const series = useMemo((): Series[] => { + const seriesList = seriesIds.map((id) => { + return allSeries.find((s) => s.id === id); + }) as Series[]; + + return seriesList.sort(sortByProp('sortTitle')); + }, [allSeries, seriesIds]); + + const { totalEpisodeFileCount, totalSizeOnDisk } = useMemo(() => { + return series.reduce( + (acc, { statistics = {} }) => { + const { episodeFileCount = 0, sizeOnDisk = 0 } = statistics; + + acc.totalEpisodeFileCount += episodeFileCount; + acc.totalSizeOnDisk += sizeOnDisk; + + return acc; + }, + { + totalEpisodeFileCount: 0, + totalSizeOnDisk: 0, + } + ); + }, [series]); + + return { + series, + seriesIds, + totalEpisodeFileCount, + totalSizeOnDisk, + }; +} + +export default useSelectedSeriesStats; diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx index 0d3cc20f2..df6a1aede 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx @@ -14,6 +14,7 @@ import { } from 'Series/useSeries'; import translate from 'Utilities/String/translate'; import DeleteSeriesModal from './Delete/DeleteSeriesModal'; +import DeleteSeriesFilesModal from './Delete/Files/DeleteSeriesFilesModal'; import EditSeriesModal from './Edit/EditSeriesModal'; import OrganizeSeriesModal from './Organize/OrganizeSeriesModal'; import ChangeMonitoringModal from './SeasonPass/ChangeMonitoringModal'; @@ -34,6 +35,9 @@ function SeriesIndexSelectFooter() { const { updateSeriesMonitor, isUpdatingSeriesMonitor } = useUpdateSeriesMonitor(); const { isBulkDeleting, bulkDeleteError } = useBulkDeleteSeries(); + const isDeleteFilesCommandExecuting = useCommandExecuting( + CommandNames.DeleteSeriesFiles + ); const isOrganizingSeries = useCommandExecuting(CommandNames.RenameSeries); @@ -46,6 +50,7 @@ function SeriesIndexSelectFooter() { const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); const [isMonitoringModalOpen, setIsMonitoringModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDeleteFilesModalOpen, setIsDeleteFilesModalOpen] = useState(false); const [isSavingSeries, setIsSavingSeries] = useState(false); const [isSavingTags, setIsSavingTags] = useState(false); const [isSavingMonitoring, setIsSavingMonitoring] = useState(false); @@ -132,6 +137,14 @@ function SeriesIndexSelectFooter() { setIsDeleteModalOpen(false); }, []); + const onDeleteFilesPress = useCallback(() => { + setIsDeleteFilesModalOpen(true); + }, []); + + const onDeleteFilesModalClose = useCallback(() => { + setIsDeleteFilesModalOpen(false); + }, []); + useEffect(() => { if (!isSaving) { setIsSavingSeries(false); @@ -195,6 +208,15 @@ function SeriesIndexSelectFooter() { > {translate('Delete')} </SpinnerButton> + + <SpinnerButton + kind={kinds.DANGER} + isSpinning={isDeleteFilesCommandExecuting} + isDisabled={!anySelected || isDeleteFilesCommandExecuting} + onPress={onDeleteFilesPress} + > + {translate('DeleteFiles')} + </SpinnerButton> </div> </div> @@ -229,6 +251,11 @@ function SeriesIndexSelectFooter() { isOpen={isDeleteModalOpen} onModalClose={onDeleteModalClose} /> + + <DeleteSeriesFilesModal + isOpen={isDeleteFilesModalOpen} + onModalClose={onDeleteFilesModalClose} + /> </PageContentFooter> ); } diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteEpisodeFileFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteEpisodeFileFixture.cs index 6bbd36434..2bec1811e 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteEpisodeFileFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteEpisodeFileFixture.cs @@ -5,6 +5,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.RootFolders; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; using NzbDrone.Test.Common; @@ -41,6 +42,10 @@ public void Setup() private void GivenRootFolderExists() { + Mocker.GetMock<IRootFolderService>() + .Setup(s => s.GetBestRootFolderPath(_series.Path)) + .Returns(ROOT_FOLDER); + Mocker.GetMock<IDiskProvider>() .Setup(s => s.FolderExists(ROOT_FOLDER)) .Returns(true); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index d5e2d43a1..04e91e4f2 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -364,6 +364,7 @@ "DeleteEpisodeFromDisk": "Delete episode from disk", "DeleteEpisodesFiles": "Delete {episodeFileCount} Episode Files", "DeleteEpisodesFilesHelpText": "Delete the episode files and series folder", + "DeleteFiles": "Delete Files", "DeleteImportList": "Delete Import List", "DeleteImportListExclusion": "Delete Import List Exclusion", "DeleteImportListExclusionMessageText": "Are you sure you want to delete this import list exclusion?", @@ -391,6 +392,8 @@ "DeleteSelectedIndexers": "Delete Indexer(s)", "DeleteSelectedIndexersMessageText": "Are you sure you want to delete {count} selected indexer(s)?", "DeleteSelectedSeries": "Delete Selected Series", + "DeleteSelectedSeriesFiles": "Delete Selected Series Files", + "DeleteSeriesFilesConfirmation": "Are you sure you want to delete all tracked episode files for {count} selected series?", "DeleteSeriesFolder": "Delete Series Folder", "DeleteSeriesFolderConfirmation": "The series folder `{path}` and all of its content will be deleted.", "DeleteSeriesFolderCountConfirmation": "Are you sure you want to delete {count} selected series?", diff --git a/src/NzbDrone.Core/MediaFiles/Commands/DeleteSeriesFilesCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/DeleteSeriesFilesCommand.cs new file mode 100644 index 000000000..a3ee57b91 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Commands/DeleteSeriesFilesCommand.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.MediaFiles.Commands +{ + public class DeleteSeriesFilesCommand : Command + { + public List<int> SeriesIds { get; set; } + + public override bool SendUpdatesToClient => true; + public override bool RequiresDiskAccess => true; + + public DeleteSeriesFilesCommand() + { + SeriesIds = new List<int>(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs index bd8d66025..463346976 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs @@ -4,11 +4,15 @@ using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging; +using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.RootFolders; using NzbDrone.Core.Tv; using NzbDrone.Core.Tv.Events; @@ -20,6 +24,7 @@ public interface IDeleteMediaFiles } public class MediaFileDeletionService : IDeleteMediaFiles, + IExecute<DeleteSeriesFilesCommand>, IHandleAsync<SeriesDeletedEvent>, IHandle<EpisodeFileDeletedEvent> { @@ -27,7 +32,9 @@ public class MediaFileDeletionService : IDeleteMediaFiles, private readonly IRecycleBinProvider _recycleBinProvider; private readonly IMediaFileService _mediaFileService; private readonly ISeriesService _seriesService; + private readonly IRootFolderService _rootFolderService; private readonly IConfigService _configService; + private readonly ICommandResultReporter _commandResultReporter; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; @@ -35,7 +42,9 @@ public MediaFileDeletionService(IDiskProvider diskProvider, IRecycleBinProvider recycleBinProvider, IMediaFileService mediaFileService, ISeriesService seriesService, + IRootFolderService rootFolderService, IConfigService configService, + ICommandResultReporter commandResultReporter, IEventAggregator eventAggregator, Logger logger) { @@ -43,7 +52,9 @@ public MediaFileDeletionService(IDiskProvider diskProvider, _recycleBinProvider = recycleBinProvider; _mediaFileService = mediaFileService; _seriesService = seriesService; + _rootFolderService = rootFolderService; _configService = configService; + _commandResultReporter = commandResultReporter; _eventAggregator = eventAggregator; _logger = logger; } @@ -51,7 +62,7 @@ public MediaFileDeletionService(IDiskProvider diskProvider, public void DeleteEpisodeFile(Series series, EpisodeFile episodeFile) { var fullPath = Path.Combine(series.Path, episodeFile.RelativePath); - var rootFolder = _diskProvider.GetParentFolder(series.Path); + var rootFolder = _rootFolderService.GetBestRootFolderPath(series.Path); if (!_diskProvider.FolderExists(rootFolder)) { @@ -88,6 +99,81 @@ public void DeleteEpisodeFile(Series series, EpisodeFile episodeFile) _eventAggregator.PublishEvent(new DeleteCompletedEvent()); } + public void Execute(DeleteSeriesFilesCommand message) + { + foreach (var seriesId in message.SeriesIds) + { + try + { + var series = _seriesService.GetSeries(seriesId); + var mediaFiles = _mediaFileService.GetFilesBySeries(seriesId); + + _logger.ProgressDebug("{0}: Deleting episode files}", series.Title); + + if (mediaFiles.Count == 0) + { + _logger.Debug("No files found for series: {0}", series.Title); + continue; + } + + var rootFolder = _rootFolderService.GetBestRootFolderPath(series.Path); + + if (!_diskProvider.FolderExists(rootFolder)) + { + _logger.Warn("Series' root folder ({0}) doesn't exist.", rootFolder); + _commandResultReporter.Report(CommandResult.Indeterminate); + continue; + } + + if (_diskProvider.GetDirectories(rootFolder).Empty()) + { + _logger.Warn("Series' root folder ({0}) is empty.", rootFolder); + _commandResultReporter.Report(CommandResult.Indeterminate); + continue; + } + + if (!_diskProvider.FolderExists(series.Path)) + { + _logger.Warn("Series' folder ({0}) does not exist.", series.Path); + _commandResultReporter.Report(CommandResult.Indeterminate); + continue; + } + + foreach (var episodeFile in mediaFiles) + { + var fullPath = Path.Combine(series.Path, episodeFile.RelativePath); + + if (_diskProvider.FileExists(fullPath)) + { + _logger.Info("Deleting episode file: {0}", fullPath); + + var subfolder = _diskProvider.GetParentFolder(series.Path).GetRelativePath(_diskProvider.GetParentFolder(fullPath)); + + try + { + _recycleBinProvider.DeleteFile(fullPath, subfolder); + } + catch (Exception e) + { + _logger.Error(e, "Unable to delete episode file"); + _commandResultReporter.Report(CommandResult.Indeterminate); + continue; + } + + _mediaFileService.Delete(episodeFile, DeleteMediaFileReason.Manual); + } + } + + _logger.ProgressDebug("{0}: Deleted episode files", series.Title); + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to delete files for series with ID: {0}", seriesId); + _commandResultReporter.Report(CommandResult.Indeterminate); + } + } + } + public void HandleAsync(SeriesDeletedEvent message) { if (message.DeleteFiles) diff --git a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs index 63cbf7257..f18f8e7f9 100644 --- a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs +++ b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs @@ -231,7 +231,7 @@ public void Execute(RefreshSeriesCommand message) _logger.Error("Series '{0}' (tvdbid {1}) was not found, it may have been removed from TheTVDB.", series.Title, series.TvdbId); // Mark the result as indeterminate so it's not marked as a full success, - // // but we can still process other series if needed. + // but we can still process other series if needed. _commandResultReporter.Report(CommandResult.Indeterminate); } catch (Exception e) diff --git a/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs index aed000529..1bfef1f4c 100644 --- a/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs +++ b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs @@ -135,7 +135,7 @@ public object DeleteEpisodeFiles([FromBody] EpisodeFileListResource resource) _mediaFileDeletionService.DeleteEpisodeFile(series, episodeFile); } - return new { }; + return NoContent(); } [HttpPut("bulk")] diff --git a/src/Sonarr.Api.V5/Queue/QueueActionController.cs b/src/Sonarr.Api.V5/Queue/QueueActionController.cs index 959cebc7a..c9e79e5ee 100644 --- a/src/Sonarr.Api.V5/Queue/QueueActionController.cs +++ b/src/Sonarr.Api.V5/Queue/QueueActionController.cs @@ -31,7 +31,7 @@ public async Task<object> Grab([FromRoute] int id) await _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null); - return new { }; + return NoContent(); } [HttpPost("grab/bulk")] @@ -50,7 +50,7 @@ public async Task<object> Grab([FromBody] QueueBulkResource resource) await _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null); } - return new { }; + return NoContent(); } } } diff --git a/src/Sonarr.Api.V5/Series/SeriesEditorController.cs b/src/Sonarr.Api.V5/Series/SeriesEditorController.cs index 9ce021a3c..ae30ff4c3 100644 --- a/src/Sonarr.Api.V5/Series/SeriesEditorController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesEditorController.cs @@ -109,6 +109,6 @@ public object DeleteSeries([FromBody] SeriesEditorResource resource) { _seriesService.DeleteSeries(resource.SeriesIds, resource.DeleteFiles, resource.AddImportListExclusion); - return new { }; + return NoContent(); } } From b135e5a2a43de73a382c7d505263d4318c00d647 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 2 Mar 2026 17:40:06 -0800 Subject: [PATCH 042/110] New: Replace HTML Encoded values in release titles --- .../Files/Indexers/encoded_title.xml | 18 ++++++++++++++++++ .../IndexerTests/BasicRssParserFixture.cs | 11 +++++++++++ src/NzbDrone.Core.Test/Sonarr.Core.Test.csproj | 3 +++ src/NzbDrone.Core/Indexers/RssParser.cs | 4 +++- 4 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/NzbDrone.Core.Test/Files/Indexers/encoded_title.xml diff --git a/src/NzbDrone.Core.Test/Files/Indexers/encoded_title.xml b/src/NzbDrone.Core.Test/Files/Indexers/encoded_title.xml new file mode 100644 index 000000000..c0144045d --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Indexers/encoded_title.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<rss version="2.0"> + + <channel> + + <item> + <title>Series.&amp;.Title.S02E19.EAC3.5.1.1080p.WEBRip.x265-iVy + https://my.indexer.com/info.php?guid=abc123 + https://my.indexer.com/api?t=get&id=abc123&apikey=secret + https://my.indexer.com/info.php?guid=abc123 + Fri, 20 Dec 2024 05:16:34 +0000 + TV > HD + Series.&.Title.S02E19.EAC3.5.1.1080p.WEBRip.x265-iVy + + + + + \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs index b2e171a58..bf4695826 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs @@ -58,5 +58,16 @@ public void should_handle_relative_url() result.First().CommentUrl.Should().Be("http://my.indexer.com/details/123#comments"); result.First().DownloadUrl.Should().Be("http://my.indexer.com/getnzb/123.nzb&i=782&r=123"); } + + [Test] + public void should_decode_html_entities_in_item_title() + { + var xml = ReadAllText("Files/Indexers/encoded_title.xml"); + + var result = Subject.ParseResponse(CreateResponse("http://my.indexer.com/rss", xml)); + + result.Should().HaveCount(1); + result.First().Title.Should().Be("Series.&.Title.S02E19.EAC3.5.1.1080p.WEBRip.x265-iVy"); + } } } diff --git a/src/NzbDrone.Core.Test/Sonarr.Core.Test.csproj b/src/NzbDrone.Core.Test/Sonarr.Core.Test.csproj index 1bf36c807..f5f9283ae 100644 --- a/src/NzbDrone.Core.Test/Sonarr.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/Sonarr.Core.Test.csproj @@ -18,5 +18,8 @@ PreserveNewest + + PreserveNewest + diff --git a/src/NzbDrone.Core/Indexers/RssParser.cs b/src/NzbDrone.Core/Indexers/RssParser.cs index 4f4f5ce43..012ed823a 100644 --- a/src/NzbDrone.Core/Indexers/RssParser.cs +++ b/src/NzbDrone.Core/Indexers/RssParser.cs @@ -186,7 +186,9 @@ protected virtual string GetGuid(XElement item) protected virtual string GetTitle(XElement item) { - return item.TryGetValue("title", "Unknown"); + var title = item.TryGetValue("title", "Unknown"); + + return WebUtility.HtmlDecode(title); } protected virtual DateTime GetPublishDate(XElement item) From 209087f20579aff7bac2af4b43d03cfe4d5ef5b5 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 17 Mar 2026 01:55:30 +0200 Subject: [PATCH 043/110] Fixed: Trakt import lists with null IDs Closes #8459 --- src/NzbDrone.Core/ImportLists/Trakt/TraktAPI.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/ImportLists/Trakt/TraktAPI.cs b/src/NzbDrone.Core/ImportLists/Trakt/TraktAPI.cs index b1ad00098..13115fb97 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/TraktAPI.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/TraktAPI.cs @@ -5,7 +5,7 @@ namespace NzbDrone.Core.ImportLists.Trakt { public class TraktSeriesIdsResource { - public int Trakt { get; set; } + public int? Trakt { get; set; } public string Slug { get; set; } public string Imdb { get; set; } public int? Tmdb { get; set; } From d7769866c787f6edc2ddf0d379c7c02defb01a34 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 7 Mar 2026 09:02:39 -0800 Subject: [PATCH 044/110] Improve validation and test skipping in v5 API --- frontend/src/Settings/useProviderSettings.ts | 174 ++++++++++++++---- .../Provider/ProviderControllerBase.cs | 46 ++--- src/Sonarr.Api.V5/Provider/SkipValidation.cs | 9 + 3 files changed, 174 insertions(+), 55 deletions(-) create mode 100644 src/Sonarr.Api.V5/Provider/SkipValidation.cs diff --git a/frontend/src/Settings/useProviderSettings.ts b/frontend/src/Settings/useProviderSettings.ts index 06ef539eb..809547b5c 100644 --- a/frontend/src/Settings/useProviderSettings.ts +++ b/frontend/src/Settings/useProviderSettings.ts @@ -1,14 +1,24 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { useCallback, useMemo, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useMemo, useRef, useState } from 'react'; import ModelBase from 'App/ModelBase'; -import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import useApiMutation, { + getValidationFailures, +} from 'Helpers/Hooks/useApiMutation'; import useApiQuery, { QueryOptions } from 'Helpers/Hooks/useApiQuery'; import { usePendingChangesStore } from 'Helpers/Hooks/usePendingChangesStore'; import { usePendingFieldsStore } from 'Helpers/Hooks/usePendingFieldsStore'; import selectSettings from 'Store/Selectors/selectSettings'; import { PendingSection } from 'typings/pending'; import Provider from 'typings/Provider'; -import { ApiError } from 'Utilities/Fetch/fetchJson'; +import fetchJson, { ApiError } from 'Utilities/Fetch/fetchJson'; +import getQueryPath from 'Utilities/Fetch/getQueryPath'; +import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString'; + +export type SkipValidation = 'none' | 'warnings' | 'all'; +export interface SaveOptions { + skipTesting?: boolean; + skipValidation?: SkipValidation; +} interface BaseManageProviderSettings extends Omit>, 'settings'> { @@ -80,28 +90,60 @@ export const useSaveProviderSettings = ( ) => { const queryClient = useQueryClient(); - const { mutate, isPending, error } = useApiMutation({ - path: id ? `${path}/${id}` : path, - method: id ? 'PUT' : 'POST', - mutationOptions: { - onSuccess: (updatedSettings: T) => { - queryClient.setQueryData([path], (oldData = []) => { - if (id) { - return oldData.map((item) => - item.id === updatedSettings.id ? updatedSettings : item - ); - } + const { mutate, isPending, error } = useMutation< + T, + ApiError, + { + data: T; + } & SaveOptions + >({ + mutationFn: async ({ data, skipTesting, skipValidation }) => { + const queryParams: QueryParams = {}; - return [...oldData, updatedSettings]; - }); - onSuccess?.(updatedSettings); - }, - onError, + if (skipTesting) { + queryParams.skipTesting = true; + } + + if (skipValidation && skipValidation !== 'none') { + queryParams.skipValidation = skipValidation; + } + + return fetchJson({ + path: + getQueryPath(id ? `${path}/${id}` : path) + + getQueryString(queryParams), + method: id ? 'PUT' : 'POST', + headers: { + 'X-Api-Key': window.Sonarr.apiKey, + 'X-Sonarr-Client': 'Sonarr', + }, + body: data, + }); }, + onSuccess: (updatedSettings: T) => { + queryClient.setQueryData([path], (oldData = []) => { + if (id) { + return oldData.map((item) => + item.id === updatedSettings.id ? updatedSettings : item + ); + } + + return [...oldData, updatedSettings]; + }); + onSuccess?.(updatedSettings); + }, + onError, }); + const save = useCallback( + (data: T, options?: SaveOptions) => { + mutate({ data, ...options }); + }, + [mutate] + ); + return { - save: mutate, + save, isSaving: isPending, saveError: error, }; @@ -112,17 +154,41 @@ export const useTestProvider = ( onSuccess?: () => void, onError?: (error: ApiError) => void ) => { - const { mutate, isPending, error } = useApiMutation({ - path: `${path}/test`, - method: 'POST', - mutationOptions: { - onSuccess, - onError, + const { mutate, isPending, error } = useMutation< + void, + ApiError, + { data: T } & SaveOptions + >({ + mutationFn: async ({ data, skipValidation }) => { + const queryParams: QueryParams = {}; + + if (skipValidation && skipValidation !== 'none') { + queryParams.skipValidation = skipValidation; + } + + return fetchJson({ + path: getQueryPath(`${path}/test`) + getQueryString(queryParams), + method: 'POST', + headers: { + 'X-Api-Key': window.Sonarr.apiKey, + 'X-Sonarr-Client': 'Sonarr', + }, + body: data, + }); }, + onSuccess, + onError, }); + const test = useCallback( + (data: T, options?: SaveOptions) => { + mutate({ data, ...options }); + }, + [mutate] + ); + return { - test: mutate, + test, isTesting: isPending, testError: error, }; @@ -135,12 +201,14 @@ export const useManageProviderSettings = ( ): ManageProviderSettings => { const provider = useProviderWithDefault(id, defaultProvider, path); const [mutationError, setMutationError] = useState(null); + const lastSaveData = useRef(null); const { pendingChanges, setPendingChange, unsetPendingChange, clearPendingChanges, + hasPendingChanges, } = usePendingChangesStore({}); const { @@ -154,6 +222,7 @@ export const useManageProviderSettings = ( setMutationError(null); clearPendingChanges(); clearPendingFields(); + lastSaveData.current = null; }, [clearPendingChanges, clearPendingFields]); const handleTestSuccess = useCallback(() => { @@ -219,8 +288,40 @@ export const useManageProviderSettings = ( } as T; } - save(updatedSettings); - }, [provider, pendingChanges, pendingFields, save]); + const serializedSettings = JSON.stringify(updatedSettings); + const isResave = lastSaveData.current === serializedSettings; + lastSaveData.current = serializedSettings; + + const saveOptions: SaveOptions = {}; + + // For existing providers with no pending changes, skip testing and all validation. + if (provider.id > 0 && !hasPendingChanges && !hasPendingFields) { + saveOptions.skipTesting = true; + saveOptions.skipValidation = 'all'; + } else { + // If resaving the exact same settings as the previous attempt, skip testing. + if (isResave) { + saveOptions.skipTesting = true; + } + + // If the last save returned only warnings, skip warning validation on the next save. + const { errors, warnings } = getValidationFailures(mutationError); + + if (errors.length === 0 && warnings.length > 0) { + saveOptions.skipValidation = 'warnings'; + } + } + + save(updatedSettings, saveOptions); + }, [ + provider, + pendingChanges, + pendingFields, + hasPendingChanges, + hasPendingFields, + mutationError, + save, + ]); const testProvider = useCallback(() => { let updatedSettings: T = { @@ -246,8 +347,17 @@ export const useManageProviderSettings = ( } as T; } - test(updatedSettings); - }, [provider, pendingChanges, pendingFields, test]); + const testOptions: SaveOptions = {}; + + // If the last operation returned only warnings, skip warning validation on the next test. + const { errors, warnings } = getValidationFailures(mutationError); + + if (errors.length === 0 && warnings.length > 0) { + testOptions.skipValidation = 'warnings'; + } + + test(updatedSettings, testOptions); + }, [provider, pendingChanges, pendingFields, mutationError, test]); const updateValue = useCallback( (key: K, value: T[K]) => { diff --git a/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs b/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs index 6b5ceb3d0..faaa2aa2e 100644 --- a/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs +++ b/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs @@ -76,13 +76,13 @@ public List GetAll() [RestPostById] [Consumes("application/json")] [Produces("application/json")] - public ActionResult CreateProvider([FromBody] TProviderResource providerResource, [FromQuery] bool forceSave = false) + public ActionResult CreateProvider([FromBody] TProviderResource providerResource, [FromQuery] bool skipTesting = false, [FromQuery] SkipValidation skipValidation = SkipValidation.None) { - var providerDefinition = GetDefinition(providerResource, null, true, !forceSave, false); + var providerDefinition = GetDefinition(providerResource, null, skipValidation, false); - if (providerDefinition.Enable) + if (providerDefinition.Enable && !skipTesting) { - Test(providerDefinition, !forceSave); + Test(providerDefinition, skipValidation); } providerDefinition = _providerFactory.Create(providerDefinition); @@ -93,7 +93,7 @@ public ActionResult CreateProvider([FromBody] TProviderResour [RestPutById] [Consumes("application/json")] [Produces("application/json")] - public ActionResult UpdateProvider([FromRoute] int id, [FromBody] TProviderResource providerResource, [FromQuery] bool forceSave = false) + public ActionResult UpdateProvider([FromRoute] int id, [FromBody] TProviderResource providerResource, [FromQuery] bool skipTesting = false, [FromQuery] SkipValidation skipValidation = SkipValidation.None) { // TODO: Remove fallback to Id from body in next API version bump var existingDefinition = _providerFactory.Find(id) ?? _providerFactory.Find(providerResource.Id); @@ -103,15 +103,15 @@ public ActionResult UpdateProvider([FromRoute] int id, [FromB return NotFound(); } - var providerDefinition = GetDefinition(providerResource, existingDefinition, true, !forceSave, false); + var providerDefinition = GetDefinition(providerResource, existingDefinition, skipValidation, false); // Compare settings separately because they are not serialized with the definition. var hasDefinitionChanged = !existingDefinition.Equals(providerDefinition) || !existingDefinition.Settings.Equals(providerDefinition.Settings); - // Only test existing definitions if it is enabled and forceSave isn't set and the definition has changed. - if (providerDefinition.Enable && !forceSave && hasDefinitionChanged) + // Only test existing definitions if it is enabled, skipTesting isn't set and the definition has changed. + if (providerDefinition.Enable && !skipTesting && hasDefinitionChanged) { - Test(providerDefinition, true); + Test(providerDefinition, skipValidation); } if (hasDefinitionChanged) @@ -163,13 +163,13 @@ public virtual ActionResult UpdateProvider([FromBody] TBulkPr return Accepted(_providerFactory.Update(definitionsToUpdate).Select(x => _resourceMapper.ToResource(x))); } - private TProviderDefinition GetDefinition(TProviderResource providerResource, TProviderDefinition? existingDefinition, bool validate, bool includeWarnings, bool forceValidate) + private TProviderDefinition GetDefinition(TProviderResource providerResource, TProviderDefinition? existingDefinition, SkipValidation skipValidation, bool forceValidate) { var definition = _resourceMapper.ToModel(providerResource, existingDefinition); - if (validate && (definition.Enable || forceValidate)) + if (skipValidation != SkipValidation.All && (definition.Enable || forceValidate)) { - Validate(definition, includeWarnings); + Validate(definition, skipValidation); } return definition; @@ -218,12 +218,12 @@ public List GetTemplates() [SkipValidation(true, false)] [HttpPost("test")] [Consumes("application/json")] - public ActionResult Test([FromBody] TProviderResource providerResource, [FromQuery] bool forceTest = false) + public ActionResult Test([FromBody] TProviderResource providerResource, [FromQuery] SkipValidation skipValidation = SkipValidation.None) { var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; - var providerDefinition = GetDefinition(providerResource, existingDefinition, true, !forceTest, true); + var providerDefinition = GetDefinition(providerResource, existingDefinition, skipValidation, true); - Test(providerDefinition, true); + Test(providerDefinition, skipValidation); return NoContent(); } @@ -261,7 +261,7 @@ public IActionResult TestAll() public IActionResult RequestAction([FromRoute] string name, [FromBody] TProviderResource providerResource) { var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; - var providerDefinition = GetDefinition(providerResource, existingDefinition, false, false, false); + var providerDefinition = GetDefinition(providerResource, existingDefinition, SkipValidation.All, false); var query = Request.Query.ToDictionary(x => x.Key, x => x.Value.ToString()); @@ -288,30 +288,30 @@ public virtual void Handle(ProviderDeletedEvent message) BroadcastResourceChange(ModelAction.Deleted, message.ProviderId); } - private void Validate(TProviderDefinition definition, bool includeWarnings) + private void Validate(TProviderDefinition definition, SkipValidation skipValidation) { var validationResult = definition.Settings.Validate(); - VerifyValidationResult(validationResult, includeWarnings); + VerifyValidationResult(validationResult, skipValidation); } - protected virtual void Test(TProviderDefinition definition, bool includeWarnings) + protected virtual void Test(TProviderDefinition definition, SkipValidation skipValidation) { var validationResult = _providerFactory.Test(definition); - VerifyValidationResult(validationResult, includeWarnings); + VerifyValidationResult(validationResult, skipValidation); } - protected void VerifyValidationResult(ValidationResult validationResult, bool includeWarnings) + protected void VerifyValidationResult(ValidationResult validationResult, SkipValidation skipValidation) { var result = validationResult as NzbDroneValidationResult ?? new NzbDroneValidationResult(validationResult.Errors); - if (includeWarnings && (!result.IsValid || result.HasWarnings)) + if (skipValidation == SkipValidation.None && (!result.IsValid || result.HasWarnings)) { throw new ValidationException(result.Failures); } - if (!result.IsValid) + if (skipValidation == SkipValidation.Warnings && !result.IsValid) { throw new ValidationException(result.Errors); } diff --git a/src/Sonarr.Api.V5/Provider/SkipValidation.cs b/src/Sonarr.Api.V5/Provider/SkipValidation.cs new file mode 100644 index 000000000..93b4a2b93 --- /dev/null +++ b/src/Sonarr.Api.V5/Provider/SkipValidation.cs @@ -0,0 +1,9 @@ +namespace Sonarr.Api.V5.Provider +{ + public enum SkipValidation + { + None = 0, + Warnings = 1, + All = 2 + } +} From 5b79ee6d112ea17726c0355d966b5bf0f1521d38 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 8 Mar 2026 10:20:48 -0700 Subject: [PATCH 045/110] Add v5 Indexer options endpoints --- .../Settings/IndexerSettingsController.cs | 30 +++++++++++++++++++ .../Settings/IndexerSettingsResource.cs | 27 +++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/Sonarr.Api.V5/Settings/IndexerSettingsController.cs create mode 100644 src/Sonarr.Api.V5/Settings/IndexerSettingsResource.cs diff --git a/src/Sonarr.Api.V5/Settings/IndexerSettingsController.cs b/src/Sonarr.Api.V5/Settings/IndexerSettingsController.cs new file mode 100644 index 000000000..07b383932 --- /dev/null +++ b/src/Sonarr.Api.V5/Settings/IndexerSettingsController.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using NzbDrone.Core.Configuration; +using Sonarr.Http; +using Sonarr.Http.Validation; + +namespace Sonarr.Api.V5.Settings +{ + [V5ApiController("settings/indexer")] + public class IndexerSettingsController : SettingsController + { + public IndexerSettingsController(IConfigFileProvider configFileProvider, + IConfigService configService) + : base(configFileProvider, configService) + { + SharedValidator.RuleFor(c => c.MinimumAge) + .GreaterThanOrEqualTo(0); + + SharedValidator.RuleFor(c => c.Retention) + .GreaterThanOrEqualTo(0); + + SharedValidator.RuleFor(c => c.RssSyncInterval) + .IsValidRssSyncInterval(); + } + + protected override IndexerSettingsResource ToResource(IConfigFileProvider configFile, IConfigService model) + { + return IndexerConfigResourceMapper.ToResource(model); + } + } +} diff --git a/src/Sonarr.Api.V5/Settings/IndexerSettingsResource.cs b/src/Sonarr.Api.V5/Settings/IndexerSettingsResource.cs new file mode 100644 index 000000000..3c1ffaac5 --- /dev/null +++ b/src/Sonarr.Api.V5/Settings/IndexerSettingsResource.cs @@ -0,0 +1,27 @@ +using NzbDrone.Core.Configuration; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Settings +{ + public class IndexerSettingsResource : RestResource + { + public int MinimumAge { get; set; } + public int Retention { get; set; } + public int MaximumSize { get; set; } + public int RssSyncInterval { get; set; } + } + + public static class IndexerConfigResourceMapper + { + public static IndexerSettingsResource ToResource(IConfigService model) + { + return new IndexerSettingsResource + { + MinimumAge = model.MinimumAge, + Retention = model.Retention, + MaximumSize = model.MaximumSize, + RssSyncInterval = model.RssSyncInterval + }; + } + } +} From 7a455dd0f880ed1b0dbc8e9a5104a958cb8d6099 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 8 Mar 2026 10:22:58 -0700 Subject: [PATCH 046/110] Use react-query for Indexers Options --- frontend/src/App/State/SettingsAppState.ts | 6 -- .../Indexers/Options/IndexerOptions.tsx | 39 ++++------- .../Indexers/Options/useIndexerSettings.ts | 18 ++++++ .../Store/Actions/Settings/indexerOptions.js | 64 ------------------- frontend/src/Store/Actions/settingsActions.js | 11 +--- .../src/typings/Settings/IndexerOptions.ts | 6 -- 6 files changed, 33 insertions(+), 111 deletions(-) create mode 100644 frontend/src/Settings/Indexers/Options/useIndexerSettings.ts delete mode 100644 frontend/src/Store/Actions/Settings/indexerOptions.js delete mode 100644 frontend/src/typings/Settings/IndexerOptions.ts diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 52b940692..f4190547e 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -16,7 +16,6 @@ import ImportListExclusion from 'typings/ImportListExclusion'; import ImportListOptionsSettings from 'typings/ImportListOptionsSettings'; import IndexerFlag from 'typings/IndexerFlag'; import DownloadClientOptions from 'typings/Settings/DownloadClientOptions'; -import IndexerOptions from 'typings/Settings/IndexerOptions'; type Presets = T & { presets: T[]; @@ -58,10 +57,6 @@ export interface ImportListAppState isTestingAll: boolean; } -export interface IndexerOptionsAppState - extends AppSectionItemState, - AppSectionSaveState {} - export interface CustomFormatAppState extends AppSectionState, AppSectionDeleteState, @@ -99,7 +94,6 @@ interface SettingsAppState { importListOptions: ImportListOptionsSettingsAppState; importLists: ImportListAppState; indexerFlags: IndexerFlagSettingsAppState; - indexerOptions: IndexerOptionsAppState; } export default SettingsAppState; diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptions.tsx b/frontend/src/Settings/Indexers/Options/IndexerOptions.tsx index 88cc7d63c..1719f6148 100644 --- a/frontend/src/Settings/Indexers/Options/IndexerOptions.tsx +++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import Alert from 'Components/Alert'; import FieldSet from 'Components/FieldSet'; import Form from 'Components/Form/Form'; @@ -9,21 +8,13 @@ import FormLabel from 'Components/Form/FormLabel'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import { inputTypes, kinds } from 'Helpers/Props'; import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore'; -import { clearPendingChanges } from 'Store/Actions/baseActions'; -import { - fetchIndexerOptions, - saveIndexerOptions, - setIndexerOptionsValue, -} from 'Store/Actions/settingsActions'; -import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; import { InputChanged } from 'typings/inputs'; import { OnChildStateChange, SetChildSave, } from 'typings/Settings/SettingsState'; import translate from 'Utilities/String/translate'; - -const SECTION = 'indexerOptions'; +import { useManageIndexerSettings } from './useIndexerSettings'; interface IndexerOptionsProps { setChildSave: SetChildSave; @@ -34,31 +25,31 @@ function IndexerOptions({ setChildSave, onChildStateChange, }: IndexerOptionsProps) { - const dispatch = useDispatch(); const { isFetching, - isPopulated, + isFetched, isSaving, error, settings, hasSettings, hasPendingChanges, - } = useSelector(createSettingsSectionSelector(SECTION)); + saveSettings, + updateSetting, + } = useManageIndexerSettings(); const showAdvancedSettings = useShowAdvancedSettings(); const handleInputChange = useCallback( - (change: InputChanged) => { - // @ts-expect-error - actions aren't typed - dispatch(setIndexerOptionsValue(change)); + ({ name, value }: InputChanged) => { + // @ts-expect-error - InputChanged name/value are not typed as keyof IndexerSettingsModel + updateSetting(name, value); }, - [dispatch] + [updateSetting] ); useEffect(() => { - dispatch(fetchIndexerOptions()); - setChildSave(() => dispatch(saveIndexerOptions())); - }, [dispatch, setChildSave]); + setChildSave(saveSettings); + }, [saveSettings, setChildSave]); useEffect(() => { onChildStateChange({ @@ -67,12 +58,6 @@ function IndexerOptions({ }); }, [hasPendingChanges, isSaving, onChildStateChange]); - useEffect(() => { - return () => { - dispatch(clearPendingChanges({ section: `settings.${SECTION}` })); - }; - }, [dispatch]); - return (
{isFetching ? : null} @@ -83,7 +68,7 @@ function IndexerOptions({ ) : null} - {hasSettings && isPopulated && !error ? ( + {hasSettings && isFetched && !error ? (
{translate('MinimumAge')} diff --git a/frontend/src/Settings/Indexers/Options/useIndexerSettings.ts b/frontend/src/Settings/Indexers/Options/useIndexerSettings.ts new file mode 100644 index 000000000..2bcd225a1 --- /dev/null +++ b/frontend/src/Settings/Indexers/Options/useIndexerSettings.ts @@ -0,0 +1,18 @@ +import { useManageSettings, useSettings } from 'Settings/useSettings'; + +export interface IndexerSettingsModel { + minimumAge: number; + retention: number; + maximumSize: number; + rssSyncInterval: number; +} + +const PATH = '/settings/indexer'; + +export const useIndexerSettings = () => { + return useSettings(PATH); +}; + +export const useManageIndexerSettings = () => { + return useManageSettings(PATH); +}; diff --git a/frontend/src/Store/Actions/Settings/indexerOptions.js b/frontend/src/Store/Actions/Settings/indexerOptions.js deleted file mode 100644 index bafc2735d..000000000 --- a/frontend/src/Store/Actions/Settings/indexerOptions.js +++ /dev/null @@ -1,64 +0,0 @@ -import { createAction } from 'redux-actions'; -import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; -import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; -import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; -import { createThunk } from 'Store/thunks'; - -// -// Variables - -const section = 'settings.indexerOptions'; - -// -// Actions Types - -export const FETCH_INDEXER_OPTIONS = 'settings/indexerOptions/fetchIndexerOptions'; -export const SAVE_INDEXER_OPTIONS = 'settings/indexerOptions/saveIndexerOptions'; -export const SET_INDEXER_OPTIONS_VALUE = 'settings/indexerOptions/setIndexerOptionsValue'; - -// -// Action Creators - -export const fetchIndexerOptions = createThunk(FETCH_INDEXER_OPTIONS); -export const saveIndexerOptions = createThunk(SAVE_INDEXER_OPTIONS); -export const setIndexerOptionsValue = createAction(SET_INDEXER_OPTIONS_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -// -// Details - -export default { - - // - // State - - defaultState: { - isFetching: false, - isPopulated: false, - error: null, - pendingChanges: {}, - isSaving: false, - saveError: null, - item: {} - }, - - // - // Action Handlers - - actionHandlers: { - [FETCH_INDEXER_OPTIONS]: createFetchHandler(section, '/config/indexer'), - [SAVE_INDEXER_OPTIONS]: createSaveHandler(section, '/config/indexer') - }, - - // - // Reducers - - reducers: { - [SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer(section) - } - -}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 35d04611e..1d1e2c6ed 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -11,7 +11,6 @@ import importListExclusions from './Settings/importListExclusions'; import importListOptions from './Settings/importListOptions'; import importLists from './Settings/importLists'; import indexerFlags from './Settings/indexerFlags'; -import indexerOptions from './Settings/indexerOptions'; export * from './Settings/autoTaggingSpecifications'; export * from './Settings/autoTaggings'; @@ -24,7 +23,6 @@ export * from './Settings/importListOptions'; export * from './Settings/importLists'; export * from './Settings/importListExclusions'; export * from './Settings/indexerFlags'; -export * from './Settings/indexerOptions'; // // Variables @@ -46,8 +44,7 @@ export const defaultState = { importLists: importLists.defaultState, importListExclusions: importListExclusions.defaultState, importListOptions: importListOptions.defaultState, - indexerFlags: indexerFlags.defaultState, - indexerOptions: indexerOptions.defaultState + indexerFlags: indexerFlags.defaultState }; export const persistState = [ @@ -68,8 +65,7 @@ export const actionHandlers = handleThunks({ ...importLists.actionHandlers, ...importListExclusions.actionHandlers, ...importListOptions.actionHandlers, - ...indexerFlags.actionHandlers, - ...indexerOptions.actionHandlers + ...indexerFlags.actionHandlers }); // @@ -86,7 +82,6 @@ export const reducers = createHandleActions({ ...importLists.reducers, ...importListExclusions.reducers, ...importListOptions.reducers, - ...indexerFlags.reducers, - ...indexerOptions.reducers + ...indexerFlags.reducers }, defaultState, section); diff --git a/frontend/src/typings/Settings/IndexerOptions.ts b/frontend/src/typings/Settings/IndexerOptions.ts deleted file mode 100644 index 1eb21de6e..000000000 --- a/frontend/src/typings/Settings/IndexerOptions.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default interface IndexerOptions { - minimumAge: number; - retention: number; - maximumSize: number; - rssSyncInterval: number; -} From fbb70519b15364a8b674edb9f1031b3ee4ecfbc8 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 8 Mar 2026 10:46:55 -0700 Subject: [PATCH 047/110] Use react-query for Indexers Flags --- frontend/src/App/State/SettingsAppState.ts | 4 -- .../Form/Select/IndexerFlagsSelectInput.tsx | 55 +++++++++---------- frontend/src/Episode/IndexerFlags.tsx | 7 +-- frontend/src/Helpers/Hooks/useAppPage.ts | 21 +++---- .../src/Settings/Indexers/useIndexerFlags.ts | 25 +++++++++ .../Store/Actions/Settings/indexerFlags.js | 48 ---------------- frontend/src/Store/Actions/settingsActions.js | 11 +--- .../Selectors/createIndexerFlagsSelector.ts | 9 --- frontend/src/typings/IndexerFlag.ts | 6 -- 9 files changed, 67 insertions(+), 119 deletions(-) create mode 100644 frontend/src/Settings/Indexers/useIndexerFlags.ts delete mode 100644 frontend/src/Store/Actions/Settings/indexerFlags.js delete mode 100644 frontend/src/Store/Selectors/createIndexerFlagsSelector.ts delete mode 100644 frontend/src/typings/IndexerFlag.ts diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index f4190547e..910071dc1 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -14,7 +14,6 @@ import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; import ImportListExclusion from 'typings/ImportListExclusion'; import ImportListOptionsSettings from 'typings/ImportListOptionsSettings'; -import IndexerFlag from 'typings/IndexerFlag'; import DownloadClientOptions from 'typings/Settings/DownloadClientOptions'; type Presets = T & { @@ -80,8 +79,6 @@ export interface ImportListExclusionsSettingsAppState pendingChanges: Partial; } -export type IndexerFlagSettingsAppState = AppSectionState; - interface SettingsAppState { autoTaggings: AutoTaggingAppState; autoTaggingSpecifications: AutoTaggingSpecificationAppState; @@ -93,7 +90,6 @@ interface SettingsAppState { importListExclusions: ImportListExclusionsSettingsAppState; importListOptions: ImportListOptionsSettingsAppState; importLists: ImportListAppState; - indexerFlags: IndexerFlagSettingsAppState; } export default SettingsAppState; diff --git a/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx b/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx index e4f149d3c..8a7b3b749 100644 --- a/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx +++ b/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx @@ -1,35 +1,8 @@ -import React, { useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; +import React, { useCallback, useMemo } from 'react'; +import useIndexerFlags from 'Settings/Indexers/useIndexerFlags'; import { EnhancedSelectInputChanged } from 'typings/inputs'; import EnhancedSelectInput from './EnhancedSelectInput'; -const selectIndexerFlagsValues = (selectedFlags: number) => - createSelector( - (state: AppState) => state.settings.indexerFlags, - (indexerFlags) => { - const value = indexerFlags.items.reduce((acc: number[], { id }) => { - // eslint-disable-next-line no-bitwise - if ((selectedFlags & id) === id) { - acc.push(id); - } - - return acc; - }, []); - - const values = indexerFlags.items.map(({ id, name }) => ({ - key: id, - value: name, - })); - - return { - value, - values, - }; - } - ); - export interface IndexerFlagsSelectInputProps { name: string; indexerFlags: number; @@ -42,7 +15,29 @@ function IndexerFlagsSelectInput({ onChange, ...otherProps }: IndexerFlagsSelectInputProps) { - const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags)); + const { data: allIndexerFlags } = useIndexerFlags(); + + const value = useMemo( + () => + allIndexerFlags.reduce((acc: number[], { id }) => { + // eslint-disable-next-line no-bitwise + if ((indexerFlags & id) === id) { + acc.push(id); + } + + return acc; + }, []), + [allIndexerFlags, indexerFlags] + ); + + const values = useMemo( + () => + allIndexerFlags.map(({ id, name }) => ({ + key: id, + value: name, + })), + [allIndexerFlags] + ); const handleChange = useCallback( (change: EnhancedSelectInputChanged) => { diff --git a/frontend/src/Episode/IndexerFlags.tsx b/frontend/src/Episode/IndexerFlags.tsx index 74e2e033c..b78e297a8 100644 --- a/frontend/src/Episode/IndexerFlags.tsx +++ b/frontend/src/Episode/IndexerFlags.tsx @@ -1,15 +1,14 @@ import React from 'react'; -import { useSelector } from 'react-redux'; -import createIndexerFlagsSelector from 'Store/Selectors/createIndexerFlagsSelector'; +import useIndexerFlags from 'Settings/Indexers/useIndexerFlags'; interface IndexerFlagsProps { indexerFlags: number; } function IndexerFlags({ indexerFlags = 0 }: IndexerFlagsProps) { - const allIndexerFlags = useSelector(createIndexerFlagsSelector); + const { data: allIndexerFlags } = useIndexerFlags(); - const flags = allIndexerFlags.items.filter( + const flags = allIndexerFlags.filter( // eslint-disable-next-line no-bitwise (item) => (indexerFlags & item.id) === item.id ); diff --git a/frontend/src/Helpers/Hooks/useAppPage.ts b/frontend/src/Helpers/Hooks/useAppPage.ts index 578c296f8..24115e1d6 100644 --- a/frontend/src/Helpers/Hooks/useAppPage.ts +++ b/frontend/src/Helpers/Hooks/useAppPage.ts @@ -8,19 +8,18 @@ import useCustomFilters from 'Filters/useCustomFilters'; import { useInitializeLanguage } from 'Language/useLanguageName'; import { useLanguages } from 'Language/useLanguages'; import useSeries from 'Series/useSeries'; +import useIndexerFlags from 'Settings/Indexers/useIndexerFlags'; import { useQualityProfiles } from 'Settings/Profiles/Quality/useQualityProfiles'; import { useUiSettings } from 'Settings/UI/useUiSettings'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; -import { - fetchImportLists, - fetchIndexerFlags, -} from 'Store/Actions/settingsActions'; +import { fetchImportLists } from 'Store/Actions/settingsActions'; import useSystemStatus from 'System/Status/useSystemStatus'; import useTags from 'Tags/useTags'; import { ApiError } from 'Utilities/Fetch/fetchJson'; const createErrorsSelector = ({ customFiltersError, + indexerFlagsError, systemStatusError, tagsError, translationsError, @@ -30,6 +29,7 @@ const createErrorsSelector = ({ languagesError, }: { customFiltersError: ApiError | null; + indexerFlagsError: ApiError | null; systemStatusError: ApiError | null; tagsError: ApiError | null; translationsError: ApiError | null; @@ -40,8 +40,7 @@ const createErrorsSelector = ({ }) => createSelector( (state: AppState) => state.settings.importLists.error, - (state: AppState) => state.settings.indexerFlags.error, - (importListsError, indexerFlagsError) => { + (importListsError) => { const hasError = !!( customFiltersError || seriesError || @@ -102,15 +101,17 @@ const useAppPage = () => { const { isFetched: isLanguagesFetched, error: languagesError } = useLanguages(); + const { isFetched: isIndexerFlagsFetched, error: indexerFlagsError } = + useIndexerFlags(); + const isAppStatePopulated = useSelector( - (state: AppState) => - state.settings.importLists.isPopulated && - state.settings.indexerFlags.isPopulated + (state: AppState) => state.settings.importLists.isPopulated ); const isPopulated = isAppStatePopulated && isCustomFiltersFetched && + isIndexerFlagsFetched && isSeriesFetched && isSystemStatusFetched && isTagsFetched && @@ -122,6 +123,7 @@ const useAppPage = () => { const { hasError, errors } = useSelector( createErrorsSelector({ customFiltersError, + indexerFlagsError, seriesError, systemStatusError, tagsError, @@ -148,7 +150,6 @@ const useAppPage = () => { useEffect(() => { dispatch(fetchCustomFilters()); dispatch(fetchImportLists()); - dispatch(fetchIndexerFlags()); }, [dispatch]); return useMemo(() => { diff --git a/frontend/src/Settings/Indexers/useIndexerFlags.ts b/frontend/src/Settings/Indexers/useIndexerFlags.ts new file mode 100644 index 000000000..454a9614e --- /dev/null +++ b/frontend/src/Settings/Indexers/useIndexerFlags.ts @@ -0,0 +1,25 @@ +import useApiQuery from 'Helpers/Hooks/useApiQuery'; + +export interface IndexerFlag { + id: number; + name: string; +} + +const DEFAULT_INDEXER_FLAGS: IndexerFlag[] = []; + +const useIndexerFlags = () => { + const result = useApiQuery({ + path: '/indexerFlag', + queryOptions: { + gcTime: Infinity, + staleTime: Infinity, + }, + }); + + return { + ...result, + data: result.data ?? DEFAULT_INDEXER_FLAGS, + }; +}; + +export default useIndexerFlags; diff --git a/frontend/src/Store/Actions/Settings/indexerFlags.js b/frontend/src/Store/Actions/Settings/indexerFlags.js deleted file mode 100644 index a53fe1c61..000000000 --- a/frontend/src/Store/Actions/Settings/indexerFlags.js +++ /dev/null @@ -1,48 +0,0 @@ -import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; -import { createThunk } from 'Store/thunks'; - -// -// Variables - -const section = 'settings.indexerFlags'; - -// -// Actions Types - -export const FETCH_INDEXER_FLAGS = 'settings/indexerFlags/fetchIndexerFlags'; - -// -// Action Creators - -export const fetchIndexerFlags = createThunk(FETCH_INDEXER_FLAGS); - -// -// Details - -export default { - - // - // State - - defaultState: { - isFetching: false, - isPopulated: false, - error: null, - items: [] - }, - - // - // Action Handlers - - actionHandlers: { - [FETCH_INDEXER_FLAGS]: createFetchHandler(section, '/indexerFlag') - }, - - // - // Reducers - - reducers: { - - } - -}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 1d1e2c6ed..19a34d9eb 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -10,7 +10,6 @@ import downloadClients from './Settings/downloadClients'; import importListExclusions from './Settings/importListExclusions'; import importListOptions from './Settings/importListOptions'; import importLists from './Settings/importLists'; -import indexerFlags from './Settings/indexerFlags'; export * from './Settings/autoTaggingSpecifications'; export * from './Settings/autoTaggings'; @@ -22,7 +21,6 @@ export * from './Settings/downloadClientOptions'; export * from './Settings/importListOptions'; export * from './Settings/importLists'; export * from './Settings/importListExclusions'; -export * from './Settings/indexerFlags'; // // Variables @@ -43,8 +41,7 @@ export const defaultState = { downloadClientOptions: downloadClientOptions.defaultState, importLists: importLists.defaultState, importListExclusions: importListExclusions.defaultState, - importListOptions: importListOptions.defaultState, - indexerFlags: indexerFlags.defaultState + importListOptions: importListOptions.defaultState }; export const persistState = [ @@ -64,8 +61,7 @@ export const actionHandlers = handleThunks({ ...downloadClientOptions.actionHandlers, ...importLists.actionHandlers, ...importListExclusions.actionHandlers, - ...importListOptions.actionHandlers, - ...indexerFlags.actionHandlers + ...importListOptions.actionHandlers }); // @@ -81,7 +77,6 @@ export const reducers = createHandleActions({ ...downloadClientOptions.reducers, ...importLists.reducers, ...importListExclusions.reducers, - ...importListOptions.reducers, - ...indexerFlags.reducers + ...importListOptions.reducers }, defaultState, section); diff --git a/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts b/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts deleted file mode 100644 index 90587639c..000000000 --- a/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; - -const createIndexerFlagsSelector = createSelector( - (state: AppState) => state.settings.indexerFlags, - (indexerFlags) => indexerFlags -); - -export default createIndexerFlagsSelector; diff --git a/frontend/src/typings/IndexerFlag.ts b/frontend/src/typings/IndexerFlag.ts deleted file mode 100644 index 2c7d97a73..000000000 --- a/frontend/src/typings/IndexerFlag.ts +++ /dev/null @@ -1,6 +0,0 @@ -interface IndexerFlag { - id: number; - name: string; -} - -export default IndexerFlag; From 526ef5428d0d443c9104481d3bd73501dad35fea Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 8 Mar 2026 11:31:41 -0700 Subject: [PATCH 048/110] Improve scene mapping updating Closes #8452 --- .../Scene/SceneMappingServiceFixture.cs | 12 ++++-- .../DataAugmentation/Scene/SceneMapping.cs | 1 + .../Scene/SceneMappingRepository.cs | 6 +-- .../Scene/SceneMappingService.cs | 41 +++++++++++++++++-- .../DataAugmentation/Xem/XemProxy.cs | 1 + .../230_add_mapping_id_to_scene_mappings.cs | 15 +++++++ 6 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 src/NzbDrone.Core/Datastore/Migration/230_add_mapping_id_to_scene_mappings.cs diff --git a/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingServiceFixture.cs b/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingServiceFixture.cs index e4671f91b..9c0bf09b4 100644 --- a/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingServiceFixture.cs +++ b/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingServiceFixture.cs @@ -46,6 +46,10 @@ public void Setup() _provider2 = new Mock(); _provider2.Setup(s => s.GetSceneMappings()).Returns(_fakeMappings); + + Mocker.GetMock() + .Setup(c => c.GetAllByType(It.IsAny())) + .Returns(new List()); } private void GivenProviders(IEnumerable> providers) @@ -375,15 +379,15 @@ public void should_pick_best_season() private void AssertNoUpdate() { _provider1.Verify(c => c.GetSceneMappings(), Times.Once()); - Mocker.GetMock().Verify(c => c.Clear(It.IsAny()), Times.Never()); - Mocker.GetMock().Verify(c => c.InsertMany(_fakeMappings), Times.Never()); + Mocker.GetMock().Verify(c => c.InsertMany(It.IsAny>()), Times.Never()); + Mocker.GetMock().Verify(c => c.UpdateMany(It.IsAny>()), Times.Never()); + Mocker.GetMock().Verify(c => c.DeleteMany(It.IsAny>()), Times.Never()); } private void AssertMappingUpdated() { _provider1.Verify(c => c.GetSceneMappings(), Times.Once()); - Mocker.GetMock().Verify(c => c.Clear(It.IsAny()), Times.Once()); - Mocker.GetMock().Verify(c => c.InsertMany(_fakeMappings), Times.Once()); + Mocker.GetMock().Verify(c => c.InsertMany(It.IsAny>()), Times.Once()); foreach (var sceneMapping in _fakeMappings) { diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs index 52cb21160..20c116d65 100644 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs +++ b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs @@ -5,6 +5,7 @@ namespace NzbDrone.Core.DataAugmentation.Scene { public class SceneMapping : ModelBase { + public string MappingId { get; set; } public string Title { get; set; } public string ParseTerm { get; set; } diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingRepository.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingRepository.cs index 92119ec55..45d91f362 100644 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingRepository.cs +++ b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingRepository.cs @@ -7,7 +7,7 @@ namespace NzbDrone.Core.DataAugmentation.Scene public interface ISceneMappingRepository : IBasicRepository { List FindByTvdbid(int tvdbId); - void Clear(string type); + List GetAllByType(string type); } public class SceneMappingRepository : BasicRepository, ISceneMappingRepository @@ -22,9 +22,9 @@ public List FindByTvdbid(int tvdbId) return Query(x => x.TvdbId == tvdbId); } - public void Clear(string type) + public List GetAllByType(string type) { - Delete(s => s.Type == type); + return Query(x => x.Type == type); } } } diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs index ee4c331de..e0a145760 100644 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs +++ b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs @@ -143,7 +143,7 @@ private void UpdateMappings() if (mappings.Any()) { - _repository.Clear(sceneMappingProvider.GetType().Name); + var providerType = sceneMappingProvider.GetType().Name; mappings.RemoveAll(sceneMapping => { @@ -160,10 +160,45 @@ private void UpdateMappings() foreach (var sceneMapping in mappings) { sceneMapping.ParseTerm = sceneMapping.Title.CleanSeriesTitle(); - sceneMapping.Type = sceneMappingProvider.GetType().Name; + sceneMapping.Type = providerType; } - _repository.InsertMany(mappings.ToList()); + var existing = _repository.GetAllByType(providerType); + var existingByMappingId = new Dictionary(); + + foreach (var e in existing) + { + existingByMappingId[e.MappingId ?? $"{e.Id}"] = e; + } + + var toInsert = new List(); + var toUpdate = new List(); + + foreach (var mapping in mappings) + { + if (mapping.MappingId.IsNullOrWhiteSpace()) + { + _logger.Warn("Scene mapping with missing MappingId found for: {0} {1}, skipping", mapping.TvdbId, mapping.Title); + continue; + } + + if (existingByMappingId.TryGetValue(mapping.MappingId, out var existingMapping)) + { + mapping.Id = existingMapping.Id; + toUpdate.Add(mapping); + existingByMappingId.Remove(mapping.MappingId); + } + else + { + toInsert.Add(mapping); + } + } + + var toDelete = existingByMappingId.Values.ToList(); + + _repository.DeleteMany(toDelete); + _repository.UpdateMany(toUpdate); + _repository.InsertMany(toInsert); } else { diff --git a/src/NzbDrone.Core/DataAugmentation/Xem/XemProxy.cs b/src/NzbDrone.Core/DataAugmentation/Xem/XemProxy.cs index 4ebe2ba4b..86796ae8f 100644 --- a/src/NzbDrone.Core/DataAugmentation/Xem/XemProxy.cs +++ b/src/NzbDrone.Core/DataAugmentation/Xem/XemProxy.cs @@ -101,6 +101,7 @@ public List GetSceneTvdbNames() result.Add(new SceneMapping { + MappingId = $"x-{series.Key}_S{seasonNumber}_{n.Key.Replace(' ', '_')}", Title = n.Key, SearchTerm = n.Key, SceneSeasonNumber = seasonNumber, diff --git a/src/NzbDrone.Core/Datastore/Migration/230_add_mapping_id_to_scene_mappings.cs b/src/NzbDrone.Core/Datastore/Migration/230_add_mapping_id_to_scene_mappings.cs new file mode 100644 index 000000000..991e2f541 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/230_add_mapping_id_to_scene_mappings.cs @@ -0,0 +1,15 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(230)] + public class add_mapping_id_to_scene_mappings : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("SceneMappings") + .AddColumn("MappingId").AsString().Nullable(); + } + } +} From fa69c485e96c43b2d67b4dfec63a4deedccb0287 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 8 Mar 2026 11:42:40 -0700 Subject: [PATCH 049/110] Maintain relative path when reprocessing items in manual import --- frontend/src/InteractiveImport/useInteractiveImport.ts | 2 ++ src/Sonarr.Api.V5/ManualImport/ManualImportController.cs | 5 +++++ .../ManualImport/ManualImportReprocessResource.cs | 1 + 3 files changed, 8 insertions(+) diff --git a/frontend/src/InteractiveImport/useInteractiveImport.ts b/frontend/src/InteractiveImport/useInteractiveImport.ts index ae075e3d2..998de496c 100644 --- a/frontend/src/InteractiveImport/useInteractiveImport.ts +++ b/frontend/src/InteractiveImport/useInteractiveImport.ts @@ -116,6 +116,7 @@ export const useUpdateInteractiveImportItems = () => { interface ReprocessInteractiveImportItem extends ModelBase { path: string; + relativePath: string; seriesId: number | undefined; seasonNumber: number | undefined; episodeIds: number[] | undefined; @@ -179,6 +180,7 @@ export const useReprocessInteractiveImportItems = () => { acc.push({ id, path: item.path, + relativePath: item.relativePath, seriesId: item.series ? item.series.id : undefined, seasonNumber: item.seasonNumber, episodeIds: (item.episodes || []).map((e) => e.id), diff --git a/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs b/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs index 2fb8d79b0..2d8e8653a 100644 --- a/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs +++ b/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs @@ -87,6 +87,11 @@ public List ReprocessItems([FromBody] List Episodes { get; set; } = []; From 494f446b05cece4e7df505ad8edbaab503553497 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 8 Mar 2026 12:11:59 -0700 Subject: [PATCH 050/110] New: Show error if manual import has the same episode assigned multiple times Closes #8458 --- .../InteractiveImportModalContent.css | 1 + .../InteractiveImportModalContent.tsx | 32 ++++++++++++++++--- src/NzbDrone.Core/Localization/Core/en.json | 1 + 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css index b15e0196e..0dd44621d 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css @@ -18,6 +18,7 @@ .leftButtons, .rightButtons { display: flex; + align-items: center; flex-wrap: wrap; min-width: 0; } diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx index 86bdeb23f..85f94c546 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx @@ -500,6 +500,9 @@ function InteractiveImportModalContentInner( return; } + const seenEpisodeIds = new Set(); + let hasDuplicateEpisodes = false; + items.forEach((item) => { const isSelected = selectedIds.indexOf(item.id) > -1; @@ -552,6 +555,19 @@ function InteractiveImportModalContentInner( return; } + if (!hasDuplicateEpisodes) { + for (const episode of episodes) { + const hasAlreadySeen = seenEpisodeIds.has(episode.id); + + seenEpisodeIds.add(episode.id); + + if (hasAlreadySeen) { + hasDuplicateEpisodes = true; + return; + } + } + } + setInteractiveImportErrorMessage(null); if (episodeFileId) { @@ -587,6 +603,14 @@ function InteractiveImportModalContentInner( } }); + if (hasDuplicateEpisodes) { + setInteractiveImportErrorMessage( + translate('InteractiveImportDuplicateEpisodes') + ); + + return; + } + let shouldClose = false; if (existingFiles.length) { @@ -953,13 +977,13 @@ function InteractiveImportModalContentInner(
- - - {interactiveImportErrorMessage && ( + {interactiveImportErrorMessage ? ( {interactiveImportErrorMessage} - )} + ) : null} + + + + {isInUse ? ( + + {seriesCount ? ( +
+ {translate('QualityProfileUsedInCountSeries', { + count: seriesCount, + })} +
+ ) : null} + {importListCount ? ( +
+ {translate('QualityProfileUsedInCountImportLists', { + count: importListCount, + })} +
+ ) : null} +
+ } + anchor={ + + } + /> + ) : null} ) : null} diff --git a/frontend/src/Settings/Profiles/Quality/useQualityProfileInUse.ts b/frontend/src/Settings/Profiles/Quality/useQualityProfileInUse.ts index 29322c190..d0154e46b 100644 --- a/frontend/src/Settings/Profiles/Quality/useQualityProfileInUse.ts +++ b/frontend/src/Settings/Profiles/Quality/useQualityProfileInUse.ts @@ -11,13 +11,18 @@ function useQualityProfileInUse(id: number | undefined) { return useMemo(() => { if (!id) { - return false; + return { + seriesCount: 0, + importsCount: 0, + }; } - return ( - series.some((s) => s.qualityProfileId === id) || - importLists.some((list) => list.qualityProfileId === id) - ); + return { + seriesCount: series.filter((s) => s.qualityProfileId === id).length, + importListCount: importLists.filter( + (list) => list.qualityProfileId === id + ).length, + }; }, [id, series, importLists]); } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index bc0f86a39..db9577107 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1703,6 +1703,9 @@ "QualityDefinitionsSizeNotice": "Size restrictions have been moved to Quality Profiles", "QualityProfile": "Quality Profile", "QualityProfileInUseSeriesListCollection": "Can't delete a quality profile that is attached to a series, list, or collection", + "QualityProfileUsage": "Quality Profile Usage", + "QualityProfileUsedInCountImportLists": "Used in {count} import lists", + "QualityProfileUsedInCountSeries": "Used in {count} series", "QualityProfiles": "Quality Profiles", "QualityProfilesLoadError": "Unable to load Quality Profiles", "QualitySettings": "Quality Settings", From 116c602992fc5cbdb713184375bbac3747d22b37 Mon Sep 17 00:00:00 2001 From: Andrew Ukkonen Date: Sun, 22 Mar 2026 19:04:24 -0500 Subject: [PATCH 058/110] Update src/NzbDrone.Core/Localization/Core/en.json Co-authored-by: Mark McDowall --- src/NzbDrone.Core/Localization/Core/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 348f77766..a932cbafb 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -948,7 +948,7 @@ "ImportListsTraktSettingsAdditionalParametersHelpText": "Additional Trakt API parameters", "ImportListsTraktSettingsAuthenticateWithTrakt": "Authenticate with Trakt", "ImportListsTraktSettingsGenres": "Genres", - "ImportListsTraktSettingsGenresSeriesHelpText": "Filter series by Trakt Genre slug (action,comedy)", + "ImportListsTraktSettingsGenresSeriesHelpText": "Filter series by Trakt Genre slug, comma separated (action,comedy)", "ImportListsTraktSettingsLimit": "Limit", "ImportListsTraktSettingsLimitSeriesHelpText": "Limit the number of series to get", "ImportListsTraktSettingsListName": "List Name", From e4173077dc34a2cf6d7abbd09db32d56ce25c8c1 Mon Sep 17 00:00:00 2001 From: Weblate Date: Sat, 11 Apr 2026 16:27:30 +0000 Subject: [PATCH 059/110] Multiple Translations updated by Weblate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ignore-downstream Co-authored-by: Havok Dan Co-authored-by: Lordomus Co-authored-by: Mateusz Lesiak Co-authored-by: Pietro Co-authored-by: Storm Co-authored-by: Weblate Co-authored-by: Yassir Co-authored-by: anonymous951 Co-authored-by: fordas Co-authored-by: qq734231785 <734231785@qq.com> Co-authored-by: ugyes Co-authored-by: 康小广 Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pl/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_Hans/ Translation: Servarr/Sonarr --- src/NzbDrone.Core/Localization/Core/de.json | 1 + src/NzbDrone.Core/Localization/Core/es.json | 8 + src/NzbDrone.Core/Localization/Core/hu.json | 4 + src/NzbDrone.Core/Localization/Core/it.json | 136 +- src/NzbDrone.Core/Localization/Core/pl.json | 2267 ++++++++++++++++- .../Localization/Core/pt_BR.json | 4 + src/NzbDrone.Core/Localization/Core/ru.json | 1 + .../Localization/Core/zh_CN.json | 139 +- .../Localization/Core/zh_Hans.json | 3 +- 9 files changed, 2493 insertions(+), 70 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/de.json b/src/NzbDrone.Core/Localization/Core/de.json index 681daa3c0..eecc14ee2 100644 --- a/src/NzbDrone.Core/Localization/Core/de.json +++ b/src/NzbDrone.Core/Localization/Core/de.json @@ -13,6 +13,7 @@ "AddConditionError": "Neue Bedingung konnte nicht hinzugefügt werden, bitte erneut versuchen.", "AddConditionImplementation": "Bedingung hinzufügen - {implementationName}", "AddConnection": "Verbindung hinzufügen", + "AddConnectionError": "Neue Verbindung konnte nicht hinzugefügt werden. Bitte erneut versuchen.", "AddConnectionImplementation": "Verbindung hinzufügen - {implementationName}", "AddCustomFilter": "Eigenen Filter hinzufügen", "AddCustomFormat": "Eigenes Format hinzufügen", diff --git a/src/NzbDrone.Core/Localization/Core/es.json b/src/NzbDrone.Core/Localization/Core/es.json index 29fa79e85..f82b097b0 100644 --- a/src/NzbDrone.Core/Localization/Core/es.json +++ b/src/NzbDrone.Core/Localization/Core/es.json @@ -364,6 +364,7 @@ "DeleteEpisodeFromDisk": "Eliminar episodio del disco", "DeleteEpisodesFiles": "Eliminar {episodeFileCount} archivos de episodios", "DeleteEpisodesFilesHelpText": "Eliminar archivos de episodios y directorio de series", + "DeleteFiles": "Eliminar archivos", "DeleteImportList": "Eliminar lista de importación", "DeleteImportListExclusion": "Eliminar exclusión de listas de importación", "DeleteImportListExclusionMessageText": "¿Estás seguro que quieres eliminar esta exclusión de la lista de importación?", @@ -391,6 +392,8 @@ "DeleteSelectedIndexers": "Borrar indexador(es)", "DeleteSelectedIndexersMessageText": "¿Estás seguro que quieres eliminar {count} indexador(es) seleccionado(s)?", "DeleteSelectedSeries": "Eliminar serie seleccionada", + "DeleteSelectedSeriesFiles": "Eliminar archivos de series seleccionadas", + "DeleteSeriesFilesConfirmation": "¿Estás seguro de que quieres eliminar todos los archivos de episodios rastreados para las {count} series seleccionadas?", "DeleteSeriesFolder": "Eliminar directorio de series", "DeleteSeriesFolderConfirmation": "El directorio de series '{path}' y todos sus contenidos seran eliminados.", "DeleteSeriesFolderCountConfirmation": "Esta seguro que desea eliminar '{count}' series seleccionadas?", @@ -1104,6 +1107,7 @@ "InstanceName": "Nombre de la Instancia", "InstanceNameHelpText": "Nombre de la instancia en la pestaña y para la aplicación Syslog", "InteractiveImport": "Importación Interactiva", + "InteractiveImportDuplicateEpisodes": "Uno o más episodios fueron asignados a múltiples archivos", "InteractiveImportLoadError": "No se pueden cargar elementos de la importación manual", "InteractiveImportMultipleQueueItems": "Múltiples colas de elementos", "InteractiveImportNoEpisode": "Hay que elegir uno o varios episodios para cada archivo seleccionado", @@ -1699,6 +1703,9 @@ "QualityDefinitionsSizeNotice": "Las restricciones de tamaño se han movido a los Perfiles de Calidad", "QualityProfile": "Perfil de calidad", "QualityProfileInUseSeriesListCollection": "No se puede borrar un perfil de calidad que está asignado a una serie, lista o colección", + "QualityProfileUsage": "Uso de perfil de calidad", + "QualityProfileUsedInCountImportLists": "Usado en {count} listas de importación", + "QualityProfileUsedInCountSeries": "Usado en {count} series", "QualityProfiles": "Perfiles de calidad", "QualityProfilesLoadError": "No se pudo cargar los perfiles de calidad", "QualitySettings": "Opciones de calidad", @@ -1958,6 +1965,7 @@ "SeriesFolderImportedTooltip": "Episodio importado de la carpeta de serie", "SeriesFootNote": "Opcionalmente controla el truncamiento hasta un número máximo de bytes, incluyendo elipsis (`...`). Está soportado truncar tanto desde el final (p. ej. `{Título de serie:30}`) como desde el principio (p. ej. `{Título de serie:-30}`).", "SeriesID": "ID de serie", + "SeriesInImportListExclusions": "La serie está en las exclusiones de la lista de importación", "SeriesIndexFooterContinuing": "Continuando (Todos los episodios descargados)", "SeriesIndexFooterDownloading": "Descargando (Uno o más episodios)", "SeriesIndexFooterEnded": "FInalizado (Todos los episodios descargados)", diff --git a/src/NzbDrone.Core/Localization/Core/hu.json b/src/NzbDrone.Core/Localization/Core/hu.json index 0a16429ea..52870ca08 100644 --- a/src/NzbDrone.Core/Localization/Core/hu.json +++ b/src/NzbDrone.Core/Localization/Core/hu.json @@ -364,6 +364,7 @@ "DeleteEpisodeFromDisk": "Epizód törlése a lemezről", "DeleteEpisodesFiles": "{episodeFileCount} epizódfájl törlése", "DeleteEpisodesFilesHelpText": "Törölje az epizódfájlokat és a sorozat mappáját", + "DeleteFiles": "Fájlok törlése", "DeleteImportList": "Importálási lista törlése", "DeleteImportListExclusion": "Importálási lista kizárásának törlése", "DeleteImportListExclusionMessageText": "Biztosan törli ezt az importlista-kizárást?", @@ -391,6 +392,8 @@ "DeleteSelectedIndexers": "Indexelő(k) törlése", "DeleteSelectedIndexersMessageText": "Biztosan törölni szeretne {count} kiválasztott indexelőt?", "DeleteSelectedSeries": "A kiválasztott sorozat törlése", + "DeleteSelectedSeriesFiles": "A kijelölt sorozatfájlok törlése", + "DeleteSeriesFilesConfirmation": "Biztosan törölni szeretné az összes nyomon követett epizódfájlt a {count} kiválasztott sorozatból?", "DeleteSeriesFolder": "Sorozatmappa törlése", "DeleteSeriesFolderConfirmation": "A sorozat könyvtár \"{path}\" és minden tartalma törlésre kerül.", "DeleteSeriesFolderCountConfirmation": "Biztosan törölni szeretne {count} kiválasztott sorozatot?", @@ -1958,6 +1961,7 @@ "SeriesFolderImportedTooltip": "Az epizód a sorozat mappájából importálva", "SeriesFootNote": "Opcionálisan szabályozható a maximális bájtméretre csonkítás, beleértve az ellipszist (...) is. A végéről történő csonkítás (pl. {Sorozatcím:30}) és az elejéről történő csonkítás (pl. {Sorozatcím:-30}) egyaránt támogatott.", "SeriesID": "Sorozat ID", + "SeriesInImportListExclusions": "A sorozat szerepel az importálási lista kizárásai között", "SeriesIndexFooterContinuing": "Folytatás (Minden epizód letöltve)", "SeriesIndexFooterDownloading": "Folytatás (Minden epizód letöltve)", "SeriesIndexFooterEnded": "Befejeződött (az összes epizód letöltve)", diff --git a/src/NzbDrone.Core/Localization/Core/it.json b/src/NzbDrone.Core/Localization/Core/it.json index 6f3426323..a627a2f89 100644 --- a/src/NzbDrone.Core/Localization/Core/it.json +++ b/src/NzbDrone.Core/Localization/Core/it.json @@ -13,6 +13,7 @@ "AddConditionError": "Non è stato possibile aggiungere una nuova condizione. Riprova.", "AddConditionImplementation": "Aggiungi Condizione - {implementationName}", "AddConnection": "Aggiungi Connessione", + "AddConnectionError": "Impossibile aggiungere una nuova connessione, riprova.", "AddConnectionImplementation": "Aggiungi Connessione - {implementationName}", "AddCustomFilter": "Aggiungi Filtro Personalizzato", "AddCustomFormat": "Aggiungi Formato Personalizzato", @@ -61,25 +62,29 @@ "AgeWhenGrabbed": "Età (quando recuperato)", "Agenda": "Agenda", "AirDate": "Data di Trasmissione", + "AirDateGracePeriod": "Periodo di Tolleranza per la Data di Trasmissione", + "AirDateGracePeriodHelpText": "Valori negativi permettono di recuperare prima della data di trasmissione, valori positivi impediscono di recuperare dopo la data di trasmissione.", + "AirDateRestriction": "Rifiuta Trasmissioni Inedite", + "AirDateRestrictionHelpText": "Impedisce a {appName} di recuperare uscite che contengono episodi non ancora trasmessi.", "Airs": "Trasmesso", "AirsDateAtTimeOn": "il {date} alle {time} su {networkLabel}", - "AirsTbaOn": "Verrà trasmesso su {networkLabel}", + "AirsTbaOn": "Verrà annunciato su {networkLabel}", "AirsTimeOn": "alle {time} su {networkLabel}", "AirsTomorrowOn": "Domani alle {time} su {networkLabel}", "All": "Tutti", "AllFiles": "Tutti i File", "AllResultsAreHiddenByTheAppliedFilter": "Tutti i risultati sono nascosti dal filtro applicato", - "AllSeriesAreHiddenByTheAppliedFilter": "Tutti i risultati sono nascosti dal filtro", + "AllSeriesAreHiddenByTheAppliedFilter": "Tutti i risultati sono nascosti dal filtro applicato", "AllSeriesInRootFolderHaveBeenImported": "Tutte le serie in {path} sono state importate", "AllTitles": "Tutti i Titoli", "AlreadyInYourLibrary": "Già presente nella tua libreria", - "AlternateTitles": "Titolo alternativo", + "AlternateTitles": "Titoli alternativi", "Always": "Sempre", - "AnEpisodeIsDownloading": "Un episodio è in download", + "AnEpisodeIsDownloading": "Un Episodio è in download", "AnalyseVideoFiles": "Analizza i file video", "AnalyseVideoFilesHelpText": "Estrai le informazioni video come risoluzione, durata e codec dai file. Questo richiede che {appName} legga delle parti dei file, ciò potrebbe causare un alto utilizzo del disco e della rete durante le scansioni.", "Analytics": "Statistiche", - "AnalyticsEnabledHelpText": "Inviare informazioni anonime sull'utilizzo e sugli errori ai server di {appName}. Ciò include informazioni sul tuo browser, quali pagine dell'interfaccia di {appName} usi, la segnalazione di errori così come la versione del sistema operativo e del runtime. Utilizzeremo queste informazioni per dare priorità alle nuove funzioni e alle correzioni di bug.", + "AnalyticsEnabledHelpText": "Invia informazioni anonime sull'utilizzo e sugli errori ai server di {appName}. Ciò include informazioni sul tuo browser, quali pagine dell'interfaccia di {appName} usi, la segnalazione di errori così come la versione del sistema operativo e del runtime. Utilizzeremo queste informazioni per dare priorità alle nuove funzioni e alle correzioni di bug.", "Anime": "Anime", "AnimeEpisodeFormat": "Formato Episodi Anime", "AnimeEpisodeTypeDescription": "Episodi rilasciati utilizzando un numero di episodio assoluto", @@ -131,6 +136,7 @@ "AutoTaggingSpecificationMaximumYear": "Anno Massimo", "AutoTaggingSpecificationMinimumYear": "Anno Minimo", "AutoTaggingSpecificationNetwork": "Rete(i)", + "AutoTaggingSpecificationOriginalCountry": "Nazione", "AutoTaggingSpecificationOriginalLanguage": "Lingua", "AutoTaggingSpecificationQualityProfile": "Profilo Qualità", "AutoTaggingSpecificationRootFolder": "Cartella Radice", @@ -166,8 +172,10 @@ "BlocklistRelease": "Release in Lista dei Blocchi", "BlocklistReleaseHelpText": "Impedisci a {appName} di scaricare nuovamente questa release via RSS o Ricerca Automatica", "BlocklistReleases": "Blocca questa Release", - "Branch": "Branca", - "BranchUpdate": "Branca da usare per aggiornare {appName}", + "Blocklisted": "In Blocklist", + "BlocklistedAt": "In Blocklist dal {date}", + "Branch": "Ramo", + "BranchUpdate": "Ramo da usare per aggiornare {appName}", "BranchUpdateMechanism": "Ramo utilizzato dal sistema di aggiornamento esterno", "BrowserReloadRequired": "Richiede il reload del Browser", "BuiltIn": "Incluso", @@ -176,7 +184,7 @@ "BypassDelayIfAboveCustomFormatScoreMinimumScore": "Punteggio minimo del Formato Personalizzato", "BypassDelayIfAboveCustomFormatScoreMinimumScoreHelpText": "Punteggio minimo del Formato Personalizzato per ignorare il ritardo del protocollo preferito", "BypassDelayIfHighestQuality": "Ignora se è alla qualità massima", - "BypassDelayIfHighestQualityHelpText": "Ignora il ritardo quando la release ha la massima qualità abilitata nel profilo qualità con il protocollo preferito", + "BypassDelayIfHighestQualityHelpText": "Ignora il ritardo quando l'uscita ha la massima qualità abilitata nel profilo qualità con il protocollo preferito", "BypassProxyForLocalAddresses": "Ignora il Proxy per Indirizzi Locali", "Calendar": "Calendario", "CalendarFeed": "Feed calendario {appName}", @@ -220,7 +228,7 @@ "ClickToChangeLanguage": "Click per cambiare lingua", "ClickToChangeQuality": "Click per cambiare qualità", "ClickToChangeReleaseGroup": "Clicca per cambiare gruppo di rilascio", - "ClickToChangeReleaseType": "Clicca per cambiare il tipo di release", + "ClickToChangeReleaseType": "Clicca per cambiare il tipo di uscita", "ClickToChangeSeason": "Click per cambiare stagione", "ClickToChangeSeries": "Click per cambiare serie", "ClientPriority": "Priorità Client", @@ -253,6 +261,7 @@ "ConnectionLostToBackend": "{appName} ha perso la connessione al backend e dovrà essere ricaricato per ripristinare la funzionalità.", "ConnectionSettingsUrlBaseHelpText": "Aggiunge un prefisso all'url della {connectionName}, come {url}", "Connections": "Connessioni", + "ConnectionsLoadError": "Impossibile caricare le Connessioni", "Continuing": "In Corso", "ContinuingOnly": "Solo In Corso", "ContinuingSeriesDescription": "Altri episodi/stagioni sono attesi", @@ -274,20 +283,25 @@ "CreateGroup": "Crea gruppo", "CurrentlyInstalled": "Attualmente Installato", "Custom": "Personalizzato", + "CustomColonReplacement": "Sostituzione Personalizzata Due Punti", "CustomColonReplacementFormatHelpText": "Caratteri da utilizzare al posto dei due punti", + "CustomColonReplacementFormatHint": "Carattere valido del file system come i Due Punti (Lettera)", "CustomFilter": "Filtro Personalizzato", "CustomFilters": "Filtri Personalizzati", "CustomFormat": "Formato Personalizzato", "CustomFormatHelpText": "{appName} valuta ogni release usando la somma dei punteggi dei corrispondenti formati personalizzati. Se una nuova versione migliorasse il punteggio, con una qualità uguale o migliore, {appName} lo prenderà.", "CustomFormatJson": "Formato Personalizzato JSON", "CustomFormatScore": "Formato Personalizzato Punteggio", + "CustomFormatUnknownCondition": "Condizione del Formato Personalizzato '{implementation}' sconosciuta", "CustomFormatUnknownConditionOption": "Opzione sconosciuta '{key}' per la condizione '{implementation}'", "CustomFormats": "Formati Personalizzati", "CustomFormatsLoadError": "Impossibile a caricare Formati Personalizzati", "CustomFormatsSettings": "Formati Personalizzati Impostazioni", "CustomFormatsSettingsSummary": "Formati Personalizzati Impostazioni", + "CustomFormatsSettingsTriggerInfo": "Un Formato Personalizzato sarà applicato ad un'uscita o a un file quando corrisponde ad almeno una delle diverse tipologie di condizioni selezionate.", "CustomFormatsSpecificationExceptLanguage": "Escludi lingua", "CustomFormatsSpecificationExceptLanguageHelpText": "Si applica se qualsiasi lingua diversa da quella selezionata è presente", + "CustomFormatsSpecificationFlag": "Flag", "CustomFormatsSpecificationLanguage": "Lingua", "CustomFormatsSpecificationMaximumSize": "Dimensione Massima", "CustomFormatsSpecificationMaximumSizeHelpText": "La release deve essere minore o uguale a questa dimensione", @@ -295,7 +309,7 @@ "CustomFormatsSpecificationMinimumSizeHelpText": "La release deve essere maggiore di questa dimensione", "CustomFormatsSpecificationRegularExpression": "Espressione Regolare", "CustomFormatsSpecificationRegularExpressionHelpText": "L'espressione regolare del Formato Personalizzato ignora le maiuscole/minuscole", - "CustomFormatsSpecificationReleaseGroup": "Gruppo Release", + "CustomFormatsSpecificationReleaseGroup": "Gruppo Uscita", "CustomFormatsSpecificationResolution": "Risoluzione", "CustomFormatsSpecificationSource": "Fonte", "Cutoff": "Soglia", @@ -350,7 +364,10 @@ "DeleteEpisodeFromDisk": "Cancella episodio dal disco", "DeleteEpisodesFiles": "Elimina i File di {episodeFileCount} Episodi", "DeleteEpisodesFilesHelpText": "Cancella i file degli episodi e la cartella della serie", + "DeleteFiles": "Elimina File", "DeleteImportList": "Cancella la lista di importazione", + "DeleteImportListExclusion": "Rimuovi Esclusione dalla Lista Importazioni", + "DeleteImportListExclusionMessageText": "Sei sicuro di voler rimuovere questa esclusione dalla lista di esclusioni delle importazioni?", "DeleteImportListMessageText": "Sei sicuro di voler eliminare la lista '{name}'?", "DeleteIndexer": "Cancella Indice", "DeleteIndexerMessageText": "Sei sicuro di voler eliminare l'indice '{name}'?", @@ -359,9 +376,12 @@ "DeleteQualityProfile": "Elimina Profilo Qualità", "DeleteQualityProfileMessageText": "Sicuro di voler cancellare il profilo di qualità '{name}'?", "DeleteReleaseProfile": "Cancellare il profilo release", + "DeleteReleaseProfileMessageText": "Sicuro di voler eliminare il profilo uscita '{name}'?", + "DeleteRemotePathMapping": "Elimina la Mappatura dei Percorsi Remoti", "DeleteSelectedDownloadClients": "Cancella i Client di Download", "DeleteSelectedDownloadClientsMessageText": "Sei sicuro di voler eliminare i '{count}' client di download selezionato/i?", "DeleteSelectedEpisodeFiles": "Elimina i File degli Episodi Selezionati", + "DeleteSelectedImportLists": "Cancella la(e) lista(e) di importazione", "DeleteSelectedImportListsMessageText": "Confermi di voler eliminare le {count} liste di importazione selezionate?", "DeleteSelectedIndexers": "Elimina Indice/i", "DeleteSelectedIndexersMessageText": "Confermi di voler eliminare i {count} indici selezionati?", @@ -369,6 +389,8 @@ "Deleted": "Cancellato", "Destination": "Destinazione", "DetailedProgressBarHelpText": "Mostra testo sulla barra di avanzamento", + "Details": "Dettagli", + "Disabled": "Disabilitato", "Discord": "Discord", "DiskSpace": "Spazio sul Disco", "Docker": "Docker", @@ -376,6 +398,7 @@ "Donate": "Dona", "Donations": "Donazioni", "DotNetVersion": ".NET", + "Download": "Scarica", "DownloadClient": "Client di Download", "DownloadClientCheckNoneAvailableHealthCheckMessage": "Non è disponibile nessun client di download", "DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Impossibile comunicare con {downloadClientName}. {errorMessage}", @@ -411,6 +434,7 @@ "DownloadClientQbittorrentValidationCategoryUnsupportedDetail": "Categorie non supportate fino alla versione 3.3.0 di qBittorrent. Per favore aggiorna o prova con una Categoria vuota.", "DownloadClientRTorrentSettingsAddStopped": "Aggiungi Fermato", "DownloadClientRTorrentSettingsUrlPath": "Percorso Url", + "DownloadClientRootFolderHealthCheckMessage": "Il client di download {downloadClientName} colloca i download nella cartella radice {rootFolderPath}. Non dovresti scaricare in una cartella radice.", "DownloadClientSabnzbdValidationEnableDisableDateSorting": "Disattiva ordinamento per data", "DownloadClientSabnzbdValidationUnknownVersion": "Versione sconosciuta: {rawVersion}", "DownloadClientSettingsAddPaused": "Aggiungi In Pausa", @@ -438,6 +462,7 @@ "DownloadClientValidationUnknownException": "Eccezione sconosciuta: {exception}", "DownloadClientValidationVerifySsl": "Verifica impostazioni SSL", "DownloadClientValidationVerifySslDetail": "Per favore verifica la tua configurazione SSL su entrambi {clientName} e {appName}", + "DownloadClients": "Client di Download", "DownloadFailed": "Download Fallito", "DownloadFailedEpisodeTooltip": "Download dell'episodio fallito", "DownloadIgnored": "Download Ignorato", @@ -485,21 +510,29 @@ "EpisodeFileRenamedTooltip": "Episodio del file rinominato", "EpisodeImported": "Episodio Importato", "EpisodeInfo": "Info Episodio", + "EpisodeNumbers": "Numero(i) di Episodio", "EpisodeRequested": "Episodio Richiesto", "EpisodeTitle": "Titolo Episodio", "EpisodeTitleRequired": "Titolo Episodio Richiesto", "Episodes": "Episodi", "Error": "Errore", "ErrorLoadingContent": "Si è verificato un errore caricando questo contenuto", + "ErrorRestoringBackup": "Errore durante il ripristino del backup", "Events": "Eventi", "Example": "Esempio", "Exception": "Eccezione", "Existing": "Esistente", + "ExistingTag": "Etichetta esistente", + "ExportCustomFormat": "Esporta formato personalizzato", "External": "Esterno", + "ExternalUpdater": "{appName} è configurato per utilizzare un meccanismo di aggiornamento esterno", "Failed": "Fallito", + "FailedToFetchUpdates": "Impossibile recuperare aggiornamenti", "False": "Falso", + "FeatureRequests": "Richieste di funzionalità", "File": "File", "FileManagement": "Gestione File", + "Filename": "Nome del File", "Files": "File", "Filter": "Filtro", "FilterContains": "contiene", @@ -523,6 +556,7 @@ "Filters": "Filtri", "FinaleTooltip": "Serie o finale di stagione", "FirstDayOfWeek": "Primo Giorno della Settimana", + "Fixed": "Corretto", "Folder": "Cartella", "Folders": "Cartelle", "FormatAgeDay": "giorno", @@ -540,8 +574,13 @@ "FormatShortTimeSpanSeconds": "{seconds} secondo/i", "FormatTimeSpanDays": "{days}d {time}", "Formats": "Formati", + "Forums": "Forum", "FreeSpace": "Spazio Libero", "From": "Da", + "FullSeason": "Stagione Intera", + "General": "Generale", + "GeneralSettings": "Impostazioni Generali", + "Health": "Salute", "HiddenClickToShow": "Nascosto, premi per mostrare", "HideAdvanced": "Nascondi Avanzate", "History": "Storico", @@ -558,6 +597,7 @@ "ICalLink": "Link iCal", "ICalShowAsAllDayEvents": "Mostra come eventi di tutta la giornata", "ICalShowAsAllDayEventsHelpText": "Gli eventi appariranno come eventi di un giorno intero nel tuo calendario", + "IRC": "IRC", "IRCLinkText": "#sonarr su Libera", "IconForCutoffUnmet": "Icona per Soglia non raggiunta", "IconForCutoffUnmetHelpText": "Mostra un'icona per i file che non raggiungono la soglia", @@ -565,9 +605,11 @@ "IgnoreDownloads": "Ignora Download", "Implementation": "Implementazione", "ImportListRootFolderMissingRootHealthCheckMessage": "Persa la cartella principale per l’importazione delle liste : {rootFolderInfo}", + "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Diverse cartelle radici sono mancanti per le liste di importazione: {rootFolderInfo}", "ImportListSettings": "Impostazioni delle Liste", "ImportListStatusAllUnavailableHealthCheckMessage": "Tutte le liste non sono disponibili a causa di errori", "ImportListStatusUnavailableHealthCheckMessage": "Liste non disponibili a causa di errori: {importListNames}", + "ImportLists": "Liste di Importazione", "ImportListsSonarrSettingsFullUrl": "URL Completo", "ImportListsTraktSettingsAdditionalParameters": "Parametri Addizionali", "ImportListsTraktSettingsAuthenticateWithTrakt": "Autentica con Trakt", @@ -576,6 +618,9 @@ "ImportListsTraktSettingsListName": "Nome Lista", "ImportListsTraktSettingsListType": "Tipo Lista", "ImportListsTraktSettingsRating": "Valutazione", + "ImportMechanismEnableCompletedDownloadHandlingIfPossibleHealthCheckMessage": "Abilita Gestione dei Download Completati se possibile", + "ImportMechanismEnableCompletedDownloadHandlingIfPossibleMultiComputerHealthCheckMessage": "Abilita la Gestione dei Download Completati se possibile (Multi-Computer non supportato)", + "ImportMechanismHandlingDisabledHealthCheckMessage": "Abilita la Gestione dei Download Completati", "ImportSeries": "Importa Serie", "Imported": "Importato", "Importing": "Importando", @@ -585,6 +630,7 @@ "IndexerHDBitsSettingsCategories": "Categorie", "IndexerHDBitsSettingsCodecsHelpText": "Se non specificato, saranno utilizzate tutte le opzioni.", "IndexerHDBitsSettingsMediumsHelpText": "Se non specificato, saranno utilizzate tutte le opzioni.", + "IndexerJackettAllHealthCheckMessage": "Indici che usano l'endpoint non supportato 'all' di Hackett: {indexerNames}", "IndexerLongTermStatusAllUnavailableHealthCheckMessage": "Nessun indice è disponibile da più di 6 ore a causa di errori", "IndexerLongTermStatusUnavailableHealthCheckMessage": "Alcuni indici non sono disponibili da più di 6 ore a causa di errori: {indexerNames}", "IndexerOptionsLoadError": "Impossibile caricare le opzioni dell'indice", @@ -609,23 +655,39 @@ "Indexers": "Indici", "IndexersLoadError": "Impossibile caricare gli Indici", "InfoUrl": "URL Info", + "InstallLatest": "Installa il più Recente", "InteractiveImportNoFilesFound": "Nessun video trovato nella castella selezionata", "InteractiveImportNoImportMode": "Una modalità di importazione deve essere selezionata", "InteractiveImportNoQuality": "Una qualità deve essere scelta per ogni file selezionato", + "Interval": "Intervallo", "InvalidUILanguage": "L'interfaccia è impostata in una lingua non valida, correggi e salva le tue impostazioni", "LabelIsRequired": "Etichetta richiesta", "Language": "Lingua", "Languages": "Lingue", "LanguagesLoadError": "Impossibile caricare le lingue", + "LastDuration": "Ultima Durata", + "LastExecution": "Ultima esecuzione", + "LastWriteTime": "Orario di Ultima Scrittura", "LiberaWebchat": "Chat web Libera", + "LibraryImport": "Importazione Libreria", "ListWillRefreshEveryInterval": "Le liste verranno aggiornate ogni {refreshInterval}", + "Location": "Posizione", + "LogFiles": "File di Log", "LogFilesLocation": "File di Log localizzati in: {location}", + "Logs": "Registri", + "MaintenanceRelease": "Release di Manutenzione: bug fix e altri miglioramenti. Vedi la storia dei commit Github per maggiori dettagli", "ManageClients": "Gestisci Clients", "ManageDownloadClients": "Gestisci Clients di Download", + "ManageImportLists": "Gestisci Liste di Importazione", "ManageIndexers": "Gestisci Indici", "ManageLists": "Gestisci Liste", + "Manual": "Manuale", "MassSearchCancelWarning": "Questa non può essere annullata una volta avviata senza riavviare {appName} o disattivando tutti i tuoi indici.", + "MatchedToEpisodes": "Abbinato agli Episodi", + "MatchedToSeason": "Abbinato alla Stagione", + "MatchedToSeries": "Abbinato alla Serie", "MaximumSize": "Dimensione Massima", + "MediaManagement": "Gestione Media", "Message": "Messaggio", "Metadata": "Metadati", "MetadataLoadError": "Impossibile caricare i Metadati", @@ -684,6 +746,7 @@ "MoveFiles": "Sposta File", "MoveSeriesFoldersDontMoveFiles": "No, Sposterò i File da Solo", "MoveSeriesFoldersMoveFiles": "Sì, Sposta i File", + "MultiSeason": "Multi Stagione", "MustContain": "Deve Contenere", "MustContainHelpText": "La release deve contenere almeno uno di questi termini (senza distinzione tra maiuscole e minuscole)", "MustNotContain": "Non Deve Contenere", @@ -694,6 +757,8 @@ "Network": "Rete", "Never": "Mai", "New": "Nuovo", + "NextAiring": "Prossima Trasmissione", + "NextExecution": "Prossima esecuzione", "No": "No", "NoBackupsAreAvailable": "Nessun backup disponibile", "NoChange": "Nessun Cambio", @@ -702,8 +767,12 @@ "NoEpisodesInThisSeason": "Nessun episodio in questa stagione", "NoEventsFound": "Nessun evento trovato", "NoHistoryFound": "Nessun storico trovato", + "NoImportListsFound": "Nessuna lista di importazione trovata", "NoIndexersFound": "Nessun indice trovato", + "NoIssuesWithYourConfiguration": "La tua configurazione non presenta problemi", + "NoLeaveIt": "No, Lascialo", "NoLogFiles": "Nessun file di log", + "NoSeasons": "Nessuna stagione", "NoUpdatesAreAvailable": "Nessun aggiornamento disponibile", "NotificationsCustomScriptSettingsName": "Script personalizzato", "NotificationsCustomScriptValidationFileDoesNotExist": "File non esiste", @@ -763,6 +832,7 @@ "NotificationsValidationUnableToSendTestMessageApiResponse": "Impossibile inviare messaggio di prova. Risposta dalle API: {error}", "OnLatestVersion": "L'ultima versione di {appName} è già installata", "OneMinute": "1 Minuto", + "OneSeason": "1 Stagione", "OnlyTorrent": "Solo Torrent", "OnlyUsenet": "Solo Usenet", "OpenBrowserOnStart": "Apri browser all'avvio", @@ -810,6 +880,8 @@ "Preferred": "Preferito", "PreferredProtocol": "Protocollo Preferito", "PreferredSize": "Dimensione Preferita", + "PrefixedRange": "Intervallo Prefisso", + "PreviousAiring": "Trasmissione Precedente", "PreviouslyInstalled": "Precedentemente Installato", "Priority": "Priorità", "PrioritySettings": "Priorità: {priority}", @@ -817,11 +889,13 @@ "ProfilesSettingsSummary": "Profili di Qualità, Lingua, Ritardo e Release", "Progress": "Progressi", "ProgressBarProgress": "Barra Progressi al {progress}%", + "Proper": "Proper", "Protocol": "Protocollo", "Proxy": "Proxy", "ProxyBadRequestHealthCheckMessage": "Test del proxy fallito: Status Code: {statusCode}", "ProxyBypassFilterHelpText": "Usa ',' come separatore, e '*.' come wildcard per i sottodomini", "ProxyFailedToTestHealthCheckMessage": "Test del proxy fallito: {url}", + "ProxyResolveIpHealthCheckMessage": "Impossibile risolvere l'indirizzo IP per l'Host Configurato del Proxy {proxyHostName}", "ProxyType": "Tipo Proxy", "PublishedDate": "Data Pubblicazione", "Qualities": "Qualità", @@ -841,6 +915,7 @@ "Real": "Reale", "Reason": "Ragione", "RecentChanges": "Cambiamenti Recenti", + "RecycleBinUnableToWriteHealthCheckMessage": "Impossibile scrivere nella cartella cestino configurata: {path}. Assicurarsi che questo percorso esista e che sia scrivibile dall'utente che esegue {appName}", "RecyclingBin": "Cestino", "RecyclingBinCleanup": "Pulizia Cestino", "RecyclingBinCleanupHelpText": "Imposta a 0 per disattivare la pulizia automatica", @@ -848,8 +923,28 @@ "RefreshAndScan": "Aggiorna & Scansiona", "RefreshAndScanTooltip": "Aggiorna informazioni e scansiona disco", "RefreshSeries": "Aggiorna Serie", + "Release": "Uscita", + "ReleaseGroup": "Gruppo Uscita", + "ReleaseHash": "Hash di Uscita", + "ReleaseTitle": "Titolo Uscita", "Reload": "Ricarica", + "RemotePathMappingBadDockerPathHealthCheckMessage": "Stai usando docker; il client di download {downloadClientName} inserisce gli scaricamenti in {path} ma questo non è un percorso valido in {osName}. Controlla la mappatura dei percorsi remoti e le impostazioni del client di download.", + "RemotePathMappingDockerFolderMissingHealthCheckMessage": "Stai usando docker; il client di download {downloadClientName} inserisce gli scaricamenti in {path} ma questa cartella non sembra essere presente nel container. Controlla la mappatura dei percorsi remoti e le impostazioni dei volumi del container.", + "RemotePathMappingFileRemovedHealthCheckMessage": "Il file {path} è stato rimosso durante l'elaborazione.", + "RemotePathMappingFilesBadDockerPathHealthCheckMessage": "Stai usando docker; il client di download {downloadClientName} ha segnalato i file in {path} ma questo non è un percorso valido in {osName}. Controlla la mappatura dei percorsi remoti e le impostazioni del client di download.", + "RemotePathMappingFilesGenericPermissionsHealthCheckMessage": "Il client di download {downloadClientName} ha segnalato i file in {path} ma {appName} non trova questo percorso. Potrebbe essere necessario modificare i permessi della cartella.", + "RemotePathMappingFilesLocalWrongOSPathHealthCheckMessage": "Il client di download locale {downloadClientName} riporta files in {path} ma questo non è un percorso valido in {osName}. Controlla le impostazioni del client di download.", + "RemotePathMappingFilesWrongOSPathHealthCheckMessage": "Il client di download remoto {downloadClientName} riporta files in {path} ma questo non è un percorso valido in {osName}. Controlla la mappatura dei percorsi remoto e le impostazioni del client di download.", + "RemotePathMappingFolderPermissionsHealthCheckMessage": "{appName} riesce a vedere ma non ad accedere alla cartella di download {downloadPath}. Probabilmente un errore di permessi.", + "RemotePathMappingGenericPermissionsHealthCheckMessage": "Il client di download {downloadClientName} inserisce i file scaricati in {path} ma {appName} non riesce a vedere questo percorso. Potrebbe essere necessario modificare i permessi della cartella.", + "RemotePathMappingLocalFolderMissingHealthCheckMessage": "Il client di download remoto {downloadClientName} inserisce gli scaricamenti in {path} ma questa cartella non sembra esistere. Probabilmente la mappatura dei percorsi remoti è incorretta o mancante.", + "RemotePathMappingLocalWrongOSPathHealthCheckMessage": "Il client di download locale {downloadClientName} inserisce gli scaricamenti in {path} ma questo non è un percorso valido in {osName}. Controlla le impostazioni del client di download.", + "RemotePathMappingRemoteDownloadClientHealthCheckMessage": "Il client di download remoto {downloadClientName} riporta files in {path} ma questa cartella non sembra esistere. Probabilmente manca la mappatura dei percorsi remoti.", + "RemotePathMappingWrongOSPathHealthCheckMessage": "Il client di download remoto {downloadClientName} riporta files in {path} ma questo non è un percorso valido in {osName}. Controlla le impostazioni del client di download.", + "Remove": "Rimuovi", + "RemoveCompleted": "Rimuovi completati", "RemoveCompletedDownloads": "Rimuovi Download Completati", + "RemoveFailed": "Rimuovi Falliti", "RemoveFailedDownloads": "Rimuovi Download Falliti", "RemoveFromDownloadClient": "Rimuovi dal client di download", "RemoveFromDownloadClientHint": "Rimuovi il download e i file dal client di download", @@ -857,9 +952,17 @@ "RemoveQueueItem": "Rimuovi - {sourceTitle}", "RemoveQueueItemConfirmation": "Sei sicuro di voler rimuovere '{sourceTitle}' dalla coda?", "RemoveQueueItemRemovalMethod": "Metodo di Rimozione", + "RemoveSelectedItem": "Rimuovi elemento selezionato", "RemoveSelectedItemQueueMessageText": "Sei sicuro di voler rimuovere 1 elemento dalla coda?", + "RemoveSelectedItems": "Rimuovi elementi selezionati", "RemoveSelectedItemsQueueMessageText": "Sei sicuro di voler rimuovere {selectedCount} elementi dalla coda?", "RemovedFromTaskQueue": "Rimosso dalla coda", + "RemovedSeriesMultipleRemovedHealthCheckMessage": "Le serie {series} sono state rimosse da TheTVDB", + "RemovedSeriesSingleRemovedHealthCheckMessage": "La serie {series} è stata rimossa da TheTVDB", + "RemovingTag": "Eliminando l'etichetta", + "Repack": "Repack", + "Replace": "Sostituisci", + "Required": "Necessario", "RequiredHelpText": "Questa condizione per {implementationName} deve corrispondere perché si applichi il formato personalizzato. Altrimenti, è sufficiente una singola corrispondenza tra quelle per {implementationName}.", "Reset": "Reimposta", "ResetQualityDefinitions": "Reimposta definizioni delle Qualità", @@ -875,6 +978,10 @@ "RestrictionsLoadError": "Impossibile caricare le Restrizioni", "Result": "Risultato", "RetryingDownloadOn": "Riprovando il download il {date} alle {time}", + "RootFolder": "Cartella Radice", + "RootFolderMissingHealthCheckMessage": "Cartella radice mancante: {rootFolderPath}", + "RootFolderMultipleMissingHealthCheckMessage": "Ci sono più cartelle radice mancanti: {rootFolderPaths}", + "RootFolderPath": "Percorso Cartella Radice", "RootFolderSelectFreeSpace": "{freeSpace} Libero", "Rss": "RSS", "RssIsNotSupportedWithThisIndexer": "RSS non supportato con questo indice", @@ -943,6 +1050,7 @@ "SeriesTypes": "Tipi Serie", "SetIndexerFlags": "Configura Etichette dell'Indice", "SetPermissions": "Imposta Permessi", + "SetTags": "Imposta Etichette", "Settings": "Impostazioni", "ShowAdvanced": "Mostra Avanzate", "ShowDateAdded": "Mostra Data Aggiunta", @@ -952,6 +1060,7 @@ "ShowNetwork": "Mostra Rete", "ShowPath": "Mostra Percorso", "ShowTitle": "Mostra Titolo", + "ShownClickToHide": "Visibile, clicca per nascondere", "Shutdown": "Spegnimento", "SingleEpisode": "Episodio Singolo", "Size": "Dimensione", @@ -978,6 +1087,7 @@ "SubtitleLanguages": "Lingue dei Sottotitoli", "Sunday": "Domenica", "System": "Sistema", + "SystemTimeHealthCheckMessage": "L'orario di sistema è sbagliato di più di un giorno. Le attività pianificate potrebbero non essere eseguite correttamente fino alla correzione", "Table": "Tabella", "TableColumns": "Colonne", "TableOptions": "Opzioni Tabella", @@ -986,11 +1096,14 @@ "TablePageSizeMaximum": "La dimensione della pagina non deve superare {maximumValue}", "TablePageSizeMinimum": "La dimensione della pagina deve essere almeno {minimumValue}", "TagDetails": "Dettagli Etichetta - {label}", + "Tags": "Etichette", "TaskUserAgentTooltip": "User-Agent fornito dalla app che ha chiamato la API", + "Tasks": "Attività", "Tba": "TBA", "Test": "Prova", "TestAll": "Prova Tutto", "TestAllIndexers": "Prova tutti gli Indici", + "TestParsing": "Prova l'Analisi", "TheLogLevelDefault": "Il livello di log predefinito è 'Debug' e può essere modificato nelle [Impostazioni Generali](settings/general)", "TheTvdb": "TheTVDB", "Theme": "Tema", @@ -1029,6 +1142,7 @@ "UnknownDownloadState": "Stato download sconosciuto: {state}", "UnknownEventTooltip": "Evento sconosciuto", "Unlimited": "Illimitato", + "Unmonitored": "Non Monitorato", "UnsavedChanges": "Cambiamenti Non Salvati", "UnselectAll": "Deseleziona Tutto", "Upcoming": "In arrivo", @@ -1036,6 +1150,8 @@ "UpdateAppDirectlyLoadError": "Impossibile aggiornare {appName} direttamente,", "UpdateAvailableHealthCheckMessage": "Aggiornamento disponibile: {version}", "UpdateMechanismHelpText": "Usa il sistema di aggiornamento incorporato di {appName} o uno script", + "UpdateStartupNotWritableHealthCheckMessage": "Impossibile installare l'aggiornamento perché l'utente '{userName}' non ha i permessi di scrittura per la cartella di avvio '{startupFolder}'.", + "UpdateStartupTranslocationHealthCheckMessage": "Impossibile installare l'aggiornamento perché la cartella '{startupFolder}' si trova in una cartella di \"App Translocation\".", "Updates": "Aggiornamenti", "Uppercase": "Maiuscolo", "Uptime": "Tempo di attività", diff --git a/src/NzbDrone.Core/Localization/Core/pl.json b/src/NzbDrone.Core/Localization/Core/pl.json index 21449a285..4c34cf5e9 100644 --- a/src/NzbDrone.Core/Localization/Core/pl.json +++ b/src/NzbDrone.Core/Localization/Core/pl.json @@ -1,98 +1,2253 @@ { - "About": "Informacje", - "Absolute": "Absolutny", - "AbsoluteEpisodeNumber": "Absolutny Numer Odcinka", - "AbsoluteEpisodeNumbers": "Absolutne Numery Odcinków", + "About": "O programie", + "Absolute": "Bezwzględny", + "AbsoluteEpisodeNumber": "Bezwzględny numer odcinka", + "AbsoluteEpisodeNumbers": "Bezwzględny numer(y) odcinka", "Actions": "Akcje", "Activity": "Aktywność", "Add": "Dodaj", "AddANewPath": "Dodaj nową ścieżkę", - "AddAutoTag": "Dodaj automatyczne tagi", - "AddAutoTagError": "Nie można dodać nowego tagu automatycznego, spróbuj ponownie.", + "AddAutoTag": "Dodaj automatyczny tag", + "AddAutoTagError": "Nie można dodać nowego automatycznego tagu, spróbuj ponownie.", "AddCondition": "Dodaj warunek", "AddConditionError": "Nie można dodać nowego warunku, spróbuj ponownie.", - "AddConditionImplementation": "Dodaj condition - {implementationName}", + "AddConditionImplementation": "Dodaj warunek - {implementationName}", "AddConnection": "Dodaj połączenie", - "AddConnectionImplementation": "Dodaj Connection - {implementationName}", + "AddConnectionError": "Nie można dodać nowego połączenia, spróbuj ponownie.", + "AddConnectionImplementation": "Dodaj połączenie - {implementationName}", "AddCustomFilter": "Dodaj niestandardowy filtr", "AddCustomFormat": "Dodaj format niestandardowy", "AddCustomFormatError": "Nie można dodać nowego formatu niestandardowego, spróbuj ponownie.", "AddDelayProfile": "Dodaj profil opóźnienia", - "AddDelayProfileError": "Nie można dodać nowego profilu opóźnienia, spróbuj później.", + "AddDelayProfileError": "Nie można dodać nowego profilu opóźnienia, spróbuj ponownie.", "AddDownloadClient": "Dodaj klienta pobierania", "AddDownloadClientError": "Nie można dodać nowego klienta pobierania, spróbuj ponownie.", "AddDownloadClientImplementation": "Dodaj klienta pobierania - {implementationName}", - "AddExclusion": "Dodaj wyjątek", + "AddExclusion": "Dodaj wykluczenie", "AddImportList": "Dodaj listę importu", "AddImportListExclusion": "Dodaj wykluczenie listy importu", - "AddImportListExclusionError": "Nie można dodać nowego wykluczenia listy, spróbuj ponownie.", - "AddImportListImplementation": "Dodaj Listę Importu - {implementationName}", + "AddImportListExclusionError": "Nie można dodać nowego wykluczenia listy importu, spróbuj ponownie.", + "AddImportListImplementation": "Dodaj listę importu - {implementationName}", "AddIndexer": "Dodaj indekser", - "AddIndexerError": "Nie można dodać nowego indeksatora, spróbuj ponownie.", - "AddIndexerImplementation": "Dodaj indeks - {implementationName}", + "AddIndexerError": "Nie można dodać nowego indeksera, spróbuj ponownie.", + "AddIndexerImplementation": "Dodaj indekser - {implementationName}", "AddList": "Dodaj listę", "AddListError": "Nie można dodać nowej listy, spróbuj ponownie.", - "AddListExclusion": "Dodaj wykluczenie z listy", + "AddListExclusion": "Dodaj wykluczenie listy", "AddListExclusionError": "Nie można dodać nowego wykluczenia listy, spróbuj ponownie.", - "AddNew": "Dodaj nowy", + "AddListExclusionSeriesHelpText": "Zapobiegaj dodawaniu seriali do {appName} przez listy", + "AddNew": "Dodaj nowe", "AddNewRestriction": "Dodaj nowe ograniczenie", "AddNewSeries": "Dodaj nowy serial", - "AddNewSeriesError": "Nie udało się załadować wyników wyszukiwania, spróbuj ponownie.", - "AddNewSeriesHelpText": "Latwo dodać nowy serial, po prostu zacznij pisać nazwę serialu który chcesz dodać.", - "AddNewSeriesSearchForMissingEpisodes": "Zacznij szukać brakujących odcinków", + "AddNewSeriesError": "Nie udało się wczytać wyników wyszukiwania, spróbuj ponownie.", + "AddNewSeriesHelpText": "Dodanie nowego serialu jest proste, zacznij wpisywać nazwę serialu, który chcesz dodać.", + "AddNewSeriesRootFolderHelpText": "Podfolder '{folder}' zostanie utworzony automatycznie", + "AddNewSeriesSearchForCutoffUnmetEpisodes": "Rozpocznij wyszukiwanie odcinków bez osiągniętego progu", + "AddNewSeriesSearchForMissingEpisodes": "Rozpocznij wyszukiwanie brakujących odcinków", "AddQualityProfile": "Dodaj profil jakości", - "AddQualityProfileError": "Nie udało się dodać nowego profilu jakości, spróbuj później.", - "AddReleaseProfile": "Dodaj Profil Wydania", - "AddRemotePathMapping": "Dodaj mapowanie ścieżek zdalnych", - "AgeWhenGrabbed": "Wiek (przy złapaniu)", + "AddQualityProfileError": "Nie udało się dodać nowego profilu jakości, spróbuj ponownie.", + "AddReleaseProfile": "Dodaj profil wydań", + "AddRemotePathMapping": "Dodaj mapowanie ścieżki zdalnej", + "AddRemotePathMappingError": "Nie można dodać nowego mapowania ścieżki zdalnej, spróbuj ponownie.", + "AddRootFolder": "Dodaj folder główny", + "AddRootFolderError": "Nie można dodać folderu głównego", + "AddSeriesWithTitle": "Dodaj {title}", + "AddToDownloadQueue": "Dodaj do kolejki pobierania", + "Added": "Dodano", + "AddedDate": "Dodano: {date}", + "AddedToDownloadQueue": "Dodano do kolejki pobierania", + "AddingTag": "Dodawanie tagu", + "AfterManualRefresh": "Po ręcznym odświeżeniu", + "Age": "Wiek", + "AgeWhenGrabbed": "Wiek (w momencie pobrania)", + "Agenda": "Agenda", + "AirDate": "Data emisji", + "AirDateGracePeriod": "Okres tolerancji daty emisji", + "AirDateGracePeriodHelpText": "Wartości ujemne pozwalają pobierać przed datą emisji, wartości dodatnie uniemożliwiają pobieranie po dacie emisji.", + "AirDateRestriction": "Odrzucaj niewyemitowane wydania", + "AirDateRestrictionHelpText": "Uniemożliwia {appName} pobieranie wydań zawierających odcinki, które nie zostały jeszcze wyemitowane.", + "Airs": "Emisja", + "AirsDateAtTimeOn": "{date} o {time} na {networkLabel}", + "AirsTbaOn": "TBA na {networkLabel}", + "AirsTimeOn": "{time} na {networkLabel}", + "AirsTomorrowOn": "Jutro o {time} na {networkLabel}", + "All": "Wszystko", + "AllFiles": "Wszystkie pliki", + "AllResultsAreHiddenByTheAppliedFilter": "Wszystkie wyniki są ukryte przez zastosowany filtr", + "AllSeriesAreHiddenByTheAppliedFilter": "Wszystkie wyniki są ukryte przez zastosowany filtr", + "AllSeriesInRootFolderHaveBeenImported": "Wszystkie seriale w {path} zostały zaimportowane", "AllTitles": "Wszystkie tytuły", - "Any": "Dowolny", - "ApiKeyValidationHealthCheckMessage": "Zaktualizuj swój klucz API aby był długi na co najmniej {length} znaków. Możesz to zrobić poprzez ustawienia lub plik konfiguracyjny", + "AlreadyInYourLibrary": "Już w Twojej bibliotece", + "AlternateTitles": "Tytuły alternatywne", + "Always": "Zawsze", + "AnEpisodeIsDownloading": "Odcinek jest pobierany", + "AnalyseVideoFiles": "Analizuj pliki wideo", + "AnalyseVideoFilesHelpText": "Wyodrębnij informacje o wideo, takie jak rozdzielczość, czas trwania i kodeki. Wymaga to od {appName} odczytu fragmentów pliku, co może powodować wysoką aktywność dysku lub sieci podczas skanowania.", + "Analytics": "Analityka", + "AnalyticsEnabledHelpText": "Wysyłaj anonimowe dane o użyciu i błędach na serwery {appName}. Obejmuje to informacje o przeglądarce, używanych stronach WebUI {appName}, raportach błędów oraz wersji systemu i środowiska uruchomieniowego. Użyjemy tych informacji do priorytetyzacji funkcji i poprawek błędów.", + "Anime": "Anime", + "AnimeEpisodeFormat": "Format odcinka anime", + "AnimeEpisodeTypeDescription": "Odcinki wydane z użyciem bezwzględnego numeru odcinka", + "AnimeEpisodeTypeFormat": "Bezwzględny numer odcinka ({format})", + "Any": "Dowolne", + "ApiKey": "Klucz API", + "ApiKeyValidationHealthCheckMessage": "Zaktualizuj klucz API tak, aby miał co najmniej {length} znaków. Możesz to zrobić w ustawieniach lub pliku konfiguracyjnym", "AppDataDirectory": "Katalog AppData", - "AppUpdated": "{appName} Zaktualizowany", - "ApplicationUrlHelpText": "Zewnętrzny URL tej aplikacji zawierający http(s)://, port i adres URL", + "AppDataLocationHealthCheckMessage": "Aktualizacja nie będzie możliwa, aby zapobiec usunięciu AppData podczas aktualizacji", + "AppUpdated": "Zaktualizowano {appName}", + "AppUpdatedVersion": "{appName} został zaktualizowany do wersji `{version}`. Aby zobaczyć najnowsze zmiany, musisz ponownie wczytać {appName} ", + "ApplicationURL": "URL aplikacji", + "ApplicationUrlHelpText": "Zewnętrzny URL tej aplikacji, w tym http(s)://, port i bazowy URL", "Apply": "Zastosuj", "ApplyChanges": "Zastosuj zmiany", - "ApplyTagsHelpTextHowToApplyDownloadClients": "Jak", - "ApplyTagsHelpTextHowToApplyImportLists": "Jak zastosować tagi do wybranych list", - "ApplyTagsHelpTextHowToApplyIndexers": "Jak zastosować tagi do wybranych indeksatorów", - "ApplyTagsHelpTextRemove": "Usuń: usuń wprowadzone tagi", + "ApplyTags": "Zastosuj tagi", + "ApplyTagsHelpTextAdd": "Dodaj: Dodaj tagi do istniejącej listy tagów", + "ApplyTagsHelpTextHowToApplyDownloadClients": "Jak zastosować tagi do wybranych klientów pobierania", + "ApplyTagsHelpTextHowToApplyImportLists": "Jak zastosować tagi do wybranych list importu", + "ApplyTagsHelpTextHowToApplyIndexers": "Jak zastosować tagi do wybranych indekserów", + "ApplyTagsHelpTextHowToApplySeries": "Jak zastosować tagi do wybranych seriali", + "ApplyTagsHelpTextRemove": "Usuń: Usuń wprowadzone tagi", + "ApplyTagsHelpTextReplace": "Zastąp: Zastąp tagi wprowadzonymi tagami (nie wpisuj tagów, aby wyczyścić wszystkie)", + "AptUpdater": "Użyj apt, aby zainstalować aktualizację", "AudioInfo": "Informacje o audio", - "AudioLanguages": "Języki Dźwięku", - "Authentication": "Autoryzacja", - "AuthenticationMethod": "Metoda Autoryzacji", - "AuthenticationMethodHelpTextWarning": "Wybierz prawidłową metodę autoryzacji", - "AuthenticationRequired": "Wymagana Autoryzacja", + "AudioLanguages": "Języki audio", + "AuthBasic": "Basic (okno przeglądarki)", + "AuthForm": "Formularze (strona logowania)", + "Authentication": "Uwierzytelnianie", + "AuthenticationMethod": "Metoda uwierzytelniania", + "AuthenticationMethodHelpText": "Wymagaj nazwy użytkownika i hasła, aby uzyskać dostęp do {appName}", + "AuthenticationMethodHelpTextWarning": "Wybierz prawidłową metodę uwierzytelniania", + "AuthenticationRequired": "Wymagane uwierzytelnianie", + "AuthenticationRequiredHelpText": "Zmień, które żądania wymagają uwierzytelniania. Nie zmieniaj, jeśli nie rozumiesz ryzyka.", + "AuthenticationRequiredPasswordConfirmationHelpTextWarning": "Potwierdź nowe hasło", + "AuthenticationRequiredPasswordHelpTextWarning": "Wprowadź nowe hasło", + "AuthenticationRequiredUsernameHelpTextWarning": "Wprowadź nową nazwę użytkownika", + "AuthenticationRequiredWarning": "Aby zapobiec zdalnemu dostępowi bez uwierzytelniania, {appName} wymaga teraz włączenia uwierzytelniania. Opcjonalnie możesz wyłączyć uwierzytelnianie dla adresów lokalnych.", + "AutoAdd": "Automatyczne dodawanie", + "AutoRedownloadFailed": "Automatyczne ponowne pobranie nieudane", + "AutoRedownloadFailedFromInteractiveSearch": "Automatyczne ponowne pobranie nieudane z wyszukiwania interaktywnego", + "AutoRedownloadFailedFromInteractiveSearchHelpText": "Automatycznie wyszukaj i spróbuj pobrać inne wydanie, gdy nieudane wydanie zostało pobrane z wyszukiwania interaktywnego", + "AutoRedownloadFailedHelpText": "Automatycznie wyszukaj i spróbuj pobrać inne wydanie", "AutoTagging": "Automatyczne tagowanie", + "AutoTaggingLoadError": "Nie można wczytać automatycznego tagowania", + "AutoTaggingNegateHelpText": "Jeśli zaznaczone, reguła automatycznego tagowania nie zostanie zastosowana, jeśli ten warunek {implementationName} pasuje.", + "AutoTaggingRequiredHelpText": "Ten warunek {implementationName} musi pasować, aby reguła automatycznego tagowania została zastosowana. W przeciwnym razie wystarczy pojedyncze dopasowanie {implementationName}.", + "AutoTaggingSpecificationGenre": "Gatunek(i)", + "AutoTaggingSpecificationMaximumYear": "Maksymalny rok", + "AutoTaggingSpecificationMinimumYear": "Minimalny rok", + "AutoTaggingSpecificationNetwork": "Sieć(ci)", + "AutoTaggingSpecificationOriginalCountry": "Kraj", + "AutoTaggingSpecificationOriginalLanguage": "Język", + "AutoTaggingSpecificationQualityProfile": "Profil jakości", + "AutoTaggingSpecificationRootFolder": "Folder główny", + "AutoTaggingSpecificationSeriesType": "Typ serialu", + "AutoTaggingSpecificationStatus": "Status", + "AutoTaggingSpecificationTag": "Tag", + "Automatic": "Automatycznie", + "AutomaticAdd": "Automatyczne dodawanie", + "AutomaticSearch": "Wyszukiwanie automatyczne", + "AutomaticUpdatesDisabledDocker": "Automatyczne aktualizacje nie są bezpośrednio obsługiwane przy użyciu mechanizmu aktualizacji Docker. Musisz zaktualizować obraz kontenera poza {appName} lub użyć skryptu", + "Backup": "Kopia zapasowa", + "BackupFolderHelpText": "Ścieżki względne będą w katalogu AppData aplikacji {appName}", + "BackupIntervalHelpText": "Interwał między automatycznymi kopiami zapasowymi", + "BackupNow": "Utwórz kopię teraz", + "BackupRetentionHelpText": "Automatyczne kopie zapasowe starsze niż okres przechowywania będą automatycznie czyszczone", + "Backups": "Kopie zapasowe", + "BackupsLoadError": "Nie można wczytać kopii zapasowych", + "BeforeUpdate": "Przed aktualizacją", + "BindAddress": "Adres nasłuchu", "BindAddressHelpText": "Prawidłowy adres IP, localhost lub '*' dla wszystkich interfejsów", - "BlocklistRelease": "Dodaj wersję do czarnej listy", - "BlocklistReleases": "Dodaj wersje do czarnej listy", + "BlackholeFolderHelpText": "Folder, w którym {appName} zapisze plik {extension}", + "BlackholeWatchFolder": "Folder obserwowany", + "BlackholeWatchFolderHelpText": "Folder, z którego {appName} powinien importować ukończone pobrania", + "Blocklist": "Czarna lista", + "BlocklistAndSearch": "Dodaj do czarnej listy i wyszukaj", + "BlocklistAndSearchHint": "Rozpocznij wyszukiwanie zamiennika po dodaniu do czarnej listy", + "BlocklistAndSearchMultipleHint": "Rozpocznij wyszukiwania zamienników po dodaniu do czarnej listy", + "BlocklistFilterHasNoItems": "Wybrany filtr czarnej listy nie zawiera elementów", + "BlocklistLoadError": "Nie można wczytać czarnej listy", + "BlocklistMultipleOnlyHint": "Dodaj do czarnej listy bez wyszukiwania zamienników", + "BlocklistOnly": "Tylko czarna lista", + "BlocklistOnlyHint": "Czarna lista bez wyszukiwania zamiennika", + "BlocklistRelease": "Dodaj wydanie do czarnej listy", + "BlocklistReleaseHelpText": "Blokuje ponowne pobranie tego wydania przez {appName} przez RSS lub wyszukiwanie automatyczne", + "BlocklistReleases": "Wydania na czarnej liście", + "Blocklisted": "Na czarnej liście", + "BlocklistedAt": "Dodano do czarnej listy: {date}", + "Branch": "Gałąź", + "BranchUpdate": "Gałąź używana do aktualizacji {appName}", + "BranchUpdateMechanism": "Gałąź używana przez zewnętrzny mechanizm aktualizacji", + "BrowserReloadRequired": "Wymagane ponowne wczytanie przeglądarki", + "BuiltIn": "Wbudowane", + "BypassDelayIfAboveCustomFormatScore": "Pomiń opóźnienie, jeśli wynik formatu niestandardowego jest wyższy", + "BypassDelayIfAboveCustomFormatScoreHelpText": "Włącz pomijanie opóźnienia, gdy wydanie ma wynik wyższy niż skonfigurowane minimum formatu niestandardowego", + "BypassDelayIfAboveCustomFormatScoreMinimumScore": "Minimalny wynik formatu niestandardowego", + "BypassDelayIfAboveCustomFormatScoreMinimumScoreHelpText": "Minimalny wynik formatu niestandardowego wymagany do pominięcia opóźnienia dla preferowanego protokołu", + "BypassDelayIfHighestQuality": "Pomiń opóźnienie przy najwyższej jakości", + "BypassDelayIfHighestQualityHelpText": "Pomiń opóźnienie, gdy wydanie ma najwyższą włączoną jakość w profilu jakości z preferowanym protokołem", + "BypassProxyForLocalAddresses": "Pomiń proxy dla adresów lokalnych", + "Calendar": "Kalendarz", + "CalendarFeed": "Kanał kalendarza {appName}", + "CalendarLegendEpisodeDownloadedTooltip": "Odcinek został pobrany i posortowany", + "CalendarLegendEpisodeDownloadingTooltip": "Odcinek jest obecnie pobierany", + "CalendarLegendEpisodeMissingTooltip": "Odcinek został wyemitowany i brakuje go na dysku", + "CalendarLegendEpisodeOnAirTooltip": "Odcinek jest obecnie emitowany", + "CalendarLegendEpisodeUnairedTooltip": "Odcinek nie został jeszcze wyemitowany", + "CalendarLegendEpisodeUnmonitoredTooltip": "Odcinek nie jest monitorowany", + "CalendarLegendSeriesFinaleTooltip": "Finał serialu lub sezonu", + "CalendarLegendSeriesPremiereTooltip": "Premiera serialu lub sezonu", + "CalendarLoadError": "Nie można wczytać kalendarza", "CalendarOptions": "Opcje kalendarza", + "Cancel": "Anuluj", + "CancelPendingTask": "Czy na pewno chcesz anulować to oczekujące zadanie?", + "CancelProcessing": "Anuluj przetwarzanie", + "Category": "Kategoria", + "CertificateValidation": "Weryfikacja certyfikatu", + "CertificateValidationHelpText": "Zmień poziom rygoru walidacji certyfikatu HTTPS. Nie zmieniaj, jeśli nie rozumiesz ryzyka.", + "Certification": "Certyfikacja", + "ChangeCategory": "Zmień kategorię", + "ChangeCategoryHint": "Zmienia pobranie na kategorię 'Post-Import Category' klienta pobierania", + "ChangeCategoryMultipleHint": "Zmienia pobrania na kategorię 'Post-Import Category' klienta pobierania", + "ChangeFileDate": "Zmień datę pliku", + "ChangeFileDateHelpText": "Zmień datę pliku podczas importu/przeskanowania", + "CheckDownloadClientForDetails": "sprawdź klienta pobierania, aby uzyskać więcej szczegółów", + "ChmodFolder": "Folder chmod", + "ChmodFolderHelpText": "Wartość ósemkowa, stosowana podczas importu/zmiany nazwy do folderów i plików multimediów (bez bitów wykonywania)", + "ChmodFolderHelpTextWarning": "To działa tylko, jeśli użytkownik uruchamiający {appName} jest właścicielem pliku. Lepiej upewnić się, że klient pobierania ustawia uprawnienia prawidłowo.", + "ChooseAnotherFolder": "Wybierz inny folder", + "ChooseImportMode": "Wybierz tryb importu", + "ChownGroup": "Grupa chown", + "ChownGroupHelpText": "Nazwa grupy lub gid. Użyj gid dla zdalnych systemów plików.", + "ChownGroupHelpTextWarning": "To działa tylko, jeśli użytkownik uruchamiający {appName} jest właścicielem pliku. Lepiej upewnić się, że klient pobierania używa tej samej grupy co {appName}.", + "CleanLibraryLevel": "Poziom czyszczenia biblioteki", + "Clear": "Wyczyść", + "ClearBlocklist": "Wyczyść czarną listę", + "ClearBlocklistMessageText": "Czy na pewno chcesz usunąć wszystkie elementy z czarnej listy?", + "ClickToChangeEpisode": "Kliknij, aby zmienić odcinek", + "ClickToChangeIndexerFlags": "Kliknij, aby zmienić flagi indeksera", + "ClickToChangeLanguage": "Kliknij, aby zmienić język", + "ClickToChangeQuality": "Kliknij, aby zmienić jakość", + "ClickToChangeReleaseGroup": "Kliknij, aby zmienić grupę wydania", + "ClickToChangeReleaseType": "Kliknij, aby zmienić typ wydania", + "ClickToChangeSeason": "Kliknij, aby zmienić sezon", + "ClickToChangeSeries": "Kliknij, aby zmienić serial", + "ClientPriority": "Priorytet klienta", + "Clone": "Klonuj", + "CloneAutoTag": "Klonuj automatyczny tag", + "CloneCondition": "Klonuj warunek", + "CloneCustomFormat": "Klonuj format niestandardowy", + "CloneImportList": "Klonuj listę importu", + "CloneIndexer": "Klonuj indekser", + "CloneProfile": "Klonuj profil", "Close": "Zamknij", + "CollapseAll": "Zwiń wszystko", + "CollapseMultipleEpisodes": "Zwiń wiele odcinków", + "CollapseMultipleEpisodesHelpText": "Zwiń wiele odcinków emitowanych tego samego dnia", + "CollectionsLoadError": "Nie można wczytać kolekcji", + "ColonReplacement": "Zastępowanie dwukropka", + "ColonReplacementFormatHelpText": "Zmień sposób, w jaki {appName} obsługuje zastępowanie dwukropka", + "Completed": "Ukończono", + "CompletedDownloadHandling": "Obsługa ukończonych pobrań", + "Component": "Komponent", + "Condition": "Warunek", + "ConditionUsingRegularExpressions": "Ten warunek dopasowuje przy użyciu wyrażeń regularnych. Zwróć uwagę, że znaki `\\^$.|?*+()[{` mają specjalne znaczenie i wymagają escapowania przez `\\`", + "Conditions": "Warunki", "Connect": "Połączenia", - "CouldNotFindResults": "Nie można znaleźć żadnych wyników dla „{term}”", - "CustomFormatUnknownCondition": "Nieznany warunek formatu niestandardowego „{implementation}\"", - "CustomFormatUnknownConditionOption": "Nieznana opcja „{key}” dla warunku „{implementation}”", - "CutoffUnmet": "Odcięcie niespełnione", - "Dash": "Dash", - "DeleteBackupMessageText": "Czy na pewno chcesz usunąć kopię zapasową „{name}”?", - "DeleteConditionMessageText": "Czy na pewno chcesz usunąć tag „{name}”?", - "DeleteDownloadClientMessageText": "Czy na pewno chcesz usunąć klienta pobierania „{name}”?", - "DeleteIndexerMessageText": "Czy na pewno chcesz usunąć indeksator „{name}”?", - "DeleteReleaseProfileMessageText": "Czy na pewno usunąć informacje dodatkowe '{name}'?", - "DownloadClientDownloadStationValidationApiVersion": "Pobrana wersja Station API nie jest wspierana, minimalna wspierana wersja to {requiredVersion}. Powinna być między {minVersion} a {maxVersion}", + "ConnectSettings": "Ustawienia połączeń", + "ConnectSettingsSummary": "Powiadomienia, połączenia z serwerami/odtwarzaczami multimediów i niestandardowe skrypty", + "Connection": "Połączenie", + "ConnectionLost": "Utracono połączenie", + "ConnectionLostReconnect": "{appName} spróbuje połączyć się automatycznie lub możesz kliknąć przeładuj poniżej.", + "ConnectionLostToBackend": "{appName} utracił połączenie z backendem i musi zostać ponownie wczytany, aby przywrócić funkcjonalność.", + "ConnectionSettingsUrlBaseHelpText": "Dodaje prefiks do adresu URL {connectionName}, np. {url}", + "Connections": "Połączenia", + "ConnectionsLoadError": "Nie można wczytać połączeń", + "Continuing": "Kontynuowany", + "ContinuingOnly": "Tylko kontynuowane", + "ContinuingSeriesDescription": "Oczekiwane są kolejne odcinki/kolejny sezon", + "CopyToClipboard": "Kopiuj do schowka", + "CopyUsingHardlinksHelpTextWarning": "Czasami blokady plików mogą uniemożliwić zmianę nazwy plików, które są seedowane. Możesz tymczasowo wyłączyć seedowanie i użyć funkcji zmiany nazwy {appName} jako obejścia.", + "CopyUsingHardlinksSeriesHelpText": "Dowiązania twarde pozwalają {appName} importować seedowane torrenty do folderu serialu bez zajmowania dodatkowego miejsca na dysku i bez kopiowania całej zawartości pliku. Dowiązania twarde zadziałają tylko wtedy, gdy źródło i cel są na tym samym woluminie", + "CouldNotFindResults": "Nie znaleziono wyników dla '{term}'", + "CountCustomFormatsSelected": "Wybrano {count} format(ów) niestandardowych", + "CountDownloadClientsSelected": "Wybrano {count} klient(ów) pobierania", + "CountImportListsSelected": "Wybrano {count} list(y) importu", + "CountIndexersSelected": "Wybrano {count} indekser(y)", + "CountSeasons": "{count} sezonów", + "CountSelectedFile": "Wybrano {selectedCount} plik", + "CountSelectedFiles": "Wybrano {selectedCount} pliki", + "CountSeriesSelected": "Wybrano {count} seriali", + "CountVotes": "{votes} głosów", + "CreateEmptySeriesFolders": "Twórz puste foldery seriali", + "CreateEmptySeriesFoldersHelpText": "Twórz brakujące foldery seriali podczas skanowania dysku", + "CreateGroup": "Utwórz grupę", + "CurrentlyInstalled": "Obecnie zainstalowane", + "Custom": "Niestandardowe", + "CustomColonReplacement": "Niestandardowe zastępowanie dwukropka", + "CustomColonReplacementFormatHelpText": "Znaki używane do zastąpienia dwukropków", + "CustomColonReplacementFormatHint": "Prawidłowy znak systemu plików, np. dwukropek (litera)", + "CustomFilter": "Filtr niestandardowy", + "CustomFilters": "Filtry niestandardowe", + "CustomFormat": "Format niestandardowy", + "CustomFormatHelpText": "{appName} ocenia każde wydanie na podstawie sumy punktów za pasujące formaty niestandardowe. Jeśli nowe wydanie poprawia wynik przy tej samej lub lepszej jakości, {appName} je pobierze.", + "CustomFormatJson": "JSON formatu niestandardowego", + "CustomFormatScore": "Wynik formatu niestandardowego", + "CustomFormatUnknownCondition": "Nieznany warunek formatu niestandardowego '{implementation}'", + "CustomFormatUnknownConditionOption": "Nieznana opcja '{key}' dla warunku '{implementation}'", + "CustomFormats": "Formaty niestandardowe", + "CustomFormatsLoadError": "Nie można wczytać formatów niestandardowych", + "CustomFormatsSettings": "Ustawienia formatów niestandardowych", + "CustomFormatsSettingsSummary": "Formaty niestandardowe i ustawienia", + "CustomFormatsSettingsTriggerInfo": "Format niestandardowy zostanie zastosowany do wydania lub pliku, gdy dopasuje co najmniej jeden warunek z każdego wybranego typu warunku.", + "CustomFormatsSpecificationExceptLanguage": "Poza językiem", + "CustomFormatsSpecificationExceptLanguageHelpText": "Dopasowuje, jeśli obecny jest jakikolwiek język inny niż wybrany", + "CustomFormatsSpecificationFlag": "Flaga", + "CustomFormatsSpecificationLanguage": "Język", + "CustomFormatsSpecificationMaximumSize": "Maksymalny rozmiar", + "CustomFormatsSpecificationMaximumSizeHelpText": "Wydanie musi być mniejsze lub równe temu rozmiarowi", + "CustomFormatsSpecificationMinimumSize": "Minimalny rozmiar", + "CustomFormatsSpecificationMinimumSizeHelpText": "Wydanie musi być większe niż ten rozmiar", + "CustomFormatsSpecificationRegularExpression": "Wyrażenie regularne", + "CustomFormatsSpecificationRegularExpressionHelpText": "RegEx formatu niestandardowego nie rozróżnia wielkości liter", + "CustomFormatsSpecificationReleaseGroup": "Grupa wydania", + "CustomFormatsSpecificationResolution": "Rozdzielczość", + "CustomFormatsSpecificationSource": "Źródło", + "Cutoff": "Próg", + "CutoffNotMet": "Próg nieosiągnięty", + "CutoffUnmet": "Nieosiągnięty próg", + "CutoffUnmetLoadError": "Błąd ładowania elementów z nieosiągniętym progiem", + "CutoffUnmetNoItems": "Brak elementów z nieosiągniętym progiem", + "Daily": "Dzienny", + "DailyEpisodeFormat": "Dzienny format odcinka", + "DailyEpisodeTypeDescription": "Odcinki wydawane codziennie lub rzadziej, używające formatu rok-miesiąc-dzień (2023-08-04)", + "DailyEpisodeTypeFormat": "Data ({format})", + "Dash": "Myślnik", + "Database": "Baza danych", + "DatabaseMigration": "Migracja bazy danych", + "Date": "Data", + "Dates": "Daty", + "Day": "Dzień", + "DayOfWeekAt": "{day} o {time}", + "Debug": "Debugowanie", + "Default": "Domyślne", + "DefaultCase": "Domyślny przypadek", + "DefaultDelayProfileSeries": "To jest profil domyślny. Ma zastosowanie do wszystkich seriali, które nie mają przypisanego jawnego profilu.", + "DefaultNameCopiedImportList": "{name} - Kopia", + "DefaultNameCopiedProfile": "{name} - Kopia", + "DefaultNameCopiedSpecification": "{name} - Kopia", + "DefaultNotFoundMessage": "Chyba się zgubiłeś, nie ma tu nic do zobaczenia.", + "Delay": "Opóźnienie", + "DelayMinutes": "{delay} minut", + "DelayProfile": "Profil opóźnienia", + "DelayProfileProtocol": "Protokół: {preferredProtocol}", + "DelayProfileSeriesTagsHelpText": "Dotyczy seriali z co najmniej jednym pasującym tagiem", + "DelayProfiles": "Profile opóźnienia", + "DelayProfilesLoadError": "Nie można wczytać profili opóźnienia", + "DelayingDownloadUntil": "Opóźnianie pobierania do {date} o {time}", + "Delete": "Usuń", + "DeleteAutoTag": "Usuń automatyczny tag", + "DeleteAutoTagHelpText": "Czy na pewno chcesz usunąć automatyczny tag '{name}'?", + "DeleteBackup": "Usuń kopię zapasową", + "DeleteBackupMessageText": "Czy na pewno chcesz usunąć kopię zapasową '{name}'?", + "DeleteCondition": "Usuń warunek", + "DeleteConditionMessageText": "Czy na pewno chcesz usunąć warunek '{name}'?", + "DeleteCustomFormat": "Usuń format niestandardowy", + "DeleteCustomFormatMessageText": "Czy na pewno chcesz usunąć format niestandardowy '{name}'?", + "DeleteDelayProfile": "Usuń profil opóźnienia", + "DeleteDelayProfileMessageText": "Czy na pewno chcesz usunąć ten profil opóźnienia?", + "DeleteDownloadClient": "Usuń klienta pobierania", + "DeleteDownloadClientMessageText": "Czy na pewno chcesz usunąć klienta pobierania '{name}'?", + "DeleteEmptyFolders": "Usuń puste foldery", + "DeleteEmptySeriesFoldersHelpText": "Usuwaj puste foldery seriali i sezonów podczas skanowania dysku oraz gdy pliki odcinków są usuwane", + "DeleteEpisodeFile": "Usuń plik odcinka", + "DeleteEpisodeFileMessage": "Czy na pewno chcesz usunąć '{path}'?", + "DeleteEpisodeFromDisk": "Usuń odcinek z dysku", + "DeleteEpisodesFiles": "Usuń {episodeFileCount} plików odcinków", + "DeleteEpisodesFilesHelpText": "Usuń pliki odcinków i folder serialu", + "DeleteFiles": "Usuń pliki", + "DeleteImportList": "Usuń listę importu", + "DeleteImportListExclusion": "Usuń wykluczenie listy importu", + "DeleteImportListExclusionMessageText": "Czy na pewno chcesz usunąć to wykluczenie listy importu?", + "DeleteImportListMessageText": "Czy na pewno chcesz usunąć listę '{name}'?", + "DeleteIndexer": "Usuń indekser", + "DeleteIndexerMessageText": "Czy na pewno chcesz usunąć indekser '{name}'?", + "DeleteNotification": "Usuń powiadomienie", + "DeleteNotificationMessageText": "Czy na pewno chcesz usunąć powiadomienie '{name}'?", + "DeleteQualityProfile": "Usuń profil jakości", + "DeleteQualityProfileMessageText": "Czy na pewno chcesz usunąć profil jakości '{name}'?", + "DeleteReleaseProfile": "Usuń profil wydań", + "DeleteReleaseProfileMessageText": "Czy na pewno chcesz usunąć profil wydań '{name}'?", + "DeleteRemotePathMapping": "Usuń mapowanie ścieżki zdalnej", + "DeleteRemotePathMappingMessageText": "Czy na pewno chcesz usunąć to mapowanie ścieżki zdalnej?", + "DeleteSelected": "Usuń zaznaczone", + "DeleteSelectedCustomFormats": "Usuń format(y) niestandardowe", + "DeleteSelectedCustomFormatsMessageText": "Czy na pewno chcesz usunąć {count} zaznaczonych formatów niestandardowych?", + "DeleteSelectedDownloadClients": "Usuń klienta(ów) pobierania", + "DeleteSelectedDownloadClientsMessageText": "Czy na pewno chcesz usunąć {count} zaznaczonych klientów pobierania?", + "DeleteSelectedEpisodeFiles": "Usuń zaznaczone pliki odcinków", + "DeleteSelectedEpisodeFilesHelpText": "Czy na pewno chcesz usunąć zaznaczone pliki odcinków?", + "DeleteSelectedImportListExclusionsMessageText": "Czy na pewno chcesz usunąć zaznaczone wykluczenia list importu?", + "DeleteSelectedImportLists": "Usuń listę/listy importu", + "DeleteSelectedImportListsMessageText": "Czy na pewno chcesz usunąć {count} zaznaczonych list importu?", + "DeleteSelectedIndexers": "Usuń indekser(y)", + "DeleteSelectedIndexersMessageText": "Czy na pewno chcesz usunąć {count} zaznaczonych indekserów?", + "DeleteSelectedSeries": "Usuń zaznaczone seriale", + "DeleteSelectedSeriesFiles": "Usuń pliki zaznaczonych seriali", + "DeleteSeriesFilesConfirmation": "Czy na pewno chcesz usunąć wszystkie śledzone pliki odcinków dla {count} zaznaczonych seriali?", + "DeleteSeriesFolder": "Usuń folder serialu", + "DeleteSeriesFolderConfirmation": "Folder serialu `{path}` oraz cała jego zawartość zostaną usunięte.", + "DeleteSeriesFolderCountConfirmation": "Czy na pewno chcesz usunąć {count} zaznaczonych seriali?", + "DeleteSeriesFolderCountWithFilesConfirmation": "Czy na pewno chcesz usunąć {count} zaznaczonych seriali i całą ich zawartość?", + "DeleteSeriesFolderEpisodeCount": "{episodeFileCount} plików odcinków o łącznym rozmiarze {size}", + "DeleteSeriesFolderHelpText": "Usuń folder serialu i jego zawartość", + "DeleteSeriesFolders": "Usuń foldery seriali", + "DeleteSeriesFoldersHelpText": "Usuń foldery seriali i całą ich zawartość", + "DeleteSeriesModalHeader": "Usuń - {title}", + "DeleteSpecification": "Usuń specyfikację", + "DeleteSpecificationHelpText": "Czy na pewno chcesz usunąć specyfikację '{name}'?", + "DeleteTag": "Usuń tag", + "DeleteTagMessageText": "Czy na pewno chcesz usunąć tag '{label}'?", + "Deleted": "Usunięto", + "DeletedReasonEpisodeMissingFromDisk": "{appName} nie mógł znaleźć pliku na dysku, więc plik został odłączony od odcinka w bazie danych", + "DeletedReasonManual": "Plik został usunięty przez {appName}, ręcznie lub przez inne narzędzie przez API", + "DeletedReasonUpgrade": "Plik został usunięty w celu zaimportowania ulepszonej wersji", + "DeletedSeriesDescription": "Serial został usunięty z TheTVDB", + "Destination": "Miejsce docelowe", + "DestinationPath": "Ścieżka docelowa", + "DestinationRelativePath": "Względna ścieżka docelowa", + "DetailedProgressBar": "Szczegółowy pasek postępu", + "DetailedProgressBarHelpText": "Pokazuj tekst na pasku postępu", + "Details": "Szczegóły", + "Directory": "Katalog", + "Disabled": "Wyłączone", + "DisabledForLocalAddresses": "Wyłączone dla adresów lokalnych", + "Discord": "Discord", + "DiskSpace": "Miejsce na dysku", + "DoNotBlocklist": "Nie dodawaj do czarnej listy", + "DoNotBlocklistHint": "Usuń bez dodawania do czarnej listy", + "DoNotPrefer": "Nie preferuj", + "DoNotUpgradeAutomatically": "Nie aktualizuj automatycznie", + "Docker": "Docker", + "DockerUpdater": "Zaktualizuj kontener Docker, aby otrzymać aktualizację", + "Donate": "Wesprzyj", + "Donations": "Darowizny", + "DoneEditingGroups": "Zakończono edycję grup", + "DoneEditingSizes": "Zakończono edycję rozmiarów", + "DotNetVersion": ".NET", + "Download": "Pobierz", + "DownloadClient": "Klient pobierania", + "DownloadClientAriaSettingsDirectoryHelpText": "Opcjonalna lokalizacja pobrań, pozostaw puste aby użyć domyślnej lokalizacji Aria2", + "DownloadClientCheckNoneAvailableHealthCheckMessage": "Brak dostępnego klienta pobierania", + "DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Nie można komunikować się z {downloadClientName}. {errorMessage}", + "DownloadClientDelugeSettingsDirectory": "Katalog pobierania", + "DownloadClientDelugeSettingsDirectoryCompleted": "Katalog przenoszenia po ukończeniu", + "DownloadClientDelugeSettingsDirectoryCompletedHelpText": "Opcjonalna lokalizacja przenoszenia ukończonych pobrań, pozostaw puste aby użyć domyślnej lokalizacji Deluge", + "DownloadClientDelugeSettingsDirectoryHelpText": "Opcjonalna lokalizacja pobrań, pozostaw puste aby użyć domyślnej lokalizacji Deluge", + "DownloadClientDelugeSettingsUrlBaseHelpText": "Dodaje prefiks do URL JSON Deluge, zobacz {url}", + "DownloadClientDelugeTorrentStateError": "Deluge zgłasza błąd", + "DownloadClientDelugeValidationLabelPluginFailure": "Konfiguracja etykiety nie powiodła się", + "DownloadClientDelugeValidationLabelPluginFailureDetail": "{appName} nie mógł dodać etykiety do {clientName}.", + "DownloadClientDelugeValidationLabelPluginInactive": "Wtyczka Label nie jest aktywna", + "DownloadClientDelugeValidationLabelPluginInactiveDetail": "Aby używać kategorii, musisz mieć włączoną wtyczkę Label w {clientName}.", + "DownloadClientDownloadStationProviderMessage": "{appName} nie może połączyć się z Download Station, jeśli na Twoim koncie DSM jest włączone uwierzytelnianie dwuskładnikowe", + "DownloadClientDownloadStationSettingsDirectoryHelpText": "Opcjonalny folder współdzielony, do którego trafiają pobrania; pozostaw puste, aby użyć domyślnej lokalizacji Download Station", + "DownloadClientDownloadStationValidationApiVersion": "Wersja API Download Station nie jest obsługiwana, powinna wynosić co najmniej {requiredVersion}. Obsługiwany zakres: od {minVersion} do {maxVersion}", "DownloadClientDownloadStationValidationFolderMissing": "Folder nie istnieje", - "DownloadClientDownloadStationValidationFolderMissingDetail": "Folder \"{downloadDir}\" nie istnieje. Musi on zostać utworzony manualnie wewnątrz folderu współdzielonego \"{sharedFolder}\".", - "DownloadClientDownloadStationValidationNoDefaultDestination": "Nie zdefiniowano domyślne lokalizacji", - "DownloadClientDownloadStationValidationNoDefaultDestinationDetail": "Musisz zalogować się do swojej stacji DiskStation jako {username} i ręcznie skonfigurować to w ustawieniach DownloadStation w sekcji BT/HTTP/FTP/NZB -> Lokalizacja.", + "DownloadClientDownloadStationValidationFolderMissingDetail": "Folder '{downloadDir}' nie istnieje, musi zostać utworzony ręcznie wewnątrz folderu współdzielonego '{sharedFolder}'.", + "DownloadClientDownloadStationValidationNoDefaultDestination": "Brak domyślnego miejsca docelowego", + "DownloadClientDownloadStationValidationNoDefaultDestinationDetail": "Zaloguj się do Diskstation jako {username} i ustaw to ręcznie w ustawieniach DownloadStation: BT/HTTP/FTP/NZB -> Location.", "DownloadClientDownloadStationValidationSharedFolderMissing": "Folder współdzielony nie istnieje", - "DownloadClientDownloadStationValidationSharedFolderMissingDetail": "Stacja DiskStation nie posiada folderu współdzielonego o nazwie „{sharedFolder}”. Czy na pewno podana nazwa jest poprawna?", + "DownloadClientDownloadStationValidationSharedFolderMissingDetail": "Diskstation nie ma folderu współdzielonego o nazwie '{sharedFolder}'. Czy na pewno podano poprawną nazwę?", + "DownloadClientFloodSettingsAdditionalTags": "Dodatkowe tagi", + "DownloadClientFloodSettingsAdditionalTagsHelpText": "Dodaje właściwości mediów jako tagi. Podpowiedzi są przykładami.", "DownloadClientFloodSettingsPostImportTags": "Tagi po imporcie", + "DownloadClientFloodSettingsPostImportTagsHelpText": "Dodaje tagi po zaimportowaniu pobrania. Torrenty ze wszystkimi tymi tagami będą filtrowane z kolejki i nie będą usuwane automatycznie.", + "DownloadClientFloodSettingsRemovalInfo": "{appName} będzie automatycznie usuwać torrenty na podstawie bieżących kryteriów seedowania w Ustawienia -> Indeksery", + "DownloadClientFloodSettingsStartOnAdd": "Uruchom przy dodaniu", + "DownloadClientFloodSettingsTagsHelpText": "Początkowe tagi pobrania. Aby zostało rozpoznane, pobranie musi mieć wszystkie tagi początkowe. Pozwala to uniknąć konfliktów z niepowiązanymi pobraniami.", + "DownloadClientFloodSettingsUrlBaseHelpText": "Dodaje prefiks do API Flood, np. {url}", + "DownloadClientFreeboxApiError": "API Freebox zwróciło błąd: {errorDescription}", + "DownloadClientFreeboxAuthenticationError": "Uwierzytelnianie w API Freebox nie powiodło się. Powód: {errorDescription}", + "DownloadClientFreeboxNotLoggedIn": "Niezalogowano", + "DownloadClientFreeboxSettingsApiUrl": "URL API", + "DownloadClientFreeboxSettingsApiUrlHelpText": "Zdefiniuj bazowy URL API Freebox wraz z wersją API, np. '{url}', domyślnie '{defaultApiUrl}'", + "DownloadClientFreeboxSettingsAppId": "ID aplikacji", + "DownloadClientFreeboxSettingsAppIdHelpText": "ID aplikacji podane podczas tworzenia dostępu do API Freebox (tj. 'app_id')", + "DownloadClientFreeboxSettingsAppToken": "Token aplikacji", + "DownloadClientFreeboxSettingsAppTokenHelpText": "Token aplikacji uzyskany podczas tworzenia dostępu do API Freebox (tj. 'app_token')", + "DownloadClientFreeboxSettingsHostHelpText": "Nazwa hosta lub adres IP hosta Freebox, domyślnie '{url}' (zadziała tylko w tej samej sieci)", + "DownloadClientFreeboxSettingsPortHelpText": "Port używany do dostępu do interfejsu Freebox, domyślnie '{port}'", + "DownloadClientFreeboxUnableToReachFreebox": "Nie można połączyć się z API Freebox. Zweryfikuj ustawienia 'Host', 'Port' lub 'Use SSL'. (Błąd: {exceptionMessage})", + "DownloadClientFreeboxUnableToReachFreeboxApi": "Nie można połączyć się z API Freebox. Zweryfikuj ustawienie 'API URL' dla bazowego URL i wersji.", + "DownloadClientItemErrorMessage": "{clientName} zgłasza błąd: {message}", + "DownloadClientNzbVortexMultipleFilesMessage": "Pobranie zawiera wiele plików i nie znajduje się w folderze zadania: {outputPath}", + "DownloadClientNzbgetSettingsAddPausedHelpText": "Ta opcja wymaga co najmniej NzbGet w wersji 16.0", + "DownloadClientNzbgetValidationKeepHistoryOverMax": "Ustawienie KeepHistory w NzbGet powinno być mniejsze niż 25000", + "DownloadClientNzbgetValidationKeepHistoryOverMaxDetail": "Ustawienie KeepHistory w NzbGet jest ustawione zbyt wysoko.", + "DownloadClientNzbgetValidationKeepHistoryZero": "Ustawienie KeepHistory w NzbGet powinno być większe niż 0", + "DownloadClientNzbgetValidationKeepHistoryZeroDetail": "Ustawienie KeepHistory w NzbGet jest ustawione na 0, co uniemożliwia {appName} wykrywanie ukończonych pobrań.", + "DownloadClientOptionsLoadError": "Nie można wczytać opcji klienta pobierania", + "DownloadClientPneumaticSettingsNzbFolder": "Folder NZB", + "DownloadClientPneumaticSettingsNzbFolderHelpText": "Ten folder musi być osiągalny z XBMC", + "DownloadClientPneumaticSettingsStrmFolder": "Folder Strm", + "DownloadClientPneumaticSettingsStrmFolderHelpText": "Pliki .strm w tym folderze będą importowane przez drone", + "DownloadClientPriorityHelpText": "Priorytet klienta pobierania od 1 (najwyższy) do 50 (najniższy). Domyślnie: 1. Dla klientów z tym samym priorytetem używany jest Round-Robin.", + "DownloadClientQbittorrentSettingsAddSeriesTags": "Dodaj tagi serialu", + "DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "Dodawaj tagi serialu do nowych torrentów dodawanych do klienta pobierania (qBittorrent 4.1.0+)", + "DownloadClientQbittorrentSettingsContentLayout": "Układ zawartości", + "DownloadClientQbittorrentSettingsContentLayoutHelpText": "Czy używać układu zawartości skonfigurowanego w qBittorrent, oryginalnego układu z torrenta, czy zawsze tworzyć podfolder (qBittorrent 4.3.2+)", + "DownloadClientQbittorrentSettingsFirstAndLastFirst": "Najpierw pierwsze i ostatnie", + "DownloadClientQbittorrentSettingsFirstAndLastFirstHelpText": "Pobieraj najpierw pierwsze i ostatnie fragmenty (qBittorrent 4.1.0+)", + "DownloadClientQbittorrentSettingsInitialStateHelpText": "Stan początkowy dla torrentów dodawanych do qBittorrent. Pamiętaj, że wymuszone torrenty nie przestrzegają ograniczeń seedowania", + "DownloadClientQbittorrentSettingsSequentialOrder": "Kolejność sekwencyjna", + "DownloadClientQbittorrentSettingsSequentialOrderHelpText": "Pobieraj w kolejności sekwencyjnej (qBittorrent 4.1.0+)", + "DownloadClientQbittorrentSettingsUseSslHelpText": "Użyj bezpiecznego połączenia. Zobacz Opcje -> Web UI -> 'Use HTTPS instead of HTTP' w qBittorrent.", + "DownloadClientQbittorrentTorrentStateDhtDisabled": "qBittorrent nie może rozwiązać linku magnet przy wyłączonym DHT", + "DownloadClientQbittorrentTorrentStateError": "qBittorrent zgłasza błąd", + "DownloadClientQbittorrentTorrentStateMetadata": "qBittorrent pobiera metadane", + "DownloadClientQbittorrentTorrentStateMissingFiles": "qBittorrent zgłasza brakujące pliki", + "DownloadClientQbittorrentTorrentStatePathError": "Nie można zaimportować. Ścieżka odpowiada bazowemu katalogowi pobrań klienta, możliwe że dla tego torrenta wyłączono 'Keep top-level folder' albo 'Torrent Content Layout' NIE jest ustawione na 'Original' lub 'Create Subfolder'?", + "DownloadClientQbittorrentTorrentStateStalled": "Pobieranie utknęło bez połączeń", + "DownloadClientQbittorrentTorrentStateUnknown": "Nieznany stan pobierania: {state}", + "DownloadClientQbittorrentValidationCategoryAddFailure": "Konfiguracja kategorii nie powiodła się", + "DownloadClientQbittorrentValidationCategoryAddFailureDetail": "{appName} nie mógł dodać etykiety do qBittorrent.", + "DownloadClientQbittorrentValidationCategoryRecommended": "Kategoria jest zalecana", + "DownloadClientQbittorrentValidationCategoryRecommendedDetail": "{appName} nie będzie próbował importować ukończonych pobrań bez kategorii.", + "DownloadClientQbittorrentValidationCategoryUnsupported": "Kategoria nie jest obsługiwana", + "DownloadClientQbittorrentValidationCategoryUnsupportedDetail": "Kategorie nie są obsługiwane do wersji qBittorrent 3.3.0. Zaktualizuj lub spróbuj ponownie z pustą kategorią.", + "DownloadClientQbittorrentValidationQueueingNotEnabled": "Kolejkowanie nie jest włączone", + "DownloadClientQbittorrentValidationQueueingNotEnabledDetail": "Kolejkowanie torrentów nie jest włączone w ustawieniach qBittorrent. Włącz je w qBittorrent lub wybierz priorytet 'Ostatni'.", + "DownloadClientQbittorrentValidationRemovesAtRatioLimit": "qBittorrent jest skonfigurowany do usuwania torrentów po osiągnięciu limitu współczynnika udostępniania", + "DownloadClientQbittorrentValidationRemovesAtRatioLimitDetail": "{appName} nie będzie mógł wykonać skonfigurowanej obsługi ukończonych pobrań. Możesz to naprawić w qBittorrent ('Narzędzia -> Opcje...' w menu), zmieniając 'Opcje -> BitTorrent -> Ograniczenie współczynnika udostępniania' z 'Usuń je' na 'Wstrzymaj je'", + "DownloadClientRTorrentProviderMessage": "rTorrent nie wstrzyma torrentów po spełnieniu kryteriów seedowania. {appName} będzie automatycznie usuwał torrenty na podstawie bieżących kryteriów seedowania w Ustawienia->Indeksery tylko wtedy, gdy włączone jest Usuwanie ukończonych. Po imporcie ustawi też {importedView} jako widok rTorrent, który można wykorzystać w skryptach rTorrent do dostosowania zachowania.", + "DownloadClientRTorrentSettingsAddStopped": "Dodaj zatrzymane", + "DownloadClientRTorrentSettingsAddStoppedHelpText": "Włączenie spowoduje dodawanie torrentów i magnetów do rTorrent w stanie zatrzymanym. To może psuć pliki magnet.", + "DownloadClientRTorrentSettingsDirectoryHelpText": "Opcjonalna lokalizacja pobrań, pozostaw puste aby użyć domyślnej lokalizacji rTorrent", + "DownloadClientRTorrentSettingsUrlPath": "Ścieżka URL", + "DownloadClientRTorrentSettingsUrlPathHelpText": "Ścieżka do endpointu XMLRPC, zobacz {url}. Zwykle jest to RPC2 lub [ścieżka do ruTorrent]{url2} przy użyciu ruTorrent.", + "DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "Klient pobierania {downloadClientName} jest ustawiony na usuwanie ukończonych pobrań. Może to powodować usunięcie pobrań z klienta zanim {appName} zdąży je zaimportować.", + "DownloadClientRootFolderHealthCheckMessage": "Klient pobierania {downloadClientName} zapisuje pobrania w folderze głównym {rootFolderPath}. Nie powinieneś pobierać do folderu głównego.", + "DownloadClientSabnzbdValidationCheckBeforeDownload": "Wyłącz opcję 'Sprawdzaj przed pobraniem' w Sabnzbd", + "DownloadClientSabnzbdValidationCheckBeforeDownloadDetail": "Używanie opcji 'Sprawdzaj przed pobraniem' wpływa na zdolność {appName} do śledzenia nowych pobrań. Dodatkowo Sabnzbd zaleca zamiast tego opcję 'Przerywaj zadania, których nie można ukończyć', ponieważ jest skuteczniejsza.", + "DownloadClientSabnzbdValidationDevelopVersion": "Wersja deweloperska Sabnzbd, zakłada się wersję 3.0.0 lub nowszą.", + "DownloadClientSabnzbdValidationDevelopVersionDetail": "{appName} może nie być w stanie obsłużyć nowych funkcji dodanych do SABnzbd podczas używania wersji deweloperskich.", + "DownloadClientSabnzbdValidationEnableDisableDateSorting": "Wyłącz sortowanie po dacie", + "DownloadClientSabnzbdValidationEnableDisableDateSortingDetail": "Musisz wyłączyć sortowanie po dacie dla kategorii używanej przez {appName}, aby zapobiec problemom z importem. Przejdź do Sabnzbd, aby to naprawić.", + "DownloadClientSabnzbdValidationEnableDisableMovieSorting": "Wyłącz sortowanie filmów", + "DownloadClientSabnzbdValidationEnableDisableMovieSortingDetail": "Musisz wyłączyć sortowanie filmów dla kategorii używanej przez {appName}, aby zapobiec problemom z importem. Przejdź do Sabnzbd, aby to naprawić.", + "DownloadClientSabnzbdValidationEnableDisableTvSorting": "Wyłącz sortowanie TV", + "DownloadClientSabnzbdValidationEnableDisableTvSortingDetail": "Musisz wyłączyć sortowanie TV dla kategorii używanej przez {appName}, aby zapobiec problemom z importem. Przejdź do Sabnzbd, aby to naprawić.", + "DownloadClientSabnzbdValidationEnableJobFolders": "Włącz foldery zadań", + "DownloadClientSabnzbdValidationEnableJobFoldersDetail": "{appName} preferuje, aby każde pobranie miało osobny folder. Z dopisanym * do Folder/Path Sabnzbd nie utworzy tych folderów zadań. Przejdź do Sabnzbd, aby to naprawić.", + "DownloadClientSabnzbdValidationUnknownVersion": "Nieznana wersja: {rawVersion}", + "DownloadClientSeriesTagHelpText": "Używaj tego klienta pobierania tylko dla seriali z co najmniej jednym pasującym tagiem. Pozostaw puste, aby używać dla wszystkich seriali.", + "DownloadClientSettings": "Ustawienia klienta pobierania", + "DownloadClientSettingsAddPaused": "Dodaj wstrzymane", + "DownloadClientSettingsCategoryHelpText": "Dodanie kategorii specyficznej dla {appName} pozwala uniknąć konfliktów z niepowiązanymi pobraniami spoza {appName}. Użycie kategorii jest opcjonalne, ale zdecydowanie zalecane.", + "DownloadClientSettingsCategorySubFolderHelpText": "Dodanie kategorii specyficznej dla {appName} pozwala uniknąć konfliktów z niepowiązanymi pobraniami spoza {appName}. Użycie kategorii jest opcjonalne, ale zdecydowanie zalecane. Tworzy podkatalog [category] w katalogu wyjściowym.", + "DownloadClientSettingsDestinationHelpText": "Ręcznie określa miejsce docelowe pobierania, pozostaw puste aby użyć domyślnego", + "DownloadClientSettingsInitialState": "Stan początkowy", + "DownloadClientSettingsInitialStateHelpText": "Stan początkowy torrentów dodawanych do {clientName}", + "DownloadClientSettingsOlderPriority": "Priorytet starszych", + "DownloadClientSettingsOlderPriorityEpisodeHelpText": "Priorytet używany podczas pobierania odcinków wyemitowanych ponad 14 dni temu", + "DownloadClientSettingsPostImportCategoryHelpText": "Kategoria, którą {appName} ustawi po zaimportowaniu pobrania. {appName} nie usunie torrentów w tej kategorii nawet po zakończeniu seedowania. Pozostaw puste, aby zachować tę samą kategorię.", + "DownloadClientSettingsRecentPriority": "Priorytet nowszych", + "DownloadClientSettingsRecentPriorityEpisodeHelpText": "Priorytet używany podczas pobierania odcinków wyemitowanych w ciągu ostatnich 14 dni", + "DownloadClientSettingsUrlBaseHelpText": "Dodaje prefiks do URL klienta {clientName}, np. {url}", + "DownloadClientSettingsUseSslHelpText": "Użyj bezpiecznego połączenia przy łączeniu z {clientName}", + "DownloadClientSortingHealthCheckMessage": "Klient pobierania {downloadClientName} ma włączone sortowanie {sortingMode} dla kategorii {appName}. Powinieneś wyłączyć sortowanie w kliencie pobierania, aby uniknąć problemów z importem.", + "DownloadClientStatusAllClientHealthCheckMessage": "Wszyscy klienci pobierania są niedostępni z powodu błędów", + "DownloadClientStatusSingleClientHealthCheckMessage": "Klienci pobierania niedostępni z powodu błędów: {downloadClientNames}", + "DownloadClientTransmissionSettingsDirectoryHelpText": "Opcjonalna lokalizacja pobrań, pozostaw puste aby użyć domyślnej lokalizacji Transmission", + "DownloadClientTransmissionSettingsUrlBaseHelpText": "Dodaje prefiks do URL RPC klienta {clientName}, np. {url}, domyślnie '{defaultUrl}'", + "DownloadClientTriblerProviderMessage": "Integracja z tribler jest wysoce eksperymentalna. Testowano z wersją {clientName} {clientVersionRange}.", + "DownloadClientTriblerSettingsAnonymityLevel": "Poziom anonimowości", + "DownloadClientTriblerSettingsAnonymityLevelHelpText": "Liczba proxy używanych podczas pobierania treści. Aby wyłączyć, ustaw 0. Proxy zmniejszają prędkość pobierania/wysyłania. Zobacz {url}", + "DownloadClientTriblerSettingsApiKeyHelpText": "[api].key z pliku triblerd.conf", + "DownloadClientTriblerSettingsDirectoryHelpText": "Opcjonalna lokalizacja pobrań, pozostaw puste aby użyć domyślnej lokalizacji Tribler", + "DownloadClientTriblerSettingsSafeSeeding": "Bezpieczne seedowanie", + "DownloadClientTriblerSettingsSafeSeedingHelpText": "Po włączeniu seedowanie odbywa się tylko przez proxy.", + "DownloadClientUTorrentProviderMessage": "uTorrent ma historię dołączania cryptominerów, malware i reklam, zdecydowanie zachęcamy do wybrania innego klienta.", + "DownloadClientUTorrentTorrentStateError": "uTorrent zgłasza błąd", + "DownloadClientUnavailable": "Klient pobierania niedostępny", + "DownloadClientValidationApiKeyIncorrect": "Nieprawidłowy klucz API", + "DownloadClientValidationApiKeyRequired": "Wymagany klucz API", + "DownloadClientValidationAuthenticationFailure": "Błąd uwierzytelniania", + "DownloadClientValidationAuthenticationFailureDetail": "Zweryfikuj swoje dane logowania. Sprawdź też, czy host uruchamiający {appName} nie jest zablokowany przed dostępem do {clientName} przez ograniczenia WhiteList w konfiguracji {clientName}.", + "DownloadClientValidationCategoryMissing": "Kategoria nie istnieje", + "DownloadClientValidationCategoryMissingDetail": "Wprowadzona kategoria nie istnieje w {clientName}. Najpierw utwórz ją w {clientName}.", + "DownloadClientValidationErrorVersion": "Wersja {clientName} powinna wynosić co najmniej {requiredVersion}. Zgłoszona wersja to {reportedVersion}", + "DownloadClientValidationGroupMissing": "Grupa nie istnieje", + "DownloadClientValidationGroupMissingDetail": "Wprowadzona grupa nie istnieje w {clientName}. Najpierw utwórz ją w {clientName}.", + "DownloadClientValidationSslConnectFailure": "Nie można połączyć się przez SSL", + "DownloadClientValidationSslConnectFailureDetail": "{appName} nie może połączyć się z {clientName} przez SSL. Problem może być związany z komputerem. Spróbuj skonfigurować {appName} i {clientName} tak, aby nie używały SSL.", + "DownloadClientValidationTestNzbs": "Nie udało się pobrać listy NZB: {exceptionMessage}", + "DownloadClientValidationTestTorrents": "Nie udało się pobrać listy torrentów: {exceptionMessage}", + "DownloadClientValidationUnableToConnect": "Nie można połączyć się z {clientName}", + "DownloadClientValidationUnableToConnectDetail": "Sprawdź nazwę hosta i port.", + "DownloadClientValidationUnknownException": "Nieznany wyjątek: {exception}", + "DownloadClientValidationVerifySsl": "Zweryfikuj ustawienia SSL", + "DownloadClientValidationVerifySslDetail": "Zweryfikuj konfigurację SSL po stronie {clientName} i {appName}", + "DownloadClientVuzeValidationErrorVersion": "Wersja protokołu nie jest obsługiwana, użyj Vuze 5.0.0.0 lub nowszego z wtyczką Vuze Web Remote.", "DownloadClients": "Klienci pobierania", + "DownloadClientsLoadError": "Nie można wczytać klientów pobierania", + "DownloadClientsSettingsSummary": "Klienci pobierania, obsługa pobierania i mapowania ścieżek zdalnych", + "DownloadFailed": "Pobieranie nieudane", + "DownloadFailedEpisodeTooltip": "Pobieranie odcinka nie powiodło się", + "DownloadIgnored": "Pobranie zignorowane", + "DownloadIgnoredEpisodeTooltip": "Pobieranie odcinka zignorowano", + "DownloadPropersAndRepacks": "Propers i Repacks", + "DownloadPropersAndRepacksHelpText": "Czy automatycznie aktualizować do Propers/Repacks", + "DownloadPropersAndRepacksHelpTextCustomFormat": "Użyj opcji 'Nie preferuj', aby sortować według wyniku formatu niestandardowego ponad Propers/Repacks", + "DownloadPropersAndRepacksHelpTextWarning": "Użyj formatów niestandardowych do automatycznych aktualizacji do Propers/Repacks", + "DownloadStationStatusExtracting": "Wypakowywanie: {progress}%", + "DownloadWarning": "Ostrzeżenie pobierania: {warningMessage}", + "Downloaded": "Pobrano", + "Downloading": "Pobieranie", + "Duplicate": "Duplikat", + "Duration": "Czas trwania", + "Edit": "Edytuj", + "EditAutoTag": "Edytuj automatyczny tag", + "EditConditionImplementation": "Edytuj warunek - {implementationName}", + "EditConnectionImplementation": "Edytuj połączenie - {implementationName}", + "EditCustomFormat": "Edytuj format niestandardowy", + "EditDelayProfile": "Edytuj profil opóźnienia", + "EditDownloadClientImplementation": "Edytuj klienta pobierania - {implementationName}", + "EditGroups": "Edytuj grupy", + "EditImportListExclusion": "Edytuj wykluczenie listy importu", + "EditImportListImplementation": "Edytuj listę importu - {implementationName}", + "EditIndexerImplementation": "Edytuj indekser - {implementationName}", + "EditListExclusion": "Edytuj wykluczenie listy", + "EditMetadata": "Edytuj metadane {metadataType}", + "EditQualityProfile": "Edytuj profil jakości", + "EditReleaseProfile": "Edytuj profil wydań", + "EditRemotePathMapping": "Edytuj mapowanie ścieżki zdalnej", + "EditRestriction": "Edytuj ograniczenie", + "EditSelectedCustomFormats": "Edytuj zaznaczone formaty niestandardowe", + "EditSelectedDownloadClients": "Edytuj zaznaczonych klientów pobierania", + "EditSelectedImportLists": "Edytuj zaznaczone listy importu", + "EditSelectedIndexers": "Edytuj zaznaczone indeksery", + "EditSelectedSeries": "Edytuj zaznaczone seriale", + "EditSeries": "Edytuj serial", + "EditSeriesModalHeader": "Edytuj - {title}", + "EditSizes": "Edytuj rozmiary", + "Empty": "Puste", + "EmptyRootFolderTooltip": "Ten folder główny nie zawiera żadnych plików ani folderów. {appName} nie będzie skanować zmian ani tworzyć pustych folderów seriali.", + "Enable": "Włącz", + "EnableAutomaticAdd": "Włącz automatyczne dodawanie", + "EnableAutomaticAddSeriesHelpText": "Dodawaj seriale z tej listy do {appName}, gdy synchronizacje są wykonywane przez UI lub przez {appName}", + "EnableAutomaticSearch": "Włącz wyszukiwanie automatyczne", + "EnableAutomaticSearchHelpText": "Będzie używane, gdy automatyczne wyszukiwania są wykonywane przez UI lub przez {appName}", + "EnableAutomaticSearchHelpTextWarning": "Będzie używane przy wyszukiwaniu interaktywnym", + "EnableColorImpairedMode": "Włącz tryb dla osób z zaburzeniami rozpoznawania barw", + "EnableColorImpairedModeHelpText": "Zmodyfikowany styl, aby umożliwić osobom z zaburzeniami rozpoznawania barw łatwiejsze rozróżnianie informacji oznaczonych kolorami", + "EnableCompletedDownloadHandlingHelpText": "Automatycznie importuj ukończone pobrania z klienta pobierania", + "EnableHelpText": "Włącz tworzenie plików metadanych dla tego typu metadanych", + "EnableInteractiveSearch": "Włącz wyszukiwanie interaktywne", + "EnableInteractiveSearchHelpText": "Będzie używane przy wyszukiwaniu interaktywnym", + "EnableInteractiveSearchHelpTextWarning": "Wyszukiwanie nie jest obsługiwane przez ten indekser", + "EnableMediaInfoHelpText": "Wyodrębnij informacje o wideo, takie jak rozdzielczość, czas trwania i kodeki. Wymaga to od {appName} odczytu fragmentów pliku, co może powodować wysoką aktywność dysku lub sieci podczas skanowania.", + "EnableMetadataHelpText": "Włącz tworzenie plików metadanych dla tego typu metadanych", + "EnableProfile": "Włącz profil", + "EnableProfileHelpText": "Zaznacz, aby włączyć profil wydań", + "EnableRss": "Włącz RSS", + "EnableRssHelpText": "Będzie używane, gdy {appName} okresowo wyszukuje wydania przez synchronizację RSS", + "EnableSsl": "Włącz SSL", + "EnableSslHelpText": "Aby zadziałało, wymagany restart uruchomiony jako administrator", + "Enabled": "Włączone", + "Ended": "Zakończony", + "EndedOnly": "Tylko zakończone", + "EndedSeriesDescription": "Nie są oczekiwane żadne dodatkowe odcinki ani sezony", + "Episode": "Odcinek", + "EpisodeAirDate": "Data emisji odcinka", + "EpisodeCount": "Liczba odcinków", + "EpisodeDownloaded": "Odcinek pobrany", + "EpisodeFileDeleted": "Plik odcinka usunięty", + "EpisodeFileDeletedTooltip": "Plik odcinka usunięty", + "EpisodeFileMissingTooltip": "Brak pliku odcinka", + "EpisodeFileRenamed": "Zmieniono nazwę pliku odcinka", + "EpisodeFileRenamedTooltip": "Zmieniono nazwę pliku odcinka", + "EpisodeFilesLoadError": "Nie można wczytać plików odcinków", + "EpisodeGrabbedTooltip": "Odcinek pobrano z {indexer} i wysłano do {downloadClient}", + "EpisodeHasNotAired": "Odcinek nie został jeszcze wyemitowany", + "EpisodeHistoryLoadError": "Nie można wczytać historii odcinka", + "EpisodeImported": "Odcinek zaimportowany", + "EpisodeImportedTooltip": "Odcinek pobrany pomyślnie i przechwycony z klienta pobierania", + "EpisodeInfo": "Informacje o odcinku", + "EpisodeIsDownloading": "Odcinek jest pobierany", + "EpisodeIsNotMonitored": "Odcinek nie jest monitorowany", + "EpisodeMaybePlural": "Odcinek(i)", + "EpisodeMissingAbsoluteNumber": "Odcinek nie ma bezwzględnego numeru odcinka", + "EpisodeMissingFromDisk": "Brak odcinka na dysku", + "EpisodeMonitoring": "Monitorowanie odcinków", + "EpisodeNaming": "Nazewnictwo odcinków", + "EpisodeNumbers": "Numer(y) odcinka", + "EpisodeProgress": "Postęp odcinków", + "EpisodeRequested": "Odcinek żądany", + "EpisodeSearchResultsLoadError": "Nie można wczytać wyników wyszukiwania dla tego odcinka. Spróbuj ponownie później", + "EpisodeTitle": "Tytuł odcinka", + "EpisodeTitleFootNote": "Opcjonalnie kontroluj przycinanie do maksymalnej liczby bajtów, łącznie z wielokropkiem (`...`). Obsługiwane jest przycinanie od końca (np. `{Episode Title:30}`) lub od początku (np. `{Episode Title:-30}`). Tytuły odcinków będą automatycznie przycinane do ograniczeń systemu plików, jeśli to konieczne.", + "EpisodeTitleMaybePlural": "Tytuł(y) odcinka", + "EpisodeTitleRequired": "Wymagany tytuł odcinka", + "EpisodeTitleRequiredHelpText": "Zapobiegaj importowi przez maksymalnie 48 godzin, jeśli tytuł odcinka jest użyty w formacie nazwy i tytuł odcinka to TBA", + "EpisodeTitles": "Tytuły odcinków", + "Episodes": "Odcinki", + "EpisodesInSeason": "{episodeCount} odcinków w sezonie", + "EpisodesLoadError": "Nie można wczytać odcinków", + "EpisodesMonitoredStatus": "Status monitorowania odcinków", + "Error": "Błąd", + "ErrorLoadingContent": "Wystąpił błąd podczas ładowania tej zawartości", + "ErrorLoadingContents": "Błąd ładowania zawartości", + "ErrorLoadingItem": "Wystąpił błąd podczas ładowania tego elementu", + "ErrorLoadingPage": "Wystąpił błąd podczas ładowania tej strony", + "ErrorRestoringBackup": "Błąd przywracania kopii zapasowej", + "EventType": "Typ zdarzenia", + "Events": "Zdarzenia", + "Example": "Przykład", + "Exception": "Wyjątek", + "ExcludeUnknownSeriesItems": "Wyklucz nieznane elementy seriali", + "ExcludedReleaseProfile": "Wykluczony profil wydań", + "ExcludedReleaseProfiles": "Wykluczone profile wydań", + "ExcludedTags": "Wykluczone tagi", + "Existing": "Istniejące", + "ExistingSeries": "Istniejące seriale", + "ExistingTag": "Istniejący tag", + "ExpandAll": "Rozwiń wszystko", + "ExportCustomFormat": "Eksportuj format niestandardowy", + "Extend": "Rozszerz", + "External": "Zewnętrzne", + "ExternalUpdater": "{appName} jest skonfigurowany do używania zewnętrznego mechanizmu aktualizacji", + "ExtraFileExtensionsHelpText": "Lista dodatkowych rozszerzeń plików do importu oddzielona przecinkami (.nfo zostanie zaimportowane jako .nfo-orig)", + "ExtraFileExtensionsHelpTextsExamples": "Przykłady: '.sub, .nfo' lub 'sub,nfo'", + "Failed": "Nieudane", + "FailedAt": "Nieudane o: {date}", + "FailedToFetchSettings": "Nie udało się pobrać ustawień", + "FailedToFetchUpdates": "Nie udało się pobrać aktualizacji", + "FailedToLoadCustomFiltersFromApi": "Nie udało się wczytać filtrów niestandardowych z API", + "FailedToLoadQualityProfilesFromApi": "Nie udało się wczytać profili jakości z API", + "FailedToLoadSeriesFromApi": "Nie udało się wczytać seriali z API", + "FailedToLoadSonarr": "Nie udało się wczytać {appName}", + "FailedToLoadSystemStatusFromApi": "Nie udało się wczytać stanu systemu z API", + "FailedToLoadTagsFromApi": "Nie udało się wczytać tagów z API", + "FailedToLoadTranslationsFromApi": "Nie udało się wczytać tłumaczeń z API", + "FailedToLoadUiSettingsFromApi": "Nie udało się wczytać ustawień interfejsu z API", + "Fallback": "Zapasowe", + "False": "Fałsz", + "FavoriteFolderAdd": "Dodaj ulubiony folder", + "FavoriteFolderRemove": "Usuń ulubiony folder", + "FavoriteFolders": "Ulubione foldery", + "FeatureRequests": "Propozycje funkcji", + "File": "Plik", + "FileBrowser": "Przeglądarka plików", + "FileBrowserPlaceholderText": "Zacznij pisać lub wybierz ścieżkę poniżej", + "FileManagement": "Zarządzanie plikami", + "FileNameTokens": "Tokeny nazwy pliku", + "FileNames": "Nazwy plików", + "FileSize": "Rozmiar pliku", + "Filename": "Nazwa pliku", + "Files": "Pliki", + "Filter": "Filtr", + "FilterContains": "zawiera", + "FilterDoesNotContain": "nie zawiera", + "FilterDoesNotEndWith": "nie kończy się na", + "FilterDoesNotStartWith": "nie zaczyna się od", + "FilterEndsWith": "kończy się na", + "FilterEpisodesPlaceholder": "Filtruj odcinki po tytule lub numerze", + "FilterEqual": "równe", + "FilterGreaterThan": "większe niż", + "FilterGreaterThanOrEqual": "większe lub równe", + "FilterInLast": "w ostatnich", + "FilterInNext": "w następnych", + "FilterIs": "jest", + "FilterIsAfter": "jest po", + "FilterIsBefore": "jest przed", + "FilterIsNot": "nie jest", + "FilterLessThan": "mniejsze niż", + "FilterLessThanOrEqual": "mniejsze lub równe", + "FilterNotEqual": "nierówne", + "FilterNotInLast": "nie w ostatnich", + "FilterNotInNext": "nie w następnych", + "FilterSeriesPlaceholder": "Filtruj seriale", + "FilterStartsWith": "zaczyna się od", + "Filters": "Filtry", + "FinaleTooltip": "Finał serialu lub sezonu", + "FirstDayOfWeek": "Pierwszy dzień tygodnia", + "Fixed": "Stałe", + "Folder": "Folder", + "FolderNameTokens": "Tokeny nazwy folderu", + "Folders": "Foldery", + "Forecast": "Prognoza", + "FormatAgeDay": "dzień", + "FormatAgeDays": "dni", + "FormatAgeHour": "godzina", + "FormatAgeHours": "godziny", + "FormatAgeMinute": "minuta", + "FormatAgeMinutes": "minuty", + "FormatDateTime": "{formattedDate} {formattedTime}", + "FormatDateTimeRelative": "{relativeDay}, {formattedDate} {formattedTime}", + "FormatRuntimeHours": "{hours}h", + "FormatRuntimeMinutes": "{minutes}m", + "FormatShortTimeSpanHours": "{hours} godz.", + "FormatShortTimeSpanMinutes": "{minutes} min.", + "FormatShortTimeSpanSeconds": "{seconds} sek.", + "FormatTimeSpanDays": "{days}d {time}", + "Formats": "Formaty", + "Forums": "Fora", + "FreeSpace": "Wolne miejsce", + "Friday": "Piątek", + "From": "Od", + "FullColorEvents": "Pełnokolorowe wydarzenia", + "FullColorEventsHelpText": "Zmienia styl tak, aby kolor statusu obejmował całe wydarzenie, a nie tylko lewą krawędź. Nie dotyczy Agendy", + "FullSeason": "Pełny sezon", "General": "Ogólne", - "RemotePathMappingBadDockerPathHealthCheckMessage": "Używasz Dockera; klient pobierania {downloadClientName} umieszcza pobrane pliki w {path}, lecz nie jest to poprawna ścieżka {osName}. Sprawdź mapowanie ścieżek zdalnych i ustawienia klienta pobierania.", - "RemoveFromDownloadClient": "Usuń z Klienta Pobierania", - "StartupDirectory": "Katalog Startowy", + "GeneralSettings": "Ustawienia ogólne", + "GeneralSettingsLoadError": "Nie można wczytać ustawień ogólnych", + "GeneralSettingsSummary": "Port, SSL, nazwa użytkownika/hasło, proxy, analityka i aktualizacje", + "Genres": "Gatunki", + "Global": "Globalne", + "Grab": "Pobierz", + "GrabId": "ID pobrania", + "GrabRelease": "Pobierz wydanie", + "GrabReleaseUnknownSeriesOrEpisodeMessageText": "{appName} nie był w stanie określić, którego serialu i odcinka dotyczy to wydanie. {appName} może nie być w stanie automatycznie zaimportować tego wydania. Czy chcesz pobrać '{title}'?", + "GrabSelected": "Wybierz zaznaczone", + "Grabbed": "Wybrane", + "GrabbedAt": "Wybrane o: {date}", + "Group": "Grupa", + "HardlinkCopyFiles": "Dowiązanie twarde/kopiuj pliki", + "HasMissingSeason": "Ma brakujący sezon", + "HasUnmonitoredSeason": "Ma niemonitorowany sezon", + "Health": "Zdrowie", + "HealthIssue": "1 problem ze stanem", + "HealthIssues": "{count} problemów ze stanem", + "HealthMessagesInfoBox": "Więcej informacji o przyczynie tych komunikatów kontroli stanu znajdziesz, klikając link wiki (ikona książki) na końcu wiersza lub sprawdzając [logi]({link}). Jeśli masz trudność z interpretacją tych komunikatów, możesz skontaktować się z naszym wsparciem przez linki poniżej.", + "Here": "tutaj", + "HiddenClickToShow": "Ukryte, kliknij aby pokazać", + "HideAdvanced": "Ukryj zaawansowane", + "HideEpisodes": "Ukryj odcinki", + "History": "Historia", + "HistoryLoadError": "Nie można wczytać historii", + "HistoryModalHeaderSeason": "Historia {season}", + "HistorySeason": "Pokaż historię dla tego sezonu", + "HomePage": "Strona główna", + "Host": "Host", + "Hostname": "Nazwa hosta", + "HourShorthand": "h", + "HttpHttps": "HTTP(S)", + "ICalFeed": "Kanał iCal", + "ICalFeedHelpText": "Skopiuj ten URL do klienta(ów) lub kliknij, aby subskrybować, jeśli Twoja przeglądarka obsługuje webcal", + "ICalIncludeUnmonitoredEpisodesHelpText": "Uwzględniaj niemonitorowane odcinki w kanale iCal", + "ICalLink": "Link iCal", + "ICalSeasonPremieresOnlyHelpText": "W kanale będzie tylko pierwszy odcinek sezonu", + "ICalShowAsAllDayEvents": "Pokaż jako wydarzenia całodniowe", + "ICalShowAsAllDayEventsHelpText": "Wydarzenia będą wyświetlane w kalendarzu jako całodniowe", + "ICalTagsSeriesHelpText": "Kanał będzie zawierał tylko seriale z co najmniej jednym pasującym tagiem", + "IRC": "IRC", + "IRCLinkText": "#sonarr na Libera", + "IconForCutoffUnmet": "Ikona dla nieosiągniętego progu", + "IconForCutoffUnmetHelpText": "Pokazuj ikonę przy plikach, gdy próg nie został osiągnięty", + "IconForFinales": "Ikona dla finałów", + "IconForFinalesHelpText": "Pokazuj ikonę dla finałów serialu/sezonu na podstawie dostępnych informacji o odcinkach", + "IconForSpecials": "Ikona dla odcinków specjalnych", + "IconForSpecialsHelpText": "Pokazuj ikonę dla odcinków specjalnych (sezon 0)", + "IgnoreDownload": "Ignoruj pobranie", + "IgnoreDownloadHint": "Powoduje, że {appName} przestaje dalej przetwarzać to pobranie", + "IgnoreDownloads": "Ignoruj pobrania", + "IgnoreDownloadsHint": "Powoduje, że {appName} przestaje dalej przetwarzać te pobrania", + "Ignored": "Zignorowane", + "IgnoredAddresses": "Ignorowane adresy", + "ImageBanner": "baner", + "ImageFanart": "fanart", + "ImagePoster": "plakat", + "ImageSeason": "sezon", + "Images": "Obrazy", + "ImdbId": "ID IMDb", + "Implementation": "Implementacja", + "Import": "Importuj", + "ImportCountSeries": "Importuj {selectedCount} seriali", + "ImportCustomFormat": "Importuj format niestandardowy", + "ImportErrors": "Błędy importu", + "ImportExistingSeries": "Importuj istniejące seriale", + "ImportExtraFiles": "Importuj dodatkowe pliki", + "ImportExtraFilesEpisodeHelpText": "Importuj pasujące dodatkowe pliki (napisy, nfo itp.) po zaimportowaniu pliku odcinka", + "ImportFailed": "Import nieudany: {sourceTitle}", + "ImportList": "Lista importu", + "ImportListExclusions": "Wykluczenia listy importu", + "ImportListExclusionsLoadError": "Nie można wczytać wykluczeń listy importu", + "ImportListRootFolderMissingRootHealthCheckMessage": "Brak folderu głównego dla list importu: {rootFolderInfo}", + "ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Brak wielu folderów głównych dla list importu: {rootFolderInfo}", + "ImportListSearchForMissingEpisodes": "Szukaj brakujących odcinków", + "ImportListSearchForMissingEpisodesHelpText": "Po dodaniu serialu do {appName} automatycznie wyszukaj brakujące odcinki", + "ImportListSettings": "Ustawienia listy importu", + "ImportListStatusAllPossiblePartialFetchHealthCheckMessage": "Wszystkie listy wymagają ręcznej interakcji z powodu możliwych częściowych pobrań", + "ImportListStatusAllUnavailableHealthCheckMessage": "Wszystkie listy są niedostępne z powodu błędów", + "ImportListStatusUnavailableHealthCheckMessage": "Listy niedostępne z powodu błędów: {importListNames}", + "ImportLists": "Listy importu", + "ImportListsAniListSettingsAuthenticateWithAniList": "Uwierzytelnij przez AniList", + "ImportListsAniListSettingsImportCancelled": "Import anulowanych", + "ImportListsAniListSettingsImportCancelledHelpText": "Media: serial anulowany", + "ImportListsAniListSettingsImportCompleted": "Import ukończonych", + "ImportListsAniListSettingsImportCompletedHelpText": "Lista: ukończono oglądanie", + "ImportListsAniListSettingsImportDropped": "Import porzuconych", + "ImportListsAniListSettingsImportDroppedHelpText": "Lista: porzucone", + "ImportListsAniListSettingsImportFinished": "Import zakończonych", + "ImportListsAniListSettingsImportFinishedHelpText": "Media: wszystkie odcinki zostały wyemitowane", + "ImportListsAniListSettingsImportHiatus": "Import wstrzymanych", + "ImportListsAniListSettingsImportHiatusHelpText": "Media: serial na hiatusie", + "ImportListsAniListSettingsImportNotYetReleased": "Import jeszcze niewydanych", + "ImportListsAniListSettingsImportNotYetReleasedHelpText": "Media: emisja jeszcze się nie rozpoczęła", + "ImportListsAniListSettingsImportPaused": "Import wstrzymanych pozycji", + "ImportListsAniListSettingsImportPausedHelpText": "Lista: wstrzymane", + "ImportListsAniListSettingsImportPlanning": "Import planowanych", + "ImportListsAniListSettingsImportPlanningHelpText": "Lista: planuję obejrzeć", + "ImportListsAniListSettingsImportReleasing": "Import aktualnie emitowanych", + "ImportListsAniListSettingsImportReleasingHelpText": "Media: obecnie emitowane są nowe odcinki", + "ImportListsAniListSettingsImportRepeating": "Import powtórnie oglądanych", + "ImportListsAniListSettingsImportRepeatingHelpText": "Lista: obecnie oglądane ponownie", + "ImportListsAniListSettingsImportWatching": "Import oglądanych", + "ImportListsAniListSettingsImportWatchingHelpText": "Lista: obecnie oglądane", + "ImportListsAniListSettingsUsernameHelpText": "Nazwa użytkownika listy, z której importować", + "ImportListsCustomListSettingsName": "Lista niestandardowa", + "ImportListsCustomListSettingsUrl": "URL listy", + "ImportListsCustomListSettingsUrlHelpText": "URL listy seriali", + "ImportListsCustomListValidationAuthenticationFailure": "Błąd uwierzytelniania", + "ImportListsCustomListValidationConnectionError": "Nie można wykonać żądania do tego URL. StatusCode: {exceptionStatusCode}", + "ImportListsImdbSettingsListId": "ID listy", + "ImportListsImdbSettingsListIdHelpText": "ID listy IMDb (np. ls12345678)", + "ImportListsLoadError": "Nie można wczytać list importu", + "ImportListsMyAnimeListSettingsAuthenticateWithMyAnimeList": "Uwierzytelnij przez MyAnimeList", + "ImportListsMyAnimeListSettingsListStatus": "Status listy", + "ImportListsMyAnimeListSettingsListStatusHelpText": "Typ listy, z której chcesz importować; ustaw 'All' dla wszystkich list", + "ImportListsMyAnimeListSettingsScore": "Minimalny wynik", + "ImportListsMyAnimeListSettingsScoreHelpText": "Minimalny wynik seriali do importu", + "ImportListsPlexSettingsAuthenticateWithPlex": "Uwierzytelnij przez Plex.tv", + "ImportListsPlexSettingsWatchlistName": "Lista do obejrzenia Plex", + "ImportListsPlexSettingsWatchlistRSSName": "RSS listy do obejrzenia Plex", + "ImportListsSettingsAccessToken": "Token dostępu", + "ImportListsSettingsAuthUser": "Użytkownik auth", + "ImportListsSettingsExpires": "Wygasa", + "ImportListsSettingsRefreshToken": "Token odświeżania", + "ImportListsSettingsRssUrl": "URL RSS", + "ImportListsSettingsSummary": "Import z innej instancji {appName} lub list Trakt oraz zarządzanie wykluczeniami list", + "ImportListsSimklSettingsAuthenticatewithSimkl": "Uwierzytelnij przez Simkl", + "ImportListsSimklSettingsListType": "Typ listy", + "ImportListsSimklSettingsListTypeHelpText": "Typ listy, z której chcesz importować", + "ImportListsSimklSettingsName": "Lista do obejrzenia użytkownika Simkl", + "ImportListsSimklSettingsShowType": "Typ serialu", + "ImportListsSimklSettingsShowTypeHelpText": "Typ serialu, który chcesz importować", + "ImportListsSimklSettingsUserListTypeCompleted": "Ukończone", + "ImportListsSimklSettingsUserListTypeDropped": "Porzucone", + "ImportListsSimklSettingsUserListTypeHold": "Wstrzymane", + "ImportListsSimklSettingsUserListTypePlanToWatch": "Planowane do obejrzenia", + "ImportListsSimklSettingsUserListTypeWatching": "Oglądane", + "ImportListsSonarrSettingsApiKeyHelpText": "Klucz API instancji {appName}, z której importować", + "ImportListsSonarrSettingsFullUrl": "Pełny URL", + "ImportListsSonarrSettingsFullUrlHelpText": "URL instancji {appName}, łącznie z portem, z której importować", + "ImportListsSonarrSettingsQualityProfilesHelpText": "Profile jakości z instancji źródłowej do importu", + "ImportListsSonarrSettingsRootFoldersHelpText": "Foldery główne z instancji źródłowej do importu", + "ImportListsSonarrSettingsSyncSeasonMonitoring": "Synchronizuj monitorowanie sezonów", + "ImportListsSonarrSettingsSyncSeasonMonitoringHelpText": "Synchronizuj monitorowanie sezonów z instancji {appName}; jeśli włączone, opcja 'Monitor' będzie ignorowana", + "ImportListsSonarrSettingsTagsHelpText": "Tagi z instancji źródłowej do importu", + "ImportListsSonarrValidationInvalidUrl": "URL {appName} jest nieprawidłowy, czy brakuje bazowego URL?", + "ImportListsTraktSettingsAdditionalParameters": "Dodatkowe parametry", + "ImportListsTraktSettingsAdditionalParametersHelpText": "Dodatkowe parametry API Trakt", + "ImportListsTraktSettingsAuthenticateWithTrakt": "Uwierzytelnij przez Trakt", + "ImportListsTraktSettingsGenres": "Gatunki", + "ImportListsTraktSettingsGenresSeriesHelpText": "Filtruj seriale według slugów gatunków Trakt (oddzielone przecinkami), tylko dla list popularnych", + "ImportListsTraktSettingsLimit": "Limit", + "ImportListsTraktSettingsLimitSeriesHelpText": "Ogranicz liczbę pobieranych seriali", + "ImportListsTraktSettingsListName": "Nazwa listy", + "ImportListsTraktSettingsListNameHelpText": "Nazwa listy do importu; lista musi być publiczna lub musisz mieć do niej dostęp", + "ImportListsTraktSettingsListType": "Typ listy", + "ImportListsTraktSettingsListTypeHelpText": "Typ listy, z której chcesz importować", + "ImportListsTraktSettingsPopularListTypeAnticipatedShows": "Oczekiwane seriale", + "ImportListsTraktSettingsPopularListTypePopularShows": "Popularne seriale", + "ImportListsTraktSettingsPopularListTypeRecommendedAllTimeShows": "Polecane seriale wszech czasów", + "ImportListsTraktSettingsPopularListTypeRecommendedMonthShows": "Polecane seriale miesiąca", + "ImportListsTraktSettingsPopularListTypeRecommendedWeekShows": "Polecane seriale tygodnia", + "ImportListsTraktSettingsPopularListTypeRecommendedYearShows": "Polecane seriale roku", + "ImportListsTraktSettingsPopularListTypeTopAllTimeShows": "Najczęściej oglądane seriale wszech czasów", + "ImportListsTraktSettingsPopularListTypeTopMonthShows": "Najczęściej oglądane seriale miesiąca", + "ImportListsTraktSettingsPopularListTypeTopWeekShows": "Najczęściej oglądane seriale tygodnia", + "ImportListsTraktSettingsPopularListTypeTopYearShows": "Najczęściej oglądane seriale roku", + "ImportListsTraktSettingsPopularListTypeTrendingShows": "Trendujące seriale", + "ImportListsTraktSettingsPopularName": "Popularna lista Trakt", + "ImportListsTraktSettingsRating": "Ocena", + "ImportListsTraktSettingsRatingSeriesHelpText": "Filtruj seriale według zakresu ocen (0-100)", + "ImportListsTraktSettingsUserListName": "Użytkownik Trakt", + "ImportListsTraktSettingsUserListTypeCollection": "Lista kolekcji użytkownika", + "ImportListsTraktSettingsUserListTypeWatch": "Lista do obejrzenia użytkownika", + "ImportListsTraktSettingsUserListTypeWatched": "Lista obejrzanych użytkownika", + "ImportListsTraktSettingsUserListUsernameHelpText": "Nazwa użytkownika listy, z której importować (pozostaw puste, aby użyć użytkownika uwierzytelnionego)", + "ImportListsTraktSettingsUsernameHelpText": "Nazwa użytkownika listy, z której importować", + "ImportListsTraktSettingsWatchListSorting": "Sortowanie listy do obejrzenia", + "ImportListsTraktSettingsWatchListSortingHelpText": "Jeśli typ listy to 'Do obejrzenia', wybierz kolejność sortowania listy", + "ImportListsTraktSettingsWatchedListFilter": "Filtr listy obejrzanych", + "ImportListsTraktSettingsWatchedListFilterSeriesHelpText": "Jeśli typ listy to 'Obejrzane', wybierz typ serialu, który chcesz importować", + "ImportListsTraktSettingsWatchedListTypeAll": "Wszystkie", + "ImportListsTraktSettingsWatchedListTypeCompleted": "100% obejrzane", + "ImportListsTraktSettingsWatchedListTypeInProgress": "W trakcie", + "ImportListsTraktSettingsYears": "Lata", + "ImportListsTraktSettingsYearsSeriesHelpText": "Filtruj seriale według roku lub zakresu lat", + "ImportListsValidationInvalidApiKey": "Klucz API jest nieprawidłowy", + "ImportListsValidationTestFailed": "Test został przerwany z powodu błędu: {exceptionMessage}", + "ImportListsValidationUnableToConnectException": "Nie można połączyć się z listą importu: {exceptionMessage}. Sprawdź log wokół tego błędu, aby poznać szczegóły.", + "ImportMechanismEnableCompletedDownloadHandlingIfPossibleHealthCheckMessage": "Włącz obsługę ukończonych pobrań, jeśli to możliwe", + "ImportMechanismEnableCompletedDownloadHandlingIfPossibleMultiComputerHealthCheckMessage": "Włącz obsługę ukończonych pobrań, jeśli to możliwe (Multi-Computer nieobsługiwane)", + "ImportMechanismHandlingDisabledHealthCheckMessage": "Włącz obsługę ukończonych pobrań", + "ImportScriptPath": "Ścieżka skryptu importu", + "ImportScriptPathHelpText": "Ścieżka do skryptu używanego do importowania", + "ImportSelected": "Importuj zaznaczone", + "ImportSeries": "Importuj seriale", + "ImportUsingScript": "Importuj przy użyciu skryptu", + "ImportUsingScriptHelpText": "Kopiuj pliki do importu przy użyciu skryptu (np. do transkodowania)", + "Imported": "Zaimportowano", + "ImportedTo": "Zaimportowano do", + "Importing": "Importowanie", + "IncludeCustomFormatWhenRenaming": "Uwzględnij format niestandardowy podczas zmiany nazwy", + "IncludeCustomFormatWhenRenamingHelpText": "Uwzględnij w formacie zmiany nazwy {Custom Formats}", + "IncludeHealthWarnings": "Uwzględnij ostrzeżenia stanu", + "IncludeSpecials": "Uwzględnij odcinki specjalne", + "IncludeUnmonitored": "Uwzględnij niemonitorowane", + "Indexer": "Indekser", + "IndexerDownloadClientHealthCheckMessage": "Indeksery z nieprawidłowymi klientami pobierania: {indexerNames}.", + "IndexerDownloadClientHelpText": "Określ, który klient pobierania jest używany dla pobrań z tego indeksera", + "IndexerFlags": "Flagi indeksera", + "IndexerHDBitsSettingsCategories": "Kategorie", + "IndexerHDBitsSettingsCategoriesHelpText": "Jeśli nie określono, używane są wszystkie opcje.", + "IndexerHDBitsSettingsCodecs": "Kodeki", + "IndexerHDBitsSettingsCodecsHelpText": "Jeśli nie określono, używane są wszystkie opcje.", + "IndexerHDBitsSettingsMediums": "Nośniki", + "IndexerHDBitsSettingsMediumsHelpText": "Jeśli nie określono, używane są wszystkie opcje.", + "IndexerIPTorrentsSettingsFeedUrl": "URL kanału", + "IndexerIPTorrentsSettingsFeedUrlHelpText": "Pełny URL kanału RSS wygenerowany przez IPTorrents, używający tylko wybranych kategorii (HD, SD, x264 itd.)", + "IndexerJackettAllHealthCheckMessage": "Indeksery używające nieobsługiwanego endpointu Jackett 'all': {indexerNames}", + "IndexerLongTermStatusAllUnavailableHealthCheckMessage": "Wszystkie indeksery są niedostępne z powodu błędów przez ponad 6 godzin", + "IndexerLongTermStatusUnavailableHealthCheckMessage": "Indeksery niedostępne z powodu błędów przez ponad 6 godzin: {indexerNames}", + "IndexerOptionsLoadError": "Nie można wczytać opcji indeksera", + "IndexerPriority": "Priorytet indeksera", + "IndexerPriorityHelpText": "Priorytet indeksera od 1 (najwyższy) do 50 (najniższy). Domyślnie: 25. Używany przy pobieraniu wydań jako rozstrzygnięcie remisów dla równorzędnych wydań; {appName} nadal będzie używać wszystkich włączonych indekserów do synchronizacji RSS i wyszukiwania", + "IndexerRssNoIndexersAvailableHealthCheckMessage": "Wszystkie indeksery obsługujące RSS są tymczasowo niedostępne z powodu ostatnich błędów indekserów", + "IndexerRssNoIndexersEnabledHealthCheckMessage": "Brak dostępnych indekserów z włączoną synchronizacją RSS, {appName} nie będzie automatycznie pobierać nowych wydań", + "IndexerSearchNoAutomaticHealthCheckMessage": "Brak dostępnych indekserów z włączonym wyszukiwaniem automatycznym, {appName} nie zapewni wyników automatycznego wyszukiwania", + "IndexerSearchNoAvailableIndexersHealthCheckMessage": "Wszystkie indeksery obsługujące wyszukiwanie są tymczasowo niedostępne z powodu ostatnich błędów indekserów", + "IndexerSearchNoInteractiveHealthCheckMessage": "Brak dostępnych indekserów z włączonym wyszukiwaniem interaktywnym, {appName} nie zapewni wyników wyszukiwania interaktywnego", + "IndexerSettings": "Ustawienia indeksera", + "IndexerSettingsAdditionalNewznabParametersHelpText": "Pamiętaj, że jeśli zmienisz kategorię, będziesz musiał dodać wymagane/ograniczające reguły dotyczące podgrup, aby uniknąć wydań w obcych językach.", + "IndexerSettingsAdditionalParameters": "Dodatkowe parametry", + "IndexerSettingsAdditionalParametersNyaa": "Dodatkowe parametry", + "IndexerSettingsAllowZeroSize": "Zezwalaj na zerowy rozmiar", + "IndexerSettingsAllowZeroSizeHelpText": "Włączenie tego pozwoli używać kanałów, które nie podają rozmiaru wydania, ale uważaj: kontrole związane z rozmiarem nie będą wykonywane.", + "IndexerSettingsAnimeCategories": "Kategorie anime", + "IndexerSettingsAnimeCategoriesHelpText": "Lista rozwijana, pozostaw puste aby wyłączyć anime", + "IndexerSettingsAnimeStandardFormatSearch": "Wyszukiwanie anime w standardowym formacie", + "IndexerSettingsAnimeStandardFormatSearchHelpText": "Wyszukuj anime także z użyciem standardowej numeracji", + "IndexerSettingsApiPath": "Ścieżka API", + "IndexerSettingsApiPathHelpText": "Ścieżka do API, zwykle {url}", + "IndexerSettingsApiUrl": "URL API", + "IndexerSettingsApiUrlHelpText": "Nie zmieniaj tego, chyba że wiesz co robisz, ponieważ Twój klucz API zostanie wysłany do tego hosta.", + "IndexerSettingsCategories": "Kategorie", + "IndexerSettingsCategoriesHelpText": "Lista rozwijana, pozostaw puste aby wyłączyć standardowe/dzienne seriale", + "IndexerSettingsCookie": "Cookie", + "IndexerSettingsCookieHelpText": "Jeśli strona wymaga cookie logowania do dostępu do RSS, musisz je pobrać przez przeglądarkę.", + "IndexerSettingsFailDownloads": "Nieudane pobrania", + "IndexerSettingsFailDownloadsHelpText": "Podczas przetwarzania ukończonych pobrań {appName} potraktuje wybrane typy plików jako nieudane pobrania.", + "IndexerSettingsMinimumSeeders": "Minimalna liczba seedów", + "IndexerSettingsMinimumSeedersHelpText": "Wymagana minimalna liczba seedów.", + "IndexerSettingsMultiLanguageRelease": "Wiele języków", + "IndexerSettingsMultiLanguageReleaseHelpText": "Jakie języki zwykle występują w wielojęzycznym wydaniu na tym indekserze?", + "IndexerSettingsPasskey": "Passkey", + "IndexerSettingsRejectBlocklistedTorrentHashes": "Odrzucaj hashe torrentów z czarnej listy podczas pobierania", + "IndexerSettingsRejectBlocklistedTorrentHashesHelpText": "Jeśli torrent jest zablokowany po hashu, niektóre indeksery mogą nie odrzucić go poprawnie podczas RSS/Search. Włączenie tej opcji pozwoli odrzucić go po pobraniu torrenta, ale przed wysłaniem do klienta.", + "IndexerSettingsRssUrl": "URL RSS", + "IndexerSettingsRssUrlHelpText": "Wprowadź URL kanału RSS zgodnego z {indexer}", + "IndexerSettingsSeasonPackSeedGoal": "Cel seedowania dla paczek sezonu", + "IndexerSettingsSeasonPackSeedGoalHelpText": "Wybierz, czy używać innych celów seedowania dla paczek sezonu", + "IndexerSettingsSeasonPackSeedGoalUseSeasonPackGoals": "Użyj celów dla paczek sezonu", + "IndexerSettingsSeasonPackSeedGoalUseStandardGoals": "Użyj standardowych celów", + "IndexerSettingsSeasonPackSeedRatio": "Współczynnik seedowania paczki sezonu", + "IndexerSettingsSeasonPackSeedRatioHelpText": "Współczynnik, który torrent paczki sezonu powinien osiągnąć przed zatrzymaniem; puste używa domyślnego klienta pobierania. Współczynnik powinien wynosić co najmniej 1.0 i być zgodny z zasadami indeksera", + "IndexerSettingsSeasonPackSeedTime": "Czas seedowania paczki sezonu", + "IndexerSettingsSeasonPackSeedTimeHelpText": "Czas, przez jaki torrent paczki sezonu powinien seedować przed zatrzymaniem; puste używa domyślnego klienta pobierania", + "IndexerSettingsSeedRatio": "Współczynnik seedowania", + "IndexerSettingsSeedRatioHelpText": "Współczynnik, który torrent powinien osiągnąć przed zatrzymaniem; puste używa domyślnego klienta pobierania. Współczynnik powinien wynosić co najmniej 1.0 i być zgodny z zasadami indeksera", + "IndexerSettingsSeedTime": "Czas seedowania", + "IndexerSettingsSeedTimeHelpText": "Czas, przez jaki torrent powinien seedować przed zatrzymaniem; puste używa domyślnego klienta pobierania", + "IndexerSettingsWebsiteUrl": "URL strony", + "IndexerStatusAllUnavailableHealthCheckMessage": "Wszystkie indeksery są niedostępne z powodu błędów", + "IndexerStatusUnavailableHealthCheckMessage": "Indeksery niedostępne z powodu błędów: {indexerNames}", + "IndexerTagSeriesHelpText": "Używaj tego indeksera tylko dla seriali z co najmniej jednym pasującym tagiem. Pozostaw puste, aby używać dla wszystkich seriali.", + "IndexerValidationCloudFlareCaptchaExpired": "Token CAPTCHA CloudFlare wygasł, odśwież go.", + "IndexerValidationCloudFlareCaptchaRequired": "Strona chroniona przez CAPTCHA CloudFlare. Wymagany prawidłowy token CAPTCHA.", + "IndexerValidationFeedNotSupported": "Kanał indeksera nie jest obsługiwany: {exceptionMessage}", + "IndexerValidationInvalidApiKey": "Nieprawidłowy klucz API", + "IndexerValidationJackettAllNotSupported": "Endpoint Jackett 'all' nie jest obsługiwany, dodaj indeksery pojedynczo", + "IndexerValidationJackettAllNotSupportedHelpText": "Endpoint Jackett 'all' nie jest obsługiwany, dodaj indeksery pojedynczo", + "IndexerValidationNoResultsInConfiguredCategories": "Zapytanie zakończone powodzeniem, ale indekser nie zwrócił wyników w skonfigurowanych kategoriach. Problem może dotyczyć indeksera lub ustawień kategorii indeksera.", + "IndexerValidationNoRssFeedQueryAvailable": "Brak dostępnego zapytania kanału RSS. Problem może dotyczyć indeksera lub ustawień kategorii indeksera.", + "IndexerValidationQuerySeasonEpisodesNotSupported": "Indekser nie obsługuje bieżącego zapytania. Sprawdź, czy kategorie oraz wyszukiwanie sezonów/odcinków są obsługiwane. Sprawdź log, aby uzyskać więcej szczegółów.", + "IndexerValidationRequestLimitReached": "Osiągnięto limit żądań: {exceptionMessage}", + "IndexerValidationSearchParametersNotSupported": "Indekser nie obsługuje wymaganych parametrów wyszukiwania", + "IndexerValidationTestAbortedDueToError": "Test został przerwany z powodu błędu: {exceptionMessage}", + "IndexerValidationUnableToConnect": "Nie można połączyć się z indekserem: {exceptionMessage}. Sprawdź log wokół tego błędu, aby uzyskać szczegóły", + "IndexerValidationUnableToConnectHttpError": "Nie można połączyć się z indekserem, sprawdź ustawienia DNS i upewnij się, że IPv6 działa albo jest wyłączone. {exceptionMessage}.", + "IndexerValidationUnableToConnectInvalidCredentials": "Nie można połączyć się z indekserem, nieprawidłowe dane logowania. {exceptionMessage}.", + "IndexerValidationUnableToConnectResolutionFailure": "Nie można połączyć się z indekserem z powodu błędu połączenia. Sprawdź połączenie z serwerem indeksera i DNS. {exceptionMessage}.", + "IndexerValidationUnableToConnectServerUnavailable": "Nie można połączyć się z indekserem, serwer indeksera jest niedostępny. Spróbuj ponownie później. {exceptionMessage}.", + "IndexerValidationUnableToConnectTimeout": "Nie można połączyć się z indekserem, możliwe że z powodu przekroczenia czasu. Spróbuj ponownie lub sprawdź ustawienia sieci. {exceptionMessage}.", + "Indexers": "Indeksery", + "IndexersLoadError": "Nie można wczytać indekserów", + "IndexersSettingsSummary": "Indeksery i opcje indekserów", + "Info": "Informacje", + "InfoUrl": "URL informacji", + "Install": "Zainstaluj", + "InstallLatest": "Zainstaluj najnowsze", + "InstallMajorVersionUpdate": "Zainstaluj aktualizację", + "InstallMajorVersionUpdateMessage": "Ta aktualizacja zainstaluje nową główną wersję i może nie być kompatybilna z Twoim systemem. Czy na pewno chcesz ją zainstalować?", + "InstallMajorVersionUpdateMessageLink": "Sprawdź [{domain}]({url}), aby uzyskać więcej informacji.", + "InstanceName": "Nazwa instancji", + "InstanceNameHelpText": "Nazwa instancji na karcie i jako nazwa aplikacji Syslog", + "InteractiveImport": "Import interaktywny", + "InteractiveImportLoadError": "Nie można wczytać elementów importu ręcznego", + "InteractiveImportMultipleQueueItems": "Wiele elementów kolejki", + "InteractiveImportNoEpisode": "Dla każdego wybranego pliku trzeba wybrać co najmniej jeden odcinek", + "InteractiveImportNoFilesFound": "W wybranym folderze nie znaleziono plików wideo", + "InteractiveImportNoImportMode": "Musisz wybrać tryb importu", + "InteractiveImportNoLanguage": "Dla każdego wybranego pliku trzeba wybrać język(i)", + "InteractiveImportNoQuality": "Dla każdego wybranego pliku trzeba wybrać jakość", + "InteractiveImportNoSeason": "Dla każdego wybranego pliku trzeba wybrać sezon", + "InteractiveImportNoSeries": "Dla każdego wybranego pliku trzeba wybrać serial", + "InteractiveSearch": "Wyszukiwanie interaktywne", + "InteractiveSearchGrabError": "Nie udało się dodać do kolejki pobierania", + "InteractiveSearchModalHeader": "Wyszukiwanie interaktywne", + "InteractiveSearchModalHeaderSeason": "Wyszukiwanie interaktywne - {season}", + "InteractiveSearchResultsSeriesFailedErrorMessage": "Wyszukiwanie nie powiodło się, ponieważ {message}. Spróbuj odświeżyć informacje o serialu i sprawdzić, czy wymagane dane są dostępne, zanim wyszukasz ponownie.", + "InteractiveSearchSeason": "Wyszukiwanie interaktywne wszystkich odcinków w tym sezonie", + "Interval": "Interwał", + "InvalidFormat": "Nieprawidłowy format", + "InvalidUILanguage": "Twój interfejs jest ustawiony na nieprawidłowy język, popraw to i zapisz ustawienia", + "KeepAndTagSeries": "Zachowaj i otaguj serial", + "KeepAndUnmonitorSeries": "Zachowaj i przestań monitorować serial", + "KeyboardShortcuts": "Skróty klawiaturowe", + "KeyboardShortcutsCloseModal": "Zamknij bieżące okno modalne", + "KeyboardShortcutsConfirmModal": "Potwierdź okno potwierdzenia", + "KeyboardShortcutsFocusSearchBox": "Ustaw fokus na polu wyszukiwania", + "KeyboardShortcutsOpenModal": "Otwórz to okno modalne", + "KeyboardShortcutsSaveSettings": "Zapisz ustawienia", + "Label": "Etykieta", + "LabelIsRequired": "Etykieta jest wymagana", + "Language": "Język", + "Languages": "Języki", + "LanguagesLoadError": "Nie można wczytać języków", + "Large": "Duży", + "LastDuration": "Ostatni czas trwania", + "LastExecution": "Ostatnie wykonanie", + "LastSearched": "Ostatnio wyszukiwano", + "LastUsed": "Ostatnio użyte", + "LastWriteTime": "Czas ostatniego zapisu", + "LatestSeason": "Najnowszy sezon", + "Level": "Poziom", + "LiberaWebchat": "Webchat Libera", + "LibraryImport": "Import biblioteki", + "LibraryImportSeriesHeader": "Importuj seriale, które już masz", + "LibraryImportTips": "Wskazówki, aby import przebiegł sprawnie:", + "LibraryImportTipsDontUseDownloadsFolder": "Nie używaj tego do importu pobrań z klienta pobierania, to jest tylko dla istniejących uporządkowanych bibliotek, nie dla nieposortowanych plików.", + "LibraryImportTipsQualityInEpisodeFilename": "Upewnij się, że nazwy plików zawierają jakość, np. `episode.s02e15.bluray.mkv`", + "LibraryImportTipsSeriesUseRootFolder": "Wskaż w {appName} folder zawierający wszystkie seriale TV, a nie pojedynczy serial, np. \"`{goodFolderExample}`\" a nie \"`{badFolderExample}`\". Dodatkowo każdy serial musi znajdować się w osobnym folderze w folderze głównym/biblioteki.", + "Links": "Linki", + "ListExclusionsLoadError": "Nie można wczytać wykluczeń list", + "ListOptionsLoadError": "Nie można wczytać opcji listy", + "ListQualityProfileHelpText": "Elementy listy będą dodawane z profilem jakości", + "ListRootFolderHelpText": "Elementy listy będą dodawane do folderu głównego", + "ListSyncLevelHelpText": "Seriale w bibliotece będą obsługiwane zgodnie z Twoim wyborem, jeśli wypadną z list(y) lub przestaną się na niej pojawiać", + "ListSyncTag": "Tag synchronizacji list", + "ListSyncTagHelpText": "Ten tag zostanie dodany, gdy serial wypadnie z list(y) lub już się na niej nie pojawia", + "ListTagsHelpText": "Tagi, które zostaną dodane przy imporcie z tej listy", + "ListWillRefreshEveryInterval": "Lista będzie odświeżana co {refreshInterval}", + "ListsLoadError": "Nie można wczytać list", + "Local": "Lokalny", + "LocalAirDate": "Lokalna data emisji", + "LocalPath": "Lokalna ścieżka", + "LocalStorageIsNotSupported": "Local Storage nie jest obsługiwany lub jest wyłączony. Wtyczka albo tryb prywatny mogły go wyłączyć.", + "Location": "Lokalizacja", + "LogFiles": "Pliki logów", + "LogFilesLocation": "Pliki logów znajdują się w: {location}", + "LogLevel": "Poziom logowania", + "LogLevelTraceHelpTextWarning": "Logowanie Trace powinno być włączane tylko tymczasowo", + "LogOnly": "Tylko loguj", + "LogSizeLimit": "Limit rozmiaru logu", + "LogSizeLimitHelpText": "Maksymalny rozmiar pliku logu w MB przed archiwizacją. Domyślnie 1MB.", + "Logging": "Logowanie", + "Logout": "Wyloguj", + "Logs": "Logi", + "LongDateFormat": "Długi format daty", + "Lowercase": "Małe litery", + "MaintenanceRelease": "Wydanie konserwacyjne: poprawki błędów i inne usprawnienia. Zobacz historię commitów GitHub, aby uzyskać więcej szczegółów", + "ManageClients": "Zarządzaj klientami", + "ManageCustomFormats": "Zarządzaj formatami niestandardowymi", + "ManageDownloadClients": "Zarządzaj klientami pobierania", + "ManageEpisodes": "Zarządzaj odcinkami", + "ManageEpisodesSeason": "Zarządzaj plikami odcinków w tym sezonie", + "ManageFormats": "Zarządzaj formatami", + "ManageImportLists": "Zarządzaj listami importu", + "ManageIndexers": "Zarządzaj indekserami", + "ManageLists": "Zarządzaj listami", + "Manual": "Ręcznie", + "ManualGrab": "Ręczne pobranie", + "ManualImport": "Import ręczny", + "ManualImportItemsLoadError": "Nie można wczytać elementów importu ręcznego", + "MappedNetworkDrivesWindowsService": "Mapowane dyski sieciowe nie są dostępne przy uruchomieniu jako usługa Windows. Więcej informacji w [FAQ]({url}).", + "Mapping": "Mapowanie", + "MarkAsFailed": "Oznacz jako nieudane", + "MarkAsFailedConfirmation": "Czy na pewno chcesz oznaczyć '{sourceTitle}' jako nieudane?", + "MassSearchCancelWarning": "Po uruchomieniu nie można tego anulować bez restartu {appName} lub wyłączenia wszystkich indekserów.", + "MatchedToEpisodes": "Dopasowano do odcinków", + "MatchedToSeason": "Dopasowano do sezonu", + "MatchedToSeries": "Dopasowano do serialu", + "Maximum": "Maksimum", + "MaximumLimits": "Maksymalne limity", + "MaximumSingleEpisodeAge": "Maksymalny wiek pojedynczego odcinka", + "MaximumSingleEpisodeAgeHelpText": "Podczas pełnego wyszukiwania sezonu dozwolone będą tylko paczki sezonu, gdy ostatni odcinek sezonu jest starszy niż to ustawienie. Dotyczy tylko standardowych seriali. Ustaw 0, aby wyłączyć.", + "MaximumSize": "Maksymalny rozmiar", + "MaximumSizeHelpText": "Maksymalny rozmiar wydania do pobrania w MB. Ustaw zero, aby brak limitu", + "Mechanism": "Mechanizm", + "MediaInfo": "Informacje o mediach", + "MediaInfoAudioStreamHeader": "Strumień audio #{number}", + "MediaInfoFootNote": "MediaInfo Full/AudioLanguages/SubtitleLanguages obsługuje sufiks `:EN+DE`, który pozwala filtrować języki uwzględniane w nazwie pliku. Użyj `-DE`, aby wykluczyć określone języki. Dodanie `+` (np. `:EN+`) zwróci `[EN]`/`[EN+--]`/`[--]` zależnie od wykluczonych języków. Przykład: `{MediaInfo Full:EN+DE}`.", + "MediaInfoFootNote2": "MediaInfo AudioLanguages wyklucza angielski, jeśli jest jedynym językiem. Użyj MediaInfo AudioLanguagesAll, aby uwzględnić przypadki tylko z angielskim", + "MediaInfoForced": "Wymuszone", + "MediaInfoHearingImpaired": "Dla niesłyszących", + "MediaInfoSubtitlesHeader": "Napisy", + "MediaManagement": "Zarządzanie mediami", + "MediaManagementSettings": "Ustawienia zarządzania mediami", + "MediaManagementSettingsLoadError": "Nie można wczytać ustawień zarządzania mediami", + "MediaManagementSettingsSummary": "Nazewnictwo, ustawienia zarządzania plikami i foldery główne", + "Medium": "Średni", + "Menu": "Menu", + "Message": "Wiadomość", + "Metadata": "Metadane", + "MetadataKometaDeprecated": "Pliki Kometa nie będą już tworzone, obsługa zostanie całkowicie usunięta w v5", + "MetadataKometaDeprecatedSetting": "Przestarzałe", + "MetadataLoadError": "Nie można wczytać metadanych", + "MetadataPlexSettingsEpisodeMappings": "Mapowania odcinków", + "MetadataPlexSettingsEpisodeMappingsHelpText": "Uwzględnij mapowania odcinków dla wszystkich plików w pliku .plexmatch", + "MetadataPlexSettingsSeriesPlexMatchFile": "Plik Plex Match serialu", + "MetadataPlexSettingsSeriesPlexMatchFileHelpText": "Tworzy plik .plexmatch w folderze serialu", + "MetadataProvidedBy": "Metadane są dostarczane przez {provider}", + "MetadataSettings": "Ustawienia metadanych", + "MetadataSettingsEpisodeImages": "Obrazy odcinków", + "MetadataSettingsEpisodeMetadata": "Metadane odcinków", + "MetadataSettingsEpisodeMetadataImageThumbs": "Miniatury obrazów metadanych odcinków", + "MetadataSettingsSeasonImages": "Obrazy sezonów", + "MetadataSettingsSeriesImages": "Obrazy serialu", + "MetadataSettingsSeriesMetadata": "Metadane serialu", + "MetadataSettingsSeriesMetadataEpisodeGuide": "Przewodnik odcinków w metadanych serialu", + "MetadataSettingsSeriesMetadataUrl": "URL metadanych serialu", + "MetadataSettingsSeriesSummary": "Twórz pliki metadanych przy imporcie odcinków lub odświeżaniu serialu", + "MetadataSource": "Źródło metadanych", + "MetadataSourceSettings": "Ustawienia źródła metadanych", + "MetadataSourceSettingsSeriesSummary": "Informacje o tym, skąd {appName} pobiera informacje o serialach i odcinkach", + "MetadataXmbcSettingsEpisodeMetadataImageThumbsHelpText": "Uwzględnij tagi miniatur obrazów w .nfo (wymaga 'Episode Metadata')", + "MetadataXmbcSettingsSeriesMetadataEpisodeGuideHelpText": "Uwzględnij element przewodnika odcinków w formacie JSON w tvshow.nfo (wymaga 'Series Metadata')", + "MetadataXmbcSettingsSeriesMetadataHelpText": "tvshow.nfo z pełnymi metadanymi serialu", + "MetadataXmbcSettingsSeriesMetadataUrlHelpText": "Uwzględnij URL serialu TheTVDB w tvshow.nfo (można łączyć z 'Series Metadata')", + "MidseasonFinale": "Finał śródsezonowy", + "Minimum": "Minimum", + "MinimumAge": "Minimalny wiek", + "MinimumAgeHelpText": "Tylko Usenet: minimalny wiek NZB w minutach przed pobraniem. Użyj tego, aby dać nowym wydaniom czas na propagację do dostawcy Usenet.", + "MinimumCustomFormatScore": "Minimalny wynik formatu niestandardowego", + "MinimumCustomFormatScoreHelpText": "Minimalny dozwolony wynik formatu niestandardowego do pobrania", + "MinimumCustomFormatScoreIncrement": "Minimalny przyrost wyniku formatu niestandardowego", + "MinimumCustomFormatScoreIncrementHelpText": "Minimalna wymagana poprawa wyniku formatu niestandardowego między istniejącymi i nowymi wydaniami, zanim {appName} uzna to za ulepszenie", + "MinimumFreeSpace": "Minimalna wolna przestrzeń", + "MinimumFreeSpaceHelpText": "Zablokuj import, jeśli po nim wolna przestrzeń będzie mniejsza niż ta wartość", + "MinimumLimits": "Minimalne limity", + "Minute": "minuta", + "MinuteShorthand": "m", + "MinutesFortyFive": "45 minut: {fortyFive}", + "MinutesSixty": "60 minut: {sixty}", + "MinutesThirty": "30 minut: {thirty}", + "Missing": "Brakujące", + "MissingEpisodes": "Brakujące odcinki", + "MissingLoadError": "Błąd ładowania brakujących elementów", + "MissingNoItems": "Brak brakujących elementów", + "Mixed": "Mieszane", + "Mode": "Tryb", + "Monday": "Poniedziałek", + "Monitor": "Monitoruj", + "MonitorAllEpisodes": "Wszystkie odcinki", + "MonitorAllEpisodesDescription": "Monitoruj wszystkie odcinki poza specjalnymi", + "MonitorAllSeasons": "Wszystkie sezony", + "MonitorAllSeasonsDescription": "Automatycznie monitoruj wszystkie nowe sezony", + "MonitorEpisodes": "Monitoruj odcinki", + "MonitorEpisodesModalInfo": "To ustawienie zmienia tylko to, które odcinki lub sezony są monitorowane w obrębie serialu. Wybranie opcji 'Brak' wyłączy monitorowanie serialu", + "MonitorExistingEpisodes": "Istniejące odcinki", + "MonitorExistingEpisodesDescription": "Monitoruj odcinki, które mają pliki lub nie zostały jeszcze wyemitowane", + "MonitorFirstSeason": "Pierwszy sezon", + "MonitorFirstSeasonDescription": "Monitoruj wszystkie odcinki pierwszego sezonu. Pozostałe sezony będą ignorowane", + "MonitorFutureEpisodes": "Przyszłe odcinki", + "MonitorFutureEpisodesDescription": "Monitoruj odcinki, które nie zostały jeszcze wyemitowane", + "MonitorLastSeason": "Ostatni sezon", + "MonitorLastSeasonDescription": "Monitoruj wszystkie odcinki ostatniego sezonu", + "MonitorMissingEpisodes": "Brakujące odcinki", + "MonitorMissingEpisodesDescription": "Monitoruj odcinki, które nie mają plików lub nie zostały jeszcze wyemitowane", + "MonitorNewItems": "Monitoruj nowe elementy", + "MonitorNewSeasons": "Monitoruj nowe sezony", + "MonitorNewSeasonsHelpText": "Które nowe sezony mają być monitorowane automatycznie", + "MonitorNoEpisodes": "Brak", + "MonitorNoEpisodesDescription": "Żadne odcinki nie będą monitorowane", + "MonitorNoNewSeasons": "Bez nowych sezonów", + "MonitorNoNewSeasonsDescription": "Nie monitoruj automatycznie żadnych nowych sezonów", + "MonitorPilotEpisode": "Odcinek pilotowy", + "MonitorPilotEpisodeDescription": "Monitoruj tylko pierwszy odcinek pierwszego sezonu", + "MonitorRecentEpisodes": "Najnowsze odcinki", + "MonitorRecentEpisodesDescription": "Monitoruj odcinki wyemitowane w ciągu ostatnich 90 dni oraz przyszłe odcinki", + "MonitorSelected": "Monitoruj zaznaczone", + "MonitorSeries": "Monitoruj serial", + "MonitorSpecialEpisodes": "Monitoruj odcinki specjalne", + "MonitorSpecialEpisodesDescription": "Monitoruj wszystkie odcinki specjalne bez zmiany statusu monitorowania pozostałych odcinków", + "Monitored": "Monitorowane", + "MonitoredAll": "Wszystkie", + "MonitoredEpisodesHelpText": "Pobieraj monitorowane odcinki tego serialu", + "MonitoredNone": "Brak", + "MonitoredOnly": "Tylko monitorowane", + "MonitoredPartial": "Częściowo", + "MonitoredStatus": "Monitorowane/Status", + "Monitoring": "Monitorowanie", + "MonitoringOptions": "Opcje monitorowania", + "Month": "Miesiąc", + "More": "Więcej", + "MoreDetails": "Więcej szczegółów", + "MoreInfo": "Więcej informacji", + "MountSeriesHealthCheckMessage": "Punkt montowania zawierający ścieżkę serialu jest zamontowany tylko do odczytu: ", + "MoveAutomatically": "Przenoś automatycznie", + "MoveFiles": "Przenieś pliki", + "MoveSeriesFoldersDontMoveFiles": "Nie, sam przeniosę pliki", + "MoveSeriesFoldersMoveFiles": "Tak, przenieś pliki", + "MoveSeriesFoldersToNewPath": "Czy chcesz przenieść pliki serialu z '{originalPath}' do '{destinationPath}'?", + "MoveSeriesFoldersToRootFolder": "Czy chcesz przenieść foldery seriali do '{destinationRootFolder}'?", + "MultiEpisode": "Wiele odcinków", + "MultiEpisodeInvalidFormat": "Wiele odcinków: nieprawidłowy format", + "MultiEpisodeStyle": "Styl wielu odcinków", + "MultiLanguages": "Wiele języków", + "MultiSeason": "Wiele sezonów", + "MultipleEpisodes": "Wiele odcinków", + "MustContain": "Musi zawierać", + "MustContainHelpText": "Wydanie musi zawierać co najmniej jeden z tych terminów (bez rozróżniania wielkości liter)", + "MustNotContain": "Nie może zawierać", + "MustNotContainHelpText": "Wydanie zostanie odrzucone, jeśli zawiera jeden lub więcej z terminów (bez rozróżniania wielkości liter)", + "MyComputer": "Mój komputer", + "Name": "Nazwa", + "NamingSettings": "Ustawienia nazewnictwa", + "NamingSettingsLoadError": "Nie można wczytać ustawień nazewnictwa", + "Negate": "Neguj", + "NegateHelpText": "Jeśli zaznaczone, format niestandardowy nie zostanie zastosowany, jeśli ten warunek {implementationName} pasuje.", + "Negated": "Zanegowane", + "Network": "Sieć", + "Never": "Nigdy", + "New": "Nowe", + "NextAiring": "Następna emisja", + "NextAiringDate": "Następna emisja: {date}", + "NextExecution": "Następne wykonanie", + "No": "Nie", + "NoBackupsAreAvailable": "Brak dostępnych kopii zapasowych", + "NoBlocklistItems": "Brak elementów na czarnej liście", + "NoChange": "Bez zmian", + "NoChanges": "Brak zmian", + "NoCustomFormatsFound": "Nie znaleziono formatów niestandardowych", + "NoDelay": "Brak opóźnienia", + "NoDownloadClientsFound": "Nie znaleziono klientów pobierania", + "NoEpisodeHistory": "Brak historii odcinka", + "NoEpisodeInformation": "Brak dostępnych informacji o odcinku.", + "NoEpisodeOverview": "Brak opisu odcinka", + "NoEpisodesFoundForSelectedSeason": "Nie znaleziono odcinków dla wybranego sezonu", + "NoEpisodesInThisSeason": "Brak odcinków w tym sezonie", + "NoEventsFound": "Nie znaleziono zdarzeń", + "NoHistory": "Brak historii", + "NoHistoryFound": "Nie znaleziono historii", + "NoImportListsFound": "Nie znaleziono list importu", + "NoIndexersFound": "Nie znaleziono indekserów", + "NoIssuesWithYourConfiguration": "Brak problemów z konfiguracją", + "NoLeaveIt": "Nie, zostaw", + "NoLimitForAnyRuntime": "Brak limitu dla dowolnego czasu trwania", + "NoLinks": "Brak linków", + "NoLogFiles": "Brak plików logów", + "NoMatchFound": "Nie znaleziono dopasowania!", + "NoMinimumForAnyRuntime": "Brak minimum dla dowolnego czasu trwania", + "NoMonitoredEpisodes": "Brak monitorowanych odcinków w tym serialu", + "NoMonitoredEpisodesSeason": "Brak monitorowanych odcinków w tym sezonie", + "NoResultsFound": "Nie znaleziono wyników", + "NoSeasons": "Brak sezonów", + "NoSeriesFoundImportOrAdd": "Nie znaleziono seriali. Na początek zaimportuj istniejące seriale albo dodaj nowy serial.", + "NoSeriesHaveBeenAdded": "Nie dodano jeszcze żadnych seriali. Chcesz najpierw zaimportować część lub wszystkie seriale?", + "NoTagsHaveBeenAddedYet": "Nie dodano jeszcze żadnych tagów", + "NoUpdatesAreAvailable": "Brak dostępnych aktualizacji", + "None": "Brak", + "NotSeasonPack": "To nie jest paczka sezonu", + "NotificationStatusAllClientHealthCheckMessage": "Wszystkie powiadomienia są niedostępne z powodu błędów", + "NotificationStatusSingleClientHealthCheckMessage": "Powiadomienia niedostępne z powodu błędów: {notificationNames}", + "NotificationTriggers": "Wyzwalacze powiadomień", + "NotificationTriggersHelpText": "Wybierz, które zdarzenia mają wyzwalać to powiadomienie", + "NotificationsAppriseSettingsConfigurationKey": "Klucz konfiguracji Apprise", + "NotificationsAppriseSettingsConfigurationKeyHelpText": "Klucz konfiguracji dla rozwiązania trwałego przechowywania. Pozostaw puste, jeśli używane są bezstanowe URL-e.", + "NotificationsAppriseSettingsIncludePoster": "Uwzględnij plakat", + "NotificationsAppriseSettingsIncludePosterHelpText": "Uwzględnij plakat w wiadomości", + "NotificationsAppriseSettingsNotificationType": "Typ powiadomienia Apprise", + "NotificationsAppriseSettingsPasswordHelpText": "Hasło HTTP Basic Auth", + "NotificationsAppriseSettingsServerUrl": "URL serwera Apprise", + "NotificationsAppriseSettingsServerUrlHelpText": "URL serwera Apprise, w tym http(s):// i port, jeśli potrzebny", + "NotificationsAppriseSettingsStatelessUrls": "Bezstanowe URL-e Apprise", + "NotificationsAppriseSettingsStatelessUrlsHelpText": "Jeden lub więcej URL-i oddzielonych przecinkami, określających dokąd wysłać powiadomienie. Pozostaw puste, jeśli używane jest trwałe przechowywanie.", + "NotificationsAppriseSettingsTags": "Tagi Apprise", + "NotificationsAppriseSettingsTagsHelpText": "Opcjonalnie powiadamiaj tylko elementy z odpowiednimi tagami.", + "NotificationsAppriseSettingsUsernameHelpText": "Nazwa użytkownika HTTP Basic Auth", + "NotificationsCustomScriptSettingsArguments": "Argumenty", + "NotificationsCustomScriptSettingsArgumentsHelpText": "Argumenty przekazywane do skryptu", + "NotificationsCustomScriptSettingsName": "Skrypt niestandardowy", + "NotificationsCustomScriptSettingsProviderMessage": "Test uruchomi skrypt z typem zdarzenia ustawionym na {eventTypeTest}. Upewnij się, że Twój skrypt obsługuje to poprawnie", + "NotificationsCustomScriptValidationFileDoesNotExist": "Plik nie istnieje", + "NotificationsDiscordSettingsAuthor": "Autor", + "NotificationsDiscordSettingsAuthorHelpText": "Nadpisz autora embeda wyświetlanego dla tego powiadomienia. Puste oznacza nazwę instancji", + "NotificationsDiscordSettingsAvatar": "Awatar", + "NotificationsDiscordSettingsAvatarHelpText": "Zmień awatar używany dla wiadomości z tej integracji", + "NotificationsDiscordSettingsOnGrabFields": "Pola dla zdarzenia przy pobraniu", + "NotificationsDiscordSettingsOnGrabFieldsHelpText": "Zmień pola przekazywane dla powiadomienia 'przy pobraniu'", + "NotificationsDiscordSettingsOnImportFields": "Pola dla zdarzenia przy imporcie", + "NotificationsDiscordSettingsOnImportFieldsHelpText": "Zmień pola przekazywane dla powiadomienia 'przy imporcie'", + "NotificationsDiscordSettingsOnManualInteractionFields": "Pola dla zdarzenia przy interakcji ręcznej", + "NotificationsDiscordSettingsOnManualInteractionFieldsHelpText": "Zmień pola przekazywane dla powiadomienia 'przy interakcji ręcznej'", + "NotificationsDiscordSettingsUsernameHelpText": "Nazwa użytkownika, pod którą publikować; domyślnie używana jest domyślna nazwa webhooka Discord", + "NotificationsDiscordSettingsWebhookUrlHelpText": "URL webhooka kanału Discord", + "NotificationsEmailSettingsBccAddress": "Adres(y) BCC", + "NotificationsEmailSettingsBccAddressHelpText": "Lista adresów BCC oddzielonych przecinkami", + "NotificationsEmailSettingsCcAddress": "Adres(y) CC", + "NotificationsEmailSettingsCcAddressHelpText": "Lista adresów CC oddzielonych przecinkami", + "NotificationsEmailSettingsFromAddress": "Adres nadawcy", + "NotificationsEmailSettingsName": "Email", + "NotificationsEmailSettingsRecipientAddress": "Adres(y) odbiorców", + "NotificationsEmailSettingsRecipientAddressHelpText": "Lista adresów odbiorców email oddzielonych przecinkami", + "NotificationsEmailSettingsServer": "Serwer", + "NotificationsEmailSettingsServerHelpText": "Nazwa hosta lub IP serwera email", + "NotificationsEmailSettingsUseEncryption": "Użyj szyfrowania", + "NotificationsEmailSettingsUseEncryptionHelpText": "Czy preferować szyfrowanie, jeśli serwer je obsługuje, zawsze używać szyfrowania przez SSL (tylko port 465) lub StartTLS (dowolny inny port), czy nigdy nie używać szyfrowania", + "NotificationsEmbySettingsSendNotifications": "Wysyłaj powiadomienia", + "NotificationsEmbySettingsSendNotificationsHelpText": "Pozwól Emby wysyłać powiadomienia do skonfigurowanych dostawców. Nieobsługiwane w Jellyfin.", + "NotificationsEmbySettingsUpdateLibraryHelpText": "Aktualizuj bibliotekę przy imporcie, zmianie nazwy lub usunięciu", + "NotificationsGotifySettingIncludeSeriesPoster": "Uwzględnij plakat serialu", + "NotificationsGotifySettingIncludeSeriesPosterHelpText": "Uwzględnij plakat serialu w wiadomości", + "NotificationsGotifySettingsAppToken": "Token aplikacji", + "NotificationsGotifySettingsAppTokenHelpText": "Token aplikacji wygenerowany przez Gotify", + "NotificationsGotifySettingsMetadataLinks": "Linki metadanych", + "NotificationsGotifySettingsMetadataLinksHelpText": "Dodawaj linki do metadanych serialu przy wysyłaniu powiadomień", + "NotificationsGotifySettingsPreferredMetadataLink": "Preferowany link metadanych", + "NotificationsGotifySettingsPreferredMetadataLinkHelpText": "Link metadanych dla klientów obsługujących tylko jeden link", + "NotificationsGotifySettingsPriorityHelpText": "Priorytet powiadomienia", + "NotificationsGotifySettingsServer": "Serwer Gotify", + "NotificationsGotifySettingsServerHelpText": "URL serwera Gotify, w tym http(s):// i port, jeśli potrzebny", + "NotificationsJoinSettingsApiKeyHelpText": "Klucz API z ustawień konta Join (kliknij przycisk Join API).", + "NotificationsJoinSettingsDeviceIds": "ID urządzeń", + "NotificationsJoinSettingsDeviceIdsHelpText": "Przestarzałe, użyj nazw urządzeń. Lista ID urządzeń oddzielona przecinkami, do których chcesz wysyłać powiadomienia. Jeśli puste, powiadomienia trafią do wszystkich urządzeń.", + "NotificationsJoinSettingsDeviceNames": "Nazwy urządzeń", + "NotificationsJoinSettingsDeviceNamesHelpText": "Lista pełnych lub częściowych nazw urządzeń oddzielona przecinkami, do których chcesz wysyłać powiadomienia. Jeśli puste, powiadomienia trafią do wszystkich urządzeń.", + "NotificationsJoinSettingsNotificationPriority": "Priorytet powiadomienia", + "NotificationsJoinValidationInvalidDeviceId": "ID urządzeń wydają się nieprawidłowe.", + "NotificationsKodiSettingAlwaysUpdate": "Zawsze aktualizuj", + "NotificationsKodiSettingAlwaysUpdateHelpText": "Aktualizować bibliotekę nawet gdy odtwarzane jest wideo?", + "NotificationsKodiSettingsCleanLibrary": "Wyczyść bibliotekę", + "NotificationsKodiSettingsCleanLibraryHelpText": "Czyść bibliotekę po aktualizacji", + "NotificationsKodiSettingsDisplayTime": "Czas wyświetlania", + "NotificationsKodiSettingsDisplayTimeHelpText": "Jak długo powiadomienie ma być wyświetlane (w sekundach)", + "NotificationsKodiSettingsGuiNotification": "Powiadomienie GUI", + "NotificationsKodiSettingsUpdateLibraryHelpText": "Aktualizować bibliotekę przy imporcie i zmianie nazwy?", + "NotificationsMailgunSettingsApiKeyHelpText": "Klucz API wygenerowany przez MailGun", + "NotificationsMailgunSettingsSenderDomain": "Domena nadawcy", + "NotificationsMailgunSettingsUseEuEndpoint": "Użyj endpointu UE", + "NotificationsMailgunSettingsUseEuEndpointHelpText": "Włącz, aby używać endpointu UE MailGun", + "NotificationsNotifiarrSettingsApiKeyHelpText": "Twój klucz API z profilu", + "NotificationsNtfySettingsAccessToken": "Token dostępu", + "NotificationsNtfySettingsAccessTokenHelpText": "Opcjonalne uwierzytelnianie tokenem. Ma priorytet nad nazwą użytkownika/hasłem", + "NotificationsNtfySettingsClickUrl": "URL kliknięcia", + "NotificationsNtfySettingsClickUrlHelpText": "Opcjonalny link po kliknięciu powiadomienia", + "NotificationsNtfySettingsPasswordHelpText": "Opcjonalne hasło", + "NotificationsNtfySettingsServerUrl": "URL serwera", + "NotificationsNtfySettingsServerUrlHelpText": "Pozostaw puste, aby użyć publicznego serwera ({url})", + "NotificationsNtfySettingsTagsEmojis": "Tagi i emoji Ntfy", + "NotificationsNtfySettingsTagsEmojisHelpText": "Opcjonalna lista tagów lub emoji do użycia", + "NotificationsNtfySettingsTopics": "Tematy", + "NotificationsNtfySettingsTopicsHelpText": "Lista tematów, do których wysyłać powiadomienia", + "NotificationsNtfySettingsUsernameHelpText": "Opcjonalna nazwa użytkownika", + "NotificationsNtfyValidationAuthorizationRequired": "Wymagana autoryzacja", + "NotificationsPlexSettingsAuthToken": "Token uwierzytelniania", + "NotificationsPlexSettingsAuthenticateWithPlexTv": "Uwierzytelnij przez Plex.tv", + "NotificationsPlexSettingsServer": "Serwer", + "NotificationsPlexSettingsServerHelpText": "Wybierz serwer z konta plex.tv po uwierzytelnieniu", + "NotificationsPlexValidationNoTvLibraryFound": "Wymagana jest co najmniej jedna biblioteka TV", + "NotificationsPushBulletSettingSenderId": "ID nadawcy", + "NotificationsPushBulletSettingSenderIdHelpText": "ID urządzenia, z którego wysyłać powiadomienia; użyj device_iden z URL urządzenia na pushbullet.com (pozostaw puste, aby wysyłać od siebie)", + "NotificationsPushBulletSettingsAccessToken": "Token dostępu", + "NotificationsPushBulletSettingsChannelTags": "Tagi kanałów", + "NotificationsPushBulletSettingsChannelTagsHelpText": "Lista tagów kanałów, do których wysyłać powiadomienia", + "NotificationsPushBulletSettingsDeviceIds": "ID urządzeń", + "NotificationsPushBulletSettingsDeviceIdsHelpText": "Lista ID urządzeń (pozostaw puste, aby wysyłać do wszystkich urządzeń)", + "NotificationsPushcutSettingsApiKeyHelpText": "Kluczami API można zarządzać w widoku Account aplikacji Pushcut", + "NotificationsPushcutSettingsIncludePoster": "Uwzględnij plakat", + "NotificationsPushcutSettingsIncludePosterHelpText": "Uwzględnij plakat w powiadomieniu", + "NotificationsPushcutSettingsMetadataLinks": "Linki metadanych", + "NotificationsPushcutSettingsMetadataLinksHelpText": "Dodawaj linki do metadanych serialu przy wysyłaniu powiadomień", + "NotificationsPushcutSettingsNotificationName": "Nazwa powiadomienia", + "NotificationsPushcutSettingsNotificationNameHelpText": "Nazwa powiadomienia z zakładki Notifications aplikacji Pushcut", + "NotificationsPushcutSettingsTimeSensitive": "Wrażliwe czasowo", + "NotificationsPushcutSettingsTimeSensitiveHelpText": "Włącz, aby oznaczyć powiadomienie jako \"Time Sensitive\"", + "NotificationsPushoverSettingsDevices": "Urządzenia", + "NotificationsPushoverSettingsDevicesHelpText": "Lista nazw urządzeń (pozostaw puste, aby wysyłać do wszystkich urządzeń)", + "NotificationsPushoverSettingsExpire": "Wygaśnięcie", + "NotificationsPushoverSettingsExpireHelpText": "Maksymalny czas ponawiania alertów Emergency, maksymalnie 86400 sekund", + "NotificationsPushoverSettingsRetry": "Ponowienie", + "NotificationsPushoverSettingsRetryHelpText": "Interwał ponawiania alertów Emergency, minimum 30 sekund", + "NotificationsPushoverSettingsSound": "Dźwięk", + "NotificationsPushoverSettingsSoundHelpText": "Dźwięk powiadomienia, pozostaw puste aby użyć domyślnego", + "NotificationsPushoverSettingsTtl": "Czas życia", + "NotificationsPushoverSettingsTtlHelpText": "Czas w sekundach przed wygaśnięciem wiadomości. Ustaw 0 dla czasu nieograniczonego", + "NotificationsPushoverSettingsUserKey": "Klucz użytkownika", + "NotificationsSendGridSettingsApiKeyHelpText": "Klucz API wygenerowany przez SendGrid", + "NotificationsSettingsUpdateLibrary": "Aktualizuj bibliotekę", + "NotificationsSettingsUpdateMapPathsFrom": "Mapuj ścieżki z", + "NotificationsSettingsUpdateMapPathsFromSeriesHelpText": "Ścieżka {appName}, używana do modyfikacji ścieżek seriali, gdy {serviceName} widzi lokalizację biblioteki inaczej niż {appName} (wymaga 'Update Library')", + "NotificationsSettingsUpdateMapPathsTo": "Mapuj ścieżki do", + "NotificationsSettingsUpdateMapPathsToSeriesHelpText": "Ścieżka {serviceName}, używana do modyfikacji ścieżek seriali, gdy {serviceName} widzi lokalizację biblioteki inaczej niż {appName} (wymaga 'Update Library')", + "NotificationsSettingsUseSslHelpText": "Łącz z {serviceName} przez HTTPS zamiast HTTP", + "NotificationsSettingsWebhookHeaders": "Nagłówki", + "NotificationsSettingsWebhookMethod": "Metoda", + "NotificationsSettingsWebhookMethodHelpText": "Której metody HTTP użyć do wysłania danych do usługi web", + "NotificationsSettingsWebhookUrl": "URL webhooka", + "NotificationsSignalSettingsGroupIdPhoneNumber": "ID grupy / numer telefonu", + "NotificationsSignalSettingsGroupIdPhoneNumberHelpText": "ID grupy / numer telefonu odbiorcy", + "NotificationsSignalSettingsPasswordHelpText": "Hasło używane do uwierzytelniania żądań do signal-api", + "NotificationsSignalSettingsSenderNumber": "Numer nadawcy", + "NotificationsSignalSettingsSenderNumberHelpText": "Numer telefonu nadawcy zarejestrowany w signal-api", + "NotificationsSignalSettingsUsernameHelpText": "Nazwa użytkownika używana do uwierzytelniania żądań do signal-api", + "NotificationsSignalValidationSslRequired": "Wygląda na to, że SSL jest wymagany", + "NotificationsSimplepushSettingsEvent": "Zdarzenie", + "NotificationsSimplepushSettingsEventHelpText": "Dostosuj działanie powiadomień push", + "NotificationsSimplepushSettingsKey": "Klucz", + "NotificationsSlackSettingsChannel": "Kanał", + "NotificationsSlackSettingsChannelHelpText": "Nadpisuje domyślny kanał dla przychodzącego webhooka (#inny-kanal)", + "NotificationsSlackSettingsIcon": "Ikona", + "NotificationsSlackSettingsIconHelpText": "Zmień ikonę używaną w wiadomościach wysyłanych do Slacka (Emoji lub URL)", + "NotificationsSlackSettingsUsernameHelpText": "Nazwa użytkownika, jako który publikować w Slacku", + "NotificationsSlackSettingsWebhookUrlHelpText": "URL webhooka kanału Slack", + "NotificationsSynologySettingsUpdateLibraryHelpText": "Wywołaj synoindex na localhost, aby zaktualizować plik biblioteki", + "NotificationsSynologyValidationInvalidOs": "Musi to być Synology", + "NotificationsSynologyValidationTestFailed": "To nie Synology albo synoindex jest niedostępny", + "NotificationsTagsSeriesHelpText": "Wysyłaj powiadomienia tylko dla seriali z co najmniej jednym pasującym tagiem", + "NotificationsTelegramSettingsBotToken": "Token bota", + "NotificationsTelegramSettingsChatId": "ID czatu", + "NotificationsTelegramSettingsChatIdHelpText": "Aby odbierać wiadomości, musisz rozpocząć rozmowę z botem lub dodać go do grupy", + "NotificationsTelegramSettingsIncludeAppName": "Uwzględnij {appName} w tytule", + "NotificationsTelegramSettingsIncludeAppNameHelpText": "Opcjonalnie dodaj przedrostek {appName} do tytułu wiadomości, aby odróżnić powiadomienia z różnych aplikacji", + "NotificationsTelegramSettingsIncludeInstanceName": "Uwzględnij nazwę instancji w tytule", + "NotificationsTelegramSettingsIncludeInstanceNameHelpText": "Opcjonalnie uwzględnij nazwę instancji w powiadomieniu", + "NotificationsTelegramSettingsLinkPreview": "Podgląd linku", + "NotificationsTelegramSettingsLinkPreviewHelpText": "Określa, który link będzie podglądany w powiadomieniu Telegram. Wybierz 'None', aby wyłączyć", + "NotificationsTelegramSettingsMetadataLinks": "Linki metadanych", + "NotificationsTelegramSettingsMetadataLinksHelpText": "Dodaj linki do metadanych serialu przy wysyłaniu powiadomień", + "NotificationsTelegramSettingsSendSilently": "Wysyłaj po cichu", + "NotificationsTelegramSettingsSendSilentlyHelpText": "Wysyła wiadomość bez dźwięku. Użytkownicy otrzymają powiadomienie bez dźwięku", + "NotificationsTelegramSettingsTopicId": "ID tematu", + "NotificationsTelegramSettingsTopicIdHelpText": "Podaj ID tematu, aby wysyłać powiadomienia do tego tematu. Pozostaw puste, aby użyć tematu ogólnego (tylko Supergrupy)", + "NotificationsTraktSettingsAccessToken": "Token dostępu", + "NotificationsTraktSettingsAuthUser": "Użytkownik auth", + "NotificationsTraktSettingsAuthenticateWithTrakt": "Uwierzytelnij przez Trakt", + "NotificationsTraktSettingsExpires": "Wygasa", + "NotificationsTraktSettingsRefreshToken": "Token odświeżania", + "NotificationsTwitterSettingsAccessToken": "Token dostępu", + "NotificationsTwitterSettingsAccessTokenSecret": "Sekret tokenu dostępu", + "NotificationsTwitterSettingsConnectToTwitter": "Połącz z Twitter / X", + "NotificationsTwitterSettingsConsumerKey": "Klucz konsumenta", + "NotificationsTwitterSettingsConsumerKeyHelpText": "Klucz konsumenta z aplikacji Twitter", + "NotificationsTwitterSettingsConsumerSecret": "Sekret konsumenta", + "NotificationsTwitterSettingsConsumerSecretHelpText": "Sekret konsumenta z aplikacji Twitter", + "NotificationsTwitterSettingsDirectMessage": "Wiadomość prywatna", + "NotificationsTwitterSettingsDirectMessageHelpText": "Wyślij wiadomość prywatną zamiast publicznej", + "NotificationsTwitterSettingsMention": "Wzmianka", + "NotificationsTwitterSettingsMentionHelpText": "Wspomnij tego użytkownika w wysłanych tweetach", + "NotificationsValidationInvalidAccessToken": "Token dostępu jest nieprawidłowy", + "NotificationsValidationInvalidApiKey": "Klucz API jest nieprawidłowy", + "NotificationsValidationInvalidApiKeyExceptionMessage": "Klucz API jest nieprawidłowy: {exceptionMessage}", + "NotificationsValidationInvalidAuthenticationToken": "Token uwierzytelniania jest nieprawidłowy", + "NotificationsValidationInvalidHttpCredentials": "Dane logowania HTTP Auth są nieprawidłowe: {exceptionMessage}", + "NotificationsValidationInvalidUsernamePassword": "Nieprawidłowa nazwa użytkownika lub hasło", + "NotificationsValidationUnableToConnect": "Nie można połączyć się: {exceptionMessage}", + "NotificationsValidationUnableToConnectToApi": "Nie można połączyć się z API {service}. Połączenie z serwerem nie powiodło się: ({responseCode}) {exceptionMessage}", + "NotificationsValidationUnableToConnectToService": "Nie można połączyć się z {serviceName}", + "NotificationsValidationUnableToSendTestMessage": "Nie można wysłać wiadomości testowej: {exceptionMessage}", + "NotificationsValidationUnableToSendTestMessageApiResponse": "Nie można wysłać wiadomości testowej. Odpowiedź API: {error}", + "NzbgetHistoryItemMessage": "Status PAR: {parStatus} - Status rozpakowania: {unpackStatus} - Status przenoszenia: {moveStatus} - Status skryptu: {scriptStatus} - Status usunięcia: {deleteStatus} - Status oznaczenia: {markStatus}", + "Ok": "Ok", + "OnApplicationUpdate": "Przy aktualizacji aplikacji", + "OnEpisodeFileDelete": "Przy usunięciu pliku odcinka", + "OnEpisodeFileDeleteForUpgrade": "Przy usunięciu pliku odcinka pod aktualizację", + "OnFileImport": "Przy imporcie pliku", + "OnFileUpgrade": "Przy aktualizacji pliku", + "OnGrab": "Przy pobraniu", + "OnHealthIssue": "Przy problemie stanu", + "OnHealthRestored": "Przy przywróceniu stanu", + "OnImportComplete": "Po zakończeniu importu", + "OnLatestVersion": "Najnowsza wersja {appName} jest już zainstalowana", + "OnManualInteractionRequired": "Przy wymaganej interakcji ręcznej", + "OnRename": "Przy zmianie nazwy", + "OnSeriesAdd": "Przy dodaniu serialu", + "OnSeriesDelete": "Przy usunięciu serialu", + "OneMinute": "1 minuta", + "OneSeason": "1 sezon", + "OnlyForBulkSeasonReleases": "Tylko dla zbiorczych wydań sezonu", + "OnlyTorrent": "Tylko torrent", + "OnlyUsenet": "Tylko Usenet", + "OpenBrowserOnStart": "Otwórz przeglądarkę przy starcie", + "OpenBrowserOnStartHelpText": " Otwórz przeglądarkę internetową i przejdź do strony głównej {appName} przy uruchomieniu aplikacji.", + "OpenSeries": "Otwórz serial", + "OptionalName": "Nazwa opcjonalna", + "Options": "Opcje", + "Or": "lub", + "Organize": "Organizuj", + "OrganizeLoadError": "Błąd ładowania podglądu", + "OrganizeModalHeader": "Organizuj i zmień nazwy", + "OrganizeModalHeaderSeason": "Organizuj i zmień nazwy - {season}", + "OrganizeNamingPattern": "Wzorzec nazwy: `{episodeFormat}`", + "OrganizeNothingToRename": "Sukces! Praca zakończona, brak plików do zmiany nazwy.", + "OrganizeRelativePaths": "Wszystkie ścieżki są względne do: `{path}`", + "OrganizeRenamingDisabled": "Zmiana nazw jest wyłączona, brak plików do zmiany nazwy", + "OrganizeSelectedSeriesModalAlert": "Wskazówka: aby podejrzeć zmianę nazwy, wybierz \"Anuluj\", następnie wybierz dowolny tytuł serialu i użyj tej ikony:", + "OrganizeSelectedSeriesModalConfirmation": "Czy na pewno chcesz uporządkować wszystkie pliki w {count} zaznaczonych serialach?", + "OrganizeSelectedSeriesModalHeader": "Organizuj zaznaczone seriale", + "Original": "Oryginalny", + "OriginalCountry": "Kraj oryginału", + "OriginalLanguage": "Język oryginału", + "Other": "Inne", + "OutputPath": "Ścieżka wyjściowa", + "OverrideAndAddToDownloadQueue": "Nadpisz i dodaj do kolejki pobierania", + "OverrideGrabModalTitle": "Nadpisz i pobierz - {title}", + "OverrideGrabNoEpisode": "Musi być wybrany co najmniej jeden odcinek", + "OverrideGrabNoLanguage": "Musi być wybrany co najmniej jeden język", + "OverrideGrabNoQuality": "Musi być wybrana jakość", + "OverrideGrabNoSeries": "Musi być wybrany serial", + "Overview": "Przegląd", + "OverviewOptions": "Opcje przeglądu", + "PackageVersion": "Wersja pakietu", + "PackageVersionInfo": "{packageVersion} autorstwa {packageAuthor}", + "Parse": "Parsuj", + "ParseModalErrorParsing": "Błąd analizy, spróbuj ponownie.", + "ParseModalHelpText": "Wpisz tytuł wydania w polu powyżej", + "ParseModalHelpTextDetails": "{appName} spróbuje przeanalizować tytuł i pokaże szczegóły", + "ParseModalUnableToParse": "Nie można przeanalizować podanego tytułu, spróbuj ponownie.", + "PartialSeason": "Częściowy sezon", + "Password": "Hasło", + "PasswordConfirmation": "Potwierdzenie hasła", + "Path": "Ścieżka", + "Paused": "Wstrzymane", + "Peers": "Peerzy", + "Pending": "Oczekujące", + "PendingChangesDiscardChanges": "Odrzuć zmiany i wyjdź", + "PendingChangesMessage": "Masz niezapisane zmiany, czy na pewno chcesz opuścić tę stronę?", + "PendingChangesStayReview": "Zostań i przejrzyj zmiany", + "PendingDownloadClientUnavailable": "Oczekujące - klient pobierania jest niedostępny", + "Period": "Okres", + "Permissions": "Uprawnienia", + "Port": "Port", + "PortNumber": "Numer portu", + "PostImportCategory": "Kategoria po imporcie", + "PosterOptions": "Opcje plakatu", + "PosterSize": "Rozmiar plakatu", + "Posters": "Plakaty", + "PreferAndUpgrade": "Preferuj i aktualizuj", + "PreferProtocol": "Preferuj {preferredProtocol}", + "PreferTorrent": "Preferuj torrenty", + "PreferUsenet": "Preferuj Usenet", + "Preferred": "Preferowany", + "PreferredProtocol": "Preferowany protokół", + "PreferredSize": "Preferowany rozmiar", + "PrefixedRange": "Zakres z prefiksem", + "Premiere": "Premiera", + "Presets": "Presety", + "PreviewRename": "Podgląd zmiany nazwy", + "PreviewRenameSeason": "Podgląd zmiany nazwy dla tego sezonu", + "PreviousAiring": "Poprzednia emisja", + "PreviousAiringDate": "Poprzednia emisja: {date}", + "PreviouslyInstalled": "Wcześniej zainstalowane", + "Priority": "Priorytet", + "PrioritySettings": "Priorytet: {priority}", + "ProcessingFolders": "Przetwarzanie folderów", + "Profiles": "Profile", + "ProfilesSettingsSummary": "Profile jakości, opóźnienia języka i wydań", + "Progress": "Postęp", + "ProgressBarProgress": "Pasek postępu: {progress}%", + "Proper": "Właściwy", + "Protocol": "Protokół", + "ProtocolHelpText": "Wybierz protokół(y) do użycia oraz ten preferowany przy wyborze między wydaniami równorzędnymi", + "Proxy": "Proxy", + "ProxyBadRequestHealthCheckMessage": "Nie udało się przetestować proxy. Kod statusu: {statusCode}", + "ProxyBypassFilterHelpText": "Użyj ',' jako separatora i '*.' jako wildcard dla subdomen", + "ProxyFailedToTestHealthCheckMessage": "Nie udało się przetestować proxy: {url}", + "ProxyPasswordHelpText": "Nazwę użytkownika i hasło wpisz tylko wtedy, gdy są wymagane. W przeciwnym razie pozostaw puste.", + "ProxyResolveIpHealthCheckMessage": "Nie udało się rozwiązać adresu IP dla skonfigurowanego hosta proxy {proxyHostName}", + "ProxyType": "Typ proxy", + "ProxyUsernameHelpText": "Nazwę użytkownika i hasło wpisz tylko wtedy, gdy są wymagane. W przeciwnym razie pozostaw puste.", + "PublishedDate": "Data publikacji", + "Qualities": "Jakości", + "QualitiesHelpText": "Jakości wyżej na liście są bardziej preferowane (nawet jeśli nie są zaznaczone). Jakości w tej samej grupie są równorzędne. Pożądane są tylko zaznaczone jakości", + "QualitiesLoadError": "Nie można wczytać jakości", + "Quality": "Jakość", + "QualityCutoffNotMet": "Próg jakości nie został osiągnięty", + "QualityDefinitions": "Definicje jakości", + "QualityDefinitionsLoadError": "Nie można wczytać definicji jakości", + "QualityDefinitionsSizeNotice": "Ograniczenia rozmiaru zostały przeniesione do profili jakości", + "QualityProfile": "Profil jakości", + "QualityProfileInUseSeriesListCollection": "Nie można usunąć profilu jakości przypisanego do serialu, listy lub kolekcji", + "QualityProfiles": "Profile jakości", + "QualityProfilesLoadError": "Nie można wczytać profili jakości", + "QualitySettings": "Ustawienia jakości", + "QualitySettingsSummary": "Rozmiary jakości i nazewnictwo", + "Queue": "Kolejka", + "QueueFilterHasNoItems": "Wybrany filtr kolejki nie zawiera elementów", + "QueueIsEmpty": "Kolejka jest pusta", + "QueueItem": "1 element kolejki", + "QueueItems": "{count} elementów kolejki", + "QueueLoadError": "Nie udało się wczytać kolejki", + "Queued": "W kolejce", + "QuickSearch": "Szybkie wyszukiwanie", + "Range": "Zakres", + "Rating": "Ocena", + "RatingVotes": "Liczba głosów", + "ReadTheWikiForMoreInformation": "Przeczytaj Wiki, aby uzyskać więcej informacji", + "Real": "Real", + "Reason": "Powód", + "RecentChanges": "Ostatnie zmiany", + "RecentFolders": "Ostatnie foldery", + "RecycleBinUnableToWriteHealthCheckMessage": "Nie można zapisywać do skonfigurowanego folderu kosza: {path}. Upewnij się, że ścieżka istnieje i użytkownik uruchamiający {appName} ma do niej uprawnienia zapisu", + "RecyclingBin": "Kosz", + "RecyclingBinCleanup": "Czyszczenie kosza", + "RecyclingBinCleanupHelpText": "Ustaw 0, aby wyłączyć automatyczne czyszczenie", + "RecyclingBinCleanupHelpTextWarning": "Pliki w koszu starsze niż wybrana liczba dni będą automatycznie usuwane", + "RecyclingBinHelpText": "Pliki będą trafiać tutaj po usunięciu zamiast być usuwane trwale", + "Refresh": "Odśwież", + "RefreshAndScan": "Odśwież i skanuj", + "RefreshAndScanTooltip": "Odśwież informacje i przeskanuj dysk", + "RefreshSeries": "Odśwież serial", + "RegularExpression": "Wyrażenie regularne", + "RegularExpressionsCanBeTested": "Wyrażenia regularne można testować [tutaj]({url}).", + "RegularExpressionsTutorialLink": "Więcej informacji o wyrażeniach regularnych znajdziesz [tutaj]({url}).", + "RejectionCount": "Liczba odrzuceń", + "Rejections": "Odrzucenia", + "RelativePath": "Ścieżka względna", + "Release": "Wydanie", + "ReleaseGroup": "Grupa wydania", + "ReleaseGroupFootNote": "Opcjonalnie kontroluj obcinanie do maksymalnej liczby bajtów, łącznie z wielokropkiem (`...`). Obsługiwane jest obcinanie od końca (np. `{Release Group:30}`) lub od początku (np. `{Release Group:-30}`).", + "ReleaseGroups": "Grupy wydań", + "ReleaseHash": "Hash wydania", + "ReleaseProfile": "Profil wydań", + "ReleaseProfileExcludedTagSeriesHelpText": "Profile wydań nie będą stosowane do seriali z co najmniej jednym pasującym tagiem.", + "ReleaseProfileIndexerHelpText": "Określ, do którego indeksera ma zastosowanie profil", + "ReleaseProfileIndexerHelpTextWarning": "Ustawienie konkretnego indeksera w profilu wydań spowoduje, że profil będzie stosowany tylko do wydań z tego indeksera.", + "ReleaseProfileTagSeriesHelpText": "Profile wydań będą stosowane do seriali z co najmniej jednym pasującym tagiem. Pozostaw puste, aby stosować do wszystkich seriali", + "ReleaseProfiles": "Profile wydań", + "ReleaseProfilesLoadError": "Nie można wczytać profili wydań", + "ReleasePush": "Push wydania", + "ReleaseRejected": "Wydanie odrzucone", + "ReleaseSceneIndicatorAssumingScene": "Założono numerację Scene.", + "ReleaseSceneIndicatorAssumingTvdb": "Założono numerację TVDB.", + "ReleaseSceneIndicatorMappedNotRequested": "Mapowany odcinek nie był żądany w tym wyszukiwaniu.", + "ReleaseSceneIndicatorSourceMessage": "{message} wydania mają niejednoznaczną numerację, nie można wiarygodnie zidentyfikować odcinka.", + "ReleaseSceneIndicatorUnknownMessage": "Numeracja różni się dla tego odcinka, a wydanie nie pasuje do żadnych znanych mapowań.", + "ReleaseSceneIndicatorUnknownSeries": "Nieznany odcinek lub serial.", + "ReleaseSource": "Źródło wydania", + "ReleaseTitle": "Tytuł wydania", + "ReleaseType": "Typ wydania", + "Reload": "Przeładuj", + "RemotePath": "Ścieżka zdalna", + "RemotePathMappingBadDockerPathHealthCheckMessage": "Używasz Dockera; klient pobierania {downloadClientName} zapisuje pobrania w {path}, ale to nie jest prawidłowa ścieżka {osName}. Sprawdź mapowania ścieżek zdalnych i ustawienia klienta pobierania.", + "RemotePathMappingDockerFolderMissingHealthCheckMessage": "Używasz Dockera; klient pobierania {downloadClientName} zapisuje pobrania w {path}, ale ten katalog nie wydaje się istnieć wewnątrz kontenera. Sprawdź mapowania ścieżek zdalnych i ustawienia wolumenów kontenera.", + "RemotePathMappingDownloadPermissionsEpisodeHealthCheckMessage": "{appName} widzi pobrany odcinek {path}, ale nie ma do niego dostępu. Prawdopodobny błąd uprawnień.", + "RemotePathMappingFileRemovedHealthCheckMessage": "Plik {path} został usunięty w trakcie przetwarzania.", + "RemotePathMappingFilesBadDockerPathHealthCheckMessage": "Używasz Dockera; klient pobierania {downloadClientName} zgłosił pliki w {path}, ale to nie jest prawidłowa ścieżka {osName}. Sprawdź mapowania ścieżek zdalnych i ustawienia klienta pobierania.", + "RemotePathMappingFilesGenericPermissionsHealthCheckMessage": "Klient pobierania {downloadClientName} zgłosił pliki w {path}, ale {appName} nie widzi tego katalogu. Może być konieczna korekta uprawnień folderu.", + "RemotePathMappingFilesLocalWrongOSPathHealthCheckMessage": "Lokalny klient pobierania {downloadClientName} zgłosił pliki w {path}, ale to nie jest prawidłowa ścieżka {osName}. Sprawdź ustawienia klienta pobierania.", + "RemotePathMappingFilesWrongOSPathHealthCheckMessage": "Zdalny klient pobierania {downloadClientName} zgłosił pliki w {path}, ale to nie jest prawidłowa ścieżka {osName}. Sprawdź mapowania ścieżek zdalnych i ustawienia klienta pobierania.", + "RemotePathMappingFolderPermissionsHealthCheckMessage": "{appName} widzi katalog pobierania {downloadPath}, ale nie ma do niego dostępu. Prawdopodobny błąd uprawnień.", + "RemotePathMappingGenericPermissionsHealthCheckMessage": "Klient pobierania {downloadClientName} zapisuje pobrania w {path}, ale {appName} nie widzi tego katalogu. Może być konieczna korekta uprawnień folderu.", + "RemotePathMappingHostHelpText": "Ten sam host, który został podany dla zdalnego klienta pobierania", + "RemotePathMappingImportEpisodeFailedHealthCheckMessage": "{appName} nie zdołał zaimportować odcinka/odcinków. Sprawdź logi, aby poznać szczegóły.", + "RemotePathMappingLocalFolderMissingHealthCheckMessage": "Zdalny klient pobierania {downloadClientName} zapisuje pobrania w {path}, ale ten katalog nie wydaje się istnieć. Prawdopodobnie brakuje mapowania ścieżki zdalnej lub jest ono błędne.", + "RemotePathMappingLocalPathHelpText": "Ścieżka, której {appName} powinien używać do lokalnego dostępu do ścieżki zdalnej", + "RemotePathMappingLocalWrongOSPathHealthCheckMessage": "Lokalny klient pobierania {downloadClientName} zapisuje pobrania w {path}, ale to nie jest prawidłowa ścieżka {osName}. Sprawdź ustawienia klienta pobierania.", + "RemotePathMappingRemoteDownloadClientHealthCheckMessage": "Zdalny klient pobierania {downloadClientName} zgłosił pliki w {path}, ale ten katalog nie wydaje się istnieć. Prawdopodobnie brakuje mapowania ścieżki zdalnej.", + "RemotePathMappingRemotePathHelpText": "Ścieżka główna katalogu, do którego dostęp ma klient pobierania", + "RemotePathMappingWrongOSPathHealthCheckMessage": "Zdalny klient pobierania {downloadClientName} zapisuje pobrania w {path}, ale to nie jest prawidłowa ścieżka {osName}. Sprawdź mapowania ścieżek zdalnych i ustawienia klienta pobierania.", + "RemotePathMappings": "Mapowania ścieżek zdalnych", + "RemotePathMappingsInfo": "Mapowania ścieżek zdalnych są bardzo rzadko wymagane. Jeśli {appName} i klient pobierania działają na tym samym systemie, lepiej dopasować ścieżki. Więcej informacji w [wiki]({wikiLink})", + "RemotePathMappingsLoadError": "Nie można wczytać mapowań ścieżek zdalnych", + "Remove": "Usuń", + "RemoveCompleted": "Usuń ukończone", + "RemoveCompletedDownloads": "Usuń ukończone pobrania", + "RemoveCompletedDownloadsHelpText": "Usuń zaimportowane pobrania z historii klienta pobierania", + "RemoveFailed": "Usuń nieudane", + "RemoveFailedDownloads": "Usuń nieudane pobrania", + "RemoveFailedDownloadsHelpText": "Usuń nieudane pobrania z historii klienta pobierania", + "RemoveFilter": "Usuń filtr", + "RemoveFromBlocklist": "Usuń z czarnej listy", + "RemoveFromDownloadClient": "Usuń z klienta pobierania", + "RemoveFromDownloadClientHint": "Usuwa pobranie i plik(i) z klienta pobierania", + "RemoveFromQueue": "Usuń z kolejki", + "RemoveMultipleFromDownloadClientHint": "Usuwa pobrania i pliki z klienta pobierania", + "RemoveQueueItem": "Usuń - {sourceTitle}", + "RemoveQueueItemConfirmation": "Czy na pewno chcesz usunąć '{sourceTitle}' z kolejki?", + "RemoveQueueItemRemovalMethod": "Metoda usuwania", + "RemoveQueueItemRemovalMethodHelpTextWarning": "'Usuń z klienta pobierania' usunie pobranie i plik(i) z klienta pobierania.", + "RemoveQueueItemsRemovalMethodHelpTextWarning": "'Usuń z klienta pobierania' usunie pobrania i pliki z klienta pobierania.", + "RemoveRootFolder": "Usuń folder główny", + "RemoveRootFolderWithSeriesMessageText": "Czy na pewno chcesz usunąć folder główny '{path}'? Pliki i foldery nie zostaną usunięte z dysku, a seriale w tym folderze głównym nie zostaną usunięte z {appName}.", + "RemoveSelected": "Usuń zaznaczone", + "RemoveSelectedBlocklistMessageText": "Czy na pewno chcesz usunąć zaznaczone elementy z czarnej listy?", + "RemoveSelectedItem": "Usuń zaznaczony element", + "RemoveSelectedItemQueueMessageText": "Czy na pewno chcesz usunąć 1 element z kolejki?", + "RemoveSelectedItems": "Usuń zaznaczone elementy", + "RemoveSelectedItemsQueueMessageText": "Czy na pewno chcesz usunąć {selectedCount} elementów z kolejki?", + "RemoveTagsAutomatically": "Usuwaj tagi automatycznie", + "RemoveTagsAutomaticallyHelpText": "Usuwaj tagi automatycznie, jeśli warunki nie są spełnione", + "RemovedFromTaskQueue": "Usunięto z kolejki zadań", + "RemovedSeriesMultipleRemovedHealthCheckMessage": "Seriale {series} zostały usunięte z TheTVDB", + "RemovedSeriesSingleRemovedHealthCheckMessage": "Serial {series} został usunięty z TheTVDB", + "RemovingTag": "Usuwanie tagu", + "RenameEpisodes": "Zmień nazwy odcinków", + "RenameEpisodesHelpText": "{appName} użyje istniejącej nazwy pliku, jeśli zmiana nazw jest wyłączona", + "RenameFiles": "Zmień nazwy plików", + "Renamed": "Zmieniono nazwę", + "Reorder": "Zmień kolejność", + "Repack": "Przepakowany", + "Repeat": "Powtórz", + "Replace": "Zastąp", + "ReplaceIllegalCharacters": "Zastąp niedozwolone znaki", + "ReplaceIllegalCharactersHelpText": "Zastępuj niedozwolone znaki. Jeśli odznaczone, {appName} będzie je usuwać", + "ReplaceWithDash": "Zastąp myślnikiem", + "ReplaceWithSpaceDash": "Zastąp spacją i myślnikiem", + "ReplaceWithSpaceDashSpace": "Zastąp spacją-myślnikiem-spacją", + "Required": "Wymagane", + "RequiredHelpText": "Ten warunek {implementationName} musi pasować, aby format niestandardowy został zastosowany. W przeciwnym razie wystarczy pojedyncze dopasowanie {implementationName}.", + "RescanAfterRefreshHelpTextWarning": "{appName} nie będzie automatycznie wykrywać zmian w plikach, jeśli nie ustawiono 'Always'", + "RescanAfterRefreshSeriesHelpText": "Przeskanuj folder serialu po odświeżeniu serialu", + "RescanSeriesFolderAfterRefresh": "Przeskanuj folder serialu po odświeżeniu", + "Reset": "Resetuj", + "ResetAPIKey": "Resetuj klucz API", + "ResetAPIKeyMessageText": "Czy na pewno chcesz zresetować klucz API?", + "ResetDefinitionTitlesHelpText": "Zresetuj tytuły definicji oraz wartości", + "ResetDefinitions": "Resetuj definicje", + "ResetQualityDefinitions": "Resetuj definicje jakości", + "ResetQualityDefinitionsMessageText": "Czy na pewno chcesz zresetować definicje jakości?", + "Restart": "Uruchom ponownie", + "RestartLater": "Uruchomię ponownie później", + "RestartNow": "Uruchom teraz", + "RestartReloadNote": "Uwaga: podczas przywracania {appName} automatycznie uruchomi się ponownie i przeładuje interfejs.", + "RestartRequiredHelpTextWarning": "Wymaga ponownego uruchomienia, aby zastosować", + "RestartRequiredToApplyChanges": "{appName} wymaga ponownego uruchomienia, aby zastosować zmiany. Uruchomić teraz?", + "RestartRequiredWindowsService": "W zależności od użytkownika uruchamiającego usługę {appName}, może być konieczne jednokrotne uruchomienie {appName} jako administrator, zanim usługa zacznie uruchamiać się automatycznie.", + "RestartSonarr": "Uruchom ponownie {appName}", + "Restore": "Przywróć", + "RestoreBackup": "Przywróć kopię zapasową", + "RestrictionsLoadError": "Nie można wczytać ograniczeń", + "Result": "Wynik", + "Retention": "Retencja", + "RetentionHelpText": "Tylko Usenet: ustaw na 0, aby retencja była nieograniczona", + "RetryingDownloadOn": "Ponowna próba pobrania: {date} o {time}", + "RootFolder": "Folder główny", + "RootFolderEmptyHealthCheckMessage": "Pusty folder główny: {rootFolderPath}", + "RootFolderMissingHealthCheckMessage": "Brak folderu głównego: {rootFolderPath}", + "RootFolderMultipleEmptyHealthCheckMessage": "Wiele folderów głównych jest pustych: {rootFolderPaths}", + "RootFolderMultipleMissingHealthCheckMessage": "Brakuje wielu folderów głównych: {rootFolderPaths}", + "RootFolderPath": "Ścieżka folderu głównego", + "RootFolderSelectFreeSpace": "Wolne: {freeSpace}", + "RootFolders": "Foldery główne", + "RootFoldersLoadError": "Nie można wczytać folderów głównych", + "Rss": "RSS", + "RssIsNotSupportedWithThisIndexer": "RSS nie jest obsługiwane przez ten indekser", + "RssSync": "Synchronizacja RSS", + "RssSyncInterval": "Interwał synchronizacji RSS", + "RssSyncIntervalHelpText": "Interwał w minutach. Ustaw 0, aby wyłączyć (zatrzyma to wszystkie automatyczne pobierania wydań)", + "RssSyncIntervalHelpTextWarning": "Dotyczy wszystkich indekserów, stosuj się do ich zasad", + "Runtime": "Czas trwania", + "Saturday": "Sobota", + "Save": "Zapisz", + "SaveChanges": "Zapisz zmiany", + "SaveSettings": "Zapisz ustawienia", + "Scene": "Sceny", + "SceneInfo": "Informacje o scenie", + "SceneInformation": "Informacje o scenie", + "SceneNumberNotVerified": "Numeracja sceny nie została jeszcze zweryfikowana", + "SceneNumbering": "Numeracja sceny", + "Scheduled": "Zaplanowane", + "Score": "Wynik", + "Script": "Skrypt", + "ScriptPath": "Ścieżka skryptu", + "Search": "Szukaj", + "SearchAll": "Szukaj wszystkiego", + "SearchByTvdbId": "Możesz też szukać po ID TVDB serialu, np. tvdb:71663", + "SearchFailedError": "Wyszukiwanie nie powiodło się, spróbuj ponownie później.", + "SearchForAllMissingEpisodes": "Szukaj wszystkich brakujących odcinków", + "SearchForAllMissingEpisodesConfirmationCount": "Czy na pewno chcesz wyszukać wszystkie {totalRecords} brakujące odcinki?", + "SearchForCutoffUnmetEpisodes": "Szukaj wszystkich odcinków niespełniających progu", + "SearchForCutoffUnmetEpisodesConfirmationCount": "Czy na pewno chcesz wyszukać wszystkie {totalRecords} odcinki niespełniające progu?", + "SearchForMissing": "Szukaj brakujących", + "SearchForMonitoredEpisodes": "Szukaj monitorowanych odcinków", + "SearchForMonitoredEpisodesSeason": "Szukaj monitorowanych odcinków w tym sezonie", + "SearchForQuery": "Szukaj: {query}", + "SearchIsNotSupportedWithThisIndexer": "Wyszukiwanie nie jest obsługiwane przez ten indekser", + "SearchMonitored": "Szukaj monitorowanych", + "SearchSelected": "Szukaj zaznaczonych", + "Season": "Sezon", + "SeasonCount": "Liczba sezonów", + "SeasonDetails": "Szczegóły sezonu", + "SeasonFinale": "Finał sezonu", + "SeasonFolder": "Folder sezonu", + "SeasonFolderFormat": "Format folderu sezonu", + "SeasonInformation": "Informacje o sezonie", + "SeasonNumber": "Numer sezonu", + "SeasonNumberToken": "Sezon {seasonNumber}", + "SeasonPack": "Paczka sezonu", + "SeasonPackUpgradeAllowAnyWarning": "Zezwalaj na paczkę sezonu, jeśli ulepsza dowolny odcinek. Dotyczy to wszystkich źródeł automatycznych pobrań.", + "SeasonPackUpgradeAllowHelpText": "Wymagaj, aby paczka sezonu była ulepszeniem jakości lub formatu niestandardowego dla wszystkich odcinków", + "SeasonPackUpgradeAllowLabel": "Zezwalaj na ulepszenia paczek sezonu", + "SeasonPackUpgradeThresholdHelpText": "Wymagaj, aby paczka sezonu była ulepszeniem co najmniej dla X procent odcinków.", + "SeasonPackUpgradeThresholdHelpTextExample": "{numberEpisodes} z {totalEpisodes} odcinków: {count}%", + "SeasonPackUpgradeThresholdLabel": "Próg ulepszenia paczki sezonu", + "SeasonPassEpisodesDownloaded": "Pobrane odcinki: {episodeFileCount}/{totalEpisodeCount}", + "SeasonPassTruncated": "Pokazano tylko 25 najnowszych sezonów, przejdź do szczegółów, aby zobaczyć wszystkie", + "SeasonPremiere": "Premiera sezonu", + "SeasonPremieresOnly": "Tylko premiery sezonów", + "Seasons": "Sezony", + "SeasonsMonitoredAll": "Wszystkie", + "SeasonsMonitoredNone": "Żadne", + "SeasonsMonitoredStatus": "Monitorowanie sezonów", + "SecretToken": "Sekretny token", + "Security": "Bezpieczeństwo", + "Seeders": "Seederzy", + "SelectAll": "Zaznacz wszystko", + "SelectDownloadClientModalTitle": "{modalTitle} - wybierz klienta pobierania", + "SelectDropdown": "Wybierz...", + "SelectEpisodes": "Wybierz odcinki", + "SelectEpisodesModalTitle": "{modalTitle} - wybierz odcinki", + "SelectFolder": "Wybierz folder", + "SelectFolderModalTitle": "{modalTitle} - wybierz folder", + "SelectIndexerFlags": "Wybierz flagi indekswera", + "SelectLanguage": "Wybierz język", + "SelectLanguageModalTitle": "{modalTitle} - wybierz język", + "SelectLanguages": "Wybierz języki", + "SelectQuality": "Wybierz jakość", + "SelectReleaseGroup": "Wybierz grupę wydania", + "SelectReleaseType": "Wybierz typ wydania", + "SelectSeason": "Wybierz sezon", + "SelectSeasonModalTitle": "{modalTitle} - wybierz sezon", + "SelectSeries": "Wybierz serial", + "SendAnonymousUsageData": "Wysyłaj anonimowe dane użycia", + "Series": "Seriale", + "SeriesAndEpisodeInformationIsProvidedByTheTVDB": "Informacje o serialach i odcinkach dostarcza TheTVDB.com. [Rozważ ich wsparcie]({url}).", + "SeriesCannotBeFound": "Niestety nie można znaleźć tego serialu.", + "SeriesDetailsCountEpisodeFiles": "Pliki odcinków: {episodeFileCount}", + "SeriesDetailsGoTo": "Przejdź do {title}", + "SeriesDetailsNoEpisodeFiles": "Brak plików odcinków", + "SeriesDetailsOneEpisodeFile": "1 plik odcinka", + "SeriesDetailsRuntime": "{runtime} minut", + "SeriesEditRootFolderHelpText": "Przeniesienie serialu do tego samego folderu głównego może służyć do zmiany nazw folderów seriali zgodnie z nowym tytułem lub formatem nazewnictwa", + "SeriesEditor": "Edytor seriali", + "SeriesFinale": "Finał serialu", + "SeriesFolderFormat": "Format folderu serialu", + "SeriesFolderFormatHelpText": "Używane przy dodawaniu nowego serialu lub przenoszeniu seriali przez edytor seriali", + "SeriesFolderImportedTooltip": "Odcinek zaimportowany z folderu serialu", + "SeriesFootNote": "Opcjonalnie kontroluj obcinanie do maksymalnej liczby bajtów, łącznie z wielokropkiem (`...`). Obsługiwane jest obcinanie od końca (np. `{Series Title:30}`) lub od początku (np. `{Series Title:-30}`).", + "SeriesID": "ID serialu", + "SeriesInImportListExclusions": "Serial znajduje się na liście wykluczeń importu", + "SeriesIndexFooterContinuing": "Kontynuowany (wszystkie odcinki pobrane)", + "SeriesIndexFooterDownloading": "Pobieranie (co najmniej jeden odcinek)", + "SeriesIndexFooterEnded": "Zakończony (wszystkie odcinki pobrane)", + "SeriesIndexFooterMissingMonitored": "Brakujące odcinki (serial monitorowany)", + "SeriesIndexFooterMissingUnmonitored": "Brakujące odcinki (serial niemonitorowany)", + "SeriesIsMonitored": "Serial jest monitorowany", + "SeriesIsUnmonitored": "Serial nie jest monitorowany", + "SeriesLoadError": "Nie można wczytać seriali", + "SeriesMatchType": "Typ dopasowania serialu", + "SeriesMonitoring": "Monitorowanie serialu", + "SeriesPremiere": "Premiera serialu", + "SeriesProgressBarText": "{episodeFileCount} / {episodeCount} (Łącznie: {totalEpisodeCount}, Pobieranie: {downloadingCount})", + "SeriesTitle": "Tytuł serialu", + "SeriesTitleToExcludeHelpText": "Nazwa serialu do wykluczenia", + "SeriesType": "Typ serialu", + "SeriesTypes": "Typy seriali", + "SeriesTypesHelpText": "Typ serialu jest używany do zmiany nazwy, analizy i wyszukiwania", + "SetIndexerFlags": "Ustaw flagi indekswera", + "SetIndexerFlagsModalTitle": "{modalTitle} - ustaw flagi indekswera", + "SetPermissions": "Ustaw uprawnienia", + "SetPermissionsLinuxHelpText": "Czy uruchamiać chmod podczas importu/zmiany nazw plików?", + "SetPermissionsLinuxHelpTextWarning": "Jeśli nie wiesz, do czego służą te ustawienia, nie zmieniaj ich.", + "SetReleaseGroup": "Ustaw grupę wydania", + "SetReleaseGroupModalTitle": "{modalTitle} - ustaw grupę wydania", + "SetTags": "Ustaw tagi", + "Settings": "Ustawienia", + "ShortDateFormat": "Krótki format daty", + "ShowAdvanced": "Pokaż zaawansowane", + "ShowBanners": "Pokaż bannery", + "ShowBannersHelpText": "Pokaż bannery zamiast tytułów", + "ShowDateAdded": "Pokaż datę dodania", + "ShowEpisodeInformation": "Pokaż informacje o odcinku", + "ShowEpisodeInformationHelpText": "Pokaż tytuł i numer odcinka", + "ShowEpisodes": "Pokaż odcinki", + "ShowMonitored": "Pokaż monitorowane", + "ShowMonitoredHelpText": "Pokaż status monitorowania pod plakatem", + "ShowNetwork": "Pokaż stację", + "ShowPath": "Pokaż ścieżkę", + "ShowPreviousAiring": "Pokaż poprzednią emisję", + "ShowQualityProfile": "Pokaż profil jakości", + "ShowQualityProfileHelpText": "Pokaż profil jakości pod plakatem", + "ShowRelativeDates": "Pokaż daty względne", + "ShowRelativeDatesHelpText": "Pokaż daty względne (Dzisiaj/Wczoraj/itp.) albo bezwzględne", + "ShowSearch": "Pokaż wyszukiwanie", + "ShowSearchHelpText": "Pokaż przycisk wyszukiwania po najechaniu", + "ShowSeasonCount": "Pokaż liczbę sezonów", + "ShowSeriesTitleHelpText": "Pokaż tytuł serialu pod plakatem", + "ShowSizeOnDisk": "Pokaż rozmiar na dysku", + "ShowTags": "Pokaż tagi", + "ShowTagsHelpText": "Pokaż tagi pod plakatem", + "ShowTitle": "Pokaż tytuł", + "ShownClickToHide": "Widoczne, kliknij aby ukryć", + "Shutdown": "Wyłącz", + "SingleEpisode": "Pojedynczy odcinek", + "SingleEpisodeInvalidFormat": "Pojedynczy odcinek: nieprawidłowy format", + "Size": "Rozmiar", + "SizeLimit": "Limit rozmiaru", + "SizeOnDisk": "Rozmiar na dysku", + "SkipFreeSpaceCheck": "Pomiń sprawdzanie wolnego miejsca", + "SkipFreeSpaceCheckHelpText": "Użyj, gdy {appName} nie może wykryć wolnego miejsca w folderze głównym", + "SkipRedownload": "Pomiń ponowne pobieranie", + "SkipRedownloadHelpText": "Zapobiega próbom pobrania alternatywnego wydania dla tego elementu przez {appName}", + "Small": "Mały", + "SmartReplace": "Inteligentna zamiana", + "SmartReplaceHint": "Myślnik lub spacja-myślnik zależnie od nazwy", + "Socks4": "Socks4", + "Socks5": "Socks5 (obsługa TOR)", + "SomeResultsAreHiddenByTheAppliedFilter": "Część wyników jest ukryta przez zastosowany filtr", + "SonarrTags": "Tagi {appName}", + "Sort": "Sortuj", + "Source": "Źródło", + "SourcePath": "Ścieżka źródłowa", + "SourceRelativePath": "Względna ścieżka źródłowa", + "SourceTitle": "Tytuł źródła", + "Space": "Spacja", + "Special": "Specjalny", + "SpecialEpisode": "Odcinek specjalny", + "Specials": "Odcinki specjalne", + "SpecialsFolderFormat": "Format folderu odcinków specjalnych", + "SslCertPassword": "Hasło certyfikatu SSL", + "SslCertPasswordHelpText": "Hasło do pliku pfx", + "SslCertPath": "Ścieżka certyfikatu SSL", + "SslCertPathHelpText": "Ścieżka do pliku pfx lub pem", + "SslKeyPath": "Ścieżka klucza SSL", + "SslKeyPathHelpText": "Ścieżka do pliku klucza używanego z plikiem pem", + "SslPort": "Port SSL", + "Standard": "Standard", + "StandardEpisodeFormat": "Standardowy format odcinka", + "StandardEpisodeTypeDescription": "Odcinki wydane we wzorcu SxxEyy", + "StandardEpisodeTypeFormat": "Numery sezonu i odcinka ({format})", + "StartImport": "Rozpocznij import", + "StartProcessing": "Rozpocznij przetwarzanie", + "Started": "Rozpoczęto", + "StartupDirectory": "Katalog startowy", + "Status": "Status", + "StopSelecting": "Zakończ zaznaczanie", + "Style": "Styl", + "SubtitleLanguages": "Języki napisów", + "Sunday": "Niedziela", + "SupportedAutoTaggingProperties": "{appName} obsługuje następujące właściwości dla reguł automatycznego tagowania", + "SupportedCustomConditions": "{appName} obsługuje niestandardowe warunki dla poniższych właściwości wydań.", + "SupportedDownloadClients": "{appName} obsługuje wielu popularnych klientów pobierania torrentów i Usenetu.", + "SupportedDownloadClientsMoreInfo": "Aby uzyskać więcej informacji o poszczególnych klientach pobierania, kliknij przyciski więcej informacji.", + "SupportedImportListsMoreInfo": "Aby uzyskać więcej informacji o poszczególnych listach importu, kliknij przyciski więcej informacji.", + "SupportedIndexers": "{appName} obsługuje każdy indekser korzystający ze standardu Newznab oraz inne indeksery wymienione poniżej.", + "SupportedIndexersMoreInfo": "Aby uzyskać więcej informacji o poszczególnych indekserach, kliknij przyciski więcej informacji.", + "SupportedListsMoreInfo": "Aby uzyskać więcej informacji o poszczególnych listach, kliknij przyciski więcej informacji.", + "SupportedListsSeries": "{appName} obsługuje wiele list do importowania seriali do bazy danych.", + "System": "System", + "SystemDefault": "Domyślne systemowe", + "SystemTimeHealthCheckMessage": "Czas systemowy różni się o więcej niż 1 dzień. Zaplanowane zadania mogą nie działać poprawnie, dopóki czas nie zostanie skorygowany", + "Table": "Tabela", + "TableColumns": "Kolumny", + "TableColumnsHelpText": "Wybierz, które kolumny są widoczne i w jakiej kolejności", + "TableOptions": "Opcje tabeli", + "TableOptionsButton": "Przycisk opcji tabeli", + "TablePageSize": "Rozmiar strony", + "TablePageSizeHelpText": "Liczba elementów wyświetlanych na stronie", + "TablePageSizeMaximum": "Rozmiar strony nie może przekraczać {maximumValue}", + "TablePageSizeMinimum": "Rozmiar strony musi wynosić co najmniej {minimumValue}", + "TagCannotBeDeletedWhileInUse": "Tag nie może zostać usunięty podczas użycia", + "TagDetails": "Szczegóły tagu - {label}", + "TagIsNotUsedAndCanBeDeleted": "Tag nie jest używany i można go usunąć", + "Tags": "Tagi", + "TagsLoadError": "Nie można wczytać tagów", + "TagsSettingsSummary": "Zobacz wszystkie tagi i sposób ich użycia. Nieużywane tagi można usunąć", + "TaskUserAgentTooltip": "User-Agent przekazany przez aplikację, która wywołała API", + "Tasks": "Zadania", + "Tba": "Będzie ogłoszone", + "Test": "Test", + "TestAll": "Testuj wszystko", + "TestAllClients": "Testuj wszystkich klientów", + "TestAllIndexers": "Testuj wszystkie indeksery", + "TestAllLists": "Testuj wszystkie listy", + "TestParsing": "Testuj analizę", + "TheLogLevelDefault": "Domyślny poziom logów to 'Debug' i można go zmienić w [Ustawieniach ogólnych](/settings/general)", + "TheTvdb": "TheTVDB", + "Theme": "Motyw", + "ThemeHelpText": "Zmień motyw interfejsu aplikacji. Motyw 'Auto' użyje motywu systemu operacyjnego, aby ustawić tryb jasny lub ciemny. Inspirowane Theme.Park", + "Threshold": "Próg", + "Thursday": "Czwartek", + "Time": "Czas", + "TimeFormat": "Format czasu", + "TimeLeft": "Pozostały czas", + "TimeZone": "Strefa czasowa", + "Title": "Tytuł", + "Titles": "Tytuły", + "Today": "Dzisiaj", + "TodayAt": "Dzisiaj o {time}", + "ToggleMonitoredSeriesUnmonitored": "Nie można przełączyć stanu monitorowania, gdy serial jest niemonitorowany", + "ToggleMonitoredToUnmonitored": "Monitorowany, kliknij aby przestać monitorować", + "ToggleUnmonitoredToMonitored": "Niemonitorowany, kliknij aby monitorować", + "Tomorrow": "Jutro", + "TomorrowAt": "Jutro o {time}", + "TorrentBlackhole": "Czarna dziura torrentów", + "TorrentBlackholeSaveMagnetFiles": "Zapisuj pliki magnet", + "TorrentBlackholeSaveMagnetFilesExtension": "Rozszerzenie plików magnet", + "TorrentBlackholeSaveMagnetFilesExtensionHelpText": "Rozszerzenie używane dla linków magnet, domyślnie '.magnet'", + "TorrentBlackholeSaveMagnetFilesHelpText": "Zapisz link magnet, jeśli plik .torrent nie jest dostępny (przydatne tylko, jeśli klient pobierania obsługuje magnety zapisane do pliku)", + "TorrentBlackholeSaveMagnetFilesReadOnly": "Tylko do odczytu", + "TorrentBlackholeSaveMagnetFilesReadOnlyHelpText": "Zamiast przenoszenia plików, {appName} wykona kopiowanie lub hardlink (zależnie od ustawień/konfiguracji systemu)", + "TorrentBlackholeTorrentFolder": "Folder torrentów", + "TorrentDelay": "Opóźnienie torrentów", + "TorrentDelayHelpText": "Opóźnienie w minutach przed pobraniem torrenta", + "TorrentDelayTime": "Opóźnienie torrentów: {torrentDelay}", + "Torrents": "Torrenty", + "TorrentsDisabled": "Torrenty wyłączone", + "Total": "Łącznie", + "TotalFileSize": "Łączny rozmiar plików", + "TotalRecords": "Łączna liczba rekordów: {totalRecords}", + "TotalSpace": "Łączna przestrzeń", + "Trace": "Ślad", + "True": "Prawda", + "Tuesday": "Wtorek", + "TvdbId": "ID TVDB", + "TvdbIdExcludeHelpText": "ID TVDB serialu do wykluczenia", + "Twitter": "Twitter", + "Type": "Typ", + "TypeOfList": "Lista {typeOfList}", + "Ui": "Interfejs", + "UiLanguage": "Język interfejsu", + "UiLanguageHelpText": "Język, którego {appName} będzie używać w interfejsie", + "UiSettings": "Ustawienia interfejsu", + "UiSettingsLoadError": "Nie można wczytać ustawień interfejsu", + "UiSettingsSummary": "Kalendarz, data i opcje dla osób z zaburzeniami rozpoznawania kolorów", + "Umask": "Umask", + "Umask750Description": "{octal} - właściciel: zapis, grupa: odczyt", + "Umask755Description": "{octal} - właściciel: zapis, pozostali: odczyt", + "Umask770Description": "{octal} - właściciel i grupa: zapis", + "Umask775Description": "{octal} - właściciel i grupa: zapis, pozostali: odczyt", + "Umask777Description": "{octal} - wszyscy: zapis", + "UnableToImportAutomatically": "Nie można zaimportować automatycznie", + "UnableToLoadAutoTagging": "Nie można wczytać automatycznego tagowania", + "UnableToLoadBackups": "Nie można wczytać kopii zapasowych", + "UnableToUpdateSonarrDirectly": "Nie można zaktualizować {appName} bezpośrednio,", + "Unavailable": "Niedostępne", + "Underscore": "Podkreślenie", + "Ungroup": "Rozgrupuj", + "Unknown": "Nieznane", + "UnknownDownloadState": "Nieznany stan pobierania: {state}", + "UnknownEventTooltip": "Nieznane zdarzenie", + "UnknownSeriesItems": "Nieznane elementy seriali", + "Unlimited": "Nieograniczone", + "UnmappedFilesOnly": "Tylko niezamapowane pliki", + "UnmappedFolders": "Niezamapowane foldery", + "UnmonitorDeletedEpisodes": "Przestań monitorować usunięte odcinki", + "UnmonitorDeletedEpisodesHelpText": "Odcinki usunięte z dysku są automatycznie przestawiane na niemonitorowane w {appName}", + "UnmonitorSelected": "Przestań monitorować zaznaczone", + "UnmonitorSpecialEpisodes": "Przestań monitorować odcinki specjalne", + "UnmonitorSpecialsEpisodesDescription": "Przestań monitorować wszystkie odcinki specjalne bez zmiany statusu monitorowania pozostałych odcinków", + "Unmonitored": "Niemonitorowane", + "UnmonitoredOnly": "Tylko niemonitorowane", + "UnsavedChanges": "Niezapisane zmiany", + "UnselectAll": "Odznacz wszystko", + "Upcoming": "Nadchodzące", + "UpcomingSeriesDescription": "Serial został zapowiedziany, ale nie ma jeszcze dokładnej daty emisji", + "UpdateAll": "Aktualizuj wszystko", + "UpdateAppDirectlyLoadError": "Nie można zaktualizować {appName} bezpośrednio,", + "UpdateAutomaticallyHelpText": "Automatycznie pobieraj i instaluj aktualizacje. Nadal będzie można instalować z System: Aktualizacje", + "UpdateAvailableHealthCheckMessage": "Dostępna jest nowa aktualizacja: {version}", + "UpdateFiltered": "Aktualizuj przefiltrowane", "UpdateMechanismHelpText": "Użyj wbudowanego aktualizatora {appName} lub skryptu", + "UpdateMonitoring": "Aktualizuj monitorowanie", + "UpdatePath": "Ścieżka aktualizacji", + "UpdateScriptPathHelpText": "Ścieżka do niestandardowego skryptu, który przyjmuje rozpakowany pakiet aktualizacji i obsługuje dalszą część procesu aktualizacji", + "UpdateSelected": "Aktualizuj zaznaczone", + "UpdateSeriesPath": "Aktualizuj ścieżkę serialu", + "UpdateStartupNotWritableHealthCheckMessage": "Nie można zainstalować aktualizacji, ponieważ folder startowy '{startupFolder}' nie ma praw zapisu dla użytkownika '{userName}'.", + "UpdateStartupTranslocationHealthCheckMessage": "Nie można zainstalować aktualizacji, ponieważ folder startowy '{startupFolder}' znajduje się w folderze App Translocation.", + "UpdateUiNotWritableHealthCheckMessage": "Nie można zainstalować aktualizacji, ponieważ folder UI '{uiFolder}' nie ma praw zapisu dla użytkownika '{userName}'.", + "UpdaterLogFiles": "Pliki logów aktualizatora", + "Updates": "Aktualizacje", + "UpgradeUntil": "Aktualizuj do", + "UpgradeUntilCustomFormatScore": "Aktualizuj do wyniku formatu niestandardowego", + "UpgradeUntilCustomFormatScoreEpisodeHelpText": "Gdy próg jakości zostanie osiągnięty lub przekroczony i osiągnięty zostanie ten wynik formatu niestandardowego, {appName} przestanie pobierać wydania odcinków", + "UpgradeUntilEpisodeHelpText": "Po osiągnięciu tej jakości {appName} przestanie pobierać odcinki po osiągnięciu lub przekroczeniu progu wyniku formatu niestandardowego", + "UpgradeUntilThisQualityIsMetOrExceeded": "Aktualizuj, aż ta jakość zostanie osiągnięta lub przekroczona", + "UpgradesAllowed": "Aktualizacje dozwolone", + "UpgradesAllowedHelpText": "Jeśli wyłączone, jakości nie będą aktualizowane", + "Uppercase": "Wielkie litery", + "Uptime": "Czas działania", + "UrlBase": "Baza URL", + "UrlBaseHelpText": "Dla obsługi reverse proxy, domyślnie puste", + "UseHardlinksInsteadOfCopy": "Używaj hardlinków zamiast kopiowania", + "UseProxy": "Używaj proxy", + "UseSeasonFolder": "Używaj folderu sezonu", + "UseSeasonFolderHelpText": "Sortuj odcinki do folderów sezonów", + "UseSsl": "Używaj SSL", + "Usenet": "Usenet", + "UsenetBlackhole": "Czarna dziura Usenet", + "UsenetBlackholeNzbFolder": "Folder NZB", + "UsenetDelay": "Opóźnienie Usenet", + "UsenetDelayHelpText": "Opóźnienie w minutach przed pobraniem wydania z Usenetu", + "UsenetDelayTime": "Opóźnienie Usenet: {usenetDelay}", + "UsenetDisabled": "Usenet wyłączony", + "UserInvokedSearch": "Wyszukiwanie wywołane przez użytkownika", + "UserRejectedExtensions": "Dodatkowe odrzucone rozszerzenia plików", + "UserRejectedExtensionsHelpText": "Lista rozszerzeń plików oddzielona przecinkami, które mają kończyć się błędem (Fail Downloads musi też być włączone dla indekswera)", + "UserRejectedExtensionsTextsExamples": "Przykłady: '.ext, .xyz' lub 'ext,xyz'", + "Username": "Nazwa użytkownika", + "UtcAirDate": "Data emisji UTC", + "Version": "Wersja", + "VersionNumber": "Wersja {version}", + "VideoCodec": "Kodek wideo", + "VideoDynamicRange": "Zakres dynamiczny wideo", + "View": "Widok", + "VisitTheWikiForMoreDetails": "Odwiedź wiki, aby poznać szczegóły: ", + "WaitingToImport": "Oczekiwanie na import", + "WaitingToProcess": "Oczekiwanie na przetworzenie", + "WantMoreControlAddACustomFormat": "Chcesz większej kontroli nad preferowanymi pobraniami? Dodaj [Format niestandardowy](/settings/customformats)", + "Wanted": "Poszukiwane", + "Warn": "Ostrzeż", + "Warning": "Ostrzeżenie", + "Wednesday": "Środa", + "Week": "Tydzień", + "WeekColumnHeader": "Nagłówek kolumny tygodnia", + "WeekColumnHeaderHelpText": "Wyświetlane nad każdą kolumną, gdy tydzień jest aktywnym widokiem", + "WhatsNew": "Co nowego?", + "WhyCantIFindMyShow": "Dlaczego nie mogę znaleźć mojego serialu?", + "Wiki": "Wiki", + "WithFiles": "Z plikami", + "WouldYouLikeToRestoreBackup": "Czy chcesz przywrócić kopię zapasową '{name}'?", + "XmlRpcPath": "Ścieżka XML RPC", + "Year": "Rok", + "Yes": "Tak", + "YesCancel": "Tak, anuluj", + "Yesterday": "Wczoraj", "YesterdayAt": "Wczoraj o {time}" } diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index e20cf51f3..8b91c3924 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -364,6 +364,7 @@ "DeleteEpisodeFromDisk": "Excluir episódio do disco", "DeleteEpisodesFiles": "Excluir {episodeFileCount} arquivos de episódios", "DeleteEpisodesFilesHelpText": "Excluir os arquivos de episódios e a pasta da série", + "DeleteFiles": "Excluir Arquivos", "DeleteImportList": "Excluir lista de importação", "DeleteImportListExclusion": "Excluir exclusão da lista de importação", "DeleteImportListExclusionMessageText": "Tem certeza de que deseja remover esta exclusão da lista de importação?", @@ -391,6 +392,8 @@ "DeleteSelectedIndexers": "Excluir indexador(es)", "DeleteSelectedIndexersMessageText": "Tem certeza de que deseja excluir o(s) {count} indexador(es) selecionado(s)?", "DeleteSelectedSeries": "Excluir séries selecionadas", + "DeleteSelectedSeriesFiles": "Excluir Arquivos Selecionados da Série", + "DeleteSeriesFilesConfirmation": "Você tem certeza de que quer excluir todos os arquivos de episódios rastreados para {count} das séries selecionadas?", "DeleteSeriesFolder": "Excluir pasta da série", "DeleteSeriesFolderConfirmation": "A pasta da série `{path}` e todo o seu conteúdo serão excluídos.", "DeleteSeriesFolderCountConfirmation": "Tem certeza de que deseja excluir as {count} séries selecionadas?", @@ -1958,6 +1961,7 @@ "SeriesFolderImportedTooltip": "Episódio importado da pasta da série", "SeriesFootNote": "Opcionalmente, controle o truncamento para um número máximo de bytes, incluindo reticências (`...`). Truncar do final (por exemplo, `{Series Title:30}`) ou do início (por exemplo, `{Series Title:-30}`) é suportado.", "SeriesID": "ID da Série", + "SeriesInImportListExclusions": "Séries estão nas Exclusões da Lista de Importação", "SeriesIndexFooterContinuing": "Continuando (todos os episódios baixados)", "SeriesIndexFooterDownloading": "Baixando (um ou mais episódios)", "SeriesIndexFooterEnded": "Terminado (todos os episódios baixados)", diff --git a/src/NzbDrone.Core/Localization/Core/ru.json b/src/NzbDrone.Core/Localization/Core/ru.json index 2157b6321..f3018eea4 100644 --- a/src/NzbDrone.Core/Localization/Core/ru.json +++ b/src/NzbDrone.Core/Localization/Core/ru.json @@ -13,6 +13,7 @@ "AddConditionError": "Невозможно добавить новое условие, попробуйте еще раз.", "AddConditionImplementation": "Добавить условие - {implementationName}", "AddConnection": "Добавить подключение", + "AddConnectionError": "Невозможно добавить новое соединение, пожалуйста, попробуйте еще раз.", "AddConnectionImplementation": "Добавить подключение - {implementationName}", "AddCustomFilter": "Добавить специальный фильтр", "AddCustomFormat": "Добавить свой формат", diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json index f2222dc32..509d606e8 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_CN.json +++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json @@ -13,6 +13,7 @@ "AddConditionError": "无法添加新条件,请再试一次。", "AddConditionImplementation": "添加条件 - {implementationName}", "AddConnection": "添加连接", + "AddConnectionError": "无法添加新的连接,请重试。", "AddConnectionImplementation": "添加连接- {implementationName}", "AddCustomFilter": "添加自定义过滤器", "AddCustomFormat": "添加自定义命名格式", @@ -61,6 +62,10 @@ "AgeWhenGrabbed": "年龄(抓取后)", "Agenda": "日程表", "AirDate": "播出日期", + "AirDateGracePeriod": "播出宽限期", + "AirDateGracePeriodHelpText": "负值为允许播出前抓取期限,正值为防止播出后抓取期限。", + "AirDateRestriction": "拒绝未播出版本", + "AirDateRestrictionHelpText": "避免 {appName} 抓取含有未播出集的版本。", "Airs": "播出", "AirsDateAtTimeOn": "{date} {time} 在 {networkLabel} 播出", "AirsTbaOn": "时间待公布,在 {networkLabel} 播出", @@ -131,6 +136,7 @@ "AutoTaggingSpecificationMaximumYear": "最晚年份", "AutoTaggingSpecificationMinimumYear": "最早年份", "AutoTaggingSpecificationNetwork": "网络", + "AutoTaggingSpecificationOriginalCountry": "国家", "AutoTaggingSpecificationOriginalLanguage": "语言", "AutoTaggingSpecificationQualityProfile": "质量配置", "AutoTaggingSpecificationRootFolder": "根目录文件夹", @@ -166,6 +172,8 @@ "BlocklistRelease": "发布资源黑名单", "BlocklistReleaseHelpText": "禁止 {appName}通过 RSS 或自动搜索重新下载此版本", "BlocklistReleases": "发布资源黑名单", + "Blocklisted": "已拉黑", + "BlocklistedAt": "于 {date} 拉黑", "Branch": "分支", "BranchUpdate": "更新{appName}的分支", "BranchUpdateMechanism": "外部更新机制使用的分支", @@ -253,6 +261,7 @@ "ConnectionLostToBackend": "{appName}失去了与后端的连接,需要重新加载以恢复功能。", "ConnectionSettingsUrlBaseHelpText": "向 {connectionName} URL 添加前缀,例如 {url}", "Connections": "连接", + "ConnectionsLoadError": "无法加载连接", "Continuing": "仍在继续", "ContinuingOnly": "仅包含仍在继续的", "ContinuingSeriesDescription": "预计会有更多集/下一季", @@ -355,6 +364,7 @@ "DeleteEpisodeFromDisk": "从磁盘中删除剧集", "DeleteEpisodesFiles": "删除{episodeFileCount}个剧集文件", "DeleteEpisodesFilesHelpText": "删除集文件和剧集文件夹", + "DeleteFiles": "删除文件", "DeleteImportList": "删除导入的列表", "DeleteImportListExclusion": "删除导入排除列表", "DeleteImportListExclusionMessageText": "您确认要删除此导入排除列表吗?", @@ -382,6 +392,8 @@ "DeleteSelectedIndexers": "删除索引器", "DeleteSelectedIndexersMessageText": "您确定要删除选定的 {count} 个索引器吗?", "DeleteSelectedSeries": "删除选中的剧集", + "DeleteSelectedSeriesFiles": "删除所选剧集文件", + "DeleteSeriesFilesConfirmation": "你是否确定删除 {count} 部所选剧集中所有追踪的单集?", "DeleteSeriesFolder": "删除剧集文件夹", "DeleteSeriesFolderConfirmation": "剧集文件夹 `{path}` 及所含内容将会被删除。", "DeleteSeriesFolderCountConfirmation": "你确定要删除选中的 {count} 个剧集吗?", @@ -480,6 +492,8 @@ "DownloadClientPneumaticSettingsStrmFolder": "Strm 文件夹", "DownloadClientPneumaticSettingsStrmFolderHelpText": "该文件夹中的 .strm 文件将由 drone 导入", "DownloadClientPriorityHelpText": "下载客户端优先级,从1(最高)到50(最低),默认为1。具有相同优先级的客户端将轮换使用。", + "DownloadClientQbittorrentSettingsAddSeriesTags": "添加剧集标签", + "DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "向下载客户端中新添加的种子添加标签(qBittorrent 4.1.0 以上)", "DownloadClientQbittorrentSettingsContentLayout": "内容布局", "DownloadClientQbittorrentSettingsContentLayoutHelpText": "是否使用 qBittorrent 的已配置内容布局、种子的原始布局或始终创建子文件夹(qBittorrent 4.3.2+)", "DownloadClientQbittorrentSettingsFirstAndLastFirst": "先下载首尾文件块", @@ -546,7 +560,16 @@ "DownloadClientStatusSingleClientHealthCheckMessage": "所有下载客户端都不可用:{downloadClientNames}", "DownloadClientTransmissionSettingsDirectoryHelpText": "下载位置可选择,留空使用 Transmission 默认位置", "DownloadClientTransmissionSettingsUrlBaseHelpText": "向 {clientName} RPC URL 添加前缀,例如 {url},默认为 '{defaultUrl}'", + "DownloadClientTriblerProviderMessage": "Tribler 的支持仍在初期试验中。测试过 {clientName} 的 {clientVersionRange} 版本。", + "DownloadClientTriblerSettingsAnonymityLevel": "匿名等级", + "DownloadClientTriblerSettingsAnonymityLevelHelpText": "下载时的代理数量。设为 0 以禁用。代理会降低上传下载速度。参看 {url}", + "DownloadClientTriblerSettingsApiKeyHelpText": "triblerd.conf 中的 [api].key", + "DownloadClientTriblerSettingsDirectoryHelpText": "可选的下载位置,留空则使用 Tribler 的默认位置", + "DownloadClientTriblerSettingsSafeSeeding": "安全做种", + "DownloadClientTriblerSettingsSafeSeedingHelpText": "开启时仅通过代理做种。", + "DownloadClientUTorrentProviderMessage": "uTorrent 曾经含有挖矿行为、恶意软件和广告,我们强烈建议你选择其他客户端。", "DownloadClientUTorrentTorrentStateError": "uTorrent 报告了错误", + "DownloadClientUnavailable": "下载客户端不可用", "DownloadClientValidationApiKeyIncorrect": "API Key 不正确", "DownloadClientValidationApiKeyRequired": "需要 API Key", "DownloadClientValidationAuthenticationFailure": "认证失败", @@ -607,6 +630,8 @@ "EditSelectedSeries": "编辑所选剧集", "EditSeries": "编辑剧集", "EditSeriesModalHeader": "编辑 - {title}", + "Empty": "空", + "EmptyRootFolderTooltip": "根目录不包含任何文件或文件夹。{appName} 将不会扫描改动或建立空剧集文件夹。", "Enable": "启用", "EnableAutomaticAdd": "启用自动添加", "EnableAutomaticAddSeriesHelpText": "当通过 UI 或 {appName} 执行同步时,将剧集添加到 {appName}", @@ -650,17 +675,25 @@ "EpisodeInfo": "剧集信息", "EpisodeIsDownloading": "集正在下载", "EpisodeIsNotMonitored": "集未被监控", + "EpisodeMaybePlural": "单集", "EpisodeMissingAbsoluteNumber": "集没有准确的集数", "EpisodeMissingFromDisk": "磁盘中缺失集", + "EpisodeMonitoring": "单集追踪", "EpisodeNaming": "集命名", "EpisodeNumbers": "剧集序号", "EpisodeProgress": "剧集进度", + "EpisodeRequested": "单集已请求", "EpisodeSearchResultsLoadError": "无法加载此集的搜索结果。稍后再试", - "EpisodeTitle": "剧集标题", - "EpisodeTitleRequired": "需要集标题", + "EpisodeTitle": "单集标题", + "EpisodeTitleFootNote": "可选择控制最多字节数(包含省略号`…`)。支持去尾(如 `{Episode Title:30}`)或去头(如 `{Episode Title:-30}`)。单集标题将按需自动截取至文件系统的上限。", + "EpisodeTitleMaybePlural": "单集标题", + "EpisodeTitleRequired": "需要单集标题", "EpisodeTitleRequiredHelpText": "如果单集标题为命名格式且单集标题为「待公布」,则 在48 小时内禁用导入", + "EpisodeTitles": "单集标题", "Episodes": "剧集", + "EpisodesInSeason": "季包含 {episodeCount} 集", "EpisodesLoadError": "无法加载剧集", + "EpisodesMonitoredStatus": "单集已追踪", "Error": "错误", "ErrorLoadingContent": "加载此内容时出现错误", "ErrorLoadingContents": "加载内容出错", @@ -671,6 +704,10 @@ "Events": "事件", "Example": "示例", "Exception": "例外", + "ExcludeUnknownSeriesItems": "排除未知剧集条目", + "ExcludedReleaseProfile": "已排除的版本配置", + "ExcludedReleaseProfiles": "已排除的版本配置", + "ExcludedTags": "已排除的标签", "Existing": "已存在", "ExistingSeries": "已存在剧集", "ExistingTag": "已有标签", @@ -682,6 +719,7 @@ "ExtraFileExtensionsHelpText": "要导入的额外文件列表,以逗号分隔(.nfo 文件会被导入为 .nfo-orig)", "ExtraFileExtensionsHelpTextsExamples": "示例:’.sub,.nfo‘ 或 ’sub,nfo‘", "Failed": "失败", + "FailedAt": "失败于:{date}", "FailedToFetchSettings": "设置同步失败", "FailedToFetchUpdates": "获取更新失败", "FailedToLoadCustomFiltersFromApi": "未能从API加载自定义过滤器", @@ -704,6 +742,7 @@ "FileManagement": "文件管理", "FileNameTokens": "文件名标记", "FileNames": "文件名", + "FileSize": "文件大小", "Filename": "文件名", "Files": "文件", "Filter": "过滤", @@ -729,10 +768,12 @@ "FilterNotInNext": "不在下一个", "FilterSeriesPlaceholder": "过滤剧集", "FilterStartsWith": "以...开头", + "Filters": "过滤器", "FinaleTooltip": "剧集或季完结", "FirstDayOfWeek": "每周的第一天", "Fixed": "已修复", "Folder": "文件夹", + "FolderNameTokens": "文件夹名称标记", "Folders": "文件夹", "Forecast": "预报表", "FormatAgeDay": "天", @@ -752,6 +793,7 @@ "Formats": "格式", "Forums": "论坛", "FreeSpace": "剩余空间", + "Friday": "星期五", "From": "来自", "FullColorEvents": "全彩事件", "FullColorEventsHelpText": "改变样式,用状态颜色为整个事件着色,而不仅仅是左边缘。不适用于议程", @@ -764,14 +806,18 @@ "Global": "全局", "Grab": "抓取", "GrabId": "抓取ID", - "GrabRelease": "抓取版本", + "GrabRelease": "抓取资源", "GrabReleaseUnknownSeriesOrEpisodeMessageText": "{appName}无法确定这个发布版本是哪部剧集的哪一集,{appName}可能无法自动导入此版本,你想要获取“{title}”吗?", "GrabSelected": "抓取已选", "Grabbed": "已抓取", + "GrabbedAt": "抓取于:{date}", "Group": "组", "HardlinkCopyFiles": "硬链接/复制文件", "HasMissingSeason": "有缺失的季", + "HasUnmonitoredSeason": "含有未追踪的季", "Health": "健康度", + "HealthIssue": "1 个健康度问题", + "HealthIssues": "{count} 个健康度问题", "HealthMessagesInfoBox": "您可以通过单击行尾的 wiki 链接(图书图标)或检查 [日志]({link}) 来获取导致此类运行状态消息的更多信息。如果您在理解此类信息方面有困难,您可以通过以下链接联系我们以获得支持。", "Here": "这里", "HiddenClickToShow": "隐藏,点击显示", @@ -808,6 +854,10 @@ "IgnoreDownloadsHint": "阻止 {appName} 进一步处理这些下载", "Ignored": "已忽略", "IgnoredAddresses": "已忽略地址", + "ImageBanner": "横幅", + "ImageFanart": "同人图", + "ImagePoster": "海报", + "ImageSeason": "季", "Images": "图像", "ImdbId": "IMDb ID", "Implementation": "执行", @@ -827,11 +877,93 @@ "ImportListSearchForMissingEpisodes": "搜索缺失集", "ImportListSearchForMissingEpisodesHelpText": "将系列添加到{appName}后,自动搜索缺失的剧集", "ImportListSettings": "导入列表设置", + "ImportListStatusAllPossiblePartialFetchHealthCheckMessage": "可能因为部分抓取,所有列表需要手动操作", "ImportListStatusAllUnavailableHealthCheckMessage": "所有的列表因错误不可用", "ImportListStatusUnavailableHealthCheckMessage": "列表因错误不可用:{importListNames}", "ImportLists": "导入列表", + "ImportListsAniListSettingsAuthenticateWithAniList": "验证 AniList 账号", + "ImportListsAniListSettingsImportCancelled": "导入已取消剧集", + "ImportListsAniListSettingsImportCancelledHelpText": "媒体:剧集已取消", + "ImportListsAniListSettingsImportCompleted": "导入已看过", + "ImportListsAniListSettingsImportCompletedHelpText": "列表:已看过", + "ImportListsAniListSettingsImportDropped": "导入已弃剧", + "ImportListsAniListSettingsImportDroppedHelpText": "列表:已弃剧", + "ImportListsAniListSettingsImportFinished": "导入完结剧", + "ImportListsAniListSettingsImportFinishedHelpText": "媒体:所有集数完结", + "ImportListsAniListSettingsImportHiatus": "导入断更剧", + "ImportListsAniListSettingsImportHiatusHelpText": "媒体:剧集断更中", + "ImportListsAniListSettingsImportNotYetReleased": "导入未播出剧集", + "ImportListsAniListSettingsImportNotYetReleasedHelpText": "媒体:尚未播出", + "ImportListsAniListSettingsImportPaused": "导入暂停观看剧集", + "ImportListsAniListSettingsImportPausedHelpText": "列表:暂停观看", + "ImportListsAniListSettingsImportPlanning": "导入:计划观看", + "ImportListsAniListSettingsImportPlanningHelpText": "列表:计划观看", + "ImportListsAniListSettingsImportReleasing": "导入播出中剧集", + "ImportListsAniListSettingsImportReleasingHelpText": "媒体:新单集播出中", + "ImportListsAniListSettingsImportRepeating": "导入重复观看", + "ImportListsAniListSettingsImportRepeatingHelpText": "列表:重复观看中", + "ImportListsAniListSettingsImportWatching": "导入正在观看", + "ImportListsAniListSettingsImportWatchingHelpText": "列表:观看中", + "ImportListsAniListSettingsUsernameHelpText": "导入列表的用户名", + "ImportListsCustomListSettingsName": "自定义列表", + "ImportListsCustomListSettingsUrl": "列表链接", + "ImportListsCustomListSettingsUrlHelpText": "剧单的链接", + "ImportListsCustomListValidationAuthenticationFailure": "验证失败", + "ImportListsCustomListValidationConnectionError": "无法请求该链接。状态码:{exceptionStatusCode}", + "ImportListsImdbSettingsListId": "列表 ID", + "ImportListsImdbSettingsListIdHelpText": "IMDB 列表 ID", "ImportListsLoadError": "无法加载导入列表", + "ImportListsMyAnimeListSettingsAuthenticateWithMyAnimeList": "从 MyAnimeList 验证", + "ImportListsMyAnimeListSettingsListStatus": "列表状态", + "ImportListsMyAnimeListSettingsListStatusHelpText": "你想要导入的列表类型,设为“全部”将导入全部列表", + "ImportListsMyAnimeListSettingsScore": "最低分数", + "ImportListsMyAnimeListSettingsScoreHelpText": "剧集的最低导入分数", + "ImportListsPlexSettingsAuthenticateWithPlex": "验证 Plex.tv 账户", + "ImportListsPlexSettingsWatchlistName": "Plex 观看清单", + "ImportListsPlexSettingsWatchlistRSSName": "Plex 观看清单 RSS", + "ImportListsSettingsAccessToken": "访问 Token", + "ImportListsSettingsAuthUser": "认证用户", + "ImportListsSettingsExpires": "过期", + "ImportListsSettingsRefreshToken": "刷新 Token", + "ImportListsSettingsRssUrl": "RSS 链接", "ImportListsSettingsSummary": "从另一个 {appName} 实例或 Trakt 列表导入并管理列表排除项", + "ImportListsSimklSettingsAuthenticatewithSimkl": "验证 Simkl 账户", + "ImportListsSimklSettingsListType": "列表类型", + "ImportListsSimklSettingsListTypeHelpText": "你想导入的列表类型", + "ImportListsSimklSettingsName": "Simkl 用户观看清单", + "ImportListsSimklSettingsShowType": "剧集类型", + "ImportListsSimklSettingsShowTypeHelpText": "你想导入的剧集类型", + "ImportListsSimklSettingsUserListTypeCompleted": "已观看", + "ImportListsSimklSettingsUserListTypeDropped": "已弃剧", + "ImportListsSimklSettingsUserListTypeHold": "暂停观看", + "ImportListsSimklSettingsUserListTypePlanToWatch": "计划观看", + "ImportListsSimklSettingsUserListTypeWatching": "观看中", + "ImportListsSonarrSettingsApiKeyHelpText": "从 {appName} 导入所需的 API 密钥", + "ImportListsSonarrSettingsFullUrl": "完整链接", + "ImportListsSonarrSettingsFullUrlHelpText": "网络地址:包含 {appName} 的端口", + "ImportListsSonarrSettingsQualityProfilesHelpText": "该导入源所的压片配置", + "ImportListsSonarrSettingsRootFoldersHelpText": "该导入源的根目录", + "ImportListsSonarrSettingsSyncSeasonMonitoring": "同步季追踪状态", + "ImportListsSonarrSettingsSyncSeasonMonitoringHelpText": "从 {appName} 中同步季追踪状态,若开启则“追踪“将被忽略", + "ImportListsSonarrSettingsTagsHelpText": "该导入源的标签", + "ImportListsSonarrValidationInvalidUrl": "{appName} 的网络路径不可用,你是否未提供基本 URL?", + "ImportListsTraktSettingsAdditionalParameters": "额外参数", + "ImportListsTraktSettingsAdditionalParametersHelpText": "额外的 Trakt API 参数", + "ImportListsTraktSettingsAuthenticateWithTrakt": "验证 Trakt 账户", + "ImportListsTraktSettingsGenres": "类型", + "ImportListsTraktSettingsGenresSeriesHelpText": "通过 Trakt 的 Genre Slug (逗号隔开)过滤剧集,仅对热门清单", + "ImportListsTraktSettingsLimit": "上限", + "ImportListsTraktSettingsLimitSeriesHelpText": "抓取的剧集数量上限", + "ImportListsTraktSettingsListName": "清单名称", + "ImportListsTraktSettingsListNameHelpText": "待导入的清单名称,必须为公开或你可访问的清单", + "ImportListsTraktSettingsListType": "清单类型", + "ImportListsTraktSettingsListTypeHelpText": "要导入的清单类型", + "ImportListsTraktSettingsPopularListTypeAnticipatedShows": "已期待剧集", + "ImportListsTraktSettingsPopularListTypePopularShows": "热门剧集", + "ImportListsTraktSettingsPopularListTypeRecommendedAllTimeShows": "史上最推荐剧集", + "ImportListsTraktSettingsPopularListTypeRecommendedMonthShows": "月度推荐剧集", + "ImportListsTraktSettingsPopularListTypeRecommendedWeekShows": "每周推荐剧集", + "ImportListsTraktSettingsPopularListTypeRecommendedYearShows": "年度推荐剧集", "ImportMechanismEnableCompletedDownloadHandlingIfPossibleHealthCheckMessage": "如果可能,在下载完成后自动处理", "ImportMechanismEnableCompletedDownloadHandlingIfPossibleMultiComputerHealthCheckMessage": "如果可能,启用完整的下载处理(不支持多台计算机)", "ImportMechanismHandlingDisabledHealthCheckMessage": "启用下载完成处理", @@ -1384,6 +1516,7 @@ "OrganizeSelectedSeriesModalConfirmation": "你确定要整理 {count} 个选定剧集中的所有文件?", "OrganizeSelectedSeriesModalHeader": "整理选定的剧集", "Original": "原始", + "OriginalCountry": "原国家", "OriginalLanguage": "原语言", "Other": "其他", "OutputPath": "输出路径", diff --git a/src/NzbDrone.Core/Localization/Core/zh_Hans.json b/src/NzbDrone.Core/Localization/Core/zh_Hans.json index dabfa417a..bcd21d3e2 100644 --- a/src/NzbDrone.Core/Localization/Core/zh_Hans.json +++ b/src/NzbDrone.Core/Localization/Core/zh_Hans.json @@ -2,5 +2,6 @@ "About": "关于", "Actions": "操作", "Activity": "活动", - "AddANewPath": "Add a new path" + "AddANewPath": "Add a new path", + "AddCondition": "001" } From caeb31df28ad0b3c88d7110eb6cf136c254afaa8 Mon Sep 17 00:00:00 2001 From: Sonarr Date: Mon, 6 Apr 2026 00:30:16 +0000 Subject: [PATCH 060/110] Automated API Docs update ignore-downstream --- src/Sonarr.Api.V5/openapi.json | 205 ++++++++++++++++++++++++++++++--- 1 file changed, 190 insertions(+), 15 deletions(-) diff --git a/src/Sonarr.Api.V5/openapi.json b/src/Sonarr.Api.V5/openapi.json index ee9cf2509..e1dbf3cc2 100644 --- a/src/Sonarr.Api.V5/openapi.json +++ b/src/Sonarr.Api.V5/openapi.json @@ -623,12 +623,19 @@ ], "parameters": [ { - "name": "forceSave", + "name": "skipTesting", "in": "query", "schema": { "type": "boolean", "default": false } + }, + { + "name": "skipValidation", + "in": "query", + "schema": { + "$ref": "#/components/schemas/SkipValidation" + } } ], "requestBody": { @@ -670,12 +677,19 @@ } }, { - "name": "forceSave", + "name": "skipTesting", "in": "query", "schema": { "type": "boolean", "default": false } + }, + { + "name": "skipValidation", + "in": "query", + "schema": { + "$ref": "#/components/schemas/SkipValidation" + } } ], "requestBody": { @@ -779,11 +793,10 @@ ], "parameters": [ { - "name": "forceTest", + "name": "skipValidation", "in": "query", "schema": { - "type": "boolean", - "default": false + "$ref": "#/components/schemas/SkipValidation" } } ], @@ -2058,12 +2071,19 @@ ], "parameters": [ { - "name": "forceSave", + "name": "skipTesting", "in": "query", "schema": { "type": "boolean", "default": false } + }, + { + "name": "skipValidation", + "in": "query", + "schema": { + "$ref": "#/components/schemas/SkipValidation" + } } ], "requestBody": { @@ -2105,12 +2125,19 @@ } }, { - "name": "forceSave", + "name": "skipTesting", "in": "query", "schema": { "type": "boolean", "default": false } + }, + { + "name": "skipValidation", + "in": "query", + "schema": { + "$ref": "#/components/schemas/SkipValidation" + } } ], "requestBody": { @@ -2261,11 +2288,10 @@ ], "parameters": [ { - "name": "forceTest", + "name": "skipValidation", "in": "query", "schema": { - "type": "boolean", - "default": false + "$ref": "#/components/schemas/SkipValidation" } } ], @@ -2366,6 +2392,101 @@ } } }, + "/api/v5/settings/indexer": { + "get": { + "tags": [ + "IndexerSettings" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerSettingsResource" + } + } + } + } + } + } + }, + "/api/v5/settings/indexer/{id}": { + "put": { + "tags": [ + "IndexerSettings" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerSettingsResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/IndexerSettingsResource" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerSettingsResource" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/IndexerSettingsResource" + } + } + } + } + } + }, + "get": { + "tags": [ + "IndexerSettings" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexerSettingsResource" + } + } + } + } + } + } + }, "/api/v5/language": { "get": { "tags": [ @@ -2842,12 +2963,19 @@ ], "parameters": [ { - "name": "forceSave", + "name": "skipTesting", "in": "query", "schema": { "type": "boolean", "default": false } + }, + { + "name": "skipValidation", + "in": "query", + "schema": { + "$ref": "#/components/schemas/SkipValidation" + } } ], "requestBody": { @@ -2889,12 +3017,19 @@ } }, { - "name": "forceSave", + "name": "skipTesting", "in": "query", "schema": { "type": "boolean", "default": false } + }, + { + "name": "skipValidation", + "in": "query", + "schema": { + "$ref": "#/components/schemas/SkipValidation" + } } ], "requestBody": { @@ -2998,11 +3133,10 @@ ], "parameters": [ { - "name": "forceTest", + "name": "skipValidation", "in": "query", "schema": { - "type": "boolean", - "default": false + "$ref": "#/components/schemas/SkipValidation" } } ], @@ -7691,6 +7825,32 @@ }, "additionalProperties": false }, + "IndexerSettingsResource": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "minimumAge": { + "type": "integer", + "format": "int32" + }, + "retention": { + "type": "integer", + "format": "int32" + }, + "maximumSize": { + "type": "integer", + "format": "int32" + }, + "rssSyncInterval": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, "Language": { "type": "object", "properties": { @@ -7865,6 +8025,10 @@ "type": "string", "nullable": true }, + "relativePath": { + "type": "string", + "nullable": true + }, "seriesId": { "type": "integer", "format": "int32" @@ -10283,6 +10447,14 @@ ], "type": "string" }, + "SkipValidation": { + "enum": [ + "none", + "warnings", + "all" + ], + "type": "string" + }, "SortDirection": { "enum": [ "default", @@ -10865,6 +11037,9 @@ { "name": "IndexerFlag" }, + { + "name": "IndexerSettings" + }, { "name": "Language" }, From 0a76d7789ea40025c63184775e182d056e762210 Mon Sep 17 00:00:00 2001 From: Nath Date: Mon, 16 Mar 2026 17:37:05 +0000 Subject: [PATCH 061/110] Fixed: Season action menu not working after switching from desktop to mobile --- frontend/src/Components/Menu/Menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/Components/Menu/Menu.tsx b/frontend/src/Components/Menu/Menu.tsx index 3fa0075ee..34d79a099 100644 --- a/frontend/src/Components/Menu/Menu.tsx +++ b/frontend/src/Components/Menu/Menu.tsx @@ -157,7 +157,7 @@ function Menu({ {React.cloneElement(childrenArray[1] as ReactElement, { forwardedRef: refs.setFloating, style: { - maxHeight, + maxHeight: enforceMaxHeight ? maxHeight : undefined, ...floatingStyles, }, isOpen: isMenuOpen, From 01fcc160dd470414fedc910a23082164fef2947a Mon Sep 17 00:00:00 2001 From: Nath Date: Tue, 17 Mar 2026 00:10:17 +0000 Subject: [PATCH 062/110] Fanart not rendering on series details page --- frontend/src/Series/Details/SeriesDetails.css | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/Series/Details/SeriesDetails.css b/frontend/src/Series/Details/SeriesDetails.css index 5b747a376..a80ed7fb0 100644 --- a/frontend/src/Series/Details/SeriesDetails.css +++ b/frontend/src/Series/Details/SeriesDetails.css @@ -4,6 +4,7 @@ .header { position: relative; + z-index: 0; width: 100%; } From be4a564456818fc18138bded9d981c93bb2cdd14 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 25 Mar 2026 09:27:25 +0200 Subject: [PATCH 063/110] Prevent overflow exception for big numbers in SizeSuffix and Fluent.Round --- .../ExtensionTests/NumberExtensionFixture.cs | 2 ++ .../Extensions/NumberExtensions.cs | 15 ++++++++++----- src/NzbDrone.Core.Test/FluentTest.cs | 4 +++- src/NzbDrone.Core/Fluent.cs | 4 +++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/NzbDrone.Common.Test/ExtensionTests/NumberExtensionFixture.cs b/src/NzbDrone.Common.Test/ExtensionTests/NumberExtensionFixture.cs index c51ab7ad4..167899efc 100644 --- a/src/NzbDrone.Common.Test/ExtensionTests/NumberExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/ExtensionTests/NumberExtensionFixture.cs @@ -17,6 +17,8 @@ public class NumberExtensionFixture [TestCase(-1000000, "-976.6 KB")] [TestCase(-377487360, "-360.0 MB")] [TestCase(-1255864686, "-1.2 GB")] + [TestCase(long.MinValue, "-8.0 EB")] + [TestCase(long.MaxValue, "8.0 EB")] public void should_calculate_string_correctly(long bytes, string expected) { bytes.SizeSuffix().Should().Be(expected); diff --git a/src/NzbDrone.Common/Extensions/NumberExtensions.cs b/src/NzbDrone.Common/Extensions/NumberExtensions.cs index 15037b20b..efa8b7fc1 100644 --- a/src/NzbDrone.Common/Extensions/NumberExtensions.cs +++ b/src/NzbDrone.Common/Extensions/NumberExtensions.cs @@ -11,16 +11,21 @@ public static string SizeSuffix(this long bytes) { const int bytesInKb = 1024; - if (bytes < 0) - { - return "-" + SizeSuffix(-bytes); - } - if (bytes == 0) { return "0 B"; } + if (bytes == long.MinValue) + { + return "-" + SizeSuffix(long.MaxValue); + } + + if (bytes < 0) + { + return "-" + SizeSuffix(Math.Abs(bytes)); + } + var mag = (int)Math.Log(bytes, bytesInKb); var adjustedSize = bytes / (decimal)Math.Pow(bytesInKb, mag); diff --git a/src/NzbDrone.Core.Test/FluentTest.cs b/src/NzbDrone.Core.Test/FluentTest.cs index 30a3b1ab9..11b628273 100644 --- a/src/NzbDrone.Core.Test/FluentTest.cs +++ b/src/NzbDrone.Core.Test/FluentTest.cs @@ -175,7 +175,9 @@ public void MinOrDefault_should_return_zero_when_collection_is_null() [TestCase(199, 100, 100)] [TestCase(1000, 100, 1000)] [TestCase(0, 100, 0)] - public void round_to_level(long number, int level, int result) + [TestCase(long.MinValue, 1000, -9223372036854775000L)] + [TestCase(long.MaxValue, 1000, 9223372036854775000L)] + public void round_to_level(long number, int level, long result) { number.Round(level).Should().Be(result); } diff --git a/src/NzbDrone.Core/Fluent.cs b/src/NzbDrone.Core/Fluent.cs index cc0dcfc4a..576c374e8 100644 --- a/src/NzbDrone.Core/Fluent.cs +++ b/src/NzbDrone.Core/Fluent.cs @@ -22,7 +22,9 @@ public static string WithDefault(this string actual, object defaultValue) public static long Round(this long number, long level) { - return Convert.ToInt64(Math.Floor((decimal)number / level) * level); + return number < 0 + ? Convert.ToInt64(Math.Ceiling((decimal)number / level) * level) + : Convert.ToInt64(Math.Floor((decimal)number / level) * level); } public static string ToBestDateString(this DateTime dateTime) From 9a0e23a93faaa21ba2cc264b30603aff4d10216e Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sat, 21 Mar 2026 12:33:27 +0200 Subject: [PATCH 064/110] Load initial paths for file browser --- .../src/Components/FileBrowser/FileBrowserModalContent.tsx | 6 +++++- frontend/src/Path/usePaths.ts | 1 - src/Sonarr.Api.V5/FileSystem/FileSystemController.cs | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx index aaaf9b4e0..1f02f0145 100644 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx @@ -133,7 +133,11 @@ function FileBrowserModalContent({ className={styles.scroller} scrollDirection="both" > - {error ?
{translate('ErrorLoadingContents')}
: null} + {error ? ( + + {translate('ErrorLoadingContents')} + + ) : null} {isFetched && !error ? ( diff --git a/frontend/src/Path/usePaths.ts b/frontend/src/Path/usePaths.ts index 72bef1cb6..b7a76b486 100644 --- a/frontend/src/Path/usePaths.ts +++ b/frontend/src/Path/usePaths.ts @@ -47,7 +47,6 @@ const usePaths = ({ path: '/filesystem', queryParams: { path, allowFoldersWithoutTrailingSlashes, includeFiles }, queryOptions: { - enabled: path.trim().length > 0, placeholderData: keepPreviousData, }, }); diff --git a/src/Sonarr.Api.V5/FileSystem/FileSystemController.cs b/src/Sonarr.Api.V5/FileSystem/FileSystemController.cs index 2c52ca800..ad54be2f7 100644 --- a/src/Sonarr.Api.V5/FileSystem/FileSystemController.cs +++ b/src/Sonarr.Api.V5/FileSystem/FileSystemController.cs @@ -24,7 +24,7 @@ public FileSystemController(IFileSystemLookupService fileSystemLookupService, [HttpGet] [Produces("application/json")] - public IActionResult GetContents(string path, bool includeFiles = false, bool allowFoldersWithoutTrailingSlashes = false) + public IActionResult GetContents(string? path, bool includeFiles = false, bool allowFoldersWithoutTrailingSlashes = false) { return Ok(_fileSystemLookupService.LookupContents(path, includeFiles, allowFoldersWithoutTrailingSlashes)); } From dfd5e4ba37db50edcaae4116d616e76660e64cf1 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 28 Mar 2026 13:03:25 -0700 Subject: [PATCH 065/110] Fixed: Unexpected languages stored in DB will be treated as Unknown Closes #8482 --- .../Datastore/Converters/LanguageIntConverter.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/NzbDrone.Core/Datastore/Converters/LanguageIntConverter.cs b/src/NzbDrone.Core/Datastore/Converters/LanguageIntConverter.cs index b7981367f..ca0085032 100644 --- a/src/NzbDrone.Core/Datastore/Converters/LanguageIntConverter.cs +++ b/src/NzbDrone.Core/Datastore/Converters/LanguageIntConverter.cs @@ -34,8 +34,15 @@ public override Language Parse(object value) public class LanguageIntConverter : JsonConverter { + public override bool HandleNull => true; + public override Language Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.Null) + { + return Language.Unknown; + } + var item = reader.GetInt32(); return (Language)item; } From c07dbef2c4fdcc28f90d8064312bd1bb8405aeea Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 26 Mar 2026 17:04:07 +0200 Subject: [PATCH 066/110] Display protocol for download clients in UI --- .../DownloadClients/DownloadClients/DownloadClient.tsx | 6 ++++++ .../Manage/ManageDownloadClientsModalContent.tsx | 6 ++++++ .../Manage/ManageDownloadClientsModalRow.css | 3 ++- .../Manage/ManageDownloadClientsModalRow.css.d.ts | 1 + .../Manage/ManageDownloadClientsModalRow.tsx | 8 ++++++++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.tsx index d57106a96..ddb2efd0f 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.tsx @@ -1,9 +1,11 @@ import React, { useCallback, useState } from 'react'; import { useDispatch } from 'react-redux'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import Card from 'Components/Card'; import Label from 'Components/Label'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import TagList from 'Components/TagList'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; import { kinds } from 'Helpers/Props'; import { deleteDownloadClient } from 'Store/Actions/settingsActions'; import { useTagList } from 'Tags/useTags'; @@ -14,6 +16,7 @@ import styles from './DownloadClient.css'; interface DownloadClientProps { id: number; name: string; + protocol: DownloadProtocol; enable: boolean; priority: number; tags: number[]; @@ -22,6 +25,7 @@ interface DownloadClientProps { function DownloadClient({ id, name, + protocol, enable, priority, tags, @@ -65,6 +69,8 @@ function DownloadClient({
{name}
+ + {enable ? ( ) : ( diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx index abcf9fd87..bf1450375 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -37,6 +37,12 @@ const COLUMNS: Column[] = [ isSortable: true, isVisible: true, }, + { + name: 'protocol', + label: () => translate('Protocol'), + isSortable: true, + isVisible: true, + }, { name: 'implementation', label: () => translate('Implementation'), diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css index 242e0c84e..71736b66f 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css @@ -1,4 +1,5 @@ .name, +.protocol, .enable, .tags, .priority, @@ -8,4 +9,4 @@ composes: cell from '~Components/Table/Cells/TableRowCell.css'; word-break: break-all; -} \ No newline at end of file +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts index 74553b4f9..c72af477c 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts @@ -5,6 +5,7 @@ interface CssExports { 'implementation': string; 'name': string; 'priority': string; + 'protocol': string; 'removeCompletedDownloads': string; 'removeFailedDownloads': string; 'tags': string; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx index 5cb755bc6..9bff2120b 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import { useSelect } from 'App/Select/SelectContext'; import Label from 'Components/Label'; import SeriesTagList from 'Components/SeriesTagList'; @@ -6,6 +7,7 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import Column from 'Components/Table/Column'; import TableRow from 'Components/Table/TableRow'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; import { kinds } from 'Helpers/Props'; import DownloadClient from 'typings/DownloadClient'; import { SelectStateInputProps } from 'typings/props'; @@ -15,6 +17,7 @@ import styles from './ManageDownloadClientsModalRow.css'; interface ManageDownloadClientsModalRowProps { id: number; name: string; + protocol: DownloadProtocol; enable: boolean; priority: number; removeCompletedDownloads: boolean; @@ -30,6 +33,7 @@ function ManageDownloadClientsModalRow( const { id, name, + protocol, enable, priority, removeCompletedDownloads, @@ -62,6 +66,10 @@ function ManageDownloadClientsModalRow( {name} + + + + {implementation} From a1260fa5f4dbb4950165fb3e0572f3e314c3ad11 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 26 Mar 2026 17:45:09 +0200 Subject: [PATCH 067/110] Display protocol for indexers in UI --- frontend/src/Settings/Indexers/Indexers/Indexer.tsx | 4 ++++ .../Indexers/Manage/ManageIndexersModalContent.tsx | 6 ++++++ .../Indexers/Indexers/Manage/ManageIndexersModalRow.css | 1 + .../Indexers/Manage/ManageIndexersModalRow.css.d.ts | 1 + .../Indexers/Indexers/Manage/ManageIndexersModalRow.tsx | 8 ++++++++ 5 files changed, 20 insertions(+) diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.tsx b/frontend/src/Settings/Indexers/Indexers/Indexer.tsx index 0a4b7f62b..6100e047f 100644 --- a/frontend/src/Settings/Indexers/Indexers/Indexer.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useState } from 'react'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import Card from 'Components/Card'; import Label from 'Components/Label'; import IconButton from 'Components/Link/IconButton'; @@ -19,6 +20,7 @@ interface IndexerProps extends IndexerModel { function Indexer({ id, name, + protocol, enableRss, enableAutomaticSearch, enableInteractiveSearch, @@ -79,6 +81,8 @@ function Indexer({
+ + {supportsRss && enableRss ? ( ) : null} diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx index 31a0b6785..60a2739fd 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx @@ -39,6 +39,12 @@ const COLUMNS: Column[] = [ isSortable: true, isVisible: true, }, + { + name: 'protocol', + label: () => translate('Protocol'), + isSortable: true, + isVisible: true, + }, { name: 'implementation', label: () => translate('Implementation'), diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css index cf3792c16..ec8097224 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css @@ -1,4 +1,5 @@ .name, +.protocol, .tags, .enableRss, .enableAutomaticSearch, diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts index c1083bacf..75fc726cd 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts @@ -7,6 +7,7 @@ interface CssExports { 'implementation': string; 'name': string; 'priority': string; + 'protocol': string; 'seasonSearchMaximumSingleEpisodeAge': string; 'tags': string; } diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx index f87467044..c3e5a544a 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import { useSelect } from 'App/Select/SelectContext'; import Label from 'Components/Label'; import SeriesTagList from 'Components/SeriesTagList'; @@ -6,6 +7,7 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import Column from 'Components/Table/Column'; import TableRow from 'Components/Table/TableRow'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; import { kinds } from 'Helpers/Props'; import { IndexerModel } from 'Settings/Indexers/useIndexers'; import { SelectStateInputProps } from 'typings/props'; @@ -15,6 +17,7 @@ import styles from './ManageIndexersModalRow.css'; interface ManageIndexersModalRowProps { id: number; name: string; + protocol: DownloadProtocol; enableRss: boolean; enableAutomaticSearch: boolean; enableInteractiveSearch: boolean; @@ -29,6 +32,7 @@ function ManageIndexersModalRow(props: ManageIndexersModalRowProps) { const { id, name, + protocol, enableRss, enableAutomaticSearch, enableInteractiveSearch, @@ -62,6 +66,10 @@ function ManageIndexersModalRow(props: ManageIndexersModalRowProps) { {name} + + + + {implementation} From 5e5f1835f510ff4509da0b300e69247564fb41f7 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 16 Mar 2026 10:19:03 -0700 Subject: [PATCH 068/110] Fixed: Parsing date-based releases with 3 digit number in episode title --- .../ParserTests/DailyEpisodeParserFixture.cs | 2 ++ src/NzbDrone.Core/Parser/Parser.cs | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/NzbDrone.Core.Test/ParserTests/DailyEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/DailyEpisodeParserFixture.cs index 35ae206f3..68e973279 100644 --- a/src/NzbDrone.Core.Test/ParserTests/DailyEpisodeParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/DailyEpisodeParserFixture.cs @@ -36,6 +36,8 @@ public class DailyEpisodeParserFixture : CoreTest [TestCase("Series Title - 30-04-2024 HDTV 1080p H264 AAC", "Series Title", 2024, 4, 30)] [TestCase("Series On TitleClub E76 2024 08 08 1080p WEB H264-RnB96 [TJET]", "Series On TitleClub", 2024, 8, 8)] [TestCase("Series.Title.13.02.2025.1080i.HDTV.MPA2.0.H.264", "Series Title", 2025, 2, 13)] + [TestCase("Series.2025.09.01.The.170.Million.Pound.Diamond.Scam.1080p.HDTV.H264-DEADPOOL'", "Series", 2025, 9, 1)] + [TestCase("Series.2025.09.01.The.Million.Pound.Diamond.Scam.1080p.HDTV.H264-DEADPOOL'", "Series", 2025, 9, 1)] public void should_parse_daily_episode(string postTitle, string title, int year, int month, int day) { var result = Parser.Parser.ParseTitle(postTitle); diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 02d0aa82e..9b649a2a1 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -329,6 +329,10 @@ public static class Parser new Regex(@"^(?.+?)(?:(?:[-_. ]+?Temporada.+?|\[.+?\])\[Cap)(?:[-_. ]+(?<season>(?<!\d+)\d{1,2})(?<episode>(?<!e|x)(?:[1-9][0-9]|[0][1-9])))+(?:\])", RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Episodes with airdate (2018.04.28) + new Regex(@"^(?<title>.+?)?\W*(?<airyear>\d{4})[-_. ]+(?<airmonth>[0-1][0-9])[-_. ]+(?<airday>[0-3][0-9])(?![-_. ]+[0-3][0-9])", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Supports 103/113 naming new Regex(@"^(?<title>.+?)?(?:(?:[_.-](?<![()\[!]))+(?<season>(?<!\d+)[1-9])(?<episode>[1-9][0-9]|[0][1-9])(?![a-z]|\d+))+(?:[_.]|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled), @@ -343,10 +347,6 @@ public static class Parser new Regex(@"^(?<title>.+?)(?:(?:[-_\W](?<![()\[!]|\d{1,2}-))+S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{4}(?!\d+|i|p)))+)\W?(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled), - // Episodes with airdate (2018.04.28) - new Regex(@"^(?<title>.+?)?\W*(?<airyear>\d{4})[-_. ]+(?<airmonth>[0-1][0-9])[-_. ]+(?<airday>[0-3][0-9])(?![-_. ]+[0-3][0-9])", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - // Turkish tracker releases (01 BLM, 3. Blm, 04.Bolum, etc) new Regex(@"^(?<title>.+?)[_. ](?<absoluteepisode>\d{1,4})(?:[_. ]+)(?:BLM|B[oö]l[uü]m)", RegexOptions.IgnoreCase | RegexOptions.Compiled), From 536d292838379af8e415044c82276e9a1e9ffbeb Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sat, 28 Mar 2026 14:10:11 -0700 Subject: [PATCH 069/110] Prevent duplicating providers when adding a new provider --- frontend/src/Settings/useProviderSettings.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/Settings/useProviderSettings.ts b/frontend/src/Settings/useProviderSettings.ts index 809547b5c..3bee87878 100644 --- a/frontend/src/Settings/useProviderSettings.ts +++ b/frontend/src/Settings/useProviderSettings.ts @@ -122,13 +122,17 @@ export const useSaveProviderSettings = <T extends ModelBase>( }, onSuccess: (updatedSettings: T) => { queryClient.setQueryData<T[]>([path], (oldData = []) => { - if (id) { - return oldData.map((item) => - item.id === updatedSettings.id ? updatedSettings : item - ); + const existingIndex = oldData.findIndex( + (item) => item.id === updatedSettings.id + ); + + if (existingIndex === -1) { + return [...oldData, updatedSettings]; } - return [...oldData, updatedSettings]; + return oldData.map((item) => + item.id === updatedSettings.id ? updatedSettings : item + ); }); onSuccess?.(updatedSettings); }, From 113d0c474904c28fae0b0b37f5868e5cdcb09622 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sat, 28 Mar 2026 11:19:28 -0700 Subject: [PATCH 070/110] Add v5 Import List Exclusion endpoints --- .../ImportListExclusionBulkResource.cs | 6 ++ .../ImportListExclusionController.cs | 86 +++++++++++++++++++ .../ImportListExclusionExistsValidator.cs | 31 +++++++ .../ImportListExclusionResource.cs | 38 ++++++++ 4 files changed, 161 insertions(+) create mode 100644 src/Sonarr.Api.V5/ImportLists/ImportListExclusionBulkResource.cs create mode 100644 src/Sonarr.Api.V5/ImportLists/ImportListExclusionController.cs create mode 100644 src/Sonarr.Api.V5/ImportLists/ImportListExclusionExistsValidator.cs create mode 100644 src/Sonarr.Api.V5/ImportLists/ImportListExclusionResource.cs diff --git a/src/Sonarr.Api.V5/ImportLists/ImportListExclusionBulkResource.cs b/src/Sonarr.Api.V5/ImportLists/ImportListExclusionBulkResource.cs new file mode 100644 index 000000000..667d970fb --- /dev/null +++ b/src/Sonarr.Api.V5/ImportLists/ImportListExclusionBulkResource.cs @@ -0,0 +1,6 @@ +namespace Sonarr.Api.V5.ImportLists; + +public class ImportListExclusionBulkResource +{ + public required HashSet<int> Ids { get; set; } +} diff --git a/src/Sonarr.Api.V5/ImportLists/ImportListExclusionController.cs b/src/Sonarr.Api.V5/ImportLists/ImportListExclusionController.cs new file mode 100644 index 000000000..fd9ed5cbe --- /dev/null +++ b/src/Sonarr.Api.V5/ImportLists/ImportListExclusionController.cs @@ -0,0 +1,86 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.ImportLists.Exclusions; +using Sonarr.Http; +using Sonarr.Http.Extensions; +using Sonarr.Http.REST; +using Sonarr.Http.REST.Attributes; + +namespace Sonarr.Api.V5.ImportLists; + +[V5ApiController] +public class ImportListExclusionController : RestController<ImportListExclusionResource> +{ + private readonly IImportListExclusionService _importListExclusionService; + + public ImportListExclusionController(IImportListExclusionService importListExclusionService, + ImportListExclusionExistsValidator importListExclusionExistsValidator) + { + _importListExclusionService = importListExclusionService; + + SharedValidator.RuleFor(c => c.TvdbId).Cascade(CascadeMode.Stop) + .NotEmpty() + .SetValidator(importListExclusionExistsValidator); + + SharedValidator.RuleFor(c => c.Title).NotEmpty(); + } + + protected override ImportListExclusionResource GetResourceById(int id) + { + return _importListExclusionService.Get(id).ToResource(); + } + + [HttpGet] + [Produces("application/json")] + public PagingResource<ImportListExclusionResource> GetImportListExclusions([FromQuery] PagingRequestResource paging) + { + var pagingResource = new PagingResource<ImportListExclusionResource>(paging); + var pageSpec = pagingResource.MapToPagingSpec<ImportListExclusionResource, ImportListExclusion>( + new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "id", + "title", + "tvdbId" + }, + "id", + SortDirection.Descending); + + return pageSpec.ApplyToPage(_importListExclusionService.Paged, ImportListExclusionResourceMapper.ToResource); + } + + [RestPostById] + [Consumes("application/json")] + public ActionResult<ImportListExclusionResource> AddImportListExclusion([FromBody] ImportListExclusionResource resource) + { + var importListExclusion = _importListExclusionService.Add(resource.ToModel()); + + return Created(importListExclusion.Id); + } + + [RestPutById] + [Consumes("application/json")] + public ActionResult<ImportListExclusionResource> UpdateImportListExclusion([FromBody] ImportListExclusionResource resource) + { + _importListExclusionService.Update(resource.ToModel()); + + return Accepted(resource.Id); + } + + [RestDeleteById] + public ActionResult DeleteImportListExclusion(int id) + { + _importListExclusionService.Delete(id); + + return NoContent(); + } + + [HttpDelete("bulk")] + [Consumes("application/json")] + public ActionResult DeleteImportListExclusions([FromBody] ImportListExclusionBulkResource resource) + { + _importListExclusionService.Delete(resource.Ids.ToList()); + + return NoContent(); + } +} diff --git a/src/Sonarr.Api.V5/ImportLists/ImportListExclusionExistsValidator.cs b/src/Sonarr.Api.V5/ImportLists/ImportListExclusionExistsValidator.cs new file mode 100644 index 000000000..4b06274cc --- /dev/null +++ b/src/Sonarr.Api.V5/ImportLists/ImportListExclusionExistsValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation.Validators; +using NzbDrone.Core.ImportLists.Exclusions; + +namespace Sonarr.Api.V5.ImportLists; + +public class ImportListExclusionExistsValidator : PropertyValidator +{ + private readonly IImportListExclusionService _importListExclusionService; + + public ImportListExclusionExistsValidator(IImportListExclusionService importListExclusionService) + { + _importListExclusionService = importListExclusionService; + } + + protected override string GetDefaultMessageTemplate() => "This exclusion has already been added."; + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) + { + return true; + } + + if (context.InstanceToValidate is not ImportListExclusionResource listExclusionResource) + { + return true; + } + + return !_importListExclusionService.All().Exists(v => v.TvdbId == (int)context.PropertyValue && v.Id != listExclusionResource.Id); + } +} diff --git a/src/Sonarr.Api.V5/ImportLists/ImportListExclusionResource.cs b/src/Sonarr.Api.V5/ImportLists/ImportListExclusionResource.cs new file mode 100644 index 000000000..e3a2a6aa5 --- /dev/null +++ b/src/Sonarr.Api.V5/ImportLists/ImportListExclusionResource.cs @@ -0,0 +1,38 @@ +using NzbDrone.Core.ImportLists.Exclusions; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.ImportLists; + +public class ImportListExclusionResource : RestResource +{ + public int TvdbId { get; set; } + public string? Title { get; set; } +} + +public static class ImportListExclusionResourceMapper +{ + public static ImportListExclusionResource ToResource(this ImportListExclusion model) + { + return new ImportListExclusionResource + { + Id = model.Id, + TvdbId = model.TvdbId, + Title = model.Title, + }; + } + + public static ImportListExclusion ToModel(this ImportListExclusionResource resource) + { + return new ImportListExclusion + { + Id = resource.Id, + TvdbId = resource.TvdbId, + Title = resource.Title + }; + } + + public static List<ImportListExclusionResource> ToResource(this IEnumerable<ImportListExclusion> models) + { + return models.Select(ToResource).ToList(); + } +} From b0fac1529dbdc7ac13c07e60ea47b1341b9af0f1 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Sat, 28 Mar 2026 11:30:35 -0700 Subject: [PATCH 071/110] Use react-query for Import List Exclusions --- frontend/src/App/State/SettingsAppState.ts | 11 -- frontend/src/Helpers/Hooks/usePage.ts | 2 + .../EditImportListExclusionModal.tsx | 21 +-- .../EditImportListExclusionModalContent.css | 6 - ...itImportListExclusionModalContent.css.d.ts | 1 - .../EditImportListExclusionModalContent.tsx | 164 ++++++---------- .../ImportListExclusionRow.tsx | 29 ++- .../ImportListExclusions.tsx | 177 ++++++------------ .../importListExclusionOptionsStore.ts | 25 +++ .../useImportListExclusions.ts | 163 ++++++++++++++++ .../Actions/Settings/importListExclusions.js | 110 ----------- frontend/src/Store/Actions/settingsActions.js | 5 - frontend/src/typings/ImportListExclusion.ts | 6 - 13 files changed, 333 insertions(+), 387 deletions(-) create mode 100644 frontend/src/Settings/ImportLists/ImportListExclusions/importListExclusionOptionsStore.ts create mode 100644 frontend/src/Settings/ImportLists/ImportListExclusions/useImportListExclusions.ts delete mode 100644 frontend/src/Store/Actions/Settings/importListExclusions.js delete mode 100644 frontend/src/typings/ImportListExclusion.ts diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 910071dc1..ece88ef93 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -4,7 +4,6 @@ import AppSectionState, { AppSectionListState, AppSectionSaveState, AppSectionSchemaState, - PagedAppSectionState, } from 'App/State/AppSectionState'; import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging'; import CustomFormat from 'typings/CustomFormat'; @@ -12,7 +11,6 @@ import CustomFormatSpecification from 'typings/CustomFormatSpecification'; import DelayProfile from 'typings/DelayProfile'; import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; -import ImportListExclusion from 'typings/ImportListExclusion'; import ImportListOptionsSettings from 'typings/ImportListOptionsSettings'; import DownloadClientOptions from 'typings/Settings/DownloadClientOptions'; @@ -71,14 +69,6 @@ export interface ImportListOptionsSettingsAppState extends AppSectionItemState<ImportListOptionsSettings>, AppSectionSaveState {} -export interface ImportListExclusionsSettingsAppState - extends AppSectionState<ImportListExclusion>, - AppSectionSaveState, - PagedAppSectionState, - AppSectionDeleteState { - pendingChanges: Partial<ImportListExclusion>; -} - interface SettingsAppState { autoTaggings: AutoTaggingAppState; autoTaggingSpecifications: AutoTaggingSpecificationAppState; @@ -87,7 +77,6 @@ interface SettingsAppState { delayProfiles: DelayProfileAppState; downloadClients: DownloadClientAppState; downloadClientOptions: DownloadClientOptionsAppState; - importListExclusions: ImportListExclusionsSettingsAppState; importListOptions: ImportListOptionsSettingsAppState; importLists: ImportListAppState; } diff --git a/frontend/src/Helpers/Hooks/usePage.ts b/frontend/src/Helpers/Hooks/usePage.ts index af30c28a0..e17d94dec 100644 --- a/frontend/src/Helpers/Hooks/usePage.ts +++ b/frontend/src/Helpers/Hooks/usePage.ts @@ -7,6 +7,7 @@ interface PageStore { cutoffUnmet: number; events: number; history: number; + importListExclusion: number; missing: number; queue: number; } @@ -16,6 +17,7 @@ const pageStore = create<PageStore>(() => ({ cutoffUnmet: 1, events: 1, history: 1, + importListExclusion: 1, missing: 1, queue: 1, })); diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.tsx b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.tsx index 7f5feafab..4b2cbb213 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.tsx +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.tsx @@ -1,12 +1,12 @@ -import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import React from 'react'; import Modal from 'Components/Modal/Modal'; import { sizes } from 'Helpers/Props'; -import { clearPendingChanges } from 'Store/Actions/baseActions'; import EditImportListExclusionModalContent from './EditImportListExclusionModalContent'; interface EditImportListExclusionModalProps { id?: number; + title?: string; + tvdbId?: number; isOpen: boolean; onModalClose: () => void; onDeleteImportListExclusionPress?: () => void; @@ -17,22 +17,11 @@ function EditImportListExclusionModal( ) { const { isOpen, onModalClose, ...otherProps } = props; - const dispatch = useDispatch(); - - const handleModalClose = useCallback(() => { - dispatch( - clearPendingChanges({ - section: 'settings.importListExclusions', - }) - ); - onModalClose(); - }, [dispatch, onModalClose]); - return ( - <Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}> + <Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={onModalClose}> <EditImportListExclusionModalContent {...otherProps} - onModalClose={handleModalClose} + onModalClose={onModalClose} /> </Modal> ); diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css index 97e132552..a2b6014df 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css @@ -1,9 +1,3 @@ -.body { - composes: modalBody from '~Components/Modal/ModalBody.css'; - - flex: 1 1 430px; -} - .deleteButton { composes: button from '~Components/Link/Button.css'; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css.d.ts b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css.d.ts index 7881f9867..c5f0ef8a7 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css.d.ts +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css.d.ts @@ -1,7 +1,6 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'body': string; 'deleteButton': string; } export const cssExports: CssExports; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.tsx b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.tsx index 31e92005d..e161083c2 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.tsx +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.tsx @@ -1,115 +1,70 @@ import React, { useCallback, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; -import Alert from 'Components/Alert'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; import Button from 'Components/Link/Button'; import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { inputTypes, kinds } from 'Helpers/Props'; -import { - saveImportListExclusion, - setImportListExclusionValue, -} from 'Store/Actions/settingsActions'; -import selectSettings from 'Store/Selectors/selectSettings'; -import ImportListExclusion from 'typings/ImportListExclusion'; import { InputChanged } from 'typings/inputs'; -import { PendingSection } from 'typings/pending'; import translate from 'Utilities/String/translate'; +import { useManageImportListExclusion } from './useImportListExclusions'; import styles from './EditImportListExclusionModalContent.css'; -const newImportListExclusion = { - title: '', - tvdbId: 0, -}; - -function createImportListExclusionSelector(id?: number) { - return createSelector( - (state: AppState) => state.settings.importListExclusions, - (importListExclusions) => { - const { isFetching, error, isSaving, saveError, pendingChanges, items } = - importListExclusions; - - const mapping = id - ? items.find((i) => i.id === id)! - : newImportListExclusion; - const settings = selectSettings(mapping, pendingChanges, saveError); - - return { - isFetching, - error, - isSaving, - saveError, - item: settings.settings as PendingSection<ImportListExclusion>, - ...settings, - }; - } - ); -} - interface EditImportListExclusionModalContentProps { id?: number; + title?: string; + tvdbId?: number; onModalClose: () => void; onDeleteImportListExclusionPress?: () => void; } function EditImportListExclusionModalContent({ id, + title: existingTitle, + tvdbId: existingTvdbId, onModalClose, onDeleteImportListExclusionPress, }: EditImportListExclusionModalContentProps) { - const { isFetching, isSaving, item, error, saveError, ...otherProps } = - useSelector(createImportListExclusionSelector(id)); + const { + item, + isSaving, + saveError, + validationErrors, + validationWarnings, + updateValue, + save, + } = useManageImportListExclusion({ + id, + title: existingTitle, + tvdbId: existingTvdbId, + }); const { title, tvdbId } = item; - - const dispatch = useDispatch(); - const previousIsSaving = usePrevious(isSaving); - - const dispatchSetImportListExclusionValue = (payload: { - name: string; - value: string | number; - }) => { - // @ts-expect-error 'setImportListExclusionValue' isn't typed yet - dispatch(setImportListExclusionValue(payload)); - }; + const wasSaving = usePrevious(isSaving); useEffect(() => { - if (!id) { - Object.entries(newImportListExclusion).forEach(([name, value]) => { - dispatchSetImportListExclusionValue({ name, value }); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (previousIsSaving && !isSaving && !saveError) { + if (wasSaving && !isSaving && !saveError) { onModalClose(); } - }, [previousIsSaving, isSaving, saveError, onModalClose]); + }, [isSaving, wasSaving, saveError, onModalClose]); - const onSavePress = useCallback(() => { - dispatch(saveImportListExclusion({ id })); - }, [dispatch, id]); - - const onInputChange = useCallback( - (change: InputChanged) => { - // @ts-expect-error 'setImportListExclusionValue' isn't typed yet - dispatch(setImportListExclusionValue(change)); + const handleInputChange = useCallback( + ({ name, value }: InputChanged) => { + updateValue(name, value); }, - [dispatch] + [updateValue] ); + const handleSavePress = useCallback(() => { + save(); + }, [save]); + return ( <ModalContent onModalClose={onModalClose}> <ModalHeader> @@ -118,42 +73,35 @@ function EditImportListExclusionModalContent({ : translate('AddImportListExclusion')} </ModalHeader> - <ModalBody className={styles.body}> - {isFetching ? <LoadingIndicator /> : null} + <ModalBody> + <Form + validationErrors={validationErrors} + validationWarnings={validationWarnings} + > + <FormGroup> + <FormLabel>{translate('Title')}</FormLabel> - {!isFetching && error ? ( - <Alert kind={kinds.DANGER}> - {translate('AddImportListExclusionError')} - </Alert> - ) : null} + <FormInputGroup + type={inputTypes.TEXT} + name="title" + helpText={translate('SeriesTitleToExcludeHelpText')} + {...title} + onChange={handleInputChange} + /> + </FormGroup> - {!isFetching && !error ? ( - <Form {...otherProps}> - <FormGroup> - <FormLabel>{translate('Title')}</FormLabel> + <FormGroup> + <FormLabel>{translate('TvdbId')}</FormLabel> - <FormInputGroup - type={inputTypes.TEXT} - name="title" - helpText={translate('SeriesTitleToExcludeHelpText')} - {...title} - onChange={onInputChange} - /> - </FormGroup> - - <FormGroup> - <FormLabel>{translate('TvdbId')}</FormLabel> - - <FormInputGroup - type={inputTypes.NUMBER} - name="tvdbId" - helpText={translate('TvdbIdExcludeHelpText')} - {...tvdbId} - onChange={onInputChange} - /> - </FormGroup> - </Form> - ) : null} + <FormInputGroup + type={inputTypes.NUMBER} + name="tvdbId" + helpText={translate('TvdbIdExcludeHelpText')} + {...tvdbId} + onChange={handleInputChange} + /> + </FormGroup> + </Form> </ModalBody> <ModalFooter> @@ -172,7 +120,7 @@ function EditImportListExclusionModalContent({ <SpinnerErrorButton isSpinning={isSaving} error={saveError} - onPress={onSavePress} + onPress={handleSavePress} > {translate('Save')} </SpinnerErrorButton> diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx index 176a558a2..6e09c917c 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx @@ -1,5 +1,4 @@ import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; import { useSelect } from 'App/Select/SelectContext'; import IconButton from 'Components/Link/IconButton'; import ConfirmModal from 'Components/Modal/ConfirmModal'; @@ -8,23 +7,30 @@ import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import TableRow from 'Components/Table/TableRow'; import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; import { icons, kinds } from 'Helpers/Props'; -import { deleteImportListExclusion } from 'Store/Actions/Settings/importListExclusions'; -import ImportListExclusion from 'typings/ImportListExclusion'; import { SelectStateInputProps } from 'typings/props'; import translate from 'Utilities/String/translate'; import EditImportListExclusionModal from './EditImportListExclusionModal'; +import { + ImportListExclusion, + useDeleteImportListExclusion, +} from './useImportListExclusions'; import styles from './ImportListExclusionRow.css'; -type ImportListExclusionRowProps = ImportListExclusion; +interface ImportListExclusionRowProps extends ImportListExclusion { + onModalClose: () => void; +} function ImportListExclusionRow({ id, tvdbId, title, + onModalClose, }: ImportListExclusionRowProps) { const { toggleSelected, useIsSelected } = useSelect<ImportListExclusion>(); const isSelected = useIsSelected(id); + const { deleteImportListExclusion } = useDeleteImportListExclusion(id); + const handleSelectedChange = useCallback( ({ id, value, shiftKey = false }: SelectStateInputProps) => { toggleSelected({ @@ -36,14 +42,17 @@ function ImportListExclusionRow({ [toggleSelected] ); - const dispatch = useDispatch(); - const [ isEditImportListExclusionModalOpen, setEditImportListExclusionModalOpen, setEditImportListExclusionModalClosed, ] = useModalOpenState(false); + const handleEditModalClose = useCallback(() => { + setEditImportListExclusionModalClosed(); + onModalClose(); + }, [setEditImportListExclusionModalClosed, onModalClose]); + const [ isDeleteImportListExclusionModalOpen, setDeleteImportListExclusionModalOpen, @@ -51,8 +60,8 @@ function ImportListExclusionRow({ ] = useModalOpenState(false); const handleDeletePress = useCallback(() => { - dispatch(deleteImportListExclusion({ id })); - }, [id, dispatch]); + deleteImportListExclusion(); + }, [deleteImportListExclusion]); return ( <TableRow> @@ -74,8 +83,10 @@ function ImportListExclusionRow({ <EditImportListExclusionModal id={id} + title={title} + tvdbId={tvdbId} isOpen={isEditImportListExclusionModalOpen} - onModalClose={setEditImportListExclusionModalClosed} + onModalClose={handleEditModalClose} onDeleteImportListExclusionPress={setDeleteImportListExclusionModalOpen} /> diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx index f4fd46903..954cf8b7f 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx @@ -1,8 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; import { SelectProvider, useSelect } from 'App/Select/SelectContext'; -import AppState from 'App/State/AppState'; import FieldSet from 'Components/FieldSet'; import IconButton from 'Components/Link/IconButton'; import SpinnerButton from 'Components/Link/SpinnerButton'; @@ -14,21 +11,9 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import TablePager from 'Components/Table/TablePager'; import TableRow from 'Components/Table/TableRow'; -import usePaging from 'Components/Table/usePaging'; -import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; -import usePrevious from 'Helpers/Hooks/usePrevious'; import { icons, kinds } from 'Helpers/Props'; import { SortDirection } from 'Helpers/Props/sortDirections'; -import { - bulkDeleteImportListExclusions, - clearImportListExclusions, - fetchImportListExclusions, - gotoImportListExclusionPage, - setImportListExclusionSort, - setImportListExclusionTableOption, -} from 'Store/Actions/Settings/importListExclusions'; -import ImportListExclusion from 'typings/ImportListExclusion'; import { CheckInputChanged } from 'typings/inputs'; import { TableOptionsChangePayload } from 'typings/Table'; import { @@ -37,7 +22,16 @@ import { } from 'Utilities/pagePopulator'; import translate from 'Utilities/String/translate'; import EditImportListExclusionModal from './EditImportListExclusionModal'; +import { + setImportListExclusionOption, + setImportListExclusionSort, + useImportListExclusionOptions, +} from './importListExclusionOptionsStore'; import ImportListExclusionRow from './ImportListExclusionRow'; +import useImportListExclusions, { + ImportListExclusion, + useDeleteImportListExclusions, +} from './useImportListExclusions'; import styles from './ImportListExclusions.css'; const COLUMNS: Column[] = [ @@ -62,40 +56,27 @@ const COLUMNS: Column[] = [ }, ]; -function createImportListExclusionsSelector() { - return createSelector( - (state: AppState) => state.settings.importListExclusions, - (importListExclusions) => { - return { - ...importListExclusions, - }; - } - ); -} - function ImportListExclusionsContent() { - const requestCurrentPage = useCurrentPage(); - const { - isFetching, - isPopulated, - items, - pageSize, - sortKey, - error, - sortDirection, - page, + records, totalPages, totalRecords, - isDeleting, - deleteError, - } = useSelector(createImportListExclusionsSelector()); + isFetching, + isFetched, + isLoading, + error, + page, + goToPage, + refetch, + } = useImportListExclusions(); - const dispatch = useDispatch(); + const { pageSize, sortKey, sortDirection } = useImportListExclusionOptions(); + + const { deleteImportListExclusions, isDeleting } = + useDeleteImportListExclusions(); const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] = useState(false); - const previousIsDeleting = usePrevious(isDeleting); const { allSelected, @@ -119,100 +100,64 @@ function ImportListExclusionsContent() { const handleDeleteSelectedPress = useCallback(() => { setIsConfirmDeleteModalOpen(true); - }, [setIsConfirmDeleteModalOpen]); + }, []); const handleDeleteSelectedConfirmed = useCallback(() => { - dispatch(bulkDeleteImportListExclusions({ ids: getSelectedIds() })); + deleteImportListExclusions({ ids: getSelectedIds() }); setIsConfirmDeleteModalOpen(false); - }, [getSelectedIds, setIsConfirmDeleteModalOpen, dispatch]); + unselectAll(); + }, [getSelectedIds, deleteImportListExclusions, unselectAll]); const handleConfirmDeleteModalClose = useCallback(() => { setIsConfirmDeleteModalOpen(false); - }, [setIsConfirmDeleteModalOpen]); - - const { - handleFirstPagePress, - handlePreviousPagePress, - handleNextPagePress, - handleLastPagePress, - handlePageSelect, - } = usePaging({ - page, - totalPages, - gotoPage: gotoImportListExclusionPage, - }); + }, []); const handleSortPress = useCallback( (sortKey: string, sortDirection?: SortDirection) => { - dispatch(setImportListExclusionSort({ sortKey, sortDirection })); + setImportListExclusionSort({ sortKey, sortDirection }); }, - [dispatch] + [] ); const handleTableOptionChange = useCallback( (payload: TableOptionsChangePayload) => { - dispatch(setImportListExclusionTableOption(payload)); - if (payload.pageSize) { - dispatch(gotoImportListExclusionPage({ page: 1 })); + setImportListExclusionOption('pageSize', payload.pageSize as number); + goToPage(1); } }, - [dispatch] + [goToPage] ); - useEffect(() => { - if (requestCurrentPage) { - dispatch(fetchImportListExclusions()); - } else { - dispatch(gotoImportListExclusionPage({ page: 1 })); - } - - return () => { - dispatch(clearImportListExclusions()); - }; - }, [requestCurrentPage, dispatch]); - - useEffect(() => { - const repopulate = () => { - dispatch(fetchImportListExclusions()); - }; - - registerPagePopulator(repopulate); - - return () => { - unregisterPagePopulator(repopulate); - }; - }, [dispatch]); - - useEffect(() => { - if (previousIsDeleting && !isDeleting && !deleteError) { - unselectAll(); - - dispatch(fetchImportListExclusions()); - } - }, [ - previousIsDeleting, - isDeleting, - deleteError, - items, - dispatch, - unselectAll, - ]); - const [ isAddImportListExclusionModalOpen, setAddImportListExclusionModalOpen, setAddImportListExclusionModalClosed, ] = useModalOpenState(false); - const isFetchingForFirstTime = isFetching && !isPopulated; + const handleAddModalClose = useCallback(() => { + setAddImportListExclusionModalClosed(); + refetch(); + }, [setAddImportListExclusionModalClosed, refetch]); + + useEffect(() => { + const repopulate = () => { + refetch(); + }; + + registerPagePopulator(repopulate); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [refetch]); return ( <FieldSet legend={translate('ImportListExclusions')}> <PageSectionContent errorMessage={translate('ImportListExclusionsLoadError')} - isFetching={isFetchingForFirstTime} - isPopulated={isPopulated} + isFetching={isLoading && !isFetched} + isPopulated={isFetched} error={error} > <Table @@ -229,8 +174,14 @@ function ImportListExclusionsContent() { onSortPress={handleSortPress} > <TableBody> - {items.map((item) => { - return <ImportListExclusionRow key={item.id} {...item} />; + {records.map((item) => { + return ( + <ImportListExclusionRow + key={item.id} + {...item} + onModalClose={refetch} + /> + ); })} <TableRow> @@ -260,16 +211,12 @@ function ImportListExclusionsContent() { totalPages={totalPages} totalRecords={totalRecords} isFetching={isFetching} - onFirstPagePress={handleFirstPagePress} - onPreviousPagePress={handlePreviousPagePress} - onNextPagePress={handleNextPagePress} - onLastPagePress={handleLastPagePress} - onPageSelect={handlePageSelect} + onPageSelect={goToPage} /> <EditImportListExclusionModal isOpen={isAddImportListExclusionModalOpen} - onModalClose={setAddImportListExclusionModalClosed} + onModalClose={handleAddModalClose} /> <ConfirmModal @@ -287,10 +234,10 @@ function ImportListExclusionsContent() { } function ImportListExclusions() { - const { items } = useSelector(createImportListExclusionsSelector()); + const { records } = useImportListExclusions(); return ( - <SelectProvider items={items}> + <SelectProvider<ImportListExclusion> items={records}> <ImportListExclusionsContent /> </SelectProvider> ); diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/importListExclusionOptionsStore.ts b/frontend/src/Settings/ImportLists/ImportListExclusions/importListExclusionOptionsStore.ts new file mode 100644 index 000000000..d0c043d1f --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/importListExclusionOptionsStore.ts @@ -0,0 +1,25 @@ +import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore'; +import { SortDirection } from 'Helpers/Props/sortDirections'; + +export interface ImportListExclusionOptions { + pageSize: number; + sortKey: string; + sortDirection: SortDirection; +} + +const { useOptions, setOptions, setOption, setSort } = + createOptionsStore<ImportListExclusionOptions>( + 'import_list_exclusion_options', + () => { + return { + pageSize: 20, + sortKey: 'id', + sortDirection: 'descending', + }; + } + ); + +export const useImportListExclusionOptions = useOptions; +export const setImportListExclusionOptions = setOptions; +export const setImportListExclusionOption = setOption; +export const setImportListExclusionSort = setSort; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/useImportListExclusions.ts b/frontend/src/Settings/ImportLists/ImportListExclusions/useImportListExclusions.ts new file mode 100644 index 000000000..076677b40 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/useImportListExclusions.ts @@ -0,0 +1,163 @@ +import { keepPreviousData, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; +import ModelBase from 'App/ModelBase'; +import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import usePage from 'Helpers/Hooks/usePage'; +import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery'; +import { usePendingChangesStore } from 'Helpers/Hooks/usePendingChangesStore'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { useImportListExclusionOptions } from './importListExclusionOptionsStore'; + +export interface ImportListExclusion extends ModelBase { + tvdbId: number; + title: string; +} + +const PATH = '/importlistexclusion'; + +const NEW_IMPORT_LIST_EXCLUSION = { + title: '', + tvdbId: 0, +}; + +interface BulkImportListExclusionData { + ids: number[]; +} + +const useImportListExclusions = () => { + const { page, goToPage } = usePage('importListExclusion'); + const { pageSize, sortKey, sortDirection } = useImportListExclusionOptions(); + + const { refetch, ...query } = usePagedApiQuery<ImportListExclusion>({ + path: PATH, + page, + pageSize, + sortKey, + sortDirection, + queryOptions: { + placeholderData: keepPreviousData, + }, + }); + + return { + ...query, + goToPage, + page, + refetch, + }; +}; + +export default useImportListExclusions; + +interface ManageImportListExclusionOptions { + id?: number; + title?: string; + tvdbId?: number; +} + +export const useManageImportListExclusion = ({ + id, + title, + tvdbId, +}: ManageImportListExclusionOptions) => { + const queryClient = useQueryClient(); + + const item = useMemo(() => { + return id + ? { title: title ?? '', tvdbId: tvdbId ?? 0 } + : NEW_IMPORT_LIST_EXCLUSION; + }, [id, title, tvdbId]); + + const { pendingChanges, setPendingChange } = + usePendingChangesStore<ImportListExclusion>({}); + + const { + mutate, + isPending: isSaving, + error: saveError, + } = useApiMutation<ImportListExclusion, ImportListExclusion>({ + path: id ? `${PATH}/${id}` : PATH, + method: id ? 'PUT' : 'POST', + mutationOptions: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [PATH] }); + }, + }, + }); + + const { settings, validationErrors, validationWarnings } = useMemo(() => { + return selectSettings(item, pendingChanges, saveError); + }, [item, pendingChanges, saveError]); + + const updateValue = useCallback( + (name: string, value: unknown) => { + // @ts-expect-error - name is not yet typed + setPendingChange(name, value); + }, + [setPendingChange] + ); + + const save = useCallback(() => { + const payload = { + ...item, + ...pendingChanges, + } as ImportListExclusion; + + if (id) { + payload.id = id; + } + + mutate(payload); + }, [id, item, pendingChanges, mutate]); + + return { + item: settings, + isSaving, + saveError, + validationErrors, + validationWarnings, + updateValue, + save, + }; +}; + +export const useDeleteImportListExclusion = (id: number) => { + const queryClient = useQueryClient(); + + const { mutate, isPending } = useApiMutation<unknown, void>({ + path: `${PATH}/${id}`, + method: 'DELETE', + mutationOptions: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [PATH] }); + }, + }, + }); + + return { + deleteImportListExclusion: mutate, + isDeleting: isPending, + }; +}; + +export const useDeleteImportListExclusions = () => { + const queryClient = useQueryClient(); + + const { mutate, isPending } = useApiMutation< + unknown, + BulkImportListExclusionData + >({ + path: `${PATH}/bulk`, + method: 'DELETE', + mutationOptions: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [PATH] }); + }, + }, + }); + + return { + deleteImportListExclusions: mutate, + isDeleting: isPending, + }; +}; diff --git a/frontend/src/Store/Actions/Settings/importListExclusions.js b/frontend/src/Store/Actions/Settings/importListExclusions.js deleted file mode 100644 index a89d65208..000000000 --- a/frontend/src/Store/Actions/Settings/importListExclusions.js +++ /dev/null @@ -1,110 +0,0 @@ -import { createAction } from 'redux-actions'; -import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; -import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; -import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; -import createServerSideCollectionHandlers from 'Store/Actions/Creators/createServerSideCollectionHandlers'; -import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer'; -import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; -import createSetTableOptionReducer from 'Store/Actions/Creators/Reducers/createSetTableOptionReducer'; -import { createThunk, handleThunks } from 'Store/thunks'; -import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers'; - -// -// Variables - -const section = 'settings.importListExclusions'; - -// -// Actions Types - -export const FETCH_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/fetchImportListExclusions'; -export const GOTO_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionPage'; -export const SET_IMPORT_LIST_EXCLUSION_SORT = 'settings/importListExclusions/setImportListExclusionSort'; -export const SAVE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/saveImportListExclusion'; -export const DELETE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/deleteImportListExclusion'; -export const BULK_DELETE_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/bulkDeleteImportListExclusions'; -export const CLEAR_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/clearImportListExclusions'; - -export const SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION = 'settings/importListExclusions/setImportListExclusionTableOption'; -export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/setImportListExclusionValue'; - -// -// Action Creators - -export const fetchImportListExclusions = createThunk(FETCH_IMPORT_LIST_EXCLUSIONS); -export const gotoImportListExclusionPage = createThunk(GOTO_IMPORT_LIST_EXCLUSION_PAGE); -export const setImportListExclusionSort = createThunk(SET_IMPORT_LIST_EXCLUSION_SORT); -export const saveImportListExclusion = createThunk(SAVE_IMPORT_LIST_EXCLUSION); -export const deleteImportListExclusion = createThunk(DELETE_IMPORT_LIST_EXCLUSION); -export const bulkDeleteImportListExclusions = createThunk(BULK_DELETE_IMPORT_LIST_EXCLUSIONS); -export const clearImportListExclusions = createAction(CLEAR_IMPORT_LIST_EXCLUSIONS); - -export const setImportListExclusionTableOption = createAction(SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION); -export const setImportListExclusionValue = createAction(SET_IMPORT_LIST_EXCLUSION_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -// -// Details - -export default { - - // - // State - - defaultState: { - isFetching: false, - isPopulated: false, - error: null, - pageSize: 20, - items: [], - isSaving: false, - saveError: null, - isDeleting: false, - deleteError: null, - pendingChanges: {} - }, - - // - // Action Handlers - - actionHandlers: handleThunks({ - ...createServerSideCollectionHandlers( - section, - '/importlistexclusion/paged', - fetchImportListExclusions, - { - [serverSideCollectionHandlers.FETCH]: FETCH_IMPORT_LIST_EXCLUSIONS, - [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_IMPORT_LIST_EXCLUSION_PAGE, - [serverSideCollectionHandlers.SORT]: SET_IMPORT_LIST_EXCLUSION_SORT - } - ), - [SAVE_IMPORT_LIST_EXCLUSION]: createSaveProviderHandler(section, '/importlistexclusion'), - [DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion'), - [BULK_DELETE_IMPORT_LIST_EXCLUSIONS]: createBulkRemoveItemHandler(section, '/importlistexclusion/bulk') - }), - - // - // Reducers - - reducers: { - [SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section), - [SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION]: createSetTableOptionReducer(section), - - [CLEAR_IMPORT_LIST_EXCLUSIONS]: createClearReducer(section, { - isFetching: false, - isPopulated: false, - error: null, - items: [], - isDeleting: false, - deleteError: null, - pendingChanges: {}, - totalPages: 0, - totalRecords: 0 - }) - } - -}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 19a34d9eb..294d4dca0 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -7,7 +7,6 @@ import customFormatSpecifications from './Settings/customFormatSpecifications'; import delayProfiles from './Settings/delayProfiles'; import downloadClientOptions from './Settings/downloadClientOptions'; import downloadClients from './Settings/downloadClients'; -import importListExclusions from './Settings/importListExclusions'; import importListOptions from './Settings/importListOptions'; import importLists from './Settings/importLists'; @@ -20,7 +19,6 @@ export * from './Settings/downloadClients'; export * from './Settings/downloadClientOptions'; export * from './Settings/importListOptions'; export * from './Settings/importLists'; -export * from './Settings/importListExclusions'; // // Variables @@ -40,7 +38,6 @@ export const defaultState = { downloadClients: downloadClients.defaultState, downloadClientOptions: downloadClientOptions.defaultState, importLists: importLists.defaultState, - importListExclusions: importListExclusions.defaultState, importListOptions: importListOptions.defaultState }; @@ -60,7 +57,6 @@ export const actionHandlers = handleThunks({ ...downloadClients.actionHandlers, ...downloadClientOptions.actionHandlers, ...importLists.actionHandlers, - ...importListExclusions.actionHandlers, ...importListOptions.actionHandlers }); @@ -76,7 +72,6 @@ export const reducers = createHandleActions({ ...downloadClients.reducers, ...downloadClientOptions.reducers, ...importLists.reducers, - ...importListExclusions.reducers, ...importListOptions.reducers }, defaultState, section); diff --git a/frontend/src/typings/ImportListExclusion.ts b/frontend/src/typings/ImportListExclusion.ts deleted file mode 100644 index ec9add4dd..000000000 --- a/frontend/src/typings/ImportListExclusion.ts +++ /dev/null @@ -1,6 +0,0 @@ -import ModelBase from 'App/ModelBase'; - -export default interface ImportListExclusion extends ModelBase { - tvdbId: number; - title: string; -} From 2b3f0d837db13e29383a51e61bb58fe0ed015219 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:11:32 +0200 Subject: [PATCH 072/110] Bump FFprobe to 8.1.0 --- src/NzbDrone.Core/Sonarr.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Sonarr.Core.csproj b/src/NzbDrone.Core/Sonarr.Core.csproj index 6137bce6a..a8898a245 100644 --- a/src/NzbDrone.Core/Sonarr.Core.csproj +++ b/src/NzbDrone.Core/Sonarr.Core.csproj @@ -11,7 +11,7 @@ <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" /> <PackageReference Include="MiniProfiler.AspNetCore" Version="4.5.4" /> <PackageReference Include="Openur.FFMpegCore" Version="5.4.0.31" /> - <PackageReference Include="Openur.FFprobeStatic" Version="8.0.1.302" /> + <PackageReference Include="Openur.FFprobeStatic" Version="8.1.0.334" /> <PackageReference Include="Polly" Version="8.6.6" /> <PackageReference Include="System.Drawing.Common" Version="10.0.5" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" /> From 1449b8152545171a6f628a0e2ce6292e4c420da8 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:59:25 +0200 Subject: [PATCH 073/110] Bump Microsoft.Data.SqlClient and System.Data.SQLite --- src/NzbDrone.Common/Sonarr.Common.csproj | 2 +- src/NzbDrone.Core/Sonarr.Core.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Common/Sonarr.Common.csproj b/src/NzbDrone.Common/Sonarr.Common.csproj index b44da1915..cd9feb26d 100644 --- a/src/NzbDrone.Common/Sonarr.Common.csproj +++ b/src/NzbDrone.Common/Sonarr.Common.csproj @@ -17,7 +17,7 @@ <PackageReference Include="Sentry" Version="5.16.3" /> <PackageReference Include="SharpZipLib" Version="1.4.2" /> <PackageReference Include="SourceGear.sqlite3" Version="3.50.4.5" /> - <PackageReference Include="System.Data.SQLite" Version="2.0.2" /> + <PackageReference Include="System.Data.SQLite" Version="2.0.3" /> <PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.5" /> <PackageReference Include="System.IO.FileSystem.AccessControl" Version="6.0.0-preview.5.21301.5" /> <PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.5" /> diff --git a/src/NzbDrone.Core/Sonarr.Core.csproj b/src/NzbDrone.Core/Sonarr.Core.csproj index a8898a245..e0d598ad5 100644 --- a/src/NzbDrone.Core/Sonarr.Core.csproj +++ b/src/NzbDrone.Core/Sonarr.Core.csproj @@ -8,7 +8,7 @@ <PackageReference Include="Equ" Version="2.3.0" /> <PackageReference Include="MailKit" Version="4.15.1" /> <PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.5" /> - <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" /> + <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" /> <PackageReference Include="MiniProfiler.AspNetCore" Version="4.5.4" /> <PackageReference Include="Openur.FFMpegCore" Version="5.4.0.31" /> <PackageReference Include="Openur.FFprobeStatic" Version="8.1.0.334" /> From 33a5ccd7944ccb771615d154432e65ef8f3c7c48 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 20 Apr 2026 08:51:56 -0700 Subject: [PATCH 074/110] Bump MailKit to 4.16.0 --- src/NzbDrone.Core/Sonarr.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Sonarr.Core.csproj b/src/NzbDrone.Core/Sonarr.Core.csproj index e0d598ad5..7e42e4f9e 100644 --- a/src/NzbDrone.Core/Sonarr.Core.csproj +++ b/src/NzbDrone.Core/Sonarr.Core.csproj @@ -6,7 +6,7 @@ <PackageReference Include="Dapper" Version="2.1.72" /> <PackageReference Include="Diacritical.Net" Version="1.0.5" /> <PackageReference Include="Equ" Version="2.3.0" /> - <PackageReference Include="MailKit" Version="4.15.1" /> + <PackageReference Include="MailKit" Version="4.16.0" /> <PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.5" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" /> <PackageReference Include="MiniProfiler.AspNetCore" Version="4.5.4" /> From ffaaf5270d69b425d8dca262a58c919d1f700e13 Mon Sep 17 00:00:00 2001 From: matalvernaz <matalvernaz@gmail.com> Date: Mon, 20 Apr 2026 09:03:34 -0700 Subject: [PATCH 075/110] New: Screen reader Accessibility Improvements --- .../src/Activity/Blocklist/BlocklistRow.tsx | 7 ++- frontend/src/Activity/History/HistoryRow.tsx | 7 ++- .../Queue/Details/QueueDetailsProvider.tsx | 2 +- frontend/src/Activity/Queue/QueueRow.tsx | 2 + .../AddNewSeries/AddNewSeriesSearchResult.tsx | 10 ++++- .../Filter/Builder/FilterBuilderRow.tsx | 8 +++- .../Filter/CustomFilters/CustomFilter.tsx | 6 ++- .../Components/Form/KeyValueListInputItem.tsx | 2 + .../src/Components/Form/Tag/TagInputTag.tsx | 2 + frontend/src/Components/Link/IconButton.tsx | 3 +- frontend/src/Components/Modal/Modal.tsx | 43 ++++++++++++------- frontend/src/Components/Modal/ModalContext.ts | 11 +++++ frontend/src/Components/Modal/ModalHeader.tsx | 10 ++++- .../src/Components/MonitorToggleButton.tsx | 1 + .../src/Components/Page/Header/PageHeader.tsx | 1 + .../Components/Page/Sidebar/PageSidebar.tsx | 5 ++- .../Page/Sidebar/PageSidebarItem.tsx | 3 +- frontend/src/Components/Table/Table.tsx | 6 ++- .../src/Components/Table/TableHeaderCell.tsx | 26 +++++++++-- frontend/src/Components/Table/TablePager.tsx | 16 +++++-- frontend/src/Episode/EpisodeSearchCell.tsx | 1 + .../src/Episode/History/EpisodeHistoryRow.tsx | 1 + .../src/Episode/Summary/EpisodeFileRow.tsx | 1 + .../Folder/FavoriteFolderRow.tsx | 1 + .../Folder/RecentFolderRow.tsx | 6 +++ frontend/src/RootFolder/RootFolderRow.tsx | 1 + frontend/src/Series/Details/SeriesDetails.tsx | 6 +++ .../Series/Details/SeriesDetailsSeason.tsx | 5 +++ .../src/Series/History/SeriesHistoryRow.tsx | 1 + .../Index/Overview/SeriesIndexOverview.tsx | 1 + .../Index/Posters/SeriesIndexPoster.tsx | 1 + .../src/Series/Index/Table/SeriesIndexRow.tsx | 1 + .../Index/Table/SeriesIndexTableHeader.tsx | 6 ++- .../CustomFormats/CustomFormat.tsx | 2 + .../Manage/ManageCustomFormatsModalRow.tsx | 1 + .../Specifications/Specification.tsx | 1 + .../ImportListExclusionRow.tsx | 1 + .../ImportListExclusions.tsx | 1 + .../ImportLists/ImportLists/ImportList.tsx | 1 + .../Settings/Indexers/Indexers/Indexer.tsx | 1 + .../Profiles/Quality/QualityProfile.tsx | 1 + .../Profiles/Quality/QualityProfileItem.tsx | 1 + .../Quality/QualityProfileItemGroup.tsx | 1 + .../Settings/Tags/AutoTagging/AutoTagging.tsx | 1 + .../Specifications/Specification.tsx | 1 + frontend/src/System/Backup/BackupRow.tsx | 2 + frontend/src/System/Status/Health/Health.tsx | 1 + .../System/Status/Health/HealthItemLink.tsx | 5 +++ .../src/System/Tasks/Queued/QueuedTaskRow.tsx | 1 + .../Tasks/Scheduled/ScheduledTaskRow.tsx | 2 + src/NzbDrone.Core/Localization/Core/en.json | 9 ++++ 51 files changed, 199 insertions(+), 37 deletions(-) create mode 100644 frontend/src/Components/Modal/ModalContext.ts diff --git a/frontend/src/Activity/Blocklist/BlocklistRow.tsx b/frontend/src/Activity/Blocklist/BlocklistRow.tsx index cb6a6ffb9..3595203ef 100644 --- a/frontend/src/Activity/Blocklist/BlocklistRow.tsx +++ b/frontend/src/Activity/Blocklist/BlocklistRow.tsx @@ -137,10 +137,15 @@ function BlocklistRow({ if (name === 'actions') { return ( <TableRowCell key={name} className={styles.actions}> - <IconButton name={icons.INFO} onPress={handleDetailsPress} /> + <IconButton + name={icons.INFO} + aria-label={translate('Details')} + onPress={handleDetailsPress} + /> <IconButton title={translate('RemoveFromBlocklist')} + aria-label={translate('RemoveFromBlocklist')} name={icons.REMOVE} kind={kinds.DANGER} isSpinning={isRemoving} diff --git a/frontend/src/Activity/History/HistoryRow.tsx b/frontend/src/Activity/History/HistoryRow.tsx index 166eda8d6..fb4e2baf6 100644 --- a/frontend/src/Activity/History/HistoryRow.tsx +++ b/frontend/src/Activity/History/HistoryRow.tsx @@ -20,6 +20,7 @@ import { useSingleSeries } from 'Series/useSeries'; import CustomFormat from 'typings/CustomFormat'; import { HistoryData, HistoryEventType } from 'typings/History'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; import HistoryDetailsModal from './Details/HistoryDetailsModal'; import HistoryEventTypeCell from './HistoryEventTypeCell'; import styles from './HistoryRow.css'; @@ -221,7 +222,11 @@ function HistoryRow(props: HistoryRowProps) { if (name === 'details') { return ( <TableRowCell key={name} className={styles.details}> - <IconButton name={icons.INFO} onPress={handleDetailsPress} /> + <IconButton + name={icons.INFO} + aria-label={translate('Details')} + onPress={handleDetailsPress} + /> </TableRowCell> ); } diff --git a/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx b/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx index 05d1b8235..ef6c50ab1 100644 --- a/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx +++ b/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx @@ -46,7 +46,7 @@ export function useQueueItemForEpisode(episodeId: number) { const queue = useContext(QueueDetailsContext); return useMemo(() => { - return queue?.find((item) => item.episodeIds.includes(episodeId)); + return queue?.find((item) => item.episodeIds?.includes(episodeId)); }, [episodeId, queue]); } diff --git a/frontend/src/Activity/Queue/QueueRow.tsx b/frontend/src/Activity/Queue/QueueRow.tsx index 2cc69d690..a23c8af2a 100644 --- a/frontend/src/Activity/Queue/QueueRow.tsx +++ b/frontend/src/Activity/Queue/QueueRow.tsx @@ -369,6 +369,7 @@ function QueueRow(props: QueueRowProps) { {showInteractiveImport ? ( <IconButton name={icons.INTERACTIVE} + aria-label={translate('InteractiveSearch')} onPress={handleInteractiveImportPress} /> ) : null} @@ -377,6 +378,7 @@ function QueueRow(props: QueueRowProps) { <SpinnerIconButton name={icons.DOWNLOAD} kind={grabError ? kinds.DANGER : kinds.DEFAULT} + aria-label={translate('Grab')} isSpinning={isGrabbing} onPress={handleGrabPress} /> diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx index 76c8669fe..877cd2f25 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.tsx @@ -65,7 +65,13 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) { return ( <div className={styles.searchResult}> - <Link className={styles.underlay} {...linkProps} /> + <Link + className={styles.underlay} + aria-label={ + isExistingSeries ? title : translate('AddSeriesWithTitle', { title }) + } + {...linkProps} + /> <div className={styles.overlay}> {isSmallScreen ? null : ( @@ -113,12 +119,14 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) { <Link className={styles.tvdbLink} to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`} + aria-label={translate('ViewSeriesOnTvdb', { title })} onPress={handleTvdbLinkPress} > <Icon className={styles.tvdbLinkIcon} name={icons.EXTERNAL_LINK} size={28} + aria-hidden={true} /> </Link> </div> diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx b/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx index 0ccc3070d..00aa58936 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx @@ -10,6 +10,7 @@ import { import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes'; import { InputChanged } from 'typings/inputs'; import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; import DefaultFilterBuilderRowValue from './DefaultFilterBuilderRowValue'; @@ -300,11 +301,16 @@ function FilterBuilderRow<T>({ <div className={styles.actionsContainer}> <IconButton name={icons.SUBTRACT} + aria-label={translate('Remove')} isDisabled={filterCount === 1} onPress={handleRemovePress} /> - <IconButton name={icons.ADD} onPress={handleAddPress} /> + <IconButton + name={icons.ADD} + aria-label={translate('Add')} + onPress={handleAddPress} + /> </div> </div> ); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.tsx b/frontend/src/Components/Filter/CustomFilters/CustomFilter.tsx index c01686381..dcc7e6405 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFilter.tsx +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.tsx @@ -59,7 +59,11 @@ function CustomFilter({ <div className={styles.label}>{label}</div> <div className={styles.actions}> - <IconButton name={icons.EDIT} onPress={handleEditPress} /> + <IconButton + name={icons.EDIT} + aria-label={translate('Edit')} + onPress={handleEditPress} + /> <SpinnerIconButton title={translate('RemoveFilter')} diff --git a/frontend/src/Components/Form/KeyValueListInputItem.tsx b/frontend/src/Components/Form/KeyValueListInputItem.tsx index c63ad50a9..cec68f0f4 100644 --- a/frontend/src/Components/Form/KeyValueListInputItem.tsx +++ b/frontend/src/Components/Form/KeyValueListInputItem.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from 'react'; import IconButton from 'Components/Link/IconButton'; import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import TextInput from './TextInput'; import styles from './KeyValueListInputItem.css'; @@ -77,6 +78,7 @@ function KeyValueListInputItem({ {isNew ? null : ( <IconButton name={icons.REMOVE} + aria-label={translate('Remove')} tabIndex={-1} onPress={handleRemovePress} /> diff --git a/frontend/src/Components/Form/Tag/TagInputTag.tsx b/frontend/src/Components/Form/Tag/TagInputTag.tsx index 7b549767c..3612258dc 100644 --- a/frontend/src/Components/Form/Tag/TagInputTag.tsx +++ b/frontend/src/Components/Form/Tag/TagInputTag.tsx @@ -4,6 +4,7 @@ import IconButton from 'Components/Link/IconButton'; import Link from 'Components/Link/Link'; import MiddleTruncate from 'Components/MiddleTruncate'; import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import { TagBase } from './TagInput'; import styles from './TagInputTag.css'; @@ -66,6 +67,7 @@ function TagInputTag<T extends TagBase>({ <IconButton className={styles.editButton} name={icons.EDIT} + aria-label={translate('Edit')} size={9} onPress={handleEdit} /> diff --git a/frontend/src/Components/Link/IconButton.tsx b/frontend/src/Components/Link/IconButton.tsx index b6951c00c..52ac0b036 100644 --- a/frontend/src/Components/Link/IconButton.tsx +++ b/frontend/src/Components/Link/IconButton.tsx @@ -1,7 +1,6 @@ import classNames from 'classnames'; import React from 'react'; import Icon, { IconProps } from 'Components/Icon'; -import translate from 'Utilities/String/translate'; import Link, { LinkProps } from './Link'; import styles from './IconButton.css'; @@ -26,7 +25,6 @@ export default function IconButton({ className, otherProps.isDisabled && styles.isDisabled )} - aria-label={translate('TableOptionsButton')} {...otherProps} > <Icon @@ -35,6 +33,7 @@ export default function IconButton({ kind={kind} size={size} isSpinning={isSpinning} + aria-hidden={true} /> </Link> ); diff --git a/frontend/src/Components/Modal/Modal.tsx b/frontend/src/Components/Modal/Modal.tsx index cfc157f93..e3b54beb6 100644 --- a/frontend/src/Components/Modal/Modal.tsx +++ b/frontend/src/Components/Modal/Modal.tsx @@ -15,6 +15,7 @@ import { Size } from 'Helpers/Props/sizes'; import { isIOS } from 'Utilities/browser'; import * as keyCodes from 'Utilities/Constants/keyCodes'; import { setScrollLock } from 'Utilities/scrollLock'; +import { ModalContext } from './ModalContext'; import ModalError from './ModalError'; import styles from './Modal.css'; @@ -163,26 +164,36 @@ function Modal({ return null; } + const headerId = `${modalId}-header`; + return ReactDOM.createPortal( - <FocusLock disabled={false}> - <div className={styles.modalContainer}> - <div - ref={backgroundRef} - className={backdropClassName} - onMouseDown={handleBackdropBeginPress} - onMouseUp={handleBackdropEndPress} - > - <div className={classNames(className, styles[size])} style={style}> - <ErrorBoundary - errorComponent={ModalError} - onModalClose={onModalClose} + <ModalContext.Provider value={{ headerId }}> + <FocusLock disabled={false}> + <div className={styles.modalContainer}> + <div + ref={backgroundRef} + className={backdropClassName} + onMouseDown={handleBackdropBeginPress} + onMouseUp={handleBackdropEndPress} + > + <div + className={classNames(className, styles[size])} + style={style} + role="dialog" + aria-modal="true" + aria-labelledby={headerId} > - {children} - </ErrorBoundary> + <ErrorBoundary + errorComponent={ModalError} + onModalClose={onModalClose} + > + {children} + </ErrorBoundary> + </div> </div> </div> - </div> - </FocusLock>, + </FocusLock> + </ModalContext.Provider>, node! ); } diff --git a/frontend/src/Components/Modal/ModalContext.ts b/frontend/src/Components/Modal/ModalContext.ts new file mode 100644 index 000000000..405d5ddd1 --- /dev/null +++ b/frontend/src/Components/Modal/ModalContext.ts @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react'; + +interface ModalContextValue { + headerId: string; +} + +export const ModalContext = createContext<ModalContextValue>({ headerId: '' }); + +export function useModalContext() { + return useContext(ModalContext); +} diff --git a/frontend/src/Components/Modal/ModalHeader.tsx b/frontend/src/Components/Modal/ModalHeader.tsx index 86f2c9ac1..e90ad2f0b 100644 --- a/frontend/src/Components/Modal/ModalHeader.tsx +++ b/frontend/src/Components/Modal/ModalHeader.tsx @@ -1,4 +1,5 @@ import React, { ForwardedRef, forwardRef, ReactNode } from 'react'; +import { useModalContext } from './ModalContext'; import styles from './ModalHeader.css'; interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> { @@ -10,8 +11,15 @@ const ModalHeader = forwardRef( { children, ...otherProps }: ModalHeaderProps, ref: ForwardedRef<HTMLDivElement> ) => { + const { headerId } = useModalContext(); + return ( - <div ref={ref} className={styles.modalHeader} {...otherProps}> + <div + ref={ref} + id={headerId} + className={styles.modalHeader} + {...otherProps} + > {children} </div> ); diff --git a/frontend/src/Components/MonitorToggleButton.tsx b/frontend/src/Components/MonitorToggleButton.tsx index 1c1fcbbeb..36d95903f 100644 --- a/frontend/src/Components/MonitorToggleButton.tsx +++ b/frontend/src/Components/MonitorToggleButton.tsx @@ -54,6 +54,7 @@ function MonitorToggleButton(props: MonitorToggleButtonProps) { name={iconName} size={size} title={title} + aria-label={title} isDisabled={isDisabled} isSpinning={isSaving} {...otherProps} diff --git a/frontend/src/Components/Page/Header/PageHeader.tsx b/frontend/src/Components/Page/Header/PageHeader.tsx index c63447bbf..54a96697c 100644 --- a/frontend/src/Components/Page/Header/PageHeader.tsx +++ b/frontend/src/Components/Page/Header/PageHeader.tsx @@ -55,6 +55,7 @@ function PageHeader() { <IconButton id="sidebar-toggle-button" name={icons.NAVBAR_COLLAPSE} + aria-label={translate('Menu')} onPress={handleSidebarToggle} /> </div> diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.tsx b/frontend/src/Components/Page/Sidebar/PageSidebar.tsx index e2f5460f9..a2245daee 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.tsx +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.tsx @@ -435,10 +435,11 @@ function PageSidebar() { const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller; return ( - <div + <nav ref={sidebarRef} className={styles.sidebarContainer} style={containerStyle} + aria-label={translate('MainNavigation')} > {isSmallScreen ? ( <div className={styles.sidebarHeader}> @@ -521,7 +522,7 @@ function PageSidebar() { <Messages /> </ScrollerComponent> - </div> + </nav> ); } diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx b/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx index 37d9bafa0..c2b8cfff1 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.tsx @@ -46,11 +46,12 @@ function PageSidebarItem({ isActive && styles.isActiveLink )} to={to} + aria-current={isActive ? 'page' : undefined} onPress={handlePress} > {!!iconName && ( <span className={styles.iconContainer}> - <Icon name={iconName} /> + <Icon name={iconName} aria-hidden={true} /> </span> )} diff --git a/frontend/src/Components/Table/Table.tsx b/frontend/src/Components/Table/Table.tsx index c72b68b96..995529ec2 100644 --- a/frontend/src/Components/Table/Table.tsx +++ b/frontend/src/Components/Table/Table.tsx @@ -7,6 +7,7 @@ import { icons, scrollDirections } from 'Helpers/Props'; import { SortDirection } from 'Helpers/Props/sortDirections'; import { CheckInputChanged } from 'typings/inputs'; import { TableOptionsChangePayload } from 'typings/Table'; +import translate from 'Utilities/String/translate'; import Column from './Column'; import TableHeader from './TableHeader'; import TableHeaderCell from './TableHeaderCell'; @@ -94,7 +95,10 @@ function Table({ canModifyColumns={canModifyColumns} onTableOptionChange={onTableOptionChange} > - <IconButton name={icons.ADVANCED_SETTINGS} /> + <IconButton + name={icons.ADVANCED_SETTINGS} + aria-label={translate('AdvancedSettings')} + /> </TableOptionsModalWrapper> </TableHeaderCell> ); diff --git a/frontend/src/Components/Table/TableHeaderCell.tsx b/frontend/src/Components/Table/TableHeaderCell.tsx index 13b8cf0f7..9311a81a5 100644 --- a/frontend/src/Components/Table/TableHeaderCell.tsx +++ b/frontend/src/Components/Table/TableHeaderCell.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; import { icons, sortDirections } from 'Helpers/Props'; @@ -41,6 +41,20 @@ function TableHeaderCell({ ? icons.SORT_ASCENDING : icons.SORT_DESCENDING; + const ariaSortValue = useMemo(() => { + if (!isSortable) { + return undefined; + } + + if (!isSorting) { + return 'none'; + } + + return sortDirection === sortDirections.ASCENDING + ? 'ascending' + : 'descending'; + }, [isSorting, sortDirection, isSortable]); + const handlePress = useCallback(() => { if (fixedSortDirection) { onSortPress?.(name, fixedSortDirection); @@ -56,14 +70,20 @@ function TableHeaderCell({ className={className} // label={typeof label === 'function' ? label() : label} title={typeof columnLabel === 'function' ? columnLabel() : columnLabel} + scope="col" + aria-sort={ariaSortValue} onPress={handlePress} > {children} - {isSorting && <Icon name={sortIcon} className={styles.sortIcon} />} + {isSorting ? ( + <Icon name={sortIcon} className={styles.sortIcon} aria-hidden={true} /> + ) : null} </Link> ) : ( - <th className={className}>{children}</th> + <th className={className} scope="col"> + {children} + </th> ); } diff --git a/frontend/src/Components/Table/TablePager.tsx b/frontend/src/Components/Table/TablePager.tsx index 411b111bf..5394ccc69 100644 --- a/frontend/src/Components/Table/TablePager.tsx +++ b/frontend/src/Components/Table/TablePager.tsx @@ -108,9 +108,10 @@ function TablePager({ isFirstPage && styles.disabledPageButton )} isDisabled={isFirstPage} + aria-label={translate('PagerGoToFirstPage')} onPress={handleFirstPagePress} > - <Icon name={icons.PAGE_FIRST} /> + <Icon name={icons.PAGE_FIRST} aria-hidden={true} /> </Link> <Link @@ -119,15 +120,20 @@ function TablePager({ isFirstPage && styles.disabledPageButton )} isDisabled={isFirstPage} + aria-label={translate('PagerGoToPreviousPage')} onPress={onPreviousPagePress} > - <Icon name={icons.PAGE_PREVIOUS} /> + <Icon name={icons.PAGE_PREVIOUS} aria-hidden={true} /> </Link> <div className={styles.pageNumber}> {isShowingPageSelect ? null : ( <Link isDisabled={totalPages === 1} + aria-label={translate('PagerGoToPage', { + page, + totalPages: totalPages ?? 0, + })} onPress={handleOpenPageSelectClick} > {page} / {totalPages} @@ -153,9 +159,10 @@ function TablePager({ isLastPage && styles.disabledPageButton )} isDisabled={isLastPage} + aria-label={translate('PagerGoToNextPage')} onPress={onNextPagePress} > - <Icon name={icons.PAGE_NEXT} /> + <Icon name={icons.PAGE_NEXT} aria-hidden={true} /> </Link> <Link @@ -164,9 +171,10 @@ function TablePager({ isLastPage && styles.disabledPageButton )} isDisabled={isLastPage} + aria-label={translate('PagerGoToLastPage')} onPress={onLastPagePress} > - <Icon name={icons.PAGE_LAST} /> + <Icon name={icons.PAGE_LAST} aria-hidden={true} /> </Link> </div> </div> diff --git a/frontend/src/Episode/EpisodeSearchCell.tsx b/frontend/src/Episode/EpisodeSearchCell.tsx index 7e0f38a86..cea39a6ee 100644 --- a/frontend/src/Episode/EpisodeSearchCell.tsx +++ b/frontend/src/Episode/EpisodeSearchCell.tsx @@ -54,6 +54,7 @@ function EpisodeSearchCell({ <IconButton name={icons.INTERACTIVE} title={translate('InteractiveSearch')} + aria-label={translate('InteractiveSearch')} onPress={setDetailsModalOpen} /> diff --git a/frontend/src/Episode/History/EpisodeHistoryRow.tsx b/frontend/src/Episode/History/EpisodeHistoryRow.tsx index a5c4a14f5..1503fdccb 100644 --- a/frontend/src/Episode/History/EpisodeHistoryRow.tsx +++ b/frontend/src/Episode/History/EpisodeHistoryRow.tsx @@ -128,6 +128,7 @@ function EpisodeHistoryRow({ {eventType === 'grabbed' && ( <IconButton title={translate('MarkAsFailed')} + aria-label={translate('MarkAsFailed')} name={icons.REMOVE} size={14} onPress={handleMarkAsFailedPress} diff --git a/frontend/src/Episode/Summary/EpisodeFileRow.tsx b/frontend/src/Episode/Summary/EpisodeFileRow.tsx index d2bf5f4ba..58388697a 100644 --- a/frontend/src/Episode/Summary/EpisodeFileRow.tsx +++ b/frontend/src/Episode/Summary/EpisodeFileRow.tsx @@ -124,6 +124,7 @@ function EpisodeFileRow(props: EpisodeFileRowProps) { <IconButton title={translate('DeleteEpisodeFromDisk')} + aria-label={translate('DeleteEpisodeFromDisk')} name={icons.REMOVE} onPress={setRemoveEpisodeFileModalOpen} /> diff --git a/frontend/src/InteractiveImport/Folder/FavoriteFolderRow.tsx b/frontend/src/InteractiveImport/Folder/FavoriteFolderRow.tsx index 7f1b3d393..b72a5faf7 100644 --- a/frontend/src/InteractiveImport/Folder/FavoriteFolderRow.tsx +++ b/frontend/src/InteractiveImport/Folder/FavoriteFolderRow.tsx @@ -33,6 +33,7 @@ function FavoriteFolderRow({ folder, onPress }: FavoriteFolderRowProps) { <TableRowCell className={styles.actions}> <IconButton title={translate('FavoriteFolderRemove')} + aria-label={translate('FavoriteFolderRemove')} kind="danger" name={icons.HEART} onPress={handleRemoveFavoritePress} diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.tsx b/frontend/src/InteractiveImport/Folder/RecentFolderRow.tsx index d77c0ae03..3dc72be08 100644 --- a/frontend/src/InteractiveImport/Folder/RecentFolderRow.tsx +++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.tsx @@ -64,6 +64,11 @@ function RecentFolderRow({ ? translate('FavoriteFolderRemove') : translate('FavoriteFolderAdd') } + aria-label={ + isFavorite + ? translate('FavoriteFolderRemove') + : translate('FavoriteFolderAdd') + } kind={isFavorite ? 'danger' : 'default'} name={isFavorite ? icons.HEART : icons.HEART_OUTLINE} onPress={handleFavoritePress} @@ -71,6 +76,7 @@ function RecentFolderRow({ <IconButton title={translate('Remove')} + aria-label={translate('Remove')} name={icons.REMOVE} onPress={handleRemovePress} /> diff --git a/frontend/src/RootFolder/RootFolderRow.tsx b/frontend/src/RootFolder/RootFolderRow.tsx index 3d8c32b3a..2a2008a7c 100644 --- a/frontend/src/RootFolder/RootFolderRow.tsx +++ b/frontend/src/RootFolder/RootFolderRow.tsx @@ -83,6 +83,7 @@ function RootFolderRow(props: RootFolderRowProps) { <TableRowCell className={styles.actions}> <IconButton title={translate('RemoveRootFolder')} + aria-label={translate('RemoveRootFolder')} name={icons.REMOVE} onPress={onDeletePress} /> diff --git a/frontend/src/Series/Details/SeriesDetails.tsx b/frontend/src/Series/Details/SeriesDetails.tsx index 605710b5f..0284d7d9c 100644 --- a/frontend/src/Series/Details/SeriesDetails.tsx +++ b/frontend/src/Series/Details/SeriesDetails.tsx @@ -574,6 +574,9 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { title={translate('SeriesDetailsGoTo', { title: previousSeries.title, })} + aria-label={translate('SeriesDetailsGoTo', { + title: previousSeries.title, + })} to={`/series/${previousSeries.titleSlug}`} /> ) : null} @@ -586,6 +589,9 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { title={translate('SeriesDetailsGoTo', { title: nextSeries.title, })} + aria-label={translate('SeriesDetailsGoTo', { + title: nextSeries.title, + })} to={`/series/${nextSeries.titleSlug}`} /> ) : null} diff --git a/frontend/src/Series/Details/SeriesDetailsSeason.tsx b/frontend/src/Series/Details/SeriesDetailsSeason.tsx index d48c54b03..b8d35de02 100644 --- a/frontend/src/Series/Details/SeriesDetailsSeason.tsx +++ b/frontend/src/Series/Details/SeriesDetailsSeason.tsx @@ -432,6 +432,7 @@ function SeriesDetailsSeason({ className={styles.actionButton} name={icons.INTERACTIVE} title={translate('InteractiveSearchSeason')} + aria-label={translate('InteractiveSearchSeason')} size={24} isDisabled={!totalEpisodeCount} onPress={handleInteractiveSearchPress} @@ -441,6 +442,7 @@ function SeriesDetailsSeason({ className={styles.actionButton} name={icons.ORGANIZE} title={translate('PreviewRenameSeason')} + aria-label={translate('PreviewRenameSeason')} size={24} isDisabled={!episodeFileCount} onPress={handleOrganizePress} @@ -450,6 +452,7 @@ function SeriesDetailsSeason({ className={styles.actionButton} name={icons.EPISODE_FILE} title={translate('ManageEpisodesSeason')} + aria-label={translate('ManageEpisodesSeason')} size={24} isDisabled={!episodeFileCount} onPress={handleManageEpisodesPress} @@ -459,6 +462,7 @@ function SeriesDetailsSeason({ className={styles.actionButton} name={icons.HISTORY} title={translate('HistorySeason')} + aria-label={translate('HistorySeason')} size={24} isDisabled={!totalEpisodeCount} onPress={handleHistoryPress} @@ -506,6 +510,7 @@ function SeriesDetailsSeason({ name={icons.COLLAPSE} size={20} title={translate('HideEpisodes')} + aria-label={translate('HideEpisodes')} onPress={handleExpandPress} /> </div> diff --git a/frontend/src/Series/History/SeriesHistoryRow.tsx b/frontend/src/Series/History/SeriesHistoryRow.tsx index c1d82ae26..8968f234d 100644 --- a/frontend/src/Series/History/SeriesHistoryRow.tsx +++ b/frontend/src/Series/History/SeriesHistoryRow.tsx @@ -158,6 +158,7 @@ function SeriesHistoryRow({ {eventType === 'grabbed' ? ( <IconButton title={translate('MarkAsFailed')} + aria-label={translate('MarkAsFailed')} name={icons.REMOVE} size={14} onPress={handleMarkAsFailedPress} diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx b/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx index 975af55c2..c88b324e6 100644 --- a/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx @@ -215,6 +215,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) { <IconButton name={icons.EDIT} title={translate('EditSeries')} + aria-label={translate('EditSeries')} onPress={onEditSeriesPress} /> </div> diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx b/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx index bd2c33593..03c9f98f6 100644 --- a/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx +++ b/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx @@ -162,6 +162,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) { className={styles.action} name={icons.EDIT} title={translate('EditSeries')} + aria-label={translate('EditSeries')} tabIndex={-1} onPress={onEditSeriesPress} /> diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx index afea5d1af..18fbbb00d 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx @@ -490,6 +490,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { <IconButton name={icons.EDIT} title={translate('EditSeries')} + aria-label={translate('EditSeries')} onPress={onEditSeriesPress} /> </VirtualTableRowCell> diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx index fcf80c73e..63b9dcf0e 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.tsx @@ -16,6 +16,7 @@ import { } from 'Series/seriesOptionsStore'; import { CheckInputChanged } from 'typings/inputs'; import { TableOptionsChangePayload } from 'typings/Table'; +import translate from 'Utilities/String/translate'; import hasGrowableColumns from './hasGrowableColumns'; import SeriesIndexTableOptions from './SeriesIndexTableOptions'; import styles from './SeriesIndexTableHeader.css'; @@ -95,7 +96,10 @@ function SeriesIndexTableHeader(props: SeriesIndexTableHeaderProps) { optionsComponent={SeriesIndexTableOptions} onTableOptionChange={onTableOptionChange} > - <IconButton name={icons.ADVANCED_SETTINGS} /> + <IconButton + name={icons.ADVANCED_SETTINGS} + aria-label={translate('AdvancedSettings')} + /> </TableOptionsModalWrapper> </VirtualTableHeaderCell> ); diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.tsx index 335a7a555..922951280 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.tsx +++ b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.tsx @@ -83,6 +83,7 @@ function CustomFormat({ <IconButton className={styles.cloneButton} title={translate('CloneCustomFormat')} + aria-label={translate('CloneCustomFormat')} name={icons.CLONE} onPress={handleCloneCustomFormatPressHandler} /> @@ -90,6 +91,7 @@ function CustomFormat({ <IconButton className={styles.cloneButton} title={translate('ExportCustomFormat')} + aria-label={translate('ExportCustomFormat')} name={icons.EXPORT} onPress={handleExportCustomFormatPress} /> diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx index cfcd461a9..03891dd69 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx @@ -94,6 +94,7 @@ function ManageCustomFormatsModalRow({ <TableRowCell className={styles.actions}> <IconButton name={icons.EDIT} + aria-label={translate('Edit')} onPress={handleEditCustomFormatModalOpen} /> </TableRowCell> diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.tsx index afb91aa97..5d05ccafd 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.tsx +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.tsx @@ -73,6 +73,7 @@ function Specification({ <IconButton className={styles.cloneButton} title={translate('CloneCondition')} + aria-label={translate('CloneCondition')} name={icons.CLONE} onPress={handleCloneSpecificationPress} /> diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx index 6e09c917c..065d0cad9 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx @@ -77,6 +77,7 @@ function ImportListExclusionRow({ <TableRowCell className={styles.actions}> <IconButton name={icons.EDIT} + aria-label={translate('Edit')} onPress={setEditImportListExclusionModalOpen} /> </TableRowCell> diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx index 954cf8b7f..37ae61cd7 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx @@ -199,6 +199,7 @@ function ImportListExclusionsContent() { <TableRowCell> <IconButton name={icons.ADD} + aria-label={translate('Add')} onPress={setAddImportListExclusionModalOpen} /> </TableRowCell> diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportList.tsx b/frontend/src/Settings/ImportLists/ImportLists/ImportList.tsx index d5ebf4fa9..6613428b5 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/ImportList.tsx +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportList.tsx @@ -76,6 +76,7 @@ function ImportList({ <IconButton className={styles.cloneButton} title={translate('CloneImportList')} + aria-label={translate('CloneImportList')} name={icons.CLONE} onPress={handleCloneImportListPress} /> diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.tsx b/frontend/src/Settings/Indexers/Indexers/Indexer.tsx index 6100e047f..f3da6fa9b 100644 --- a/frontend/src/Settings/Indexers/Indexers/Indexer.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.tsx @@ -75,6 +75,7 @@ function Indexer({ <IconButton className={styles.cloneButton} title={translate('CloneIndexer')} + aria-label={translate('CloneIndexer')} name={icons.CLONE} onPress={handleCloneIndexerPress} /> diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfile.tsx index 36e0d6b02..14a70d16c 100644 --- a/frontend/src/Settings/Profiles/Quality/QualityProfile.tsx +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.tsx @@ -76,6 +76,7 @@ function QualityProfile({ <IconButton className={styles.cloneButton} title={translate('CloneProfile')} + aria-label={translate('CloneProfile')} name={icons.CLONE} onPress={handleCloneQualityProfilePress} /> diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.tsx index 09a363ded..da24aecf5 100644 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.tsx +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.tsx @@ -75,6 +75,7 @@ function QualityProfileItem({ className={styles.createGroupButton} name={icons.GROUP} title={translate('Group')} + aria-label={translate('Group')} onPress={handleCreateGroupPress} /> )} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.tsx b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.tsx index 06a840a2c..801728802 100644 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.tsx +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.tsx @@ -89,6 +89,7 @@ function QualityProfileItemGroup({ className={styles.deleteGroupButton} name={icons.UNGROUP} title={translate('Ungroup')} + aria-label={translate('Ungroup')} onPress={handleDeleteGroupPress} /> diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx index a53654897..8f1179188 100644 --- a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx @@ -74,6 +74,7 @@ export default function AutoTagging({ <IconButton className={styles.cloneButton} title={translate('CloneAutoTag')} + aria-label={translate('CloneAutoTag')} name={icons.CLONE} onPress={onClonePress} /> diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.tsx b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.tsx index 099dfd5d8..b49bf27c6 100644 --- a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.tsx @@ -70,6 +70,7 @@ export default function Specification({ <IconButton className={styles.cloneButton} title={translate('Clone')} + aria-label={translate('Clone')} name={icons.CLONE} onPress={onClonePress} /> diff --git a/frontend/src/System/Backup/BackupRow.tsx b/frontend/src/System/Backup/BackupRow.tsx index 739d07ec9..4b2d682eb 100644 --- a/frontend/src/System/Backup/BackupRow.tsx +++ b/frontend/src/System/Backup/BackupRow.tsx @@ -96,12 +96,14 @@ function BackupRow({ id, type, name, path, size, time }: BackupRowProps) { <TableRowCell className={styles.actions}> <IconButton title={translate('RestoreBackup')} + aria-label={translate('RestoreBackup')} name={icons.RESTORE} onPress={handleRestorePress} /> <IconButton title={translate('DeleteBackup')} + aria-label={translate('DeleteBackup')} name={icons.DELETE} onPress={handleDeletePress} /> diff --git a/frontend/src/System/Status/Health/Health.tsx b/frontend/src/System/Status/Health/Health.tsx index fc334b542..07ed81c24 100644 --- a/frontend/src/System/Status/Health/Health.tsx +++ b/frontend/src/System/Status/Health/Health.tsx @@ -118,6 +118,7 @@ function Health() { name={icons.WIKI} to={item.wikiUrl} title={translate('ReadTheWikiForMoreInformation')} + aria-label={translate('ReadTheWikiForMoreInformation')} /> <HealthItemLink source={source} /> diff --git a/frontend/src/System/Status/Health/HealthItemLink.tsx b/frontend/src/System/Status/Health/HealthItemLink.tsx index ac3bafade..90a9b4c3a 100644 --- a/frontend/src/System/Status/Health/HealthItemLink.tsx +++ b/frontend/src/System/Status/Health/HealthItemLink.tsx @@ -20,6 +20,7 @@ function HealthItemLink(props: HealthItemLinkProps) { <IconButton name={icons.SETTINGS} title={translate('Settings')} + aria-label={translate('Settings')} to="/settings/indexers" /> ); @@ -30,6 +31,7 @@ function HealthItemLink(props: HealthItemLinkProps) { <IconButton name={icons.SETTINGS} title={translate('Settings')} + aria-label={translate('Settings')} to="/settings/downloadclients" /> ); @@ -38,6 +40,7 @@ function HealthItemLink(props: HealthItemLinkProps) { <IconButton name={icons.SETTINGS} title={translate('Settings')} + aria-label={translate('Settings')} to="/settings/connect" /> ); @@ -46,6 +49,7 @@ function HealthItemLink(props: HealthItemLinkProps) { <IconButton name={icons.SERIES_CONTINUING} title={translate('SeriesEditor')} + aria-label={translate('SeriesEditor')} to="/serieseditor" /> ); @@ -54,6 +58,7 @@ function HealthItemLink(props: HealthItemLinkProps) { <IconButton name={icons.UPDATE} title={translate('Updates')} + aria-label={translate('Updates')} to="/system/updates" /> ); diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx b/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx index 7b426edbf..0f15dde9f 100644 --- a/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx @@ -219,6 +219,7 @@ export default function QueuedTaskRow(props: QueuedTaskRowProps) { {status === 'queued' && ( <IconButton title={translate('RemovedFromTaskQueue')} + aria-label={translate('RemovedFromTaskQueue')} name={icons.REMOVE} onPress={openCancelConfirmModal} /> diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.tsx b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.tsx index c4e5035ea..ce446fb14 100644 --- a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.tsx +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.tsx @@ -10,6 +10,7 @@ import { isCommandExecuting } from 'Utilities/Command'; import formatDate from 'Utilities/Date/formatDate'; import formatDateTime from 'Utilities/Date/formatDateTime'; import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import translate from 'Utilities/String/translate'; import styles from './ScheduledTaskRow.css'; interface ScheduledTaskRowProps { @@ -138,6 +139,7 @@ function ScheduledTaskRow({ <SpinnerIconButton name={icons.REFRESH} spinningName={icons.REFRESH} + aria-label={translate('Run')} isSpinning={isExecuting} onPress={handleExecutePress} /> diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index db9577107..51d7e3c78 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -57,6 +57,7 @@ "AddedDate": "Added: {date}", "AddedToDownloadQueue": "Added to download queue", "AddingTag": "Adding tag", + "AdvancedSettings": "Advanced Settings", "AfterManualRefresh": "After Manual Refresh", "Age": "Age", "AgeWhenGrabbed": "Age (when grabbed)", @@ -1182,6 +1183,7 @@ "Logs": "Logs", "LongDateFormat": "Long Date Format", "Lowercase": "Lowercase", + "MainNavigation": "Main Navigation", "MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details", "ManageClients": "Manage Clients", "ManageCustomFormats": "Manage Custom Formats", @@ -1635,6 +1637,11 @@ "OverviewOptions": "Overview Options", "PackageVersion": "Package Version", "PackageVersionInfo": "{packageVersion} by {packageAuthor}", + "PagerGoToFirstPage": "Go to first page", + "PagerGoToLastPage": "Go to last page", + "PagerGoToNextPage": "Go to next page", + "PagerGoToPage": "Go to page {page} of {totalPages}", + "PagerGoToPreviousPage": "Go to previous page", "Parse": "Parse", "ParseModalErrorParsing": "Error parsing, please try again.", "ParseModalHelpText": "Enter a release title in the input above", @@ -1875,6 +1882,7 @@ "RssSyncInterval": "RSS Sync Interval", "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", + "Run": "Run", "Runtime": "Runtime", "Saturday": "Saturday", "Save": "Save", @@ -2232,6 +2240,7 @@ "VideoCodec": "Video Codec", "VideoDynamicRange": "Video Dynamic Range", "View": "View", + "ViewSeriesOnTvdb": "View {title} on TVDB", "VisitTheWikiForMoreDetails": "Visit the wiki for more details: ", "WaitingToImport": "Waiting to Import", "WaitingToProcess": "Waiting to Process", From 424f8e9e8dad1680d10493f4431f2bd4a7d1b90d Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:00:00 +0300 Subject: [PATCH 076/110] Log media info title used to augment quality --- .../Augmenters/Quality/AugmentQualityFromMediaInfo.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromMediaInfo.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromMediaInfo.cs index e03748e98..c07ed59e4 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromMediaInfo.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/Augmenters/Quality/AugmentQualityFromMediaInfo.cs @@ -30,11 +30,13 @@ public AugmentQualityResult AugmentQuality(LocalEpisode localEpisode, DownloadCl var height = localEpisode.MediaInfo.Height; var source = QualitySource.Unknown; var sourceConfidence = Confidence.Default; - var title = localEpisode.MediaInfo.Title; + var title = localEpisode.MediaInfo.Title?.Trim(); if (title.IsNotNullOrWhiteSpace()) { - var parsedQuality = QualityParser.ParseQualityName(title.Trim()); + _logger.Debug("Parsing quality from media info title '{0}'", title); + + var parsedQuality = QualityParser.ParseQualityName(title); // Only use the quality if it's not unknown and the source is from the name (which is MediaInfo's title in this case) if (parsedQuality.Quality.Source != QualitySource.Unknown && From 10192460acb2670dae852a0daa3542078059fa3f Mon Sep 17 00:00:00 2001 From: Sonarr <development@sonarr.tv> Date: Mon, 13 Apr 2026 00:32:53 +0000 Subject: [PATCH 077/110] Automated API Docs update ignore-downstream --- src/Sonarr.Api.V5/openapi.json | 278 +++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) diff --git a/src/Sonarr.Api.V5/openapi.json b/src/Sonarr.Api.V5/openapi.json index e1dbf3cc2..ed7a967ae 100644 --- a/src/Sonarr.Api.V5/openapi.json +++ b/src/Sonarr.Api.V5/openapi.json @@ -2044,6 +2044,213 @@ } } }, + "/api/v5/importlistexclusion": { + "get": { + "tags": [ + "ImportListExclusion" + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 10 + } + }, + { + "name": "sortKey", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "sortDirection", + "in": "query", + "schema": { + "$ref": "#/components/schemas/SortDirection" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportListExclusionResourcePagingResource" + } + } + } + } + } + }, + "post": { + "tags": [ + "ImportListExclusion" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportListExclusionResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ImportListExclusionResource" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportListExclusionResource" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ImportListExclusionResource" + } + } + } + } + } + } + }, + "/api/v5/importlistexclusion/{id}": { + "put": { + "tags": [ + "ImportListExclusion" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportListExclusionResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ImportListExclusionResource" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportListExclusionResource" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ImportListExclusionResource" + } + } + } + } + } + }, + "delete": { + "tags": [ + "ImportListExclusion" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "ImportListExclusion" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportListExclusionResource" + } + } + } + } + } + } + }, + "/api/v5/importlistexclusion/bulk": { + "delete": { + "tags": [ + "ImportListExclusion" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportListExclusionBulkResource" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/v5/indexer": { "get": { "tags": [ @@ -7615,6 +7822,74 @@ ], "type": "string" }, + "ImportListExclusionBulkResource": { + "required": [ + "ids" + ], + "type": "object", + "properties": { + "ids": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "ImportListExclusionResource": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "tvdbId": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "ImportListExclusionResourcePagingResource": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + }, + "sortKey": { + "type": "string", + "nullable": true + }, + "sortDirection": { + "$ref": "#/components/schemas/SortDirection" + }, + "totalRecords": { + "type": "integer", + "format": "int32" + }, + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportListExclusionResource" + }, + "nullable": true + } + }, + "additionalProperties": false + }, "ImportRejectionReason": { "enum": [ "unknown", @@ -11031,6 +11306,9 @@ { "name": "History" }, + { + "name": "ImportListExclusion" + }, { "name": "Indexer" }, From c6f394ccd7b5a97909787999216bce0d6bc2c740 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Wed, 15 Apr 2026 16:46:51 -0700 Subject: [PATCH 078/110] New: Average Size per Episode column on Series list Closes #8513 --- .../Index/Menus/SeriesIndexSortMenu.tsx | 9 ++++++ .../src/Series/Index/Table/SeriesIndexRow.css | 6 ++++ .../Index/Table/SeriesIndexRow.css.d.ts | 1 + .../src/Series/Index/Table/SeriesIndexRow.tsx | 11 ++++++++ .../Index/Table/SeriesIndexTableHeader.css | 6 ++++ .../Table/SeriesIndexTableHeader.css.d.ts | 1 + frontend/src/Series/seriesOptionsStore.ts | 6 ++++ frontend/src/Series/useSeries.ts | 28 +++++++++++++++++++ src/NzbDrone.Core/Localization/Core/en.json | 2 ++ 9 files changed, 70 insertions(+) diff --git a/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx index 29805a9bd..d99645c8a 100644 --- a/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx +++ b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.tsx @@ -154,6 +154,15 @@ function SeriesIndexSortMenu(props: SeriesIndexSortMenuProps) { {translate('SizeOnDisk')} </SortMenuItem> + <SortMenuItem + name="averageSizePerEpisode" + sortKey={sortKey} + sortDirection={sortDirection} + onPress={onSortSelect} + > + {translate('AverageSizePerEpisode')} + </SortMenuItem> + <SortMenuItem name="tags" sortKey={sortKey} diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css b/frontend/src/Series/Index/Table/SeriesIndexRow.css index d0d2b6db1..e18a9ed74 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css @@ -131,6 +131,12 @@ flex: 0 0 120px; } +.averageSizePerEpisode { + composes: cell; + + flex: 0 0 160px; +} + .ratings { composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts index dfa41ccae..635b5a616 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts @@ -3,6 +3,7 @@ interface CssExports { 'actions': string; 'added': string; + 'averageSizePerEpisode': string; 'banner': string; 'bannerGrow': string; 'bannerImage': string; diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx index 18fbbb00d..b8d83211b 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx @@ -396,6 +396,17 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { ); } + if (name === 'averageSizePerEpisode') { + const averageSize = + totalEpisodeCount > 0 ? sizeOnDisk / totalEpisodeCount : 0; + + return ( + <VirtualTableRowCell key={name} className={styles[name]}> + {averageSize ? formatBytes(averageSize) : null} + </VirtualTableRowCell> + ); + } + if (name === 'genres') { const joinedGenres = genres.join(', '); diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css index df574576e..a37cb5081 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css @@ -92,6 +92,12 @@ flex: 0 0 120px; } +.averageSizePerEpisode { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 160px; +} + .ratings { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts index f30a9e786..1b566399d 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts @@ -3,6 +3,7 @@ interface CssExports { 'actions': string; 'added': string; + 'averageSizePerEpisode': string; 'banner': string; 'bannerGrow': string; 'certification': string; diff --git a/frontend/src/Series/seriesOptionsStore.ts b/frontend/src/Series/seriesOptionsStore.ts index 022bcb90d..b71c1cbb9 100644 --- a/frontend/src/Series/seriesOptionsStore.ts +++ b/frontend/src/Series/seriesOptionsStore.ts @@ -191,6 +191,12 @@ const { useOptions, useOption, setOptions, setOption, setSort, getOptions } = isSortable: true, isVisible: false, }, + { + name: 'averageSizePerEpisode', + label: () => translate('AverageSize'), + isSortable: true, + isVisible: false, + }, { name: 'genres', label: () => translate('Genres'), diff --git a/frontend/src/Series/useSeries.ts b/frontend/src/Series/useSeries.ts index 63497121d..b41428f2f 100644 --- a/frontend/src/Series/useSeries.ts +++ b/frontend/src/Series/useSeries.ts @@ -113,6 +113,14 @@ const SORT_PREDICATES = { return item.statistics?.sizeOnDisk ?? 0; }, + averageSizePerEpisode: (item: Series, _direction: SortDirection) => { + const totalEpisodeCount = item.statistics?.totalEpisodeCount ?? 0; + + return totalEpisodeCount > 0 + ? (item.statistics?.sizeOnDisk ?? 0) / totalEpisodeCount + : 0; + }, + network: (item: Series, _direction: SortDirection) => { const network = item.network; @@ -258,6 +266,20 @@ const FILTER_PREDICATES = { return predicate(sizeOnDisk, filterValue); }, + averageSizePerEpisode: ( + item: Series, + filterValue: number, + type: FilterType + ) => { + const predicate = getFilterTypePredicate(type); + const totalEpisodeCount = item.statistics?.totalEpisodeCount ?? 0; + const averageSize = + totalEpisodeCount > 0 + ? (item.statistics?.sizeOnDisk ?? 0) / totalEpisodeCount + : 0; + return predicate(averageSize, filterValue); + }, + hasMissingSeason: (item: Series, filterValue: boolean, type: FilterType) => { const predicate = getFilterTypePredicate(type); const seasons = item.seasons ?? []; @@ -444,6 +466,12 @@ export const FILTER_BUILDER: FilterBuilderProp<Series>[] = [ type: filterBuilderTypes.NUMBER, valueType: filterBuilderValueTypes.BYTES, }, + { + name: 'averageSizePerEpisode', + label: () => translate('AverageSizePerEpisode'), + type: filterBuilderTypes.NUMBER, + valueType: filterBuilderValueTypes.BYTES, + }, { name: 'genres', label: () => translate('Genres'), diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 51d7e3c78..5d0d58a94 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -148,6 +148,8 @@ "AutomaticAdd": "Automatic Add", "AutomaticSearch": "Automatic Search", "AutomaticUpdatesDisabledDocker": "Automatic updates are not directly supported when using the Docker update mechanism. You will need to update the container image outside of {appName} or use a script", + "AverageSize": "Average Size", + "AverageSizePerEpisode": "Average Size per Episode", "Backup": "Backup", "BackupFolderHelpText": "Relative paths will be under {appName}'s AppData directory", "BackupIntervalHelpText": "Interval between automatic backups", From bf46ea8ffe1658ade0642bf1efb6be4db5233bed Mon Sep 17 00:00:00 2001 From: Weblate <noreply@weblate.org> Date: Thu, 16 Apr 2026 10:25:04 +0000 Subject: [PATCH 079/110] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: swidou <belmourachid@gmail.com> Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ar/ Translation: Servarr/Sonarr --- src/NzbDrone.Core/Localization/Core/ar.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/NzbDrone.Core/Localization/Core/ar.json b/src/NzbDrone.Core/Localization/Core/ar.json index 9131d047e..057a85371 100644 --- a/src/NzbDrone.Core/Localization/Core/ar.json +++ b/src/NzbDrone.Core/Localization/Core/ar.json @@ -1,4 +1,5 @@ { + "About": "نبدة عن", "AddAutoTag": "أضف كلمات دلالية تلقائيا", "AddCondition": "إضافة شرط", "AutoTaggingNegateHelpText": "إذا تم تحديده ، فلن يتم تطبيق التنسيق المخصص إذا تطابق شرط {implementationName} هذا.", From 24d780b77ffa787e6da18060ffeee2e86976fee4 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:53:37 +0300 Subject: [PATCH 080/110] Bump .NET to 10.0.6 --- global.json | 2 +- scripts/docs.sh | 2 +- src/NzbDrone.Common/Sonarr.Common.csproj | 8 ++++---- src/NzbDrone.Core/Sonarr.Core.csproj | 8 ++++---- 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, 16 insertions(+), 16 deletions(-) diff --git a/global.json b/global.json index ce67766bb..5ee7b7cb0 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "10.0.201" + "version": "10.0.202" } } diff --git a/scripts/docs.sh b/scripts/docs.sh index 27eba9462..52b8ca815 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.5 Swashbuckle.AspNetCore.Cli +dotnet tool install --version 10.1.7 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 cd9feb26d..2e2eeb2b1 100644 --- a/src/NzbDrone.Common/Sonarr.Common.csproj +++ b/src/NzbDrone.Common/Sonarr.Common.csproj @@ -7,8 +7,8 @@ <PackageReference Include="Diacritical.Net" Version="1.0.5" /> <PackageReference Include="DryIoc.dll" Version="5.4.3" /> <PackageReference Include="IPAddressRange" Version="6.3.0" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" /> - <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" /> + <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.6" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="NLog" Version="5.5.1" /> <PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.5" /> @@ -18,9 +18,9 @@ <PackageReference Include="SharpZipLib" Version="1.4.2" /> <PackageReference Include="SourceGear.sqlite3" Version="3.50.4.5" /> <PackageReference Include="System.Data.SQLite" Version="2.0.3" /> - <PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.5" /> + <PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.6" /> <PackageReference Include="System.IO.FileSystem.AccessControl" Version="6.0.0-preview.5.21301.5" /> - <PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.5" /> + <PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.6" /> </ItemGroup> <ItemGroup> <Compile Update="EnsureThat\Resources\ExceptionMessages.Designer.cs"> diff --git a/src/NzbDrone.Core/Sonarr.Core.csproj b/src/NzbDrone.Core/Sonarr.Core.csproj index 7e42e4f9e..4f9c287c3 100644 --- a/src/NzbDrone.Core/Sonarr.Core.csproj +++ b/src/NzbDrone.Core/Sonarr.Core.csproj @@ -13,10 +13,10 @@ <PackageReference Include="Openur.FFMpegCore" Version="5.4.0.31" /> <PackageReference Include="Openur.FFprobeStatic" Version="8.1.0.334" /> <PackageReference Include="Polly" Version="8.6.6" /> - <PackageReference Include="System.Drawing.Common" Version="10.0.5" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" /> - <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.5" /> - <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.5" /> + <PackageReference Include="System.Drawing.Common" Version="10.0.6" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" /> + <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.6" /> + <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.6" /> <PackageReference Include="FluentMigrator.Runner.Core" Version="8.0.1" /> <PackageReference Include="FluentMigrator.Runner.SQLite" Version="8.0.1" /> <PackageReference Include="FluentMigrator.Runner.Postgres" Version="8.0.1" /> diff --git a/src/NzbDrone.Host/Sonarr.Host.csproj b/src/NzbDrone.Host/Sonarr.Host.csproj index 034a0b116..1d2193f40 100644 --- a/src/NzbDrone.Host/Sonarr.Host.csproj +++ b/src/NzbDrone.Host/Sonarr.Host.csproj @@ -6,8 +6,8 @@ <ItemGroup> <PackageReference Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" /> <PackageReference Include="NLog.Extensions.Logging" Version="5.5.0" /> - <PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.5" /> - <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" /> + <PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.7" /> + <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.6" /> <PackageReference Include="DryIoc.dll" Version="5.4.3" /> <PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" /> </ItemGroup> diff --git a/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj b/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj index c8e8adad6..1598f1190 100644 --- a/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj +++ b/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj @@ -4,7 +4,7 @@ <OutputType>Library</OutputType> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.5" /> + <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.6" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\NzbDrone.Test.Common\Sonarr.Test.Common.csproj" /> diff --git a/src/NzbDrone/Sonarr.csproj b/src/NzbDrone/Sonarr.csproj index 1c7a2919b..f7a64cee3 100644 --- a/src/NzbDrone/Sonarr.csproj +++ b/src/NzbDrone/Sonarr.csproj @@ -8,7 +8,7 @@ <GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources> </PropertyGroup> <ItemGroup> - <PackageReference Include="System.Resources.Extensions" Version="10.0.5" /> + <PackageReference Include="System.Resources.Extensions" Version="10.0.6" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\NzbDrone.Host\Sonarr.Host.csproj" /> diff --git a/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj b/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj index 91d2f0b53..020e9cdff 100644 --- a/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj +++ b/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj @@ -6,7 +6,7 @@ <PackageReference Include="FluentValidation" Version="9.5.4" /> <PackageReference Include="Ical.Net" Version="4.3.1" /> <PackageReference Include="NLog" Version="5.5.1" /> - <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.5" /> + <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.7" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\NzbDrone.Core\Sonarr.Core.csproj" /> diff --git a/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj b/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj index 7b52b001e..9e67c56e1 100644 --- a/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj +++ b/src/Sonarr.Api.V5/Sonarr.Api.V5.csproj @@ -9,7 +9,7 @@ <PackageReference Include="FluentValidation" Version="9.5.4" /> <PackageReference Include="Ical.Net" Version="4.3.1" /> <PackageReference Include="NLog" Version="5.5.1" /> - <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.5" /> + <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.7" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\NzbDrone.Core\Sonarr.Core.csproj" /> From e8e31def9bad02d7a493ecdcc65fb07dd2db7a6c Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Sat, 17 May 2025 17:29:00 +0300 Subject: [PATCH 081/110] New: Special Seasons filtering for Wanted Missing --- frontend/src/Wanted/Missing/Missing.tsx | 12 +++-- .../src/Wanted/Missing/MissingFilterModal.tsx | 26 +++++++++++ frontend/src/Wanted/Missing/useMissing.tsx | 46 +++++++++++++++++-- .../IndexerSearch/EpisodeSearchService.cs | 2 +- src/NzbDrone.Core/Localization/Core/en.json | 1 + src/NzbDrone.Core/Tv/EpisodeService.cs | 8 ++-- src/Sonarr.Api.V3/Wanted/MissingController.cs | 2 +- src/Sonarr.Api.V5/Wanted/MissingController.cs | 4 +- 8 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 frontend/src/Wanted/Missing/MissingFilterModal.tsx diff --git a/frontend/src/Wanted/Missing/Missing.tsx b/frontend/src/Wanted/Missing/Missing.tsx index 0e7996601..fdf5d4aa4 100644 --- a/frontend/src/Wanted/Missing/Missing.tsx +++ b/frontend/src/Wanted/Missing/Missing.tsx @@ -20,6 +20,7 @@ import TablePager from 'Components/Table/TablePager'; import Episode from 'Episode/Episode'; import { useToggleEpisodesMonitored } from 'Episode/useEpisode'; import { Filter } from 'Filters/Filter'; +import { useCustomFiltersList } from 'Filters/useCustomFilters'; import { align, icons, kinds } from 'Helpers/Props'; import { SortDirection } from 'Helpers/Props/sortDirections'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; @@ -32,6 +33,7 @@ import { unregisterPagePopulator, } from 'Utilities/pagePopulator'; import translate from 'Utilities/String/translate'; +import MissingFilterModal from './MissingFilterModal'; import { setMissingOption, setMissingOptions, @@ -39,7 +41,7 @@ import { useMissingOptions, } from './missingOptionsStore'; import MissingRow from './MissingRow'; -import useMissing, { FILTERS } from './useMissing'; +import useMissing, { FILTERS, useFilters } from './useMissing'; function getMonitoredValue( filters: Filter[], @@ -66,6 +68,9 @@ function MissingContent() { const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } = useMissingOptions(); + const filters = useFilters(); + const customFilters = useCustomFiltersList('wanted.missing'); + const isSearchingForAllEpisodes = useCommandExecuting( CommandNames.MissingEpisodeSearch ); @@ -257,8 +262,9 @@ function MissingContent() { <FilterMenu alignMenu={align.RIGHT} selectedFilterKey={selectedFilterKey} - filters={FILTERS} - customFilters={[]} + filters={filters} + customFilters={customFilters} + filterModalConnectorComponent={MissingFilterModal} onFilterSelect={handleFilterSelect} /> </PageToolbarSection> diff --git a/frontend/src/Wanted/Missing/MissingFilterModal.tsx b/frontend/src/Wanted/Missing/MissingFilterModal.tsx new file mode 100644 index 000000000..f8546bcca --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingFilterModal.tsx @@ -0,0 +1,26 @@ +import React, { useCallback } from 'react'; +import { SetFilter } from 'Components/Filter/Filter'; +import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal'; +import Episode from 'Episode/Episode'; +import { setMissingOption } from './missingOptionsStore'; +import useMissing, { FILTER_BUILDER } from './useMissing'; + +type MissingFilterModalProps = FilterModalProps<Episode>; + +export default function MissingFilterModal(props: MissingFilterModalProps) { + const { records } = useMissing(); + + const dispatchSetFilter = useCallback(({ selectedFilterKey }: SetFilter) => { + setMissingOption('selectedFilterKey', selectedFilterKey); + }, []); + + return ( + <FilterModal + {...props} + sectionItems={records} + filterBuilderProps={FILTER_BUILDER} + customFilterType="wanted.missing" + dispatchSetFilter={dispatchSetFilter} + /> + ); +} diff --git a/frontend/src/Wanted/Missing/useMissing.tsx b/frontend/src/Wanted/Missing/useMissing.tsx index 1a4b2deae..d98bf6a30 100644 --- a/frontend/src/Wanted/Missing/useMissing.tsx +++ b/frontend/src/Wanted/Missing/useMissing.tsx @@ -1,10 +1,13 @@ import { keepPreviousData } from '@tanstack/react-query'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import Episode from 'Episode/Episode'; import { setEpisodeQueryKey } from 'Episode/useEpisode'; -import { Filter } from 'Filters/Filter'; +import { Filter, FilterBuilderProp } from 'Filters/Filter'; +import { useCustomFiltersList } from 'Filters/useCustomFilters'; import usePage from 'Helpers/Hooks/usePage'; import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery'; +import { filterBuilderValueTypes } from 'Helpers/Props'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; import translate from 'Utilities/String/translate'; import { useMissingOptions } from './missingOptionsStore'; @@ -31,20 +34,49 @@ export const FILTERS: Filter[] = [ }, ], }, + { + key: 'excludeSpecials', + label: () => translate('ExcludeSpecials'), + filters: [ + { + key: 'includeSpecials', + value: [false], + type: 'equal', + }, + ], + }, +]; + +export const FILTER_BUILDER: FilterBuilderProp<Episode>[] = [ + { + name: 'monitored', + label: () => translate('Monitored'), + type: 'exact', + valueType: filterBuilderValueTypes.BOOL, + }, + { + name: 'includeSpecials', + label: () => translate('IncludeSpecials'), + type: 'equal', + valueType: filterBuilderValueTypes.BOOL, + }, ]; const useMissing = () => { const { page, goToPage } = usePage('missing'); const { pageSize, selectedFilterKey, sortKey, sortDirection } = useMissingOptions(); + const customFilters = useCustomFiltersList('wanted.missing'); + + const filters = useMemo(() => { + return findSelectedFilters(selectedFilterKey, FILTERS, customFilters); + }, [selectedFilterKey, customFilters]); const { isPlaceholderData, queryKey, ...query } = usePagedApiQuery<Episode>({ path: '/wanted/missing', page, pageSize, - queryParams: { - monitored: selectedFilterKey === 'monitored', - }, + filters, sortKey, sortDirection, queryOptions: { @@ -67,3 +99,7 @@ const useMissing = () => { }; export default useMissing; + +export const useFilters = () => { + return FILTERS; +}; diff --git a/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs b/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs index c4931b53c..0a7da938c 100644 --- a/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs @@ -151,7 +151,7 @@ public void Execute(MissingEpisodeSearchCommand message) pagingSpec.FilterExpressions.Add(v => v.Monitored == false || v.Series.Monitored == false); } - episodes = _episodeService.EpisodesWithoutFiles(pagingSpec).Records.ToList(); + episodes = _episodeService.EpisodesWithoutFiles(pagingSpec, true).Records.ToList(); } var queue = GetQueuedEpisodeIds(); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 5d0d58a94..0c58bee40 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -708,6 +708,7 @@ "Events": "Events", "Example": "Example", "Exception": "Exception", + "ExcludeSpecials": "Exclude Specials", "ExcludeUnknownSeriesItems": "Exclude Unknown Series Items", "ExcludedReleaseProfile": "Excluded Release Profile", "ExcludedReleaseProfiles": "Excluded Release Profiles", diff --git a/src/NzbDrone.Core/Tv/EpisodeService.cs b/src/NzbDrone.Core/Tv/EpisodeService.cs index b28058150..79b4f9c85 100644 --- a/src/NzbDrone.Core/Tv/EpisodeService.cs +++ b/src/NzbDrone.Core/Tv/EpisodeService.cs @@ -27,7 +27,7 @@ public interface IEpisodeService List<Episode> GetEpisodesBySeason(int seriesId, int seasonNumber); List<Episode> GetEpisodesBySceneSeason(int seriesId, int sceneSeasonNumber); List<Episode> EpisodesWithFiles(int seriesId); - PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec); + PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec, bool includeSpecials); List<Episode> GetEpisodesByFileId(int episodeFileId); void UpdateEpisode(Episode episode); void SetEpisodeMonitored(int episodeId, bool monitored); @@ -158,11 +158,9 @@ public List<Episode> EpisodesWithFiles(int seriesId) return _episodeRepository.EpisodesWithFiles(seriesId); } - public PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec) + public PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec, bool includeSpecials) { - var episodeResult = _episodeRepository.EpisodesWithoutFiles(pagingSpec, true); - - return episodeResult; + return _episodeRepository.EpisodesWithoutFiles(pagingSpec, includeSpecials); } public List<Episode> GetEpisodesByFileId(int episodeFileId) diff --git a/src/Sonarr.Api.V3/Wanted/MissingController.cs b/src/Sonarr.Api.V3/Wanted/MissingController.cs index bbfde535f..ab867459d 100644 --- a/src/Sonarr.Api.V3/Wanted/MissingController.cs +++ b/src/Sonarr.Api.V3/Wanted/MissingController.cs @@ -48,7 +48,7 @@ public PagingResource<EpisodeResource> GetMissingEpisodes([FromQuery] PagingRequ pagingSpec.FilterExpressions.Add(v => v.Monitored == false || v.Series.Monitored == false); } - var resource = pagingSpec.ApplyToPage(_episodeService.EpisodesWithoutFiles, v => MapToResource(v, includeSeries, false, includeImages)); + var resource = pagingSpec.ApplyToPage(spec => _episodeService.EpisodesWithoutFiles(spec, true), v => MapToResource(v, includeSeries, false, includeImages)); return resource; } diff --git a/src/Sonarr.Api.V5/Wanted/MissingController.cs b/src/Sonarr.Api.V5/Wanted/MissingController.cs index d9cb6aee2..e63d0bef5 100644 --- a/src/Sonarr.Api.V5/Wanted/MissingController.cs +++ b/src/Sonarr.Api.V5/Wanted/MissingController.cs @@ -24,7 +24,7 @@ public MissingController(IEpisodeService episodeService, [HttpGet] [Produces("application/json")] - public PagingResource<EpisodeResource> GetMissingEpisodes([FromQuery] PagingRequestResource paging, bool monitored = true, [FromQuery] MissingSubresource[]? includeSubresources = null) + public PagingResource<EpisodeResource> GetMissingEpisodes([FromQuery] PagingRequestResource paging, bool monitored = true, bool includeSpecials = true, [FromQuery] MissingSubresource[]? includeSubresources = null) { var pagingResource = new PagingResource<EpisodeResource>(paging); var pagingSpec = pagingResource.MapToPagingSpec<EpisodeResource, Episode>( @@ -49,7 +49,7 @@ public PagingResource<EpisodeResource> GetMissingEpisodes([FromQuery] PagingRequ var includeSeries = includeSubresources.Contains(MissingSubresource.Series); var includeImages = includeSubresources.Contains(MissingSubresource.Images); - var resource = pagingSpec.ApplyToPage(_episodeService.EpisodesWithoutFiles, v => MapToResource(v, includeSeries, false, includeImages)); + var resource = pagingSpec.ApplyToPage(spec => _episodeService.EpisodesWithoutFiles(spec, includeSpecials), v => MapToResource(v, includeSeries, false, includeImages)); return resource; } From 5c5b53d341ec57b3d8732ec4ebbbbb80c785160f Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Wed, 15 Apr 2026 16:15:23 -0700 Subject: [PATCH 082/110] New: Filter series by episode file quality Closes #8437 --- .../src/Series/Index/Table/SeriesIndexRow.css | 6 +++ .../Index/Table/SeriesIndexRow.css.d.ts | 1 + .../src/Series/Index/Table/SeriesIndexRow.tsx | 20 ++++++++ .../Index/Table/SeriesIndexTableHeader.css | 6 +++ .../Table/SeriesIndexTableHeader.css.d.ts | 1 + frontend/src/Series/Series.ts | 2 + frontend/src/Series/seriesOptionsStore.ts | 6 +++ frontend/src/Series/useSeries.ts | 18 ++++++++ src/NzbDrone.Core/Localization/Core/en.json | 1 + .../SeriesStats/SeasonStatistics.cs | 20 ++++++++ .../SeriesStats/SeriesStatistics.cs | 2 + .../SeriesStats/SeriesStatisticsRepository.cs | 7 ++- .../SeriesStats/SeriesStatisticsService.cs | 46 ++++++++++++++++--- src/NzbDrone.Core/Tv/SeriesRepository.cs | 10 ++++ src/NzbDrone.Core/Tv/SeriesService.cs | 6 +++ src/Sonarr.Api.V3/Series/SeriesController.cs | 2 +- .../Series/SeasonStatisticsResource.cs | 5 +- src/Sonarr.Api.V5/Series/SeriesController.cs | 2 +- .../Series/SeriesStatisticsResource.cs | 5 +- 19 files changed, 153 insertions(+), 13 deletions(-) diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css b/frontend/src/Series/Index/Table/SeriesIndexRow.css index e18a9ed74..4b4706a01 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css @@ -84,6 +84,12 @@ flex: 0 0 180px; } +.episodeFileQualities { + composes: cell; + + flex: 0 0 220px; +} + .seasonCount, .certification { composes: cell; diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts index 635b5a616..93da75bbf 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts @@ -11,6 +11,7 @@ interface CssExports { 'certification': string; 'checkInput': string; 'episodeCount': string; + 'episodeFileQualities': string; 'episodeProgress': string; 'genres': string; 'latestSeason': string; diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx index b8d83211b..6a997af97 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx @@ -149,6 +149,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { totalEpisodeCount = 0, sizeOnDisk = 0, releaseGroups = [], + episodeFileQualities = [], } = statistics; return ( @@ -447,6 +448,25 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { ); } + if (name === 'episodeFileQualities') { + const joinedQualities = episodeFileQualities + .map((q) => q.name) + .join(', '); + const truncatedQualities = + episodeFileQualities.length > 3 + ? `${episodeFileQualities + .slice(0, 3) + .map((q) => q.name) + .join(', ')}...` + : joinedQualities; + + return ( + <VirtualTableRowCell key={name} className={styles[name]}> + <span title={joinedQualities}>{truncatedQualities}</span> + </VirtualTableRowCell> + ); + } + if (name === 'tags') { return ( <VirtualTableRowCell key={name} className={styles[name]}> diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css index a37cb5081..0fac2d75f 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css @@ -48,6 +48,12 @@ flex: 0 0 180px; } +.episodeFileQualities { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 220px; +} + .seasonCount, .certification { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts index 1b566399d..63fd01bbe 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts @@ -8,6 +8,7 @@ interface CssExports { 'bannerGrow': string; 'certification': string; 'episodeCount': string; + 'episodeFileQualities': string; 'episodeProgress': string; 'genres': string; 'latestSeason': string; diff --git a/frontend/src/Series/Series.ts b/frontend/src/Series/Series.ts index 39d1ea1ff..47917ed2d 100644 --- a/frontend/src/Series/Series.ts +++ b/frontend/src/Series/Series.ts @@ -1,5 +1,6 @@ import ModelBase from 'App/ModelBase'; import Language from 'Language/Language'; +import Quality from 'Quality/Quality'; export type SeriesType = 'anime' | 'daily' | 'standard'; export type SeriesMonitor = @@ -34,6 +35,7 @@ export interface Statistics { percentOfEpisodes: number; previousAiring?: Date; releaseGroups: string[]; + episodeFileQualities: Quality[]; sizeOnDisk: number; totalEpisodeCount: number; monitoredEpisodeCount: number; diff --git a/frontend/src/Series/seriesOptionsStore.ts b/frontend/src/Series/seriesOptionsStore.ts index b71c1cbb9..51a37e3e0 100644 --- a/frontend/src/Series/seriesOptionsStore.ts +++ b/frontend/src/Series/seriesOptionsStore.ts @@ -221,6 +221,12 @@ const { useOptions, useOption, setOptions, setOption, setSort, getOptions } = isSortable: false, isVisible: false, }, + { + name: 'episodeFileQualities', + label: () => translate('EpisodeFileQualities'), + isSortable: false, + isVisible: false, + }, { name: 'tags', label: () => translate('Tags'), diff --git a/frontend/src/Series/useSeries.ts b/frontend/src/Series/useSeries.ts index b41428f2f..6e62301bb 100644 --- a/frontend/src/Series/useSeries.ts +++ b/frontend/src/Series/useSeries.ts @@ -254,6 +254,18 @@ const FILTER_PREDICATES = { return predicate(releaseGroups, filterValue); }, + episodeFileQualities: ( + item: Series, + filterValue: number[], + type: FilterType + ) => { + const episodeFileQualities = ( + item.statistics?.episodeFileQualities ?? [] + ).map((q) => q.id); + const predicate = getFilterTypePredicate(type); + return predicate(episodeFileQualities, filterValue); + }, + seasonCount: (item: Series, filterValue: number, type: FilterType) => { const predicate = getFilterTypePredicate(type); const seasonCount = item.statistics?.seasonCount ?? 0; @@ -521,6 +533,12 @@ export const FILTER_BUILDER: FilterBuilderProp<Series>[] = [ label: () => translate('ReleaseGroups'), type: filterBuilderTypes.ARRAY, }, + { + name: 'episodeFileQualities', + label: () => translate('EpisodeFileQualities'), + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.QUALITY, + }, { name: 'ratings', label: () => translate('Rating'), diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 0c58bee40..c6d64ac6d 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -668,6 +668,7 @@ "EpisodeFileDeleted": "Episode File Deleted", "EpisodeFileDeletedTooltip": "Episode file deleted", "EpisodeFileMissingTooltip": "Episode file missing", + "EpisodeFileQualities": "Episode File Qualities", "EpisodeFileRenamed": "Episode File Renamed", "EpisodeFileRenamedTooltip": "Episode file renamed", "EpisodeFilesLoadError": "Unable to load episode files", diff --git a/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs index cf615a731..bcca6a4d8 100644 --- a/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs +++ b/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs @@ -4,6 +4,7 @@ using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Qualities; namespace NzbDrone.Core.SeriesStats { @@ -21,6 +22,7 @@ public class SeasonStatistics : ResultSet public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public string ReleaseGroupsString { get; set; } + public string EpisodeFileQualitiesString { get; set; } public DateTime? NextAiring { @@ -110,5 +112,23 @@ public List<string> ReleaseGroups return releasegroups; } } + + public List<Quality> EpisodeFileQualities + { + get + { + if (EpisodeFileQualitiesString.IsNullOrWhiteSpace()) + { + return new List<Quality>(); + } + + return EpisodeFileQualitiesString + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(int.Parse) + .Distinct() + .Select(Quality.FindById) + .ToList(); + } + } } } diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs index e3f10d24f..41f637ee6 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Qualities; namespace NzbDrone.Core.SeriesStats { @@ -16,6 +17,7 @@ public class SeriesStatistics : ResultSet public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List<string> ReleaseGroups { get; set; } + public List<Quality> EpisodeFileQualities { get; set; } public List<SeasonStatistics> SeasonStatistics { get; set; } } } diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs index 68b4f91ff..c7098117d 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs @@ -49,6 +49,7 @@ private List<SeasonStatistics> MapResults(List<SeasonStatistics> episodesResult, e.SizeOnDisk = file?.SizeOnDisk ?? 0; e.ReleaseGroupsString = file?.ReleaseGroupsString; + e.EpisodeFileQualitiesString = file?.EpisodeFileQualitiesString; }); return episodesResult; @@ -96,7 +97,8 @@ private SqlBuilder EpisodeFilesBuilder() .Select(@"""SeriesId"", ""SeasonNumber"", SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, - GROUP_CONCAT(""ReleaseGroup"", '|') AS ReleaseGroupsString") + GROUP_CONCAT(""ReleaseGroup"", '|') AS ReleaseGroupsString, + GROUP_CONCAT(JSON_EXTRACT(""Quality"", '$.quality'), '|') AS EpisodeFileQualitiesString") .GroupBy<EpisodeFile>(x => x.SeriesId) .GroupBy<EpisodeFile>(x => x.SeasonNumber); } @@ -105,7 +107,8 @@ private SqlBuilder EpisodeFilesBuilder() .Select(@"""SeriesId"", ""SeasonNumber"", SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, - string_agg(""ReleaseGroup"", '|') AS ReleaseGroupsString") + string_agg(""ReleaseGroup"", '|') AS ReleaseGroupsString, + string_agg(""Quality""::json->>'quality', '|') AS EpisodeFileQualitiesString") .GroupBy<EpisodeFile>(x => x.SeriesId) .GroupBy<EpisodeFile>(x => x.SeasonNumber); } diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs index 6e7b75d85..62d9ba9fa 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs @@ -1,31 +1,50 @@ using System.Collections.Generic; using System.Linq; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.SeriesStats { public interface ISeriesStatisticsService { List<SeriesStatistics> SeriesStatistics(); - SeriesStatistics SeriesStatistics(int seriesId); + SeriesStatistics SeriesStatistics(int seriesId, int qualityProfileId); } public class SeriesStatisticsService : ISeriesStatisticsService { private readonly ISeriesStatisticsRepository _seriesStatisticsRepository; + private readonly ISeriesService _seriesService; + private readonly IQualityProfileService _qualityProfileService; - public SeriesStatisticsService(ISeriesStatisticsRepository seriesStatisticsRepository) + public SeriesStatisticsService(ISeriesStatisticsRepository seriesStatisticsRepository, + ISeriesService seriesService, + IQualityProfileService qualityProfileService) { _seriesStatisticsRepository = seriesStatisticsRepository; + _seriesService = seriesService; + _qualityProfileService = qualityProfileService; } public List<SeriesStatistics> SeriesStatistics() { var seasonStatistics = _seriesStatisticsRepository.SeriesStatistics(); + var seriesProfiles = _seriesService.GetAllSeriesQualityProfiles(); + var profiles = _qualityProfileService.All().ToDictionary(p => p.Id); - return seasonStatistics.GroupBy(s => s.SeriesId).Select(s => MapSeriesStatistics(s.ToList())).ToList(); + return seasonStatistics + .GroupBy(s => s.SeriesId) + .Select(s => + { + var profileId = seriesProfiles.GetValueOrDefault(s.Key); + profiles.TryGetValue(profileId, out var profile); + return MapSeriesStatistics(s.ToList(), profile); + }) + .ToList(); } - public SeriesStatistics SeriesStatistics(int seriesId) + public SeriesStatistics SeriesStatistics(int seriesId, int qualityProfileId) { var stats = _seriesStatisticsRepository.SeriesStatistics(seriesId); @@ -34,10 +53,12 @@ public SeriesStatistics SeriesStatistics(int seriesId) return new SeriesStatistics(); } - return MapSeriesStatistics(stats); + var profile = _qualityProfileService.Get(qualityProfileId); + + return MapSeriesStatistics(stats, profile); } - private SeriesStatistics MapSeriesStatistics(List<SeasonStatistics> seasonStatistics) + private SeriesStatistics MapSeriesStatistics(List<SeasonStatistics> seasonStatistics, QualityProfile profile) { var seriesStatistics = new SeriesStatistics { @@ -48,7 +69,8 @@ private SeriesStatistics MapSeriesStatistics(List<SeasonStatistics> seasonStatis TotalEpisodeCount = seasonStatistics.Sum(s => s.TotalEpisodeCount), MonitoredEpisodeCount = seasonStatistics.Sum(s => s.MonitoredEpisodeCount), SizeOnDisk = seasonStatistics.Sum(s => s.SizeOnDisk), - ReleaseGroups = seasonStatistics.SelectMany(s => s.ReleaseGroups).Distinct().ToList() + ReleaseGroups = seasonStatistics.SelectMany(s => s.ReleaseGroups).Distinct().ToList(), + EpisodeFileQualities = SortQualities(seasonStatistics.SelectMany(s => s.EpisodeFileQualities).Distinct().ToList(), profile) }; var nextAiring = seasonStatistics.Where(s => s.NextAiring != null).MinBy(s => s.NextAiring); @@ -61,5 +83,15 @@ private SeriesStatistics MapSeriesStatistics(List<SeasonStatistics> seasonStatis return seriesStatistics; } + + private static List<Quality> SortQualities(List<Quality> qualities, QualityProfile profile) + { + if (profile == null) + { + return qualities; + } + + return qualities.OrderBy(q => profile.GetIndex(q.Id).Index).ToList(); + } } } diff --git a/src/NzbDrone.Core/Tv/SeriesRepository.cs b/src/NzbDrone.Core/Tv/SeriesRepository.cs index f83545cc8..a4f6e6365 100644 --- a/src/NzbDrone.Core/Tv/SeriesRepository.cs +++ b/src/NzbDrone.Core/Tv/SeriesRepository.cs @@ -19,6 +19,7 @@ public interface ISeriesRepository : IBasicRepository<Series> List<int> AllSeriesTvdbIds(); Dictionary<int, string> AllSeriesPaths(); Dictionary<int, List<int>> AllSeriesTags(); + Dictionary<int, int> AllSeriesQualityProfiles(); } public class SeriesRepository : BasicRepository<Series>, ISeriesRepository @@ -111,6 +112,15 @@ public Dictionary<int, List<int>> AllSeriesTags() } } + public Dictionary<int, int> AllSeriesQualityProfiles() + { + using (var conn = _database.OpenConnection()) + { + var strSql = "SELECT \"Id\" AS Key, \"QualityProfileId\" AS Value FROM \"Series\""; + return conn.Query<KeyValuePair<int, int>>(strSql).ToDictionary(x => x.Key, x => x.Value); + } + } + private Series ReturnSingleSeriesOrThrow(List<Series> series) { if (series.Count == 0) diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 29cb6fac5..2430cb759 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -28,6 +28,7 @@ public interface ISeriesService Dictionary<int, string> GetAllSeriesPaths(); Dictionary<int, List<int>> GetAllSeriesTags(); List<Series> AllForTag(int tagId); + Dictionary<int, int> GetAllSeriesQualityProfiles(); Series UpdateSeries(Series series, bool updateEpisodesToMatchSeason = true, bool publishUpdatedEvent = true); List<Series> UpdateSeries(List<Series> series, bool useExistingRelativeFolder); bool SeriesPathExists(string folder); @@ -186,6 +187,11 @@ public Dictionary<int, List<int>> GetAllSeriesTags() return _seriesRepository.AllSeriesTags(); } + public Dictionary<int, int> GetAllSeriesQualityProfiles() + { + return _seriesRepository.AllSeriesQualityProfiles(); + } + public List<Series> AllForTag(int tagId) { return GetAllSeries().Where(s => s.Tags.Contains(tagId)) diff --git a/src/Sonarr.Api.V3/Series/SeriesController.cs b/src/Sonarr.Api.V3/Series/SeriesController.cs index 341373167..8f155b9ff 100644 --- a/src/Sonarr.Api.V3/Series/SeriesController.cs +++ b/src/Sonarr.Api.V3/Series/SeriesController.cs @@ -237,7 +237,7 @@ private void MapCoversToLocal(params SeriesResource[] series) private void FetchAndLinkSeriesStatistics(SeriesResource resource) { - LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id)); + LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id, resource.QualityProfileId)); } private void LinkSeriesStatistics(List<SeriesResource> resources, Dictionary<int, SeriesStatistics> seriesStatistics) diff --git a/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs b/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs index c338cfbcb..d7cb364f4 100644 --- a/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs +++ b/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs @@ -1,3 +1,4 @@ +using NzbDrone.Core.Qualities; using NzbDrone.Core.SeriesStats; namespace Sonarr.Api.V5.Series; @@ -12,6 +13,7 @@ public class SeasonStatisticsResource public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List<string>? ReleaseGroups { get; set; } + public List<Quality>? EpisodeFileQualities { get; set; } public decimal PercentOfEpisodes { @@ -40,7 +42,8 @@ public static SeasonStatisticsResource ToResource(this SeasonStatistics model) TotalEpisodeCount = model.TotalEpisodeCount, MonitoredEpisodeCount = model.MonitoredEpisodeCount, SizeOnDisk = model.SizeOnDisk, - ReleaseGroups = model.ReleaseGroups + ReleaseGroups = model.ReleaseGroups, + EpisodeFileQualities = model.EpisodeFileQualities }; } } diff --git a/src/Sonarr.Api.V5/Series/SeriesController.cs b/src/Sonarr.Api.V5/Series/SeriesController.cs index 992d4c99d..cdadfe76a 100644 --- a/src/Sonarr.Api.V5/Series/SeriesController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesController.cs @@ -277,7 +277,7 @@ private void MapCoversToLocal(params SeriesResource[] series) private void FetchAndLinkSeriesStatistics(SeriesResource resource) { - LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id)); + LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id, resource.QualityProfileId)); } private void LinkSeriesStatistics(List<SeriesResource> resources, Dictionary<int, SeriesStatistics> seriesStatistics) diff --git a/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs b/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs index d04bed7a6..b9f58ff71 100644 --- a/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs +++ b/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs @@ -1,3 +1,4 @@ +using NzbDrone.Core.Qualities; using NzbDrone.Core.SeriesStats; namespace Sonarr.Api.V5.Series; @@ -11,6 +12,7 @@ public class SeriesStatisticsResource public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List<string>? ReleaseGroups { get; set; } + public List<Quality>? EpisodeFileQualities { get; set; } public decimal PercentOfEpisodes { @@ -38,7 +40,8 @@ public static SeriesStatisticsResource ToResource(this SeriesStatistics model, L TotalEpisodeCount = model.TotalEpisodeCount, MonitoredEpisodeCount = model.MonitoredEpisodeCount, SizeOnDisk = model.SizeOnDisk, - ReleaseGroups = model.ReleaseGroups + ReleaseGroups = model.ReleaseGroups, + EpisodeFileQualities = model.EpisodeFileQualities }; } } From e38a2af1ad03898566aa23bea8a4bd6c62cb9866 Mon Sep 17 00:00:00 2001 From: realzombee <neoyoda42@protonmail.com> Date: Tue, 21 Apr 2026 00:53:36 +0100 Subject: [PATCH 083/110] Stop tracking downloads after they're removed from the download client --- src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs | 8 ++------ src/NzbDrone.Core/Download/DownloadClientItem.cs | 1 - src/NzbDrone.Core/Download/DownloadEventHub.cs | 12 ++++++------ .../Download/DownloadProcessingService.cs | 2 +- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs index 6b91b2a63..bdff884d5 100644 --- a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs +++ b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs @@ -81,8 +81,8 @@ public override IEnumerable<DownloadClientItem> GetItems() { var firstFile = torrent.Files?.FirstOrDefault(); - // skip metadata download - if (firstFile?.Path?.Contains("[METADATA]") == true) + // skip metadata download or if the torrent is already removed + if (firstFile?.Path?.Contains("[METADATA]") == true || torrent.Status == "removed") { continue; } @@ -120,9 +120,6 @@ public override IEnumerable<DownloadClientItem> GetItems() case "complete": status = DownloadItemStatus.Completed; break; - case "removed": - status = DownloadItemStatus.Failed; - break; } _logger.Trace($"- aria2 getstatus hash:'{torrent.InfoHash}' gid:'{torrent.Gid}' status:'{status}' total:{totalLength} completed:'{completedLength}'"); @@ -139,7 +136,6 @@ public override IEnumerable<DownloadClientItem> GetItems() OutputPath = outputPath, RemainingSize = totalLength - completedLength, RemainingTime = downloadSpeed == 0 ? (TimeSpan?)null : new TimeSpan(0, 0, (int)((totalLength - completedLength) / downloadSpeed)), - Removed = torrent.Status == "removed", SeedRatio = totalLength > 0 ? (double)uploadedLength / totalLength : 0, Status = status, Title = title, diff --git a/src/NzbDrone.Core/Download/DownloadClientItem.cs b/src/NzbDrone.Core/Download/DownloadClientItem.cs index 76ed0cb2c..33ae1269d 100644 --- a/src/NzbDrone.Core/Download/DownloadClientItem.cs +++ b/src/NzbDrone.Core/Download/DownloadClientItem.cs @@ -23,7 +23,6 @@ public class DownloadClientItem public bool IsEncrypted { get; set; } public bool CanMoveFiles { get; set; } public bool CanBeRemoved { get; set; } - public bool Removed { get; set; } public DownloadClientItem Clone() { diff --git a/src/NzbDrone.Core/Download/DownloadEventHub.cs b/src/NzbDrone.Core/Download/DownloadEventHub.cs index a1e4a6856..eedd6718b 100644 --- a/src/NzbDrone.Core/Download/DownloadEventHub.cs +++ b/src/NzbDrone.Core/Download/DownloadEventHub.cs @@ -12,14 +12,17 @@ public class DownloadEventHub : IHandle<DownloadFailedEvent>, { private readonly IConfigService _configService; private readonly IProvideDownloadClient _downloadClientProvider; + private readonly ITrackedDownloadService _trackedDownloadService; private readonly Logger _logger; public DownloadEventHub(IConfigService configService, IProvideDownloadClient downloadClientProvider, + ITrackedDownloadService trackedDownloadService, Logger logger) { _configService = configService; _downloadClientProvider = downloadClientProvider; + _trackedDownloadService = trackedDownloadService; _logger = logger; } @@ -28,7 +31,6 @@ public void Handle(DownloadFailedEvent message) var trackedDownload = message.TrackedDownload; if (trackedDownload == null || - message.TrackedDownload.DownloadItem.Removed || !trackedDownload.DownloadItem.CanBeRemoved) { return; @@ -53,8 +55,7 @@ public void Handle(DownloadCompletedEvent message) MarkItemAsImported(trackedDownload, downloadClient); - if (trackedDownload.DownloadItem.Removed || - !trackedDownload.DownloadItem.CanBeRemoved || + if (!trackedDownload.DownloadItem.CanBeRemoved || trackedDownload.DownloadItem.Status == DownloadItemStatus.Downloading) { return; @@ -74,8 +75,7 @@ public void Handle(DownloadCanBeRemovedEvent message) var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient); var definition = downloadClient.Definition as DownloadClientDefinition; - if (trackedDownload.DownloadItem.Removed || - !trackedDownload.DownloadItem.CanBeRemoved || + if (!trackedDownload.DownloadItem.CanBeRemoved || !definition.RemoveCompletedDownloads) { return; @@ -90,7 +90,7 @@ private void RemoveFromDownloadClient(TrackedDownload trackedDownload, IDownload { _logger.Debug("[{0}] Removing download from {1} history", trackedDownload.DownloadItem.Title, trackedDownload.DownloadItem.DownloadClientInfo.Name); downloadClient.RemoveItem(trackedDownload.DownloadItem, true); - trackedDownload.DownloadItem.Removed = true; + _trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId); } catch (NotSupportedException) { diff --git a/src/NzbDrone.Core/Download/DownloadProcessingService.cs b/src/NzbDrone.Core/Download/DownloadProcessingService.cs index cbc57c48b..0251c2dd7 100644 --- a/src/NzbDrone.Core/Download/DownloadProcessingService.cs +++ b/src/NzbDrone.Core/Download/DownloadProcessingService.cs @@ -35,7 +35,7 @@ public DownloadProcessingService(IConfigService configService, private void RemoveCompletedDownloads() { var trackedDownloads = _trackedDownloadService.GetTrackedDownloads() - .Where(t => !t.DownloadItem.Removed && t.DownloadItem.CanBeRemoved && t.State == TrackedDownloadState.Imported) + .Where(t => t.DownloadItem.CanBeRemoved && t.State == TrackedDownloadState.Imported) .ToList(); foreach (var trackedDownload in trackedDownloads) From 1c09c6005cffa1bcfdf5a4c6b750c7ef3f9d4e57 Mon Sep 17 00:00:00 2001 From: Sean Wilson <18518299+sean-wils@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:54:04 +0100 Subject: [PATCH 084/110] Propagate Trakt token refresh failures instead of swallowing exceptions --- .../ImportLists/Trakt/TraktImportBase.cs | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/NzbDrone.Core/ImportLists/Trakt/TraktImportBase.cs b/src/NzbDrone.Core/ImportLists/Trakt/TraktImportBase.cs index b7397138c..fc2e2f1ad 100644 --- a/src/NzbDrone.Core/ImportLists/Trakt/TraktImportBase.cs +++ b/src/NzbDrone.Core/ImportLists/Trakt/TraktImportBase.cs @@ -109,7 +109,7 @@ private string GetUserName(string accessToken) } catch (HttpException) { - _logger.Warn($"Error refreshing trakt access token"); + _logger.Warn("Error retrieving Trakt user settings"); } return null; @@ -125,26 +125,22 @@ private void RefreshToken() .AddQueryParam("refresh_token", Settings.RefreshToken) .Build(); - try + var response = _httpClient.Get<RefreshRequestResponse>(request); + + if (response?.Resource == null) { - var response = _httpClient.Get<RefreshRequestResponse>(request); - - if (response != null && response.Resource != null) - { - var token = response.Resource; - Settings.AccessToken = token.AccessToken; - Settings.Expires = DateTime.UtcNow.AddSeconds(token.ExpiresIn); - Settings.RefreshToken = token.RefreshToken ?? Settings.RefreshToken; - - if (Definition.Id > 0) - { - _importListRepository.UpdateSettings((ImportListDefinition)Definition); - } - } + _logger.Warn("Trakt token refresh returned an empty response"); + return; } - catch (HttpException) + + var token = response.Resource; + Settings.AccessToken = token.AccessToken; + Settings.Expires = DateTime.UtcNow.AddSeconds(token.ExpiresIn); + Settings.RefreshToken = token.RefreshToken ?? Settings.RefreshToken; + + if (Definition.Id > 0) { - _logger.Warn($"Error refreshing trakt access token"); + _importListRepository.UpdateSettings((ImportListDefinition)Definition); } } } From 9454f2940e2ae2d3b2a026b4c42a1d0ef33d1f24 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:48:18 +0300 Subject: [PATCH 085/110] Migrate mediainfo per episode file one at the time to avoid OOM --- .../Migration/225_mediainfo_multiple_streams.cs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/NzbDrone.Core/Datastore/Migration/225_mediainfo_multiple_streams.cs b/src/NzbDrone.Core/Datastore/Migration/225_mediainfo_multiple_streams.cs index 48e616158..f743a88c0 100644 --- a/src/NzbDrone.Core/Datastore/Migration/225_mediainfo_multiple_streams.cs +++ b/src/NzbDrone.Core/Datastore/Migration/225_mediainfo_multiple_streams.cs @@ -46,8 +46,6 @@ private void MigrateMediaInfoToMultipleStreams(IDbConnection conn, IDbTransactio { var existing = conn.Query<EpisodeFile224>("SELECT \"Id\", \"MediaInfo\" FROM \"EpisodeFiles\""); - var updated = new List<object>(); - foreach (var row in existing) { if (row.MediaInfo.IsNullOrWhiteSpace()) @@ -64,7 +62,7 @@ private void MigrateMediaInfoToMultipleStreams(IDbConnection conn, IDbTransactio { _logger.Warn(ex, "Episode {EpisodeId} contains invalid JSON data, skipping.", row.Id); - updated.Add(new EpisodeFile225 { Id = row.Id, MediaInfo = null }); + UpdateMediaInfoForEpisodeFile(conn, tran, new EpisodeFile225 { Id = row.Id, MediaInfo = null }); continue; } @@ -83,17 +81,17 @@ private void MigrateMediaInfoToMultipleStreams(IDbConnection conn, IDbTransactio continue; } - updated.Add(new EpisodeFile225 + UpdateMediaInfoForEpisodeFile(conn, tran, new EpisodeFile225 { Id = row.Id, MediaInfo = JsonSerializer.Serialize(newMediaInfo, _serializerSettings) }); } + } - conn.Execute( - "UPDATE \"EpisodeFiles\" SET \"MediaInfo\" = @MediaInfo WHERE \"Id\" = @Id", - updated, - transaction: tran); + private static void UpdateMediaInfoForEpisodeFile(IDbConnection conn, IDbTransaction tran, EpisodeFile225 updated) + { + conn.Execute("UPDATE \"EpisodeFiles\" SET \"MediaInfo\" = @MediaInfo WHERE \"Id\" = @Id", updated, transaction: tran); } private static MediaInfo225 MigrateMediaInfo(MediaInfo224 old) From 6131f1debd7c94a2968d52020236edaa7c73e61c Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Tue, 21 Apr 2026 02:54:47 +0300 Subject: [PATCH 086/110] Fix getting series by alias title and year --- src/NzbDrone.Core/Parser/ParsingService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index ac4b3add6..710328df1 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -144,7 +144,7 @@ private Series GetSeriesAliasTitleAndYear(ParsedEpisodeInfo parsedEpisodeInfo) { var series = _seriesService.FindByTvdbId(tvdbId.Value); - if (series.Year == year) + if (series != null && series.Year == year) { return series; } From 62e5078aeb0471cd5afcfa0fa55c58b08a30f380 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:23:06 +0300 Subject: [PATCH 087/110] Refetch page after clearing blocklist and refreshing events --- frontend/src/Activity/Blocklist/Blocklist.tsx | 3 ++- frontend/src/System/Events/LogsTable.tsx | 18 ++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/frontend/src/Activity/Blocklist/Blocklist.tsx b/frontend/src/Activity/Blocklist/Blocklist.tsx index b9c3a303e..caa909b60 100644 --- a/frontend/src/Activity/Blocklist/Blocklist.tsx +++ b/frontend/src/Activity/Blocklist/Blocklist.tsx @@ -109,9 +109,10 @@ function BlocklistContent() { const handleClearBlocklistConfirmed = useCallback(() => { executeCommand({ name: CommandNames.ClearBlocklist }, () => { goToPage(1); + refetch(); }); setIsConfirmClearModalOpen(false); - }, [setIsConfirmClearModalOpen, goToPage, executeCommand]); + }, [setIsConfirmClearModalOpen, executeCommand, goToPage, refetch]); const handleConfirmClearModalClose = useCallback(() => { setIsConfirmClearModalOpen(false); diff --git a/frontend/src/System/Events/LogsTable.tsx b/frontend/src/System/Events/LogsTable.tsx index 128726857..3919bfcbb 100644 --- a/frontend/src/System/Events/LogsTable.tsx +++ b/frontend/src/System/Events/LogsTable.tsx @@ -38,6 +38,7 @@ function LogsTable() { isLoading, page, goToPage, + refetch, } = useEvents(); const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } = @@ -77,18 +78,15 @@ function LogsTable() { const handleRefreshPress = useCallback(() => { goToPage(1); - }, [goToPage]); + refetch(); + }, [goToPage, refetch]); const handleClearLogsPress = useCallback(() => { - executeCommand( - { - name: CommandNames.ClearLog, - }, - () => { - goToPage(1); - } - ); - }, [executeCommand, goToPage]); + executeCommand({ name: CommandNames.ClearLog }, () => { + goToPage(1); + refetch(); + }); + }, [executeCommand, goToPage, refetch]); return ( <PageContent title={translate('Logs')}> From f33abc7b85841cf12f54c26663e8bd137a8d32c4 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Tue, 21 Apr 2026 02:55:14 +0300 Subject: [PATCH 088/110] Fix 'All' filter for calendar page --- frontend/src/Calendar/useCalendar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/Calendar/useCalendar.ts b/frontend/src/Calendar/useCalendar.ts index fcaaf5dbf..496b86513 100644 --- a/frontend/src/Calendar/useCalendar.ts +++ b/frontend/src/Calendar/useCalendar.ts @@ -118,7 +118,7 @@ const useCalendar = () => { return acc; }, { - includeUnmonitored: false, + includeUnmonitored: true, includeSpecials: true, } ); From 92f4354d76eee84dc04854b0bdfbf04cae4780d2 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:31:31 +0300 Subject: [PATCH 089/110] Fix series ending year on details page --- frontend/src/Series/Details/SeriesDetails.tsx | 31 ++++++++++++------- frontend/src/Series/Series.ts | 4 +-- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/frontend/src/Series/Details/SeriesDetails.tsx b/frontend/src/Series/Details/SeriesDetails.tsx index 0284d7d9c..5bad2f533 100644 --- a/frontend/src/Series/Details/SeriesDetails.tsx +++ b/frontend/src/Series/Details/SeriesDetails.tsx @@ -36,7 +36,7 @@ import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; import EditSeriesModal from 'Series/Edit/EditSeriesModal'; import SeriesHistoryModal from 'Series/History/SeriesHistoryModal'; import MonitoringOptionsModal from 'Series/MonitoringOptions/MonitoringOptionsModal'; -import { Image, Statistics } from 'Series/Series'; +import { Image, SeriesStatus, Statistics } from 'Series/Series'; import SeriesGenres from 'Series/SeriesGenres'; import SeriesPoster from 'Series/SeriesPoster'; import { getSeriesStatusDetails } from 'Series/SeriesStatus'; @@ -67,10 +67,22 @@ function getFanartUrl(images: Image[]) { return images.find((image) => image.coverType === 'fanart')?.url; } -function getDateYear(date: string | undefined) { - const dateDate = moment.utc(date); +function getDateYear(date: string) { + return moment.utc(date).format('YYYY'); +} - return dateDate.format('YYYY'); +function getRunningYears( + status: SeriesStatus, + year: number, + lastAired: string | undefined +) { + if (year === 0) { + return null; + } + + return status === 'ended' && lastAired + ? `${year}-${getDateYear(lastAired)}` + : `${year}-`; } interface ExpandedState { @@ -394,18 +406,13 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { genres, tags, year, + lastAired, } = series; - const { - episodeCount = 0, - episodeFileCount = 0, - sizeOnDisk = 0, - lastAired, - } = statistics; + const { episodeCount = 0, episodeFileCount = 0, sizeOnDisk = 0 } = statistics; const statusDetails = getSeriesStatusDetails(status); - const runningYears = - status === 'ended' ? `${year}-${getDateYear(lastAired)}` : `${year}-`; + const runningYears = getRunningYears(status, year, lastAired); let episodeFilesCountMessage = translate('SeriesDetailsNoEpisodeFiles'); diff --git a/frontend/src/Series/Series.ts b/frontend/src/Series/Series.ts index 47917ed2d..9a7793abe 100644 --- a/frontend/src/Series/Series.ts +++ b/frontend/src/Series/Series.ts @@ -39,7 +39,6 @@ export interface Statistics { sizeOnDisk: number; totalEpisodeCount: number; monitoredEpisodeCount: number; - lastAired?: string; } export interface Season { @@ -73,7 +72,8 @@ interface Series extends ModelBase { certification: string; cleanTitle: string; ended: boolean; - firstAired: string; + firstAired?: string; + lastAired?: string; genres: string[]; images: Image[]; imdbId?: string; From 2d08abde8105e8047244e8342687c27eba5a02e1 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:22:01 +0300 Subject: [PATCH 090/110] Bump Microsoft.AspNetCore.Cryptography.KeyDerivation to 10.0.6 --- src/NzbDrone.Core/Sonarr.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Sonarr.Core.csproj b/src/NzbDrone.Core/Sonarr.Core.csproj index 4f9c287c3..5ae2e9858 100644 --- a/src/NzbDrone.Core/Sonarr.Core.csproj +++ b/src/NzbDrone.Core/Sonarr.Core.csproj @@ -7,7 +7,7 @@ <PackageReference Include="Diacritical.Net" Version="1.0.5" /> <PackageReference Include="Equ" Version="2.3.0" /> <PackageReference Include="MailKit" Version="4.16.0" /> - <PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.5" /> + <PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.6" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" /> <PackageReference Include="MiniProfiler.AspNetCore" Version="4.5.4" /> <PackageReference Include="Openur.FFMpegCore" Version="5.4.0.31" /> From eb97bb563b2861eff65b9a4677315b34fcffdba0 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Tue, 21 Apr 2026 02:57:19 +0300 Subject: [PATCH 091/110] Fix getting series by TVDB ID from v5 API --- src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs | 4 +++- src/Sonarr.Api.V5/Series/SeriesController.cs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs index 6eb544c1d..45dbcd1d6 100644 --- a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs +++ b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs @@ -78,7 +78,8 @@ public static Dictionary<TKey, TValue> ToDictionaryIgnoreDuplicates<TItem, TKey, return result; } - public static void AddIfNotNull<TSource>(this List<TSource> source, TSource item) + #nullable enable + public static void AddIfNotNull<TSource>(this List<TSource> source, TSource? item) { if (item == null) { @@ -87,6 +88,7 @@ public static void AddIfNotNull<TSource>(this List<TSource> source, TSource item source.Add(item); } + #nullable disable public static bool Empty<TSource>(this IEnumerable<TSource> source) { diff --git a/src/Sonarr.Api.V5/Series/SeriesController.cs b/src/Sonarr.Api.V5/Series/SeriesController.cs index cdadfe76a..79139b1f5 100644 --- a/src/Sonarr.Api.V5/Series/SeriesController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesController.cs @@ -115,7 +115,7 @@ public List<SeriesResource> AllSeries(int? tvdbId, [FromQuery] SeriesSubresource if (tvdbId.HasValue) { - seriesResources.AddIfNotNull(_seriesService.FindByTvdbId(tvdbId.Value).ToResource(includeSeasonImages)); + seriesResources.AddIfNotNull(_seriesService.FindByTvdbId(tvdbId.Value)?.ToResource(includeSeasonImages)); } else { From 92334b3fb97a8fba66c71648dea795bb83fefb23 Mon Sep 17 00:00:00 2001 From: Weblate <noreply@weblate.org> Date: Thu, 23 Apr 2026 21:27:31 +0000 Subject: [PATCH 092/110] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: Havok Dan <havokdan@yahoo.com.br> Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com> Co-authored-by: Mateusz Lesiak <mateusz.lesiak01@gmail.com> Co-authored-by: Weblate <noreply@weblate.org> Co-authored-by: fordas <fordas15@gmail.com> Co-authored-by: ugyes <ferenc.bodi@live.com> Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pl/ Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/ Translation: Servarr/Sonarr --- src/NzbDrone.Core/Localization/Core/es.json | 12 ++++++++++ src/NzbDrone.Core/Localization/Core/hu.json | 16 ++++++++++++++ src/NzbDrone.Core/Localization/Core/pl.json | 12 ++++++++++ .../Localization/Core/pt_BR.json | 22 ++++++++++++++++--- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core/Localization/Core/es.json b/src/NzbDrone.Core/Localization/Core/es.json index f82b097b0..c975a57ec 100644 --- a/src/NzbDrone.Core/Localization/Core/es.json +++ b/src/NzbDrone.Core/Localization/Core/es.json @@ -57,6 +57,7 @@ "AddedDate": "Agregado: {date}", "AddedToDownloadQueue": "Añadido a la cola de descarga", "AddingTag": "Añadir etiqueta", + "AdvancedSettings": "Configuración avanzada", "AfterManualRefresh": "Tras Refrescar Manualmente", "Age": "Antigüedad", "AgeWhenGrabbed": "Antigüedad (cuando se añadió)", @@ -147,6 +148,8 @@ "AutomaticAdd": "Añadir Automáticamente", "AutomaticSearch": "Búsqueda Automática", "AutomaticUpdatesDisabledDocker": "Las actualizaciones automáticas no están soportadas directamente cuando se utiliza el mecanismo de actualización de Docker. Tendrá que actualizar la imagen del contenedor fuera de {appName} o utilizar un script", + "AverageSize": "Tamaño promedio", + "AverageSizePerEpisode": "Tamaño promedio por episodio", "Backup": "Copia de seguridad", "BackupFolderHelpText": "Las rutas relativas estarán en el directorio AppData de {appName}", "BackupIntervalHelpText": "Intervalo entre copias de seguridad automáticas", @@ -705,6 +708,7 @@ "Events": "Eventos", "Example": "Ejemplo", "Exception": "Excepción", + "ExcludeSpecials": "Excluir especiales", "ExcludeUnknownSeriesItems": "Excluir elementos de series desconocidas", "ExcludedReleaseProfile": "Perfil de lanzamiento excluido", "ExcludedReleaseProfiles": "Perfiles de lanzamiento excluidos", @@ -1182,6 +1186,7 @@ "Logs": "Registros", "LongDateFormat": "Formato de Fecha Larga", "Lowercase": "Minúscula", + "MainNavigation": "Navegación principal", "MaintenanceRelease": "Lanzamiento de mantenimiento: Corrección de errores y otras mejoras. Ver el historial de commits de Github para más detalles", "ManageClients": "Administrar Clientes", "ManageCustomFormats": "Gestionar formatos personalizados", @@ -1635,6 +1640,11 @@ "OverviewOptions": "Opciones de vista general", "PackageVersion": "Versión del paquete", "PackageVersionInfo": "{packageVersion} por {packageAuthor}", + "PagerGoToFirstPage": "Ir a la primera página", + "PagerGoToLastPage": "Ir a la última página", + "PagerGoToNextPage": "Ir a la siguiente página", + "PagerGoToPage": "Ir a la página {page} de {totalPages}", + "PagerGoToPreviousPage": "Ir a la página anterior", "Parse": "Analizar", "ParseModalErrorParsing": "Error analizando, por favor inténtalo de nuevo.", "ParseModalHelpText": "Introduce un título de lanzamiento en la entrada anterior", @@ -1875,6 +1885,7 @@ "RssSyncInterval": "Intervalo de sincronización RSS", "RssSyncIntervalHelpText": "Intervalo en minutos. Configurar a cero para deshabilitar (esto detendrá todas las capturas automáticas de lanzamientos)", "RssSyncIntervalHelpTextWarning": "Esto se aplicará a todos los indexadores, por favor sigue las reglas establecidas por ellos", + "Run": "Ejecutar", "Runtime": "Tiempo de duración", "Saturday": "Sábado", "Save": "Guardar", @@ -2232,6 +2243,7 @@ "VideoCodec": "Códec de vídeo", "VideoDynamicRange": "Video de Rango Dinámico", "View": "Vista", + "ViewSeriesOnTvdb": "Ver {title} en TVDB", "VisitTheWikiForMoreDetails": "Visita la wiki para más detalles: ", "WaitingToImport": "Esperar para importar", "WaitingToProcess": "Esperar al proceso", diff --git a/src/NzbDrone.Core/Localization/Core/hu.json b/src/NzbDrone.Core/Localization/Core/hu.json index 52870ca08..4f2ce7656 100644 --- a/src/NzbDrone.Core/Localization/Core/hu.json +++ b/src/NzbDrone.Core/Localization/Core/hu.json @@ -57,6 +57,7 @@ "AddedDate": "Hozzáadva: {date}", "AddedToDownloadQueue": "Hozzáadva a letöltési sorhoz", "AddingTag": "Címke hozzáadása", + "AdvancedSettings": "Speciális beállítások", "AfterManualRefresh": "Kézi frissítés után", "Age": "Kor", "AgeWhenGrabbed": "Kor (amikor megragadták)", @@ -147,6 +148,8 @@ "AutomaticAdd": "Automatikus hozzáadás", "AutomaticSearch": "Automatikus keresés", "AutomaticUpdatesDisabledDocker": "Az automatikus frissítések közvetlenül nem támogatottak a Docker frissítési mechanizmus használatakor. Frissítenie kell a tároló képét a {appName} alkalmazáson kívül, vagy szkriptet kell használnia", + "AverageSize": "Átlagos méret", + "AverageSizePerEpisode": "Átlagos méret epizódonként", "Backup": "Biztonsági mentés", "BackupFolderHelpText": "A relatív elérési utak a {appName} AppData könyvtárában találhatók", "BackupIntervalHelpText": "Az automatikus biztonsági mentések közötti időköz", @@ -705,6 +708,7 @@ "Events": "Események", "Example": "Példa", "Exception": "Kivétel", + "ExcludeSpecials": "Különkiadások kizárása", "ExcludeUnknownSeriesItems": "Ismeretlen sorozatelemek kizárása", "ExcludedReleaseProfile": "Kizárt kiadási profil", "ExcludedReleaseProfiles": "Kizárt kiadási profilok", @@ -1107,6 +1111,7 @@ "InstanceName": "Példány neve", "InstanceNameHelpText": "Instancia név a fülön és a Syslog alkalmazásnévhez", "InteractiveImport": "Interaktív Import", + "InteractiveImportDuplicateEpisodes": "Egy vagy több epizód több fájlhoz van társítva", "InteractiveImportLoadError": "Nem sikerült betölteni a kézi importálási elemeket", "InteractiveImportMultipleQueueItems": "Több sorban álló tételek", "InteractiveImportNoEpisode": "Minden kiválasztott fájlhoz legalább egy epizódot ki kell választani", @@ -1181,6 +1186,7 @@ "Logs": "Naplók", "LongDateFormat": "Hosszú dátum formátum", "Lowercase": "Kisbetűs", + "MainNavigation": "Főmenü", "MaintenanceRelease": "Karbantartási kiadás: hibajavítások és egyéb fejlesztések. További részletekért lásd: Github Commit History", "ManageClients": "Ügyfelek kezelése", "ManageCustomFormats": "Egyéni formátumok kezelése", @@ -1634,6 +1640,11 @@ "OverviewOptions": "Opciók áttekintése", "PackageVersion": "Csomagverzió", "PackageVersionInfo": "{packageVersion} {packageAuthor}-tól/től", + "PagerGoToFirstPage": "Ugrás az első oldalra", + "PagerGoToLastPage": "Ugrás az utolsó oldalra", + "PagerGoToNextPage": "Ugrás a következő oldalra", + "PagerGoToPage": "Ugrás a(z) {page}. oldalra ({totalPages} oldalból)", + "PagerGoToPreviousPage": "Vissza az előző oldalra", "Parse": "Elemzés", "ParseModalErrorParsing": "Hiba történt az elemzés közben, kérjük, próbálja újra.", "ParseModalHelpText": "Adja meg a kiadás címét a fenti bevitelben", @@ -1702,6 +1713,9 @@ "QualityDefinitionsSizeNotice": "A méretkorlátozások átkerültek a minőségi profilokhoz", "QualityProfile": "Minőségi profil", "QualityProfileInUseSeriesListCollection": "Nem törölhető egy sorozathoz, listához vagy gyűjteményhez csatolt minőségi profil", + "QualityProfileUsage": "Minőségi profil használata", + "QualityProfileUsedInCountImportLists": "{count} importlista használja", + "QualityProfileUsedInCountSeries": "{count} sorozat használja", "QualityProfiles": "Minőségi profilok", "QualityProfilesLoadError": "Nem sikerült betölteni a minőségi profilokat", "QualitySettings": "Minőség Beállítások", @@ -1871,6 +1885,7 @@ "RssSyncInterval": "RSS szinkronizálási intervallum", "RssSyncIntervalHelpText": "Intervallum percekben. A letiltáshoz állítsa nullára (ez leállítja az összes automatikus feloldást)", "RssSyncIntervalHelpTextWarning": "Ez minden indexelőre vonatkozik, kérjük, kövesse az általuk meghatározott szabályokat", + "Run": "Futtatás", "Runtime": "Futási Idő", "Saturday": "Szombat", "Save": "Mentés", @@ -2228,6 +2243,7 @@ "VideoCodec": "Videókodek", "VideoDynamicRange": "Videó dinamikatartomány", "View": "Nézet", + "ViewSeriesOnTvdb": "{title} megtekintése TVDB-n", "VisitTheWikiForMoreDetails": "További részletekért keresse fel a Wikit: ", "WaitingToImport": "Importálásra vár", "WaitingToProcess": "Feldolgozásra vár", diff --git a/src/NzbDrone.Core/Localization/Core/pl.json b/src/NzbDrone.Core/Localization/Core/pl.json index 4c34cf5e9..d19221af0 100644 --- a/src/NzbDrone.Core/Localization/Core/pl.json +++ b/src/NzbDrone.Core/Localization/Core/pl.json @@ -57,6 +57,7 @@ "AddedDate": "Dodano: {date}", "AddedToDownloadQueue": "Dodano do kolejki pobierania", "AddingTag": "Dodawanie tagu", + "AdvancedSettings": "Zaawansowane ustawienia", "AfterManualRefresh": "Po ręcznym odświeżeniu", "Age": "Wiek", "AgeWhenGrabbed": "Wiek (w momencie pobrania)", @@ -147,6 +148,8 @@ "AutomaticAdd": "Automatyczne dodawanie", "AutomaticSearch": "Wyszukiwanie automatyczne", "AutomaticUpdatesDisabledDocker": "Automatyczne aktualizacje nie są bezpośrednio obsługiwane przy użyciu mechanizmu aktualizacji Docker. Musisz zaktualizować obraz kontenera poza {appName} lub użyć skryptu", + "AverageSize": "Średni rozmiar", + "AverageSizePerEpisode": "Średni rozmiar na odcinek", "Backup": "Kopia zapasowa", "BackupFolderHelpText": "Ścieżki względne będą w katalogu AppData aplikacji {appName}", "BackupIntervalHelpText": "Interwał między automatycznymi kopiami zapasowymi", @@ -705,6 +708,7 @@ "Events": "Zdarzenia", "Example": "Przykład", "Exception": "Wyjątek", + "ExcludeSpecials": "Wyklucz specjalne", "ExcludeUnknownSeriesItems": "Wyklucz nieznane elementy seriali", "ExcludedReleaseProfile": "Wykluczony profil wydań", "ExcludedReleaseProfiles": "Wykluczone profile wydań", @@ -1181,6 +1185,7 @@ "Logs": "Logi", "LongDateFormat": "Długi format daty", "Lowercase": "Małe litery", + "MainNavigation": "Główna nawigacja", "MaintenanceRelease": "Wydanie konserwacyjne: poprawki błędów i inne usprawnienia. Zobacz historię commitów GitHub, aby uzyskać więcej szczegółów", "ManageClients": "Zarządzaj klientami", "ManageCustomFormats": "Zarządzaj formatami niestandardowymi", @@ -1634,6 +1639,11 @@ "OverviewOptions": "Opcje przeglądu", "PackageVersion": "Wersja pakietu", "PackageVersionInfo": "{packageVersion} autorstwa {packageAuthor}", + "PagerGoToFirstPage": "Idź do pierwszej strony", + "PagerGoToLastPage": "Idź do ostatniej strony", + "PagerGoToNextPage": "Idź do kolejnej strony", + "PagerGoToPage": "Idź do strony {page} z {totalPages}", + "PagerGoToPreviousPage": "Idź do poprzedniej strony", "Parse": "Parsuj", "ParseModalErrorParsing": "Błąd analizy, spróbuj ponownie.", "ParseModalHelpText": "Wpisz tytuł wydania w polu powyżej", @@ -1871,6 +1881,7 @@ "RssSyncInterval": "Interwał synchronizacji RSS", "RssSyncIntervalHelpText": "Interwał w minutach. Ustaw 0, aby wyłączyć (zatrzyma to wszystkie automatyczne pobierania wydań)", "RssSyncIntervalHelpTextWarning": "Dotyczy wszystkich indekserów, stosuj się do ich zasad", + "Run": "Uruchom", "Runtime": "Czas trwania", "Saturday": "Sobota", "Save": "Zapisz", @@ -2228,6 +2239,7 @@ "VideoCodec": "Kodek wideo", "VideoDynamicRange": "Zakres dynamiczny wideo", "View": "Widok", + "ViewSeriesOnTvdb": "Zobacz {title} w TVDB", "VisitTheWikiForMoreDetails": "Odwiedź wiki, aby poznać szczegóły: ", "WaitingToImport": "Oczekiwanie na import", "WaitingToProcess": "Oczekiwanie na przetworzenie", diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json index 8b91c3924..872d10362 100644 --- a/src/NzbDrone.Core/Localization/Core/pt_BR.json +++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json @@ -40,9 +40,9 @@ "AddNewRestriction": "Adicionar nova restrição", "AddNewSeries": "Adicionar nova série", "AddNewSeriesError": "Falha ao carregar os resultados da pesquisa. Tente novamente.", - "AddNewSeriesHelpText": "É fácil adicionar uma nova série, basta começar a digitar o nome da série que você deseja adicionar.", + "AddNewSeriesHelpText": "Comece a digitar o nome da série que você deseja adicionar, é simples assim.", "AddNewSeriesRootFolderHelpText": "A subpasta \"{folder}\" será criada automaticamente", - "AddNewSeriesSearchForCutoffUnmetEpisodes": "Iniciar a pesquisa por episódios cujos corte não atingido", + "AddNewSeriesSearchForCutoffUnmetEpisodes": "Iniciar a pesquisa por episódios cujos limites não foram atingidos", "AddNewSeriesSearchForMissingEpisodes": "Iniciar a busca por episódios ausentes", "AddQualityProfile": "Adicionar perfil de qualidade", "AddQualityProfileError": "Não foi possível adicionar um novo perfil de qualidade. Tente novamente.", @@ -57,7 +57,8 @@ "AddedDate": "Adicionado: {date}", "AddedToDownloadQueue": "Adicionado à fila de download", "AddingTag": "Adicionar etiqueta", - "AfterManualRefresh": "Após a Atualização Manual", + "AdvancedSettings": "Configurações avançadas", + "AfterManualRefresh": "Após a atualização manual", "Age": "Tempo de vida", "AgeWhenGrabbed": "Tempo de vida (quando obtido)", "Agenda": "Programação", @@ -147,6 +148,8 @@ "AutomaticAdd": "Adição automática", "AutomaticSearch": "Pesquisa automática", "AutomaticUpdatesDisabledDocker": "As atualizações automáticas não têm suporte direto ao usar o mecanismo de atualização do Docker. Você precisará atualizar a imagem do contêiner fora do {appName} ou usar um script", + "AverageSize": "Tamanho Médio", + "AverageSizePerEpisode": "Tamanho Médio por Episódio", "Backup": "Backup", "BackupFolderHelpText": "Os caminhos relativos estarão no diretório AppData do {appName}", "BackupIntervalHelpText": "Intervalo entre backups automáticos", @@ -705,6 +708,7 @@ "Events": "Eventos", "Example": "Exemplo", "Exception": "Exceção", + "ExcludeSpecials": "Excluir Especiais", "ExcludeUnknownSeriesItems": "Excluir Itens de Séries Desconhecidas", "ExcludedReleaseProfile": "Perfil de Lançamento Excluído", "ExcludedReleaseProfiles": "Perfis de Lançamentos Excluídos", @@ -1107,6 +1111,7 @@ "InstanceName": "Nome da instância", "InstanceNameHelpText": "Nome da instância na aba e para o nome do aplicativo Syslog", "InteractiveImport": "Importação interativa", + "InteractiveImportDuplicateEpisodes": "Um ou mais episódios foram atribuídos a vários arquivos", "InteractiveImportLoadError": "Não foi possível carregar itens de importação manual", "InteractiveImportMultipleQueueItems": "Múltiplos Itens da Fila", "InteractiveImportNoEpisode": "Escolha um ou mais episódios para cada arquivo selecionado", @@ -1181,6 +1186,7 @@ "Logs": "Logs", "LongDateFormat": "Formato longo de data", "Lowercase": "Minúsculas", + "MainNavigation": "Navegação Principal", "MaintenanceRelease": "Versão de manutenção: correções de bugs e outras melhorias. Consulte o Histórico de commits do Github para saber mais", "ManageClients": "Gerenciar clientes", "ManageCustomFormats": "Gerenciar formatos personalizados", @@ -1634,6 +1640,11 @@ "OverviewOptions": "Opções da visão geral", "PackageVersion": "Versão do pacote", "PackageVersionInfo": "{packageVersion} por {packageAuthor}", + "PagerGoToFirstPage": "Ir para a primeira página", + "PagerGoToLastPage": "Ir para a última página", + "PagerGoToNextPage": "Ir para a próxima página", + "PagerGoToPage": "Ir para a página {page} de {totalPages}", + "PagerGoToPreviousPage": "Ir para a página anterior", "Parse": "Analisar", "ParseModalErrorParsing": "Erro ao analisar, tente novamente.", "ParseModalHelpText": "Insira um título de lançamento na entrada acima", @@ -1702,6 +1713,9 @@ "QualityDefinitionsSizeNotice": "As restrições de tamanho foram transferidas para Perfis de Qualidade", "QualityProfile": "Perfil de qualidade", "QualityProfileInUseSeriesListCollection": "Não é possível excluir um perfil de qualidade anexado a uma série, lista ou coleção", + "QualityProfileUsage": "Uso do Perfil de Qualidade", + "QualityProfileUsedInCountImportLists": "Usado em {count} listas de importação", + "QualityProfileUsedInCountSeries": "Usado em {count} séries", "QualityProfiles": "Perfis de Qualidade", "QualityProfilesLoadError": "Não é possível carregar perfis de qualidade", "QualitySettings": "Configurações de Qualidade", @@ -1871,6 +1885,7 @@ "RssSyncInterval": "Intervalo da Sincronização RSS", "RssSyncIntervalHelpText": "Intervalo em minutos. Defina como zero para desativar (isso interromperá todas as capturas de lançamentos automáticas)", "RssSyncIntervalHelpTextWarning": "Isso se aplica a todos os indexadores, siga as regras estabelecidas por eles", + "Run": "Executar", "Runtime": "Duração", "Saturday": "Sábado", "Save": "Salvar", @@ -2228,6 +2243,7 @@ "VideoCodec": "Codec de Vídeo", "VideoDynamicRange": "Faixa Dinâmica de Vídeo", "View": "Exibir", + "ViewSeriesOnTvdb": "Exibir {title} no TVDB", "VisitTheWikiForMoreDetails": "Visite o wiki para mais detalhes: ", "WaitingToImport": "Aguardando para Importar", "WaitingToProcess": "Aguardando para Processar", From f4a160cb2902a37df8b0fabd83f1659119136af8 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:10:34 +0300 Subject: [PATCH 093/110] TypedResults for API v5 endpoints --- src/NzbDrone.Host/Startup.cs | 5 +++ src/Sonarr.Api.V3/Health/HealthController.cs | 3 +- .../Indexers/ReleaseControllerBase.cs | 3 +- .../LanguageProfileSchemaController.cs | 3 +- src/Sonarr.Api.V3/Queue/QueueController.cs | 3 +- .../Queue/QueueDetailsController.cs | 3 +- .../Queue/QueueStatusController.cs | 3 +- src/Sonarr.Api.V3/Series/SeriesController.cs | 3 +- .../Blocklist/BlocklistController.cs | 14 +++--- .../Calendar/CalendarController.cs | 6 ++- .../Calendar/CalendarFeedController.cs | 6 ++- .../Commands/CommandController.cs | 16 ++++--- .../Connections/ConnectionController.cs | 5 ++- .../CustomFilters/CustomFilterController.cs | 18 ++++---- .../DiskSpace/DiskSpaceController.cs | 6 ++- .../EpisodeFiles/EpisodeFileController.cs | 28 +++++++----- .../Episodes/EpisodeController.cs | 20 +++++---- .../Episodes/RenameEpisodeController.cs | 12 ++--- .../FileSystem/FileSystemController.cs | 20 +++++---- src/Sonarr.Api.V5/Health/HealthController.cs | 8 ++-- .../History/HistoryController.cs | 32 +++++++------- .../ImportListExclusionController.cs | 22 +++++----- .../Indexers/IndexerFlagController.cs | 8 ++-- .../Localization/LanguageController.cs | 6 ++- .../Localization/LocalizationController.cs | 14 +++--- src/Sonarr.Api.V5/Logs/LogController.cs | 8 ++-- .../Logs/LogFileControllerBase.cs | 12 ++--- .../ManualImport/ManualImportController.cs | 20 +++++---- .../Metadata/MetadataController.cs | 5 ++- src/Sonarr.Api.V5/Parse/ParseController.cs | 20 +++++---- .../Quality/QualityProfileController.cs | 18 ++++---- .../Quality/QualityProfileSchemaController.cs | 6 ++- .../Release/ReleaseProfileController.cs | 18 ++++---- .../Provider/ProviderControllerBase.cs | 44 ++++++++++--------- .../Qualities/QualityDefinitionController.cs | 15 ++++--- .../Queue/QueueActionController.cs | 10 +++-- src/Sonarr.Api.V5/Queue/QueueController.cs | 18 ++++---- .../Queue/QueueDetailsController.cs | 14 +++--- .../Queue/QueueStatusController.cs | 3 +- .../Release/ReleaseController.cs | 16 ++++--- .../Release/ReleasePushController.cs | 6 ++- .../RemotePathMappingController.cs | 18 ++++---- .../RootFolders/RootFolderController.cs | 14 +++--- .../SeasonPass/SeasonPassController.cs | 6 ++- src/Sonarr.Api.V5/Series/SeriesController.cs | 32 +++++++------- .../Series/SeriesEditorController.cs | 10 +++-- .../Series/SeriesFolderController.cs | 8 ++-- .../Series/SeriesImportController.cs | 6 ++- .../Series/SeriesLookupController.cs | 6 ++- .../Settings/GeneralSettingsController.cs | 4 +- .../Settings/NamingSettingsController.cs | 17 ++++--- .../Settings/SettingsController.cs | 23 +++++----- .../System/Backup/BackupController.cs | 22 +++++----- src/Sonarr.Api.V5/System/SystemController.cs | 24 +++++----- .../System/Tasks/TaskController.cs | 8 ++-- src/Sonarr.Api.V5/Tags/TagController.cs | 18 +++++--- .../Tags/TagDetailsController.cs | 6 ++- src/Sonarr.Api.V5/Update/UpdateController.cs | 8 ++-- src/Sonarr.Api.V5/Wanted/CutoffController.cs | 6 ++- src/Sonarr.Api.V5/Wanted/MissingController.cs | 6 ++- src/Sonarr.Http/REST/RestController.cs | 22 ++++++++-- 61 files changed, 447 insertions(+), 317 deletions(-) diff --git a/src/NzbDrone.Host/Startup.cs b/src/NzbDrone.Host/Startup.cs index 85b1bb55e..51bb431ff 100644 --- a/src/NzbDrone.Host/Startup.cs +++ b/src/NzbDrone.Host/Startup.cs @@ -108,6 +108,11 @@ public void ConfigureServices(IServiceCollection services) }) .AddControllersAsServices(); + services.ConfigureHttpJsonOptions(options => + { + STJson.ApplySerializerSettings(options.SerializerOptions); + }); + services.AddSwaggerGen(c => { c.SwaggerDoc("v3", new OpenApiInfo diff --git a/src/Sonarr.Api.V3/Health/HealthController.cs b/src/Sonarr.Api.V3/Health/HealthController.cs index fe1fd954c..10fbadcc0 100644 --- a/src/Sonarr.Api.V3/Health/HealthController.cs +++ b/src/Sonarr.Api.V3/Health/HealthController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.HealthCheck; @@ -23,7 +24,7 @@ public HealthController(IBroadcastSignalRMessage signalRBroadcaster, IHealthChec } [NonAction] - public override ActionResult<HealthResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<HealthResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } diff --git a/src/Sonarr.Api.V3/Indexers/ReleaseControllerBase.cs b/src/Sonarr.Api.V3/Indexers/ReleaseControllerBase.cs index e08475952..78e30a989 100644 --- a/src/Sonarr.Api.V3/Indexers/ReleaseControllerBase.cs +++ b/src/Sonarr.Api.V3/Indexers/ReleaseControllerBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Profiles.Qualities; @@ -17,7 +18,7 @@ public ReleaseControllerBase(IQualityProfileService qualityProfileService) } [NonAction] - public override ActionResult<ReleaseResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<ReleaseResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } diff --git a/src/Sonarr.Api.V3/Profiles/Languages/LanguageProfileSchemaController.cs b/src/Sonarr.Api.V3/Profiles/Languages/LanguageProfileSchemaController.cs index ad5a32704..bccee3469 100644 --- a/src/Sonarr.Api.V3/Profiles/Languages/LanguageProfileSchemaController.cs +++ b/src/Sonarr.Api.V3/Profiles/Languages/LanguageProfileSchemaController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Languages; using Sonarr.Http; @@ -33,7 +34,7 @@ public LanguageProfileResource GetSchema() } [NonAction] - public override ActionResult<LanguageProfileResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<LanguageProfileResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } diff --git a/src/Sonarr.Api.V3/Queue/QueueController.cs b/src/Sonarr.Api.V3/Queue/QueueController.cs index 454b497fc..684d29a10 100644 --- a/src/Sonarr.Api.V3/Queue/QueueController.cs +++ b/src/Sonarr.Api.V3/Queue/QueueController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Blocklisting; @@ -61,7 +62,7 @@ public QueueController(IBroadcastSignalRMessage broadcastSignalRMessage, } [NonAction] - public override ActionResult<QueueResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<QueueResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } diff --git a/src/Sonarr.Api.V3/Queue/QueueDetailsController.cs b/src/Sonarr.Api.V3/Queue/QueueDetailsController.cs index 152b4b733..d4d28afd9 100644 --- a/src/Sonarr.Api.V3/Queue/QueueDetailsController.cs +++ b/src/Sonarr.Api.V3/Queue/QueueDetailsController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Download.Pending; @@ -28,7 +29,7 @@ public QueueDetailsController(IBroadcastSignalRMessage broadcastSignalRMessage, } [NonAction] - public override ActionResult<QueueResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<QueueResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } diff --git a/src/Sonarr.Api.V3/Queue/QueueStatusController.cs b/src/Sonarr.Api.V3/Queue/QueueStatusController.cs index 57d87d107..fa43ad189 100644 --- a/src/Sonarr.Api.V3/Queue/QueueStatusController.cs +++ b/src/Sonarr.Api.V3/Queue/QueueStatusController.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.TPL; using NzbDrone.Core.Datastore.Events; @@ -31,7 +32,7 @@ public QueueStatusController(IBroadcastSignalRMessage broadcastSignalRMessage, I } [NonAction] - public override ActionResult<QueueStatusResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<QueueStatusResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } diff --git a/src/Sonarr.Api.V3/Series/SeriesController.cs b/src/Sonarr.Api.V3/Series/SeriesController.cs index 8f155b9ff..04a6abe82 100644 --- a/src/Sonarr.Api.V3/Series/SeriesController.cs +++ b/src/Sonarr.Api.V3/Series/SeriesController.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using FluentValidation; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.DataAugmentation.Scene; @@ -130,7 +131,7 @@ public List<SeriesResource> AllSeries(int? tvdbId, bool includeSeasonImages = fa } [NonAction] - public override ActionResult<SeriesResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<SeriesResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } diff --git a/src/Sonarr.Api.V5/Blocklist/BlocklistController.cs b/src/Sonarr.Api.V5/Blocklist/BlocklistController.cs index 22b27d3cf..f4895e6b2 100644 --- a/src/Sonarr.Api.V5/Blocklist/BlocklistController.cs +++ b/src/Sonarr.Api.V5/Blocklist/BlocklistController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Blocklisting; using NzbDrone.Core.CustomFormats; @@ -24,7 +26,7 @@ public BlocklistController(IBlocklistService blocklistService, [HttpGet] [Produces("application/json")] - public PagingResource<BlocklistResource> GetBlocklist([FromQuery] PagingRequestResource paging, [FromQuery] int[]? seriesIds = null, [FromQuery] DownloadProtocol[]? protocols = null) + public Ok<PagingResource<BlocklistResource>> GetBlocklist([FromQuery] PagingRequestResource paging, [FromQuery] int[]? seriesIds = null, [FromQuery] DownloadProtocol[]? protocols = null) { var pagingResource = new PagingResource<BlocklistResource>(paging); var pagingSpec = pagingResource.MapToPagingSpec<BlocklistResource, NzbDrone.Core.Blocklisting.Blocklist>( @@ -48,23 +50,23 @@ public PagingResource<BlocklistResource> GetBlocklist([FromQuery] PagingRequestR pagingSpec.FilterExpressions.Add(b => protocols.Contains(b.Protocol)); } - return pagingSpec.ApplyToPage(b => _blocklistService.Paged(pagingSpec), b => BlocklistResourceMapper.MapToResource(b, _formatCalculator)); + return TypedResults.Ok(pagingSpec.ApplyToPage(b => _blocklistService.Paged(pagingSpec), b => BlocklistResourceMapper.MapToResource(b, _formatCalculator))); } [RestDeleteById] - public ActionResult DeleteBlocklist(int id) + public NoContent DeleteBlocklist(int id) { _blocklistService.Delete(id); - return NoContent(); + return TypedResults.NoContent(); } [HttpDelete("bulk")] [Produces("application/json")] - public ActionResult Remove([FromBody] BlocklistBulkResource resource) + public NoContent Remove([FromBody] BlocklistBulkResource resource) { _blocklistService.Delete(resource.Ids); - return NoContent(); + return TypedResults.NoContent(); } } diff --git a/src/Sonarr.Api.V5/Calendar/CalendarController.cs b/src/Sonarr.Api.V5/Calendar/CalendarController.cs index 3eb37d4da..003c60d83 100644 --- a/src/Sonarr.Api.V5/Calendar/CalendarController.cs +++ b/src/Sonarr.Api.V5/Calendar/CalendarController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.CustomFormats; @@ -28,7 +30,7 @@ public CalendarController(IBroadcastSignalRMessage signalR, [HttpGet] [Produces("application/json")] - public List<EpisodeResource> GetCalendar(DateTime? start, DateTime? end, bool includeUnmonitored = false, bool includeSpecials = true, string tags = "", [FromQuery] CalendarSubresource[]? includeSubresources = null) + public Ok<List<EpisodeResource>> GetCalendar(DateTime? start, DateTime? end, bool includeUnmonitored = false, bool includeSpecials = true, string tags = "", [FromQuery] CalendarSubresource[]? includeSubresources = null) { var startUse = start ?? DateTime.Today; var endUse = end ?? DateTime.Today.AddDays(2); @@ -65,7 +67,7 @@ public List<EpisodeResource> GetCalendar(DateTime? start, DateTime? end, bool in var resources = MapToResource(result, includeSeries, includeEpisodeFile, includeEpisodeImages); - return resources.OrderBy(e => e.AirDateUtc).ToList(); + return TypedResults.Ok(resources.OrderBy(e => e.AirDateUtc).ToList()); } } } diff --git a/src/Sonarr.Api.V5/Calendar/CalendarFeedController.cs b/src/Sonarr.Api.V5/Calendar/CalendarFeedController.cs index e1f1ec083..c8c0b4095 100644 --- a/src/Sonarr.Api.V5/Calendar/CalendarFeedController.cs +++ b/src/Sonarr.Api.V5/Calendar/CalendarFeedController.cs @@ -2,6 +2,8 @@ using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using Ical.Net.Serialization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Tags; @@ -25,7 +27,7 @@ public CalendarFeedController(IEpisodeService episodeService, ISeriesService ser } [HttpGet("Sonarr.ics")] - public IActionResult GetCalendarFeed(int pastDays = 7, int futureDays = 28, string tags = "", bool unmonitored = false, bool premieresOnly = false, bool asAllDay = false, bool includeSpecials = true) + public ContentHttpResult GetCalendarFeed(int pastDays = 7, int futureDays = 28, string tags = "", bool unmonitored = false, bool premieresOnly = false, bool asAllDay = false, bool includeSpecials = true) { var start = DateTime.Today.AddDays(-pastDays); var end = DateTime.Today.AddDays(futureDays); @@ -96,6 +98,6 @@ public IActionResult GetCalendarFeed(int pastDays = 7, int futureDays = 28, stri var serializer = (IStringSerializer)new SerializerFactory().Build(calendar.GetType(), new SerializationContext()); var icalendar = serializer.SerializeToString(calendar); - return Content(icalendar, "text/calendar"); + return TypedResults.Content(icalendar, "text/calendar"); } } diff --git a/src/Sonarr.Api.V5/Commands/CommandController.cs b/src/Sonarr.Api.V5/Commands/CommandController.cs index ebb0b3beb..2880f3066 100644 --- a/src/Sonarr.Api.V5/Commands/CommandController.cs +++ b/src/Sonarr.Api.V5/Commands/CommandController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Composition; using NzbDrone.Common.Serializer; @@ -46,7 +48,7 @@ protected override CommandResource GetResourceById(int id) [RestPostById] [Consumes("application/json")] [Produces("application/json")] - public ActionResult<CommandResource> StartCommand([FromBody] CommandResource commandResource) + public Results<Created<CommandResource>, NotFound> StartCommand([FromBody] CommandResource commandResource) { var commandType = _knownTypes.GetImplementations(typeof(Command)) @@ -70,24 +72,26 @@ public ActionResult<CommandResource> StartCommand([FromBody] CommandResource com var trackedCommand = _commandQueueManager.Push(command, commandResource.Priority, CommandTrigger.Manual); - return Created(trackedCommand.Id); + return TypedCreated(trackedCommand.Id); } } [HttpGet] [Produces("application/json")] - public List<CommandResource> GetStartedCommands() + public Ok<List<CommandResource>> GetStartedCommands() { - return _commandQueueManager.All() + return TypedResults.Ok(_commandQueueManager.All() .OrderBy(c => c.Status, _commandPriorityComparer) .ThenByDescending(c => c.Priority) - .ToResource(); + .ToResource()); } [RestDeleteById] - public void CancelCommand(int id) + public NoContent CancelCommand(int id) { _commandQueueManager.Cancel(id); + + return TypedResults.NoContent(); } [NonAction] diff --git a/src/Sonarr.Api.V5/Connections/ConnectionController.cs b/src/Sonarr.Api.V5/Connections/ConnectionController.cs index 5349a6d8b..c5dc47d1f 100644 --- a/src/Sonarr.Api.V5/Connections/ConnectionController.cs +++ b/src/Sonarr.Api.V5/Connections/ConnectionController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Notifications; using NzbDrone.SignalR; @@ -18,13 +19,13 @@ public ConnectionController(IBroadcastSignalRMessage signalRBroadcaster, Notific } [NonAction] - public override ActionResult<ConnectionResource> UpdateProvider([FromBody] ConnectionBulkResource providerResource) + public override Results<Ok<IEnumerable<ConnectionResource>>, BadRequest> UpdateProvider([FromBody] ConnectionBulkResource providerResource) { throw new NotImplementedException(); } [NonAction] - public override ActionResult DeleteProviders([FromBody] ConnectionBulkResource resource) + public override NoContent DeleteProviders([FromBody] ConnectionBulkResource resource) { throw new NotImplementedException(); } diff --git a/src/Sonarr.Api.V5/CustomFilters/CustomFilterController.cs b/src/Sonarr.Api.V5/CustomFilters/CustomFilterController.cs index 61a267b68..6cfe21944 100644 --- a/src/Sonarr.Api.V5/CustomFilters/CustomFilterController.cs +++ b/src/Sonarr.Api.V5/CustomFilters/CustomFilterController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.CustomFilters; using Sonarr.Http; @@ -23,33 +25,33 @@ protected override CustomFilterResource GetResourceById(int id) [HttpGet] [Produces("application/json")] - public List<CustomFilterResource> GetCustomFilters() + public Ok<List<CustomFilterResource>> GetCustomFilters() { - return _customFilterService.All().ToResource(); + return TypedResults.Ok(_customFilterService.All().ToResource()); } [RestPostById] [Consumes("application/json")] - public ActionResult<CustomFilterResource> AddCustomFilter([FromBody] CustomFilterResource resource) + public Results<Created<CustomFilterResource>, NotFound> AddCustomFilter([FromBody] CustomFilterResource resource) { var customFilter = _customFilterService.Add(resource.ToModel()); - return Created(customFilter.Id); + return TypedCreated(customFilter.Id); } [RestPutById] [Consumes("application/json")] - public ActionResult<CustomFilterResource> UpdateCustomFilter([FromBody] CustomFilterResource resource) + public Results<Accepted<CustomFilterResource>, NotFound> UpdateCustomFilter([FromBody] CustomFilterResource resource) { _customFilterService.Update(resource.ToModel()); - return Accepted(resource.Id); + return TypedAccepted(resource.Id); } [RestDeleteById] - public ActionResult DeleteCustomResource(int id) + public NoContent DeleteCustomResource(int id) { _customFilterService.Delete(id); - return NoContent(); + return TypedResults.NoContent(); } } diff --git a/src/Sonarr.Api.V5/DiskSpace/DiskSpaceController.cs b/src/Sonarr.Api.V5/DiskSpace/DiskSpaceController.cs index b0a1a9abe..04cc40421 100644 --- a/src/Sonarr.Api.V5/DiskSpace/DiskSpaceController.cs +++ b/src/Sonarr.Api.V5/DiskSpace/DiskSpaceController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.DiskSpace; using Sonarr.Http; @@ -16,8 +18,8 @@ public DiskSpaceController(IDiskSpaceService diskSpaceService) [HttpGet] [Produces("application/json")] - public List<DiskSpaceResource> GetFreeSpace() + public Ok<List<DiskSpaceResource>> GetFreeSpace() { - return _diskSpaceService.GetFreeSpace().ConvertAll(DiskSpaceResourceMapper.MapToResource); + return TypedResults.Ok(_diskSpaceService.GetFreeSpace().ConvertAll(DiskSpaceResourceMapper.MapToResource)); } } diff --git a/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs index 1bfef1f4c..921b91682 100644 --- a/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs +++ b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs @@ -1,4 +1,6 @@ using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.Datastore.Events; @@ -57,7 +59,7 @@ protected override EpisodeFileResource GetResourceById(int id) [HttpGet] [Produces("application/json")] - public List<EpisodeFileResource> GetEpisodeFiles(int? seriesId, [FromQuery] List<int>? episodeFileIds) + public Results<Ok<List<EpisodeFileResource>>, BadRequest> GetEpisodeFiles(int? seriesId, [FromQuery] List<int>? episodeFileIds) { if (!seriesId.HasValue && episodeFileIds?.Any() == false) { @@ -71,25 +73,25 @@ public List<EpisodeFileResource> GetEpisodeFiles(int? seriesId, [FromQuery] List if (files == null) { - return new List<EpisodeFileResource>(); + return TypedResults.Ok(new List<EpisodeFileResource>()); } - return files.ConvertAll(e => e.ToResource(series, _upgradableSpecification, _formatCalculator)); + return TypedResults.Ok(files.ConvertAll(e => e.ToResource(series, _upgradableSpecification, _formatCalculator))); } else { var episodeFiles = _mediaFileService.Get(episodeFileIds); - return episodeFiles.GroupBy(e => e.SeriesId) + return TypedResults.Ok(episodeFiles.GroupBy(e => e.SeriesId) .SelectMany(f => f.ToList() .ConvertAll(e => e.ToResource(_seriesService.GetSeries(f.Key), _upgradableSpecification, _formatCalculator))) - .ToList(); + .ToList()); } } [RestPutById] [Consumes("application/json")] - public ActionResult<EpisodeFileResource> SetQuality([FromBody] EpisodeFileResource episodeFileResource) + public Results<Accepted<EpisodeFileResource>, NotFound> SetQuality([FromBody] EpisodeFileResource episodeFileResource) { var episodeFile = _mediaFileService.Get(episodeFileResource.Id); episodeFile.Quality = episodeFileResource.Quality; @@ -105,11 +107,11 @@ public ActionResult<EpisodeFileResource> SetQuality([FromBody] EpisodeFileResour } _mediaFileService.Update(episodeFile); - return Accepted(episodeFile.Id); + return TypedAccepted(episodeFile.Id); } [RestDeleteById] - public void DeleteEpisodeFile(int id) + public Results<NoContent, NotFound> DeleteEpisodeFile(int id) { var episodeFile = _mediaFileService.Get(id); @@ -121,11 +123,13 @@ public void DeleteEpisodeFile(int id) var series = _seriesService.GetSeries(episodeFile.SeriesId); _mediaFileDeletionService.DeleteEpisodeFile(series, episodeFile); + + return TypedResults.NoContent(); } [HttpDelete("bulk")] [Consumes("application/json")] - public object DeleteEpisodeFiles([FromBody] EpisodeFileListResource resource) + public NoContent DeleteEpisodeFiles([FromBody] EpisodeFileListResource resource) { var episodeFiles = _mediaFileService.GetFiles(resource.EpisodeFileIds); var series = _seriesService.GetSeries(episodeFiles.First().SeriesId); @@ -135,12 +139,12 @@ public object DeleteEpisodeFiles([FromBody] EpisodeFileListResource resource) _mediaFileDeletionService.DeleteEpisodeFile(series, episodeFile); } - return NoContent(); + return TypedResults.NoContent(); } [HttpPut("bulk")] [Consumes("application/json")] - public object SetPropertiesBulk([FromBody] List<EpisodeFileResource> resources) + public Ok<List<EpisodeFileResource>> SetPropertiesBulk([FromBody] List<EpisodeFileResource> resources) { var episodeFiles = _mediaFileService.GetFiles(resources.Select(r => r.Id)); @@ -184,7 +188,7 @@ public object SetPropertiesBulk([FromBody] List<EpisodeFileResource> resources) var series = _seriesService.GetSeries(episodeFiles.First().SeriesId); - return Accepted(episodeFiles.ConvertAll(f => f.ToResource(series, _upgradableSpecification, _formatCalculator))); + return TypedResults.Ok(episodeFiles.ConvertAll(f => f.ToResource(series, _upgradableSpecification, _formatCalculator))); } [NonAction] diff --git a/src/Sonarr.Api.V5/Episodes/EpisodeController.cs b/src/Sonarr.Api.V5/Episodes/EpisodeController.cs index bb6e105ef..367bd1aca 100644 --- a/src/Sonarr.Api.V5/Episodes/EpisodeController.cs +++ b/src/Sonarr.Api.V5/Episodes/EpisodeController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.DecisionEngine.Specifications; @@ -23,7 +25,7 @@ public EpisodeController(ISeriesService seriesService, [HttpGet] [Produces("application/json")] - public List<EpisodeResource> GetEpisodes(int? seriesId, int? seasonNumber, [FromQuery]List<int> episodeIds, int? episodeFileId, [FromQuery] EpisodeSubresource[]? includeSubresources = null) + public Results<Ok<List<EpisodeResource>>, BadRequest> GetEpisodes(int? seriesId, int? seasonNumber, [FromQuery]List<int> episodeIds, int? episodeFileId, [FromQuery] EpisodeSubresource[]? includeSubresources = null) { var includeSeries = includeSubresources.Contains(EpisodeSubresource.Series); var includeEpisodeFile = includeSubresources.Contains(EpisodeSubresource.EpisodeFile); @@ -33,18 +35,18 @@ public List<EpisodeResource> GetEpisodes(int? seriesId, int? seasonNumber, [From { if (seasonNumber.HasValue) { - return MapToResource(_episodeService.GetEpisodesBySeason(seriesId.Value, seasonNumber.Value), includeSeries, includeEpisodeFile, includeImages); + return TypedResults.Ok(MapToResource(_episodeService.GetEpisodesBySeason(seriesId.Value, seasonNumber.Value), includeSeries, includeEpisodeFile, includeImages)); } - return MapToResource(_episodeService.GetEpisodeBySeries(seriesId.Value), includeSeries, includeEpisodeFile, includeImages); + return TypedResults.Ok(MapToResource(_episodeService.GetEpisodeBySeries(seriesId.Value), includeSeries, includeEpisodeFile, includeImages)); } else if (episodeIds.Any()) { - return MapToResource(_episodeService.GetEpisodes(episodeIds), includeSeries, includeEpisodeFile, includeImages); + return TypedResults.Ok(MapToResource(_episodeService.GetEpisodes(episodeIds), includeSeries, includeEpisodeFile, includeImages)); } else if (episodeFileId.HasValue) { - return MapToResource(_episodeService.GetEpisodesByFileId(episodeFileId.Value), includeSeries, includeEpisodeFile, includeImages); + return TypedResults.Ok(MapToResource(_episodeService.GetEpisodesByFileId(episodeFileId.Value), includeSeries, includeEpisodeFile, includeImages)); } throw new BadRequestException("seriesId or episodeIds must be provided"); @@ -52,18 +54,18 @@ public List<EpisodeResource> GetEpisodes(int? seriesId, int? seasonNumber, [From [RestPutById] [Consumes("application/json")] - public ActionResult<EpisodeResource> SetEpisodeMonitored([FromRoute] int id, [FromBody] EpisodeResource resource) + public Ok<EpisodeResource> SetEpisodeMonitored([FromRoute] int id, [FromBody] EpisodeResource resource) { _episodeService.SetEpisodeMonitored(id, resource.Monitored); resource = MapToResource(_episodeService.GetEpisode(id), false, false, false); - return Accepted(resource); + return TypedResults.Ok(resource); } [HttpPut("monitor")] [Consumes("application/json")] - public IActionResult SetEpisodesMonitored([FromBody] EpisodesMonitoredResource resource, [FromQuery] EpisodeSubresource[]? includeSubresources = null) + public Ok<List<EpisodeResource>> SetEpisodesMonitored([FromBody] EpisodesMonitoredResource resource, [FromQuery] EpisodeSubresource[]? includeSubresources = null) { var includeImages = includeSubresources.Contains(EpisodeSubresource.Images); @@ -78,6 +80,6 @@ public IActionResult SetEpisodesMonitored([FromBody] EpisodesMonitoredResource r var resources = MapToResource(_episodeService.GetEpisodes(resource.EpisodeIds), false, false, includeImages); - return Accepted(resources); + return TypedResults.Ok(resources); } } diff --git a/src/Sonarr.Api.V5/Episodes/RenameEpisodeController.cs b/src/Sonarr.Api.V5/Episodes/RenameEpisodeController.cs index 05494a33b..7c8e5ccdb 100644 --- a/src/Sonarr.Api.V5/Episodes/RenameEpisodeController.cs +++ b/src/Sonarr.Api.V5/Episodes/RenameEpisodeController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.MediaFiles; using Sonarr.Http; @@ -17,19 +19,19 @@ public RenameEpisodeController(IRenameEpisodeFileService renameEpisodeFileServic [HttpGet] [Produces("application/json")] - public List<RenameEpisodeResource> GetEpisodes(int seriesId, int? seasonNumber) + public Ok<List<RenameEpisodeResource>> GetEpisodes(int seriesId, int? seasonNumber) { if (seasonNumber.HasValue) { - return _renameEpisodeFileService.GetRenamePreviews(seriesId, seasonNumber.Value).ToResource(); + return TypedResults.Ok(_renameEpisodeFileService.GetRenamePreviews(seriesId, seasonNumber.Value).ToResource()); } - return _renameEpisodeFileService.GetRenamePreviews(seriesId).ToResource(); + return TypedResults.Ok(_renameEpisodeFileService.GetRenamePreviews(seriesId).ToResource()); } [HttpGet("bulk")] [Produces("application/json")] - public List<RenameEpisodeResource> GetEpisodes([FromQuery] List<int> seriesIds) + public Results<Ok<List<RenameEpisodeResource>>, BadRequest> GetEpisodes([FromQuery] List<int> seriesIds) { if (seriesIds is { Count: 0 }) { @@ -41,6 +43,6 @@ public List<RenameEpisodeResource> GetEpisodes([FromQuery] List<int> seriesIds) throw new BadRequestException("seriesIds must be positive integers"); } - return _renameEpisodeFileService.GetRenamePreviews(seriesIds).ToResource(); + return TypedResults.Ok(_renameEpisodeFileService.GetRenamePreviews(seriesIds).ToResource()); } } diff --git a/src/Sonarr.Api.V5/FileSystem/FileSystemController.cs b/src/Sonarr.Api.V5/FileSystem/FileSystemController.cs index ad54be2f7..820b41810 100644 --- a/src/Sonarr.Api.V5/FileSystem/FileSystemController.cs +++ b/src/Sonarr.Api.V5/FileSystem/FileSystemController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; @@ -24,38 +26,38 @@ public FileSystemController(IFileSystemLookupService fileSystemLookupService, [HttpGet] [Produces("application/json")] - public IActionResult GetContents(string? path, bool includeFiles = false, bool allowFoldersWithoutTrailingSlashes = false) + public Ok<FileSystemResult> GetContents(string? path, bool includeFiles = false, bool allowFoldersWithoutTrailingSlashes = false) { - return Ok(_fileSystemLookupService.LookupContents(path, includeFiles, allowFoldersWithoutTrailingSlashes)); + return TypedResults.Ok(_fileSystemLookupService.LookupContents(path, includeFiles, allowFoldersWithoutTrailingSlashes)); } [HttpGet("type")] [Produces("application/json")] - public object GetEntityType(string path) + public Ok<object> GetEntityType(string path) { if (_diskProvider.FileExists(path)) { - return new { type = "file" }; + return TypedResults.Ok((object)new { type = "file" }); } // Return folder even if it doesn't exist on disk to avoid leaking anything from the UI about the underlying system - return new { type = "folder" }; + return TypedResults.Ok((object)new { type = "folder" }); } [HttpGet("mediafiles")] [Produces("application/json")] - public object GetMediaFiles(string path) + public Ok<IEnumerable<object>> GetMediaFiles(string path) { if (!_diskProvider.FolderExists(path)) { - return Array.Empty<string>(); + return TypedResults.Ok(Enumerable.Empty<object>()); } - return _diskScanService.GetVideoFiles(path).Select(f => new + return TypedResults.Ok(_diskScanService.GetVideoFiles(path).Select(object (f) => new { Path = f, RelativePath = path.GetRelativePath(f), Name = Path.GetFileName(f) - }); + })); } } diff --git a/src/Sonarr.Api.V5/Health/HealthController.cs b/src/Sonarr.Api.V5/Health/HealthController.cs index f7912fe7d..4610a1c5e 100644 --- a/src/Sonarr.Api.V5/Health/HealthController.cs +++ b/src/Sonarr.Api.V5/Health/HealthController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.HealthCheck; @@ -21,7 +23,7 @@ public HealthController(IBroadcastSignalRMessage signalRBroadcaster, IHealthChec } [NonAction] - public override ActionResult<HealthResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<HealthResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } @@ -33,9 +35,9 @@ protected override HealthResource GetResourceById(int id) [HttpGet] [Produces("application/json")] - public List<HealthResource> GetHealth() + public Ok<List<HealthResource>> GetHealth() { - return _healthCheckService.Results().ToResource(); + return TypedResults.Ok(_healthCheckService.Results().ToResource()); } [NonAction] diff --git a/src/Sonarr.Api.V5/History/HistoryController.cs b/src/Sonarr.Api.V5/History/HistoryController.cs index 38d805009..f11b960d0 100644 --- a/src/Sonarr.Api.V5/History/HistoryController.cs +++ b/src/Sonarr.Api.V5/History/HistoryController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.CustomFormats; @@ -62,7 +64,7 @@ protected HistoryResource MapToResource(EpisodeHistory model, bool includeSeries [HttpGet] [Produces("application/json")] - public PagingResource<HistoryResource> GetHistory([FromQuery] PagingRequestResource paging, [FromQuery(Name = "eventType")] int[]? eventTypes, int? episodeId, string? downloadId, [FromQuery] int[]? seriesIds = null, [FromQuery] int[]? languages = null, [FromQuery] int[]? quality = null, [FromQuery] HistorySubresource[]? includeSubresources = null) + public Ok<PagingResource<HistoryResource>> GetHistory([FromQuery] PagingRequestResource paging, [FromQuery(Name = "eventType")] int[]? eventTypes, int? episodeId, string? downloadId, [FromQuery] int[]? seriesIds = null, [FromQuery] int[]? languages = null, [FromQuery] int[]? quality = null, [FromQuery] HistorySubresource[]? includeSubresources = null) { var pagingResource = new PagingResource<HistoryResource>(paging); var pagingSpec = pagingResource.MapToPagingSpec<HistoryResource, EpisodeHistory>( @@ -97,74 +99,74 @@ public PagingResource<HistoryResource> GetHistory([FromQuery] PagingRequestResou var includeSeries = includeSubresources.Contains(HistorySubresource.Series); var includeEpisode = includeSubresources.Contains(HistorySubresource.Episode); - return pagingSpec.ApplyToPage(h => _historyService.Paged(pagingSpec, languages, quality), h => MapToResource(h, includeSeries, includeEpisode)); + return TypedResults.Ok(pagingSpec.ApplyToPage(h => _historyService.Paged(pagingSpec, languages, quality), h => MapToResource(h, includeSeries, includeEpisode))); } [HttpGet("since")] [Produces("application/json")] - public List<HistoryResource> GetHistorySince(DateTime date, EpisodeHistoryEventType? eventType = null, [FromQuery] HistorySubresource[]? includeSubresources = null) + public Ok<List<HistoryResource>> GetHistorySince(DateTime date, EpisodeHistoryEventType? eventType = null, [FromQuery] HistorySubresource[]? includeSubresources = null) { var includeSeries = includeSubresources.Contains(HistorySubresource.Series); var includeEpisode = includeSubresources.Contains(HistorySubresource.Episode); - return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeSeries, includeEpisode)).ToList(); + return TypedResults.Ok(_historyService.Since(date, eventType).Select(h => MapToResource(h, includeSeries, includeEpisode)).ToList()); } [HttpGet("series")] [Produces("application/json")] - public List<HistoryResource> GetSeriesHistory(int seriesId, EpisodeHistoryEventType? eventType = null, [FromQuery] HistorySubresource[]? includeSubresources = null) + public Ok<List<HistoryResource>> GetSeriesHistory(int seriesId, EpisodeHistoryEventType? eventType = null, [FromQuery] HistorySubresource[]? includeSubresources = null) { var series = _seriesService.GetSeries(seriesId); var includeSeries = includeSubresources.Contains(HistorySubresource.Series); var includeEpisode = includeSubresources.Contains(HistorySubresource.Episode); - return _historyService.GetBySeries(seriesId, eventType).Select(h => + return TypedResults.Ok(_historyService.GetBySeries(seriesId, eventType).Select(h => { h.Series = series; return MapToResource(h, includeSeries, includeEpisode); - }).ToList(); + }).ToList()); } [HttpGet("season")] [Produces("application/json")] - public List<HistoryResource> GetSeasonHistory(int seriesId, int seasonNumber, EpisodeHistoryEventType? eventType = null, [FromQuery] HistorySubresource[]? includeSubresources = null) + public Ok<List<HistoryResource>> GetSeasonHistory(int seriesId, int seasonNumber, EpisodeHistoryEventType? eventType = null, [FromQuery] HistorySubresource[]? includeSubresources = null) { var series = _seriesService.GetSeries(seriesId); var includeSeries = includeSubresources.Contains(HistorySubresource.Series); var includeEpisode = includeSubresources.Contains(HistorySubresource.Episode); - return _historyService.GetBySeason(seriesId, seasonNumber, eventType).Select(h => + return TypedResults.Ok(_historyService.GetBySeason(seriesId, seasonNumber, eventType).Select(h => { h.Series = series; return MapToResource(h, includeSeries, includeEpisode); - }).ToList(); + }).ToList()); } [HttpGet("episode")] [Produces("application/json")] - public List<HistoryResource> GetEpisodeHistory(int episodeId, EpisodeHistoryEventType? eventType = null, [FromQuery] HistorySubresource[]? includeSubresources = null) + public Ok<List<HistoryResource>> GetEpisodeHistory(int episodeId, EpisodeHistoryEventType? eventType = null, [FromQuery] HistorySubresource[]? includeSubresources = null) { var episode = _episodeService.GetEpisode(episodeId); var series = _seriesService.GetSeries(episode.SeriesId); var includeSeries = includeSubresources.Contains(HistorySubresource.Series); var includeEpisode = includeSubresources.Contains(HistorySubresource.Episode); - return _historyService.GetByEpisode(episodeId, eventType) + return TypedResults.Ok(_historyService.GetByEpisode(episodeId, eventType) .Select(h => { h.Series = series; h.Episode = episode; return MapToResource(h, includeSeries, includeEpisode); - }).ToList(); + }).ToList()); } [HttpPost("failed/{id}")] - public ActionResult MarkAsFailed([FromRoute] int id) + public NoContent MarkAsFailed([FromRoute] int id) { _failedDownloadService.MarkAsFailed(id); - return NoContent(); + return TypedResults.NoContent(); } } diff --git a/src/Sonarr.Api.V5/ImportLists/ImportListExclusionController.cs b/src/Sonarr.Api.V5/ImportLists/ImportListExclusionController.cs index fd9ed5cbe..afae596c0 100644 --- a/src/Sonarr.Api.V5/ImportLists/ImportListExclusionController.cs +++ b/src/Sonarr.Api.V5/ImportLists/ImportListExclusionController.cs @@ -1,4 +1,6 @@ using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore; using NzbDrone.Core.ImportLists.Exclusions; @@ -33,7 +35,7 @@ protected override ImportListExclusionResource GetResourceById(int id) [HttpGet] [Produces("application/json")] - public PagingResource<ImportListExclusionResource> GetImportListExclusions([FromQuery] PagingRequestResource paging) + public Ok<PagingResource<ImportListExclusionResource>> GetImportListExclusions([FromQuery] PagingRequestResource paging) { var pagingResource = new PagingResource<ImportListExclusionResource>(paging); var pageSpec = pagingResource.MapToPagingSpec<ImportListExclusionResource, ImportListExclusion>( @@ -46,41 +48,41 @@ public PagingResource<ImportListExclusionResource> GetImportListExclusions([From "id", SortDirection.Descending); - return pageSpec.ApplyToPage(_importListExclusionService.Paged, ImportListExclusionResourceMapper.ToResource); + return TypedResults.Ok(pageSpec.ApplyToPage(_importListExclusionService.Paged, ImportListExclusionResourceMapper.ToResource)); } [RestPostById] [Consumes("application/json")] - public ActionResult<ImportListExclusionResource> AddImportListExclusion([FromBody] ImportListExclusionResource resource) + public Results<Created<ImportListExclusionResource>, NotFound> AddImportListExclusion([FromBody] ImportListExclusionResource resource) { var importListExclusion = _importListExclusionService.Add(resource.ToModel()); - return Created(importListExclusion.Id); + return TypedCreated(importListExclusion.Id); } [RestPutById] [Consumes("application/json")] - public ActionResult<ImportListExclusionResource> UpdateImportListExclusion([FromBody] ImportListExclusionResource resource) + public Results<Accepted<ImportListExclusionResource>, NotFound> UpdateImportListExclusion([FromBody] ImportListExclusionResource resource) { _importListExclusionService.Update(resource.ToModel()); - return Accepted(resource.Id); + return TypedAccepted(resource.Id); } [RestDeleteById] - public ActionResult DeleteImportListExclusion(int id) + public NoContent DeleteImportListExclusion(int id) { _importListExclusionService.Delete(id); - return NoContent(); + return TypedResults.NoContent(); } [HttpDelete("bulk")] [Consumes("application/json")] - public ActionResult DeleteImportListExclusions([FromBody] ImportListExclusionBulkResource resource) + public NoContent DeleteImportListExclusions([FromBody] ImportListExclusionBulkResource resource) { _importListExclusionService.Delete(resource.Ids.ToList()); - return NoContent(); + return TypedResults.NoContent(); } } diff --git a/src/Sonarr.Api.V5/Indexers/IndexerFlagController.cs b/src/Sonarr.Api.V5/Indexers/IndexerFlagController.cs index 2424b98f4..79d874e0c 100644 --- a/src/Sonarr.Api.V5/Indexers/IndexerFlagController.cs +++ b/src/Sonarr.Api.V5/Indexers/IndexerFlagController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Parser.Model; using Sonarr.Http; @@ -8,12 +10,12 @@ namespace Sonarr.Api.V5.Indexers; public class IndexerFlagController : Controller { [HttpGet] - public List<IndexerFlagResource> GetAll() + public Ok<List<IndexerFlagResource>> GetAll() { - return Enum.GetValues(typeof(IndexerFlags)).Cast<IndexerFlags>().Select(f => new IndexerFlagResource + return TypedResults.Ok(Enum.GetValues(typeof(IndexerFlags)).Cast<IndexerFlags>().Select(f => new IndexerFlagResource { Id = (int)f, Name = f.ToString() - }).ToList(); + }).ToList()); } } diff --git a/src/Sonarr.Api.V5/Localization/LanguageController.cs b/src/Sonarr.Api.V5/Localization/LanguageController.cs index ea010585b..ddb5d188c 100644 --- a/src/Sonarr.Api.V5/Localization/LanguageController.cs +++ b/src/Sonarr.Api.V5/Localization/LanguageController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Languages; using Sonarr.Http; @@ -20,7 +22,7 @@ protected override LanguageResource GetResourceById(int id) } [HttpGet] - public List<LanguageResource> GetAll() + public Ok<List<LanguageResource>> GetAll() { var languageResources = Language.All.Select(l => new LanguageResource { @@ -30,6 +32,6 @@ public List<LanguageResource> GetAll() .OrderBy(l => l.Id > 0).ThenBy(l => l.Name) .ToList(); - return languageResources; + return TypedResults.Ok(languageResources); } } diff --git a/src/Sonarr.Api.V5/Localization/LocalizationController.cs b/src/Sonarr.Api.V5/Localization/LocalizationController.cs index 6e5c91eec..2f924efa8 100644 --- a/src/Sonarr.Api.V5/Localization/LocalizationController.cs +++ b/src/Sonarr.Api.V5/Localization/LocalizationController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Localization; using Sonarr.Http; @@ -17,25 +19,25 @@ public LocalizationController(ILocalizationService localizationService) protected override LocalizationResource GetResourceById(int id) { - return GetLocalization(); + return _localizationService.GetLocalizationDictionary().ToResource(); } [HttpGet] [Produces("application/json")] - public LocalizationResource GetLocalization() + public Ok<LocalizationResource> GetLocalization() { - return _localizationService.GetLocalizationDictionary().ToResource(); + return TypedResults.Ok(GetResourceById(1)); } [HttpGet("language")] [Produces("application/json")] - public LocalizationLanguageResource GetLanguage() + public Ok<LocalizationLanguageResource> GetLanguage() { var identifier = _localizationService.GetLanguageIdentifier(); - return new LocalizationLanguageResource + return TypedResults.Ok(new LocalizationLanguageResource { Identifier = identifier - }; + }); } } diff --git a/src/Sonarr.Api.V5/Logs/LogController.cs b/src/Sonarr.Api.V5/Logs/LogController.cs index 4f9a8e09a..757aec073 100644 --- a/src/Sonarr.Api.V5/Logs/LogController.cs +++ b/src/Sonarr.Api.V5/Logs/LogController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; @@ -21,11 +23,11 @@ public LogController(ILogService logService, IConfigFileProvider configFileProvi [HttpGet] [Produces("application/json")] - public PagingResource<LogResource> GetLogs([FromQuery] PagingRequestResource paging, string? level) + public Ok<PagingResource<LogResource>> GetLogs([FromQuery] PagingRequestResource paging, string? level) { if (!_configFileProvider.LogDbEnabled) { - return new PagingResource<LogResource>(); + return TypedResults.Ok(new PagingResource<LogResource>()); } var pagingResource = new PagingResource<LogResource>(paging); @@ -72,7 +74,7 @@ public PagingResource<LogResource> GetLogs([FromQuery] PagingRequestResource pag response.SortKey = "time"; } - return response; + return TypedResults.Ok(response); } } } diff --git a/src/Sonarr.Api.V5/Logs/LogFileControllerBase.cs b/src/Sonarr.Api.V5/Logs/LogFileControllerBase.cs index 01fed3b7e..877a63653 100644 --- a/src/Sonarr.Api.V5/Logs/LogFileControllerBase.cs +++ b/src/Sonarr.Api.V5/Logs/LogFileControllerBase.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NLog; using NzbDrone.Common.Disk; @@ -24,7 +26,7 @@ public LogFileControllerBase(IDiskProvider diskProvider, [HttpGet] [Produces("application/json")] - public List<LogFileResource> GetLogFilesResponse() + public Ok<List<LogFileResource>> GetLogFilesResponse() { var result = new List<LogFileResource>(); @@ -45,12 +47,12 @@ public List<LogFileResource> GetLogFilesResponse() }); } - return result.OrderByDescending(l => l.LastWriteTime).ToList(); + return TypedResults.Ok(result.OrderByDescending(l => l.LastWriteTime).ToList()); } [HttpGet(@"{filename:regex([[-.a-zA-Z0-9]]+?\.txt)}")] [Produces("text/plain")] - public IActionResult GetLogFileResponse(string filename) + public Results<PhysicalFileHttpResult, NotFound> GetLogFileResponse(string filename) { LogManager.Flush(); @@ -58,10 +60,10 @@ public IActionResult GetLogFileResponse(string filename) if (!_diskProvider.FileExists(filePath)) { - return NotFound(); + return TypedResults.NotFound(); } - return PhysicalFile(filePath, "text/plain"); + return TypedResults.PhysicalFile(filePath, "text/plain"); } protected abstract IEnumerable<string> GetLogFiles(); diff --git a/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs b/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs index 2d8e8653a..bf9ad9bec 100644 --- a/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs +++ b/src/Sonarr.Api.V5/ManualImport/ManualImportController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Languages; @@ -20,14 +22,14 @@ public ManualImportController(IManualImportService manualImportService) [HttpGet] [Produces("application/json")] - public List<ManualImportResource> GetMediaFiles(string? folder, [FromQuery] string[]? downloadIds, int? seriesId, int? seasonNumber, bool filterExistingFiles = true) + public Ok<List<ManualImportResource>> GetMediaFiles(string? folder, [FromQuery] string[]? downloadIds, int? seriesId, int? seasonNumber, bool filterExistingFiles = true) { if (seriesId.HasValue && downloadIds == null) { - return _manualImportService.GetMediaFiles(seriesId.Value, seasonNumber) + return TypedResults.Ok(_manualImportService.GetMediaFiles(seriesId.Value, seasonNumber) .ToResource() .Select(AddQualityWeight) - .ToList(); + .ToList()); } if (downloadIds != null && downloadIds.Any()) @@ -39,20 +41,20 @@ public List<ManualImportResource> GetMediaFiles(string? folder, [FromQuery] stri files.AddRange(_manualImportService.GetMediaFiles(null, downloadId, seriesId, filterExistingFiles)); } - return files.ToResource() + return TypedResults.Ok(files.ToResource() .Select(AddQualityWeight) - .ToList(); + .ToList()); } - return _manualImportService.GetMediaFiles(folder, null, seriesId, filterExistingFiles) + return TypedResults.Ok(_manualImportService.GetMediaFiles(folder, null, seriesId, filterExistingFiles) .ToResource() .Select(AddQualityWeight) - .ToList(); + .ToList()); } [HttpPost] [Consumes("application/json")] - public List<ManualImportResource> ReprocessItems([FromBody] List<ManualImportReprocessResource> items) + public Results<Ok<List<ManualImportResource>>, BadRequest> ReprocessItems([FromBody] List<ManualImportReprocessResource> items) { if (items is { Count: 0 }) { @@ -95,7 +97,7 @@ public List<ManualImportResource> ReprocessItems([FromBody] List<ManualImportRep updatedItems.Add(processedItem); } - return updatedItems.ToResource(); + return TypedResults.Ok(updatedItems.ToResource()); } private ManualImportResource AddQualityWeight(ManualImportResource item) diff --git a/src/Sonarr.Api.V5/Metadata/MetadataController.cs b/src/Sonarr.Api.V5/Metadata/MetadataController.cs index 380324856..1435db9bb 100644 --- a/src/Sonarr.Api.V5/Metadata/MetadataController.cs +++ b/src/Sonarr.Api.V5/Metadata/MetadataController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Extras.Metadata; using NzbDrone.SignalR; @@ -18,13 +19,13 @@ public MetadataController(IBroadcastSignalRMessage signalRBroadcaster, IMetadata } [NonAction] - public override ActionResult<MetadataResource> UpdateProvider([FromBody] MetadataBulkResource providerResource) + public override Results<Ok<IEnumerable<MetadataResource>>, BadRequest> UpdateProvider([FromBody] MetadataBulkResource providerResource) { throw new NotImplementedException(); } [NonAction] - public override ActionResult DeleteProviders([FromBody] MetadataBulkResource resource) + public override NoContent DeleteProviders([FromBody] MetadataBulkResource resource) { throw new NotImplementedException(); } diff --git a/src/Sonarr.Api.V5/Parse/ParseController.cs b/src/Sonarr.Api.V5/Parse/ParseController.cs index d55d5b4d7..8ca7f7b45 100644 --- a/src/Sonarr.Api.V5/Parse/ParseController.cs +++ b/src/Sonarr.Api.V5/Parse/ParseController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.CustomFormats; @@ -28,24 +30,24 @@ public ParseController(IParsingService parsingService, [HttpGet] [Produces("application/json")] - public ParseResource Parse(string? title, string? path) + public Ok<ParseResource> Parse(string? title, string? path) { if (title.IsNullOrWhiteSpace()) { - return new ParseResource + return TypedResults.Ok(new ParseResource { Title = title - }; + }); } var parsedEpisodeInfo = path.IsNotNullOrWhiteSpace() ? Parser.ParsePath(path) : Parser.ParseTitle(title); if (parsedEpisodeInfo == null) { - return new ParseResource + return TypedResults.Ok(new ParseResource { Title = title - }; + }); } var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, 0, 0, null); @@ -57,7 +59,7 @@ public ParseResource Parse(string? title, string? path) remoteEpisode.CustomFormats = _formatCalculator.ParseCustomFormat(remoteEpisode, 0); remoteEpisode.CustomFormatScore = remoteEpisode.Series?.QualityProfile?.Value.CalculateCustomFormatScore(remoteEpisode.CustomFormats) ?? 0; - return new ParseResource + return TypedResults.Ok(new ParseResource { Title = title, ParsedEpisodeInfo = remoteEpisode.ParsedEpisodeInfo, @@ -66,15 +68,15 @@ public ParseResource Parse(string? title, string? path) Languages = remoteEpisode.Languages, CustomFormats = remoteEpisode.CustomFormats?.ToResource(false), CustomFormatScore = remoteEpisode.CustomFormatScore - }; + }); } else { - return new ParseResource + return TypedResults.Ok(new ParseResource { Title = title, ParsedEpisodeInfo = parsedEpisodeInfo - }; + }); } } } diff --git a/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileController.cs b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileController.cs index a827635ab..4bd8bbc9f 100644 --- a/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileController.cs +++ b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileController.cs @@ -1,4 +1,6 @@ using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.CustomFormats; @@ -46,30 +48,30 @@ public QualityProfileController(IQualityProfileService profileService, ICustomFo [RestPostById] [Consumes("application/json")] - public ActionResult<QualityProfileResource> Create([FromBody] QualityProfileResource resource) + public Results<Created<QualityProfileResource>, NotFound> Create([FromBody] QualityProfileResource resource) { var model = resource.ToModel(); model = _profileService.Add(model); - return Created(model.Id); + return TypedCreated(model.Id); } [RestDeleteById] - public ActionResult DeleteProfile(int id) + public NoContent DeleteProfile(int id) { _profileService.Delete(id); - return NoContent(); + return TypedResults.NoContent(); } [RestPutById] [Consumes("application/json")] - public ActionResult<QualityProfileResource> Update([FromBody] QualityProfileResource resource) + public Results<Accepted<QualityProfileResource>, NotFound> Update([FromBody] QualityProfileResource resource) { var model = resource.ToModel(); _profileService.Update(model); - return Accepted(model.Id); + return TypedAccepted(model.Id); } protected override QualityProfileResource GetResourceById(int id) @@ -79,8 +81,8 @@ protected override QualityProfileResource GetResourceById(int id) [HttpGet] [Produces("application/json")] - public List<QualityProfileResource> GetAll() + public Ok<List<QualityProfileResource>> GetAll() { - return _profileService.All().ToResource(); + return TypedResults.Ok(_profileService.All().ToResource()); } } diff --git a/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileSchemaController.cs b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileSchemaController.cs index ba2a0afba..838cef4ec 100644 --- a/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileSchemaController.cs +++ b/src/Sonarr.Api.V5/Profiles/Quality/QualityProfileSchemaController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Profiles.Qualities; using Sonarr.Http; @@ -15,11 +17,11 @@ public QualityProfileSchemaController(IQualityProfileService profileService) } [HttpGet] - public QualityProfileResource GetSchema() + public Ok<QualityProfileResource> GetSchema() { var qualityProfile = _profileService.GetDefaultProfile(string.Empty); - return qualityProfile.ToResource(); + return TypedResults.Ok(qualityProfile.ToResource()); } } } diff --git a/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs b/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs index 2f51a9010..88088a0e5 100644 --- a/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs +++ b/src/Sonarr.Api.V5/Profiles/Release/ReleaseProfileController.cs @@ -1,4 +1,6 @@ using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Indexers; @@ -52,29 +54,29 @@ public ReleaseProfileController(IReleaseProfileService releaseProfileService, II } [RestPostById] - public ActionResult<ReleaseProfileResource> Create([FromBody] ReleaseProfileResource resource) + public Results<Created<ReleaseProfileResource>, NotFound> Create([FromBody] ReleaseProfileResource resource) { var model = resource.ToModel(); model = _releaseProfileService.Add(model); - return Created(model.Id); + return TypedCreated(model.Id); } [RestDeleteById] - public ActionResult DeleteProfile(int id) + public NoContent DeleteProfile(int id) { _releaseProfileService.Delete(id); - return NoContent(); + return TypedResults.NoContent(); } [RestPutById] - public ActionResult<ReleaseProfileResource> Update([FromBody] ReleaseProfileResource resource) + public Results<Accepted<ReleaseProfileResource>, NotFound> Update([FromBody] ReleaseProfileResource resource) { var model = resource.ToModel(); _releaseProfileService.Update(model); - return Accepted(model.Id); + return TypedAccepted(model.Id); } protected override ReleaseProfileResource GetResourceById(int id) @@ -83,8 +85,8 @@ protected override ReleaseProfileResource GetResourceById(int id) } [HttpGet] - public List<ReleaseProfileResource> GetAll() + public Ok<List<ReleaseProfileResource>> GetAll() { - return _releaseProfileService.All().ToResource(); + return TypedResults.Ok(_releaseProfileService.All().ToResource()); } } diff --git a/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs b/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs index faaa2aa2e..ff95083f9 100644 --- a/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs +++ b/src/Sonarr.Api.V5/Provider/ProviderControllerBase.cs @@ -1,5 +1,7 @@ using FluentValidation; using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Common.Serializer; @@ -57,7 +59,7 @@ protected override TProviderResource GetResourceById(int id) [HttpGet] [Produces("application/json")] - public List<TProviderResource> GetAll() + public Ok<List<TProviderResource>> GetAll() { var providerDefinitions = _providerFactory.All(); @@ -70,13 +72,13 @@ public List<TProviderResource> GetAll() result.Add(_resourceMapper.ToResource(definition)); } - return result.OrderBy(p => p.Name).ToList(); + return TypedResults.Ok(result.OrderBy(p => p.Name).ToList()); } [RestPostById] [Consumes("application/json")] [Produces("application/json")] - public ActionResult<TProviderResource> CreateProvider([FromBody] TProviderResource providerResource, [FromQuery] bool skipTesting = false, [FromQuery] SkipValidation skipValidation = SkipValidation.None) + public Results<Created<TProviderResource>, NotFound> CreateProvider([FromBody] TProviderResource providerResource, [FromQuery] bool skipTesting = false, [FromQuery] SkipValidation skipValidation = SkipValidation.None) { var providerDefinition = GetDefinition(providerResource, null, skipValidation, false); @@ -87,20 +89,20 @@ public ActionResult<TProviderResource> CreateProvider([FromBody] TProviderResour providerDefinition = _providerFactory.Create(providerDefinition); - return Created(providerDefinition.Id); + return TypedCreated(providerDefinition.Id); } [RestPutById] [Consumes("application/json")] [Produces("application/json")] - public ActionResult<TProviderResource> UpdateProvider([FromRoute] int id, [FromBody] TProviderResource providerResource, [FromQuery] bool skipTesting = false, [FromQuery] SkipValidation skipValidation = SkipValidation.None) + public Results<Accepted<TProviderResource>, NotFound> UpdateProvider([FromRoute] int id, [FromBody] TProviderResource providerResource, [FromQuery] bool skipTesting = false, [FromQuery] SkipValidation skipValidation = SkipValidation.None) { // TODO: Remove fallback to Id from body in next API version bump var existingDefinition = _providerFactory.Find(id) ?? _providerFactory.Find(providerResource.Id); if (existingDefinition == null) { - return NotFound(); + return TypedResults.NotFound(); } var providerDefinition = GetDefinition(providerResource, existingDefinition, skipValidation, false); @@ -119,13 +121,13 @@ public ActionResult<TProviderResource> UpdateProvider([FromRoute] int id, [FromB _providerFactory.Update(providerDefinition); } - return Accepted(existingDefinition.Id); + return TypedAccepted(existingDefinition.Id); } [HttpPut("bulk")] [Consumes("application/json")] [Produces("application/json")] - public virtual ActionResult<TProviderResource> UpdateProvider([FromBody] TBulkProviderResource providerResource) + public virtual Results<Ok<IEnumerable<TProviderResource>>, BadRequest> UpdateProvider([FromBody] TBulkProviderResource providerResource) { if (!providerResource.Ids.Any()) { @@ -160,7 +162,7 @@ public virtual ActionResult<TProviderResource> UpdateProvider([FromBody] TBulkPr _bulkResourceMapper.UpdateModel(providerResource, definitionsToUpdate); - return Accepted(_providerFactory.Update(definitionsToUpdate).Select(x => _resourceMapper.ToResource(x))); + return TypedResults.Ok(_providerFactory.Update(definitionsToUpdate).Select(x => _resourceMapper.ToResource(x))); } private TProviderDefinition GetDefinition(TProviderResource providerResource, TProviderDefinition? existingDefinition, SkipValidation skipValidation, bool forceValidate) @@ -176,25 +178,25 @@ private TProviderDefinition GetDefinition(TProviderResource providerResource, TP } [RestDeleteById] - public ActionResult DeleteProvider(int id) + public NoContent DeleteProvider(int id) { _providerFactory.Delete(id); - return NoContent(); + return TypedResults.NoContent(); } [HttpDelete("bulk")] [Consumes("application/json")] - public virtual ActionResult DeleteProviders([FromBody] TBulkProviderResource resource) + public virtual NoContent DeleteProviders([FromBody] TBulkProviderResource resource) { _providerFactory.Delete(resource.Ids); - return NoContent(); + return TypedResults.NoContent(); } [HttpGet("schema")] [Produces("application/json")] - public List<TProviderResource> GetTemplates() + public Ok<List<TProviderResource>> GetTemplates() { var defaultDefinitions = _providerFactory.GetDefaultDefinitions().OrderBy(p => p.ImplementationName).ToList(); @@ -212,25 +214,25 @@ public List<TProviderResource> GetTemplates() result.Add(providerResource); } - return result; + return TypedResults.Ok(result); } [SkipValidation(true, false)] [HttpPost("test")] [Consumes("application/json")] - public ActionResult Test([FromBody] TProviderResource providerResource, [FromQuery] SkipValidation skipValidation = SkipValidation.None) + public NoContent Test([FromBody] TProviderResource providerResource, [FromQuery] SkipValidation skipValidation = SkipValidation.None) { var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; var providerDefinition = GetDefinition(providerResource, existingDefinition, skipValidation, true); Test(providerDefinition, skipValidation); - return NoContent(); + return TypedResults.NoContent(); } [HttpPost("testall")] [Produces("application/json")] - public IActionResult TestAll() + public Results<Ok<List<ProviderTestAllResult>>, BadRequest<List<ProviderTestAllResult>>> TestAll() { var providerDefinitions = _providerFactory.All() .Where(c => c.Settings.Validate().IsValid && c.Enable) @@ -251,14 +253,14 @@ public IActionResult TestAll() }); } - return result.Any(c => !c.IsValid) ? BadRequest(result) : Ok(result); + return result.Any(c => !c.IsValid) ? TypedResults.BadRequest(result) : TypedResults.Ok(result); } [SkipValidation] [HttpPost("action/{name}")] [Consumes("application/json")] [Produces("application/json")] - public IActionResult RequestAction([FromRoute] string name, [FromBody] TProviderResource providerResource) + public Results<ContentHttpResult, BadRequest> RequestAction([FromRoute] string name, [FromBody] TProviderResource providerResource) { var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; var providerDefinition = GetDefinition(providerResource, existingDefinition, SkipValidation.All, false); @@ -267,7 +269,7 @@ public IActionResult RequestAction([FromRoute] string name, [FromBody] TProvider var data = _providerFactory.RequestAction(providerDefinition, name, query); - return Content(data.ToJson(), "application/json"); + return TypedResults.Content(data.ToJson(), "application/json"); } [NonAction] diff --git a/src/Sonarr.Api.V5/Qualities/QualityDefinitionController.cs b/src/Sonarr.Api.V5/Qualities/QualityDefinitionController.cs index ff0bc820f..bf52770f7 100644 --- a/src/Sonarr.Api.V5/Qualities/QualityDefinitionController.cs +++ b/src/Sonarr.Api.V5/Qualities/QualityDefinitionController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Messaging.Events; @@ -29,7 +31,7 @@ public QualityDefinitionController( } [RestPutById] - public ActionResult<QualityDefinitionResource> Update([FromBody] QualityDefinitionResource resource) + public Results<Accepted<QualityDefinitionResource>, NotFound> Update([FromBody] QualityDefinitionResource resource) { var model = resource.ToModel(); _qualityDefinitionService.Update(model); @@ -39,7 +41,7 @@ public ActionResult<QualityDefinitionResource> Update([FromBody] QualityDefiniti _qualityProfileService.UpdateAllSizeLimits(new QualityProfileSizeLimit(model)); } - return Accepted(model.Id); + return TypedAccepted(model.Id); } protected override QualityDefinitionResource GetResourceById(int id) @@ -48,13 +50,13 @@ protected override QualityDefinitionResource GetResourceById(int id) } [HttpGet] - public List<QualityDefinitionResource> GetAll() + public Ok<List<QualityDefinitionResource>> GetAll() { - return _qualityDefinitionService.All().ToResource(); + return TypedResults.Ok(_qualityDefinitionService.All().ToResource()); } [HttpPut] - public object UpdateMany([FromBody] List<QualityDefinitionResource> resource) + public Ok<List<QualityDefinitionResource>> UpdateMany([FromBody] List<QualityDefinitionResource> resource) { // Read from request var qualityDefinitions = resource.ToModel().ToList(); @@ -71,8 +73,7 @@ public object UpdateMany([FromBody] List<QualityDefinitionResource> resource) _qualityProfileService.UpdateAllSizeLimits(toUpdate); } - return Accepted(_qualityDefinitionService.All() - .ToResource()); + return TypedResults.Ok(_qualityDefinitionService.All().ToResource()); } [NonAction] diff --git a/src/Sonarr.Api.V5/Queue/QueueActionController.cs b/src/Sonarr.Api.V5/Queue/QueueActionController.cs index c9e79e5ee..3b4c97d86 100644 --- a/src/Sonarr.Api.V5/Queue/QueueActionController.cs +++ b/src/Sonarr.Api.V5/Queue/QueueActionController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Download; using NzbDrone.Core.Download.Pending; @@ -20,7 +22,7 @@ public QueueActionController(IPendingReleaseService pendingReleaseService, } [HttpPost("grab/{id:int}")] - public async Task<object> Grab([FromRoute] int id) + public async Task<NoContent> Grab([FromRoute] int id) { var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); @@ -31,12 +33,12 @@ public async Task<object> Grab([FromRoute] int id) await _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null); - return NoContent(); + return TypedResults.NoContent(); } [HttpPost("grab/bulk")] [Consumes("application/json")] - public async Task<object> Grab([FromBody] QueueBulkResource resource) + public async Task<NoContent> Grab([FromBody] QueueBulkResource resource) { foreach (var id in resource.Ids) { @@ -50,7 +52,7 @@ public async Task<object> Grab([FromBody] QueueBulkResource resource) await _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null); } - return NoContent(); + return TypedResults.NoContent(); } } } diff --git a/src/Sonarr.Api.V5/Queue/QueueController.cs b/src/Sonarr.Api.V5/Queue/QueueController.cs index 4fe384345..3ad9418be 100644 --- a/src/Sonarr.Api.V5/Queue/QueueController.cs +++ b/src/Sonarr.Api.V5/Queue/QueueController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Blocklisting; @@ -57,7 +59,7 @@ public QueueController(IBroadcastSignalRMessage broadcastSignalRMessage, } [NonAction] - public override ActionResult<QueueResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<QueueResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } @@ -68,7 +70,7 @@ protected override QueueResource GetResourceById(int id) } [RestDeleteById] - public ActionResult RemoveAction(int id, string? message = null, bool removeFromClient = true, bool blocklist = false, bool skipRedownload = false, bool changeCategory = false) + public Results<NoContent, NotFound> RemoveAction(int id, string? message = null, bool removeFromClient = true, bool blocklist = false, bool skipRedownload = false, bool changeCategory = false) { var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); @@ -76,7 +78,7 @@ public ActionResult RemoveAction(int id, string? message = null, bool removeFrom { Remove(pendingRelease, message, blocklist); - return NoContent(); + return TypedResults.NoContent(); } var trackedDownload = GetTrackedDownload(id); @@ -89,11 +91,11 @@ public ActionResult RemoveAction(int id, string? message = null, bool removeFrom Remove(trackedDownload, message, removeFromClient, blocklist, skipRedownload, changeCategory); _trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId); - return NoContent(); + return TypedResults.NoContent(); } [HttpDelete("bulk")] - public object RemoveMany([FromBody] QueueBulkResource resource, [FromQuery] string? message, [FromQuery] bool removeFromClient = true, [FromQuery] bool blocklist = false, [FromQuery] bool skipRedownload = false, [FromQuery] bool changeCategory = false) + public NoContent RemoveMany([FromBody] QueueBulkResource resource, [FromQuery] string? message, [FromQuery] bool removeFromClient = true, [FromQuery] bool blocklist = false, [FromQuery] bool skipRedownload = false, [FromQuery] bool changeCategory = false) { var trackedDownloadIds = new List<string>(); var pendingToRemove = new List<NzbDrone.Core.Queue.Queue>(); @@ -130,12 +132,12 @@ public object RemoveMany([FromBody] QueueBulkResource resource, [FromQuery] stri _trackedDownloadService.StopTracking(trackedDownloadIds); - return NoContent(); + return TypedResults.NoContent(); } [HttpGet] [Produces("application/json")] - public PagingResource<QueueResource> GetQueue([FromQuery] PagingRequestResource paging, bool includeUnknownSeriesItems = true, [FromQuery] int[]? seriesIds = null, DownloadProtocol? protocol = null, [FromQuery] int[]? languages = null, [FromQuery] int[]? quality = null, [FromQuery] QueueStatus[]? status = null, [FromQuery] QueueSubresource[]? includeSubresources = null) + public Ok<PagingResource<QueueResource>> GetQueue([FromQuery] PagingRequestResource paging, bool includeUnknownSeriesItems = true, [FromQuery] int[]? seriesIds = null, DownloadProtocol? protocol = null, [FromQuery] int[]? languages = null, [FromQuery] int[]? quality = null, [FromQuery] QueueStatus[]? status = null, [FromQuery] QueueSubresource[]? includeSubresources = null) { var pagingResource = new PagingResource<QueueResource>(paging); var pagingSpec = pagingResource.MapToPagingSpec<QueueResource, NzbDrone.Core.Queue.Queue>( @@ -167,7 +169,7 @@ public PagingResource<QueueResource> GetQueue([FromQuery] PagingRequestResource var includeSeries = includeSubresources.Contains(QueueSubresource.Series); var includeEpisodes = includeSubresources.Contains(QueueSubresource.Episodes); - return pagingSpec.ApplyToPage((spec) => GetQueue(spec, seriesIds?.ToHashSet() ?? [], protocol, languages?.ToHashSet() ?? [], quality?.ToHashSet() ?? [], status?.ToHashSet() ?? [], includeUnknownSeriesItems), (q) => MapToResource(q, includeSeries, includeEpisodes)); + return TypedResults.Ok(pagingSpec.ApplyToPage((spec) => GetQueue(spec, seriesIds?.ToHashSet() ?? [], protocol, languages?.ToHashSet() ?? [], quality?.ToHashSet() ?? [], status?.ToHashSet() ?? [], includeUnknownSeriesItems), (q) => MapToResource(q, includeSeries, includeEpisodes))); } private PagingSpec<NzbDrone.Core.Queue.Queue> GetQueue(PagingSpec<NzbDrone.Core.Queue.Queue> pagingSpec, HashSet<int> seriesIds, DownloadProtocol? protocol, HashSet<int> languages, HashSet<int> quality, HashSet<QueueStatus> status, bool includeUnknownSeriesItems) diff --git a/src/Sonarr.Api.V5/Queue/QueueDetailsController.cs b/src/Sonarr.Api.V5/Queue/QueueDetailsController.cs index c6d5526a9..c4ebdd73d 100644 --- a/src/Sonarr.Api.V5/Queue/QueueDetailsController.cs +++ b/src/Sonarr.Api.V5/Queue/QueueDetailsController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore.Events; @@ -25,7 +27,7 @@ public QueueDetailsController(IBroadcastSignalRMessage broadcastSignalRMessage, } [NonAction] - public override ActionResult<QueueResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<QueueResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } @@ -37,7 +39,7 @@ protected override QueueResource GetResourceById(int id) [HttpGet] [Produces("application/json")] - public List<QueueResource> GetQueue(int? seriesId, [FromQuery]List<int> episodeIds, [FromQuery] QueueSubresource[]? includeSubresources = null) + public Ok<List<QueueResource>> GetQueue(int? seriesId, [FromQuery]List<int> episodeIds, [FromQuery] QueueSubresource[]? includeSubresources = null) { var queue = _queueService.GetQueue(); var pending = _pendingReleaseService.GetPendingQueue(); @@ -47,17 +49,17 @@ public List<QueueResource> GetQueue(int? seriesId, [FromQuery]List<int> episodeI if (seriesId.HasValue) { - return fullQueue.Where(q => q.Series?.Id == seriesId).ToResource(includeSeries, includeEpisodes); + return TypedResults.Ok(fullQueue.Where(q => q.Series?.Id == seriesId).ToResource(includeSeries, includeEpisodes)); } if (episodeIds.Any()) { - return fullQueue.Where(q => q.Episodes.Any() && + return TypedResults.Ok(fullQueue.Where(q => q.Episodes.Any() && episodeIds.IntersectBy(e => e, q.Episodes, e => e.Id, null).Any()) - .ToResource(includeSeries, includeEpisodes); + .ToResource(includeSeries, includeEpisodes)); } - return fullQueue.ToResource(includeSeries, includeEpisodes); + return TypedResults.Ok(fullQueue.ToResource(includeSeries, includeEpisodes)); } [NonAction] diff --git a/src/Sonarr.Api.V5/Queue/QueueStatusController.cs b/src/Sonarr.Api.V5/Queue/QueueStatusController.cs index ff57c76bb..7ba949bbe 100644 --- a/src/Sonarr.Api.V5/Queue/QueueStatusController.cs +++ b/src/Sonarr.Api.V5/Queue/QueueStatusController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Download.Pending; @@ -29,7 +30,7 @@ public QueueStatusController(IBroadcastSignalRMessage broadcastSignalRMessage, I } [NonAction] - public override ActionResult<QueueStatusResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<QueueStatusResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } diff --git a/src/Sonarr.Api.V5/Release/ReleaseController.cs b/src/Sonarr.Api.V5/Release/ReleaseController.cs index 66a6b1ac5..968684419 100644 --- a/src/Sonarr.Api.V5/Release/ReleaseController.cs +++ b/src/Sonarr.Api.V5/Release/ReleaseController.cs @@ -1,4 +1,6 @@ using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NLog; using NzbDrone.Common.Cache; @@ -71,7 +73,7 @@ public ReleaseController(IFetchAndParseRss rssFetcherAndParser, } [NonAction] - public override ActionResult<ReleaseResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<ReleaseResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } @@ -83,7 +85,7 @@ protected override ReleaseResource GetResourceById(int id) [HttpPost] [Consumes("application/json")] - public async Task<object> DownloadRelease([FromBody] ReleaseGrabResource release) + public async Task<Results<Ok<ReleaseGrabResource>, NotFound>> DownloadRelease([FromBody] ReleaseGrabResource release) { var remoteEpisode = _remoteEpisodeCache.Find(GetCacheKey(release)); @@ -182,24 +184,24 @@ public async Task<object> DownloadRelease([FromBody] ReleaseGrabResource release throw new NzbDroneClientException(HttpStatusCode.Conflict, "Getting release from indexer failed"); } - return release; + return TypedResults.Ok(release); } [HttpGet] [Produces("application/json")] - public async Task<List<ReleaseResource>> GetReleases(int? seriesId, int? episodeId, int? seasonNumber) + public async Task<Results<Ok<List<ReleaseResource>>, BadRequest>> GetReleases(int? seriesId, int? episodeId, int? seasonNumber) { if (episodeId.HasValue) { - return await GetEpisodeReleases(episodeId.Value); + return TypedResults.Ok(await GetEpisodeReleases(episodeId.Value)); } if (seriesId.HasValue && seasonNumber.HasValue) { - return await GetSeasonReleases(seriesId.Value, seasonNumber.Value); + return TypedResults.Ok(await GetSeasonReleases(seriesId.Value, seasonNumber.Value)); } - return await GetRss(); + return TypedResults.Ok(await GetRss()); } private async Task<List<ReleaseResource>> GetEpisodeReleases(int episodeId) diff --git a/src/Sonarr.Api.V5/Release/ReleasePushController.cs b/src/Sonarr.Api.V5/Release/ReleasePushController.cs index a23e99422..8363f2d92 100644 --- a/src/Sonarr.Api.V5/Release/ReleasePushController.cs +++ b/src/Sonarr.Api.V5/Release/ReleasePushController.cs @@ -1,5 +1,7 @@ using FluentValidation; using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NLog; using NzbDrone.Common.Extensions; @@ -50,7 +52,7 @@ public ReleasePushController(IMakeDownloadDecision downloadDecisionMaker, [HttpPost] [Consumes("application/json")] - public ActionResult<ReleaseResource> Create([FromBody] ReleasePushResource release) + public Results<Ok<ReleaseResource>, BadRequest> Create([FromBody] ReleasePushResource release) { _logger.Info("Release pushed: {0} - {1}", release.Title, release.DownloadUrl ?? release.MagnetUrl); @@ -80,7 +82,7 @@ public ActionResult<ReleaseResource> Create([FromBody] ReleasePushResource relea throw new ValidationException(new List<ValidationFailure> { new("Title", "Unable to parse", release.Title) }); } - return decision.MapDecision(1, _qualityProfile); + return TypedResults.Ok(decision.MapDecision(1, _qualityProfile)); } private void ResolveIndexer(ReleaseInfo release) diff --git a/src/Sonarr.Api.V5/RemotePathMappings/RemotePathMappingController.cs b/src/Sonarr.Api.V5/RemotePathMappings/RemotePathMappingController.cs index ca7defaf1..36f167e61 100644 --- a/src/Sonarr.Api.V5/RemotePathMappings/RemotePathMappingController.cs +++ b/src/Sonarr.Api.V5/RemotePathMappings/RemotePathMappingController.cs @@ -1,4 +1,6 @@ using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.RemotePathMappings; using NzbDrone.Core.Validation.Paths; @@ -47,33 +49,33 @@ protected override RemotePathMappingResource GetResourceById(int id) [RestPostById] [Consumes("application/json")] - public ActionResult<RemotePathMappingResource> CreateMapping([FromBody] RemotePathMappingResource resource) + public Results<Created<RemotePathMappingResource>, NotFound> CreateMapping([FromBody] RemotePathMappingResource resource) { var model = resource.ToModel(); - return Created(_remotePathMappingService.Add(model).Id); + return TypedCreated(_remotePathMappingService.Add(model).Id); } [HttpGet] [Produces("application/json")] - public List<RemotePathMappingResource> GetMappings() + public Ok<List<RemotePathMappingResource>> GetMappings() { - return _remotePathMappingService.All().ToResource(); + return TypedResults.Ok(_remotePathMappingService.All().ToResource()); } [RestDeleteById] - public ActionResult DeleteMapping(int id) + public NoContent DeleteMapping(int id) { _remotePathMappingService.Remove(id); - return NoContent(); + return TypedResults.NoContent(); } [RestPutById] - public ActionResult<RemotePathMappingResource> UpdateMapping([FromBody] RemotePathMappingResource resource) + public Results<Ok<RemotePathMappingResource>, NotFound> UpdateMapping([FromBody] RemotePathMappingResource resource) { var mapping = resource.ToModel(); - return Accepted(_remotePathMappingService.Update(mapping)); + return TypedResults.Ok(_remotePathMappingService.Update(mapping).ToResource()); } } diff --git a/src/Sonarr.Api.V5/RootFolders/RootFolderController.cs b/src/Sonarr.Api.V5/RootFolders/RootFolderController.cs index 19b9a3aec..3f650a693 100644 --- a/src/Sonarr.Api.V5/RootFolders/RootFolderController.cs +++ b/src/Sonarr.Api.V5/RootFolders/RootFolderController.cs @@ -1,4 +1,6 @@ using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.RootFolders; using NzbDrone.Core.Validation.Paths; @@ -49,25 +51,25 @@ protected override RootFolderResource GetResourceById(int id) [RestPostById] [Consumes("application/json")] - public ActionResult<RootFolderResource> CreateRootFolder([FromBody] RootFolderResource rootFolderResource) + public Results<Created<RootFolderResource>, NotFound> CreateRootFolder([FromBody] RootFolderResource rootFolderResource) { var model = rootFolderResource.ToModel(); - return Created(_rootFolderService.Add(model).Id); + return TypedCreated(_rootFolderService.Add(model).Id); } [HttpGet] [Produces("application/json")] - public List<RootFolderResource> GetRootFolders() + public Ok<List<RootFolderResource>> GetRootFolders() { - return _rootFolderService.AllWithUnmappedFolders().ToResource(); + return TypedResults.Ok(_rootFolderService.AllWithUnmappedFolders().ToResource()); } [RestDeleteById] - public ActionResult DeleteFolder(int id) + public NoContent DeleteFolder(int id) { _rootFolderService.Remove(id); - return NoContent(); + return TypedResults.NoContent(); } } diff --git a/src/Sonarr.Api.V5/SeasonPass/SeasonPassController.cs b/src/Sonarr.Api.V5/SeasonPass/SeasonPassController.cs index dc55e7d74..28ddcd5c7 100644 --- a/src/Sonarr.Api.V5/SeasonPass/SeasonPassController.cs +++ b/src/Sonarr.Api.V5/SeasonPass/SeasonPassController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Tv; using Sonarr.Http; @@ -18,7 +20,7 @@ public SeasonPassController(ISeriesService seriesService, IEpisodeMonitoredServi [HttpPost] [Consumes("application/json")] - public IActionResult UpdateAll([FromBody] SeasonPassResource resource) + public NoContent UpdateAll([FromBody] SeasonPassResource resource) { var seriesToUpdate = _seriesService.GetSeries(resource.Series.Select(s => s.Id)); @@ -52,6 +54,6 @@ public IActionResult UpdateAll([FromBody] SeasonPassResource resource) _episodeMonitoredService.SetEpisodeMonitoredStatus(series, resource.MonitoringOptions.ToModel()); } - return NoContent(); + return TypedResults.NoContent(); } } diff --git a/src/Sonarr.Api.V5/Series/SeriesController.cs b/src/Sonarr.Api.V5/Series/SeriesController.cs index 79139b1f5..a0c9ffafc 100644 --- a/src/Sonarr.Api.V5/Series/SeriesController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesController.cs @@ -1,4 +1,6 @@ using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Common.TPL; @@ -107,7 +109,7 @@ public SeriesController(IBroadcastSignalRMessage signalRBroadcaster, [HttpGet] [Produces("application/json")] - public List<SeriesResource> AllSeries(int? tvdbId, [FromQuery] SeriesSubresource[]? includeSubresources = null) + public Ok<List<SeriesResource>> AllSeries(int? tvdbId, [FromQuery] SeriesSubresource[]? includeSubresources = null) { var seriesStats = _seriesStatisticsService.SeriesStatistics(); var seriesResources = new List<SeriesResource>(); @@ -127,18 +129,18 @@ public List<SeriesResource> AllSeries(int? tvdbId, [FromQuery] SeriesSubresource PopulateAlternateTitles(seriesResources); seriesResources.ForEach(LinkRootFolderPath); - return seriesResources; + return TypedResults.Ok(seriesResources); } [NonAction] - public override ActionResult<SeriesResource> GetResourceByIdWithErrorHandler(int id) + public override Results<Ok<SeriesResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { return base.GetResourceByIdWithErrorHandler(id); } [RestGetById] [Produces("application/json")] - public ActionResult<SeriesResource> GetResourceByIdWithErrorHandler(int id, [FromQuery] SeriesSubresource[]? includeSubresources = null) + public Results<Ok<SeriesResource>, NotFound> GetResourceByIdWithErrorHandler(int id, [FromQuery] SeriesSubresource[]? includeSubresources = null) { var includeSeasonImages = includeSubresources.Contains(SeriesSubresource.SeasonImages); @@ -146,11 +148,11 @@ public ActionResult<SeriesResource> GetResourceByIdWithErrorHandler(int id, [Fro { var series = GetSeriesResourceById(id, includeSeasonImages); - return series == null ? NotFound() : series; + return series == null ? TypedResults.NotFound() : TypedResults.Ok(series); } catch (ModelNotFoundException) { - return NotFound(); + return TypedResults.NotFound(); } } @@ -181,17 +183,17 @@ public ActionResult<SeriesResource> GetResourceByIdWithErrorHandler(int id, [Fro [RestPostById] [Consumes("application/json")] [Produces("application/json")] - public ActionResult<SeriesResource> AddSeries([FromBody] SeriesResource seriesResource) + public Results<Created<SeriesResource>, NotFound> AddSeries([FromBody] SeriesResource seriesResource) { var series = _addSeriesService.AddSeries(seriesResource.ToModel()); - return Created(series.Id); + return TypedCreated(series.Id); } [RestPutById] [Consumes("application/json")] [Produces("application/json")] - public ActionResult<SeriesResource> UpdateSeries([FromBody] SeriesResource seriesResource, [FromQuery] bool moveFiles = false) + public Results<Accepted<SeriesResource>, NotFound> UpdateSeries([FromBody] SeriesResource seriesResource, [FromQuery] bool moveFiles = false) { var series = _seriesService.GetSeries(seriesResource.Id); @@ -215,13 +217,13 @@ public ActionResult<SeriesResource> UpdateSeries([FromBody] SeriesResource serie BroadcastResourceChange(ModelAction.Updated, seriesResource); - return Accepted(seriesResource.Id); + return TypedAccepted(seriesResource.Id); } [HttpPut("{id}/season")] [Consumes("application/json")] [Produces("application/json")] - public ActionResult<SeasonResource> UpdateSeasonMonitored([FromRoute] int id, [FromBody] SeasonResource seasonResource) + public Results<Ok<SeasonResource>, NotFound> UpdateSeasonMonitored([FromRoute] int id, [FromBody] SeasonResource seasonResource) { lock (_seriesLockPool.GetLock(id)) { @@ -230,7 +232,7 @@ public ActionResult<SeasonResource> UpdateSeasonMonitored([FromRoute] int id, [F if (season == null) { - return NotFound(); + return TypedResults.NotFound(); } season.Monitored = seasonResource.Monitored; @@ -239,16 +241,16 @@ public ActionResult<SeasonResource> UpdateSeasonMonitored([FromRoute] int id, [F BroadcastResourceChange(ModelAction.Updated, series.ToResource()); - return season.ToResource(); + return TypedResults.Ok(season.ToResource()); } } [RestDeleteById] - public ActionResult DeleteSeries(int id, bool deleteFiles = false, bool addImportListExclusion = false) + public NoContent DeleteSeries(int id, bool deleteFiles = false, bool addImportListExclusion = false) { _seriesService.DeleteSeries(new List<int> { id }, deleteFiles, addImportListExclusion); - return NoContent(); + return TypedResults.NoContent(); } private SeriesResource? GetSeriesResource(NzbDrone.Core.Tv.Series? series, bool includeSeasonImages) diff --git a/src/Sonarr.Api.V5/Series/SeriesEditorController.cs b/src/Sonarr.Api.V5/Series/SeriesEditorController.cs index ae30ff4c3..dd3430b4b 100644 --- a/src/Sonarr.Api.V5/Series/SeriesEditorController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesEditorController.cs @@ -1,4 +1,6 @@ using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Messaging.Commands; @@ -23,7 +25,7 @@ public SeriesEditorController(ISeriesService seriesService, IManageCommandQueue } [HttpPut] - public object SaveAll([FromBody] SeriesEditorResource resource) + public Results<Ok<List<SeriesResource>>, BadRequest> SaveAll([FromBody] SeriesEditorResource resource) { var seriesToUpdate = _seriesService.GetSeries(resource.SeriesIds); var seriesToMove = new List<BulkMoveSeries>(); @@ -101,14 +103,14 @@ public object SaveAll([FromBody] SeriesEditorResource resource) }); } - return Accepted(_seriesService.UpdateSeries(seriesToUpdate, !resource.MoveFiles).ToResource()); + return TypedResults.Ok(_seriesService.UpdateSeries(seriesToUpdate, !resource.MoveFiles).ToResource()); } [HttpDelete] - public object DeleteSeries([FromBody] SeriesEditorResource resource) + public NoContent DeleteSeries([FromBody] SeriesEditorResource resource) { _seriesService.DeleteSeries(resource.SeriesIds, resource.DeleteFiles, resource.AddImportListExclusion); - return NoContent(); + return TypedResults.NoContent(); } } diff --git a/src/Sonarr.Api.V5/Series/SeriesFolderController.cs b/src/Sonarr.Api.V5/Series/SeriesFolderController.cs index b26d71286..fd1445d3b 100644 --- a/src/Sonarr.Api.V5/Series/SeriesFolderController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesFolderController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Organizer; using NzbDrone.Core.Tv; @@ -19,14 +21,14 @@ public SeriesFolderController(ISeriesService seriesService, IBuildFileNames file [HttpGet("{id}/folder")] [Produces("application/json")] - public object GetFolder([FromRoute] int id) + public Ok<object> GetFolder([FromRoute] int id) { var series = _seriesService.GetSeries(id); var folder = _fileNameBuilder.GetSeriesFolder(series); - return new + return TypedResults.Ok((object)new { folder - }; + }); } } diff --git a/src/Sonarr.Api.V5/Series/SeriesImportController.cs b/src/Sonarr.Api.V5/Series/SeriesImportController.cs index dd8d6b62a..dd5543290 100644 --- a/src/Sonarr.Api.V5/Series/SeriesImportController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesImportController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Tv; using Sonarr.Http; @@ -15,11 +17,11 @@ public SeriesImportController(IAddSeriesService addSeriesService) } [HttpPost] - public object Import([FromBody] List<SeriesResource> resource) + public Ok<List<SeriesResource>> Import([FromBody] List<SeriesResource> resource) { var newSeries = resource.ToModel(); - return _addSeriesService.AddSeries(newSeries).ToResource(); + return TypedResults.Ok(_addSeriesService.AddSeries(newSeries).ToResource()); } } } diff --git a/src/Sonarr.Api.V5/Series/SeriesLookupController.cs b/src/Sonarr.Api.V5/Series/SeriesLookupController.cs index 776fc0484..f5a697109 100644 --- a/src/Sonarr.Api.V5/Series/SeriesLookupController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesLookupController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.ImportLists.Exclusions; using NzbDrone.Core.MediaCover; @@ -25,10 +27,10 @@ public SeriesLookupController(ISearchForNewSeries searchProxy, IBuildFileNames f } [HttpGet] - public IEnumerable<SeriesResource> Search([FromQuery] string term) + public Ok<IEnumerable<SeriesResource>> Search([FromQuery] string term) { var tvDbResults = _searchProxy.SearchForNewSeries(term); - return MapToResource(tvDbResults); + return TypedResults.Ok(MapToResource(tvDbResults)); } private IEnumerable<SeriesResource> MapToResource(IEnumerable<NzbDrone.Core.Tv.Series> series) diff --git a/src/Sonarr.Api.V5/Settings/GeneralSettingsController.cs b/src/Sonarr.Api.V5/Settings/GeneralSettingsController.cs index 7b4ba433a..83e27b9b5 100644 --- a/src/Sonarr.Api.V5/Settings/GeneralSettingsController.cs +++ b/src/Sonarr.Api.V5/Settings/GeneralSettingsController.cs @@ -1,5 +1,5 @@ using FluentValidation; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http.HttpResults; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Authentication; @@ -102,7 +102,7 @@ protected override GeneralSettingsResource ToResource(IConfigFileProvider config return resource; } - public override ActionResult<GeneralSettingsResource> SaveSettings(GeneralSettingsResource resource) + public override Results<Accepted<GeneralSettingsResource>, NotFound> SaveSettings(GeneralSettingsResource resource) { if (resource.Username.IsNotNullOrWhiteSpace() && resource.Password.IsNotNullOrWhiteSpace()) { diff --git a/src/Sonarr.Api.V5/Settings/NamingSettingsController.cs b/src/Sonarr.Api.V5/Settings/NamingSettingsController.cs index 5c0210fb6..69d4fa48b 100644 --- a/src/Sonarr.Api.V5/Settings/NamingSettingsController.cs +++ b/src/Sonarr.Api.V5/Settings/NamingSettingsController.cs @@ -1,5 +1,7 @@ using FluentValidation; using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Organizer; @@ -36,27 +38,24 @@ public NamingSettingsController(INamingConfigService namingConfigService, protected override NamingSettingsResource GetResourceById(int id) { - return GetNamingConfig(); + return _namingConfigService.GetConfig().ToResource(); } [HttpGet] - public NamingSettingsResource GetNamingConfig() + public Ok<NamingSettingsResource> GetNamingConfig() { - var nameSpec = _namingConfigService.GetConfig(); - var resource = nameSpec.ToResource(); - - return resource; + return TypedResults.Ok(GetResourceById(1)); } [RestPutById] - public ActionResult<NamingSettingsResource> UpdateNamingConfig([FromBody] NamingSettingsResource resource) + public Results<Accepted<NamingSettingsResource>, NotFound> UpdateNamingConfig([FromBody] NamingSettingsResource resource) { var nameSpec = resource.ToModel(); ValidateFormatResult(nameSpec); _namingConfigService.Save(nameSpec); - return Accepted(resource.Id); + return TypedAccepted(resource.Id); } [HttpGet("examples")] @@ -64,7 +63,7 @@ public object GetExamples([FromQuery]NamingSettingsResource settings) { if (settings.Id == 0) { - settings = GetNamingConfig(); + settings = GetResourceById(1); } var nameSpec = settings.ToModel(); diff --git a/src/Sonarr.Api.V5/Settings/SettingsController.cs b/src/Sonarr.Api.V5/Settings/SettingsController.cs index cc515a6f4..c37e2052c 100644 --- a/src/Sonarr.Api.V5/Settings/SettingsController.cs +++ b/src/Sonarr.Api.V5/Settings/SettingsController.cs @@ -1,4 +1,6 @@ using System.Reflection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Configuration; using Sonarr.Http.REST; @@ -19,23 +21,24 @@ protected SettingsController(IConfigFileProvider configFileProvider, IConfigServ } protected override TResource GetResourceById(int id) - { - return GetConfig(); - } - - [HttpGet] - [Produces("application/json")] - public TResource GetConfig() { var resource = ToResource(_configFileProvider, _configService); - resource.Id = 1; + resource.Id = id; return resource; } + [HttpGet] + [Produces("application/json")] + public Ok<TResource> GetConfig() + { + return TypedResults.Ok(GetResourceById(1)); + } + [RestPutById] [Consumes("application/json")] - public virtual ActionResult<TResource> SaveSettings([FromBody] TResource resource) + [Produces("application/json")] + public virtual Results<Accepted<TResource>, NotFound> SaveSettings([FromBody] TResource resource) { var dictionary = resource.GetType() .GetProperties(BindingFlags.Instance | BindingFlags.Public) @@ -44,7 +47,7 @@ public virtual ActionResult<TResource> SaveSettings([FromBody] TResource resourc _configFileProvider.SaveConfigDictionary(dictionary); _configService.SaveConfigDictionary(dictionary); - return Accepted(resource.Id); + return TypedAccepted(resource.Id); } protected abstract TResource ToResource(IConfigFileProvider configFile, IConfigService model); diff --git a/src/Sonarr.Api.V5/System/Backup/BackupController.cs b/src/Sonarr.Api.V5/System/Backup/BackupController.cs index 483d539c8..6c729d50d 100644 --- a/src/Sonarr.Api.V5/System/Backup/BackupController.cs +++ b/src/Sonarr.Api.V5/System/Backup/BackupController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Crypto; using NzbDrone.Common.Disk; @@ -29,7 +31,7 @@ public BackupController(IBackupService backupService, [HttpGet] [Produces("application/json")] - public ActionResult<List<BackupResource>> GetAll() + public Ok<List<BackupResource>> GetAll() { var backups = _backupService.GetBackups(); @@ -45,11 +47,11 @@ public ActionResult<List<BackupResource>> GetAll() .OrderByDescending(b => b.Time) .ToList(); - return resources; + return TypedResults.Ok(resources); } [RestDeleteById] - public ActionResult Delete(int id) + public Results<NoContent, NotFound> Delete(int id) { var backup = GetBackupById(id); @@ -67,30 +69,30 @@ public ActionResult Delete(int id) _diskProvider.DeleteFile(path); - return NoContent(); + return TypedResults.NoContent(); } [HttpPost("restore/{id:int}")] [Produces("application/json")] - public ActionResult<object> Restore([FromRoute] int id) + public Results<Ok<object>, NotFound> Restore([FromRoute] int id) { var backup = GetBackupById(id); if (backup == null) { - return NotFound(); + return TypedResults.NotFound(); } var path = GetBackupPath(backup); _backupService.Restore(path); - return new { RestartRequired = true }; + return TypedResults.Ok((object)new { RestartRequired = true }); } [HttpPost("restore/upload")] [Produces("application/json")] [RequestFormLimits(MultipartBodyLengthLimit = 5000000000)] - public ActionResult<object> RestoreUpload() + public Results<Ok<object>, BadRequest<object>> RestoreUpload() { var files = Request.Form.Files; @@ -104,7 +106,7 @@ public ActionResult<object> RestoreUpload() if (!ValidExtensions.Contains(extension)) { - return BadRequest(new { error = $"Invalid extension, must be one of: {string.Join(", ", ValidExtensions)}" }); + return TypedResults.BadRequest((object)new { error = $"Invalid extension, must be one of: {string.Join(", ", ValidExtensions)}" }); } var path = Path.Combine(_appFolderInfo.TempFolder, $"sonarr_backup_restore{extension}"); @@ -113,7 +115,7 @@ public ActionResult<object> RestoreUpload() _backupService.Restore(path); _diskProvider.DeleteFile(path); - return new { RestartRequired = true }; + return TypedResults.Ok((object)new { RestartRequired = true }); } private string GetBackupPath(NzbDrone.Core.Backup.Backup backup) diff --git a/src/Sonarr.Api.V5/System/SystemController.cs b/src/Sonarr.Api.V5/System/SystemController.cs index 26e525f19..1a1ffd44c 100644 --- a/src/Sonarr.Api.V5/System/SystemController.cs +++ b/src/Sonarr.Api.V5/System/SystemController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Internal; @@ -53,9 +55,9 @@ public SystemController(IAppFolderInfo appFolderInfo, [HttpGet("status")] [Produces("application/json")] - public SystemResource GetStatus() + public Ok<SystemResource> GetStatus() { - return new SystemResource + return TypedResults.Ok(new SystemResource { AppName = BuildInfo.AppName, InstanceName = _configFileProvider.InstanceName, @@ -88,39 +90,39 @@ public SystemResource GetStatus() PackageAuthor = _deploymentInfoProvider.PackageAuthor, PackageUpdateMechanism = _deploymentInfoProvider.PackageUpdateMechanism, PackageUpdateMechanismMessage = _deploymentInfoProvider.PackageUpdateMechanismMessage - }; + }); } [HttpGet("routes")] [Produces("application/json")] - public IActionResult GetRoutes() + public ContentHttpResult GetRoutes() { using (var sw = new StringWriter()) { _graphWriter.Write(_endpointData, sw); var graph = sw.ToString(); - return Content(graph, "text/plain"); + return TypedResults.Content(graph, "text/plain"); } } [HttpGet("routes/duplicate")] [Produces("application/json")] - public object DuplicateRoutes() + public Ok<Dictionary<string, List<string>>> DuplicateRoutes() { - return _detector.GetDuplicateEndpoints(_endpointData); + return TypedResults.Ok(_detector.GetDuplicateEndpoints(_endpointData)); } [HttpPost("shutdown")] - public object Shutdown() + public Ok<object> Shutdown() { Task.Factory.StartNew(() => _lifecycleService.Shutdown()); - return new { ShuttingDown = true }; + return TypedResults.Ok((object)new { ShuttingDown = true }); } [HttpPost("restart")] - public object Restart() + public Ok<object> Restart() { Task.Factory.StartNew(() => _lifecycleService.Restart()); - return new { Restarting = true }; + return TypedResults.Ok((object)new { Restarting = true }); } } diff --git a/src/Sonarr.Api.V5/System/Tasks/TaskController.cs b/src/Sonarr.Api.V5/System/Tasks/TaskController.cs index 77b103632..912b87603 100644 --- a/src/Sonarr.Api.V5/System/Tasks/TaskController.cs +++ b/src/Sonarr.Api.V5/System/Tasks/TaskController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore.Events; @@ -22,12 +24,12 @@ public TaskController(ITaskManager taskManager, IBroadcastSignalRMessage broadca [HttpGet] [Produces("application/json")] - public ActionResult<List<TaskResource>> GetAll() + public Ok<List<TaskResource>> GetAll() { - return _taskManager.GetAll() + return TypedResults.Ok(_taskManager.GetAll() .Select(ConvertToResource) .OrderBy(t => t.Name) - .ToList(); + .ToList()); } protected override TaskResource? GetResourceById(int id) diff --git a/src/Sonarr.Api.V5/Tags/TagController.cs b/src/Sonarr.Api.V5/Tags/TagController.cs index 395d2847f..c39db3dc3 100644 --- a/src/Sonarr.Api.V5/Tags/TagController.cs +++ b/src/Sonarr.Api.V5/Tags/TagController.cs @@ -1,5 +1,7 @@ using System.Text.RegularExpressions; using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.AutoTagging; using NzbDrone.Core.Datastore.Events; @@ -38,30 +40,32 @@ protected override TagResource GetResourceById(int id) [HttpGet] [Produces("application/json")] - public List<TagResource> GetAll() + public Ok<List<TagResource>> GetAll() { - return _tagService.All().ToResource(); + return TypedResults.Ok(_tagService.All().ToResource()); } [RestPostById] [Consumes("application/json")] - public ActionResult<TagResource> Create([FromBody] TagResource resource) + public Results<Created<TagResource>, NotFound> Create([FromBody] TagResource resource) { - return Created(_tagService.Add(resource.ToModel()).Id); + return TypedCreated(_tagService.Add(resource.ToModel()).Id); } [RestPutById] [Consumes("application/json")] - public ActionResult<TagResource> Update([FromBody] TagResource resource) + public Results<Accepted<TagResource>, NotFound> Update([FromBody] TagResource resource) { _tagService.Update(resource.ToModel()); - return Accepted(resource.Id); + return TypedAccepted(resource.Id); } [RestDeleteById] - public void DeleteTag(int id) + public NoContent DeleteTag(int id) { _tagService.Delete(id); + + return TypedResults.NoContent(); } [NonAction] diff --git a/src/Sonarr.Api.V5/Tags/TagDetailsController.cs b/src/Sonarr.Api.V5/Tags/TagDetailsController.cs index 5817f31ef..eb0a81577 100644 --- a/src/Sonarr.Api.V5/Tags/TagDetailsController.cs +++ b/src/Sonarr.Api.V5/Tags/TagDetailsController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Tags; using Sonarr.Http; @@ -22,8 +24,8 @@ protected override TagDetailsResource GetResourceById(int id) [HttpGet] [Produces("application/json")] - public List<TagDetailsResource> GetAll() + public Ok<List<TagDetailsResource>> GetAll() { - return _tagService.Details().ToResource(); + return TypedResults.Ok(_tagService.Details().ToResource()); } } diff --git a/src/Sonarr.Api.V5/Update/UpdateController.cs b/src/Sonarr.Api.V5/Update/UpdateController.cs index a2d0cbabe..23c88e3a1 100644 --- a/src/Sonarr.Api.V5/Update/UpdateController.cs +++ b/src/Sonarr.Api.V5/Update/UpdateController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; @@ -23,7 +25,7 @@ public UpdateController(IRecentUpdateProvider recentUpdateProvider, IUpdateHisto [HttpGet] [Produces("application/json")] - public List<UpdateResource> GetRecentUpdates() + public Ok<List<UpdateResource>> GetRecentUpdates() { var resources = _recentUpdateProvider.GetRecentUpdatePackages() .OrderByDescending(u => u.Version) @@ -48,7 +50,7 @@ public List<UpdateResource> GetRecentUpdates() if (!_configFileProvider.LogDbEnabled) { - return resources; + return TypedResults.Ok(resources); } var updateHistory = _updateHistoryService.InstalledSince(resources.Last().ReleaseDate); @@ -65,7 +67,7 @@ public List<UpdateResource> GetRecentUpdates() } } - return resources; + return TypedResults.Ok(resources); } } } diff --git a/src/Sonarr.Api.V5/Wanted/CutoffController.cs b/src/Sonarr.Api.V5/Wanted/CutoffController.cs index cf9f3cece..426a3d189 100644 --- a/src/Sonarr.Api.V5/Wanted/CutoffController.cs +++ b/src/Sonarr.Api.V5/Wanted/CutoffController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.Datastore; @@ -28,7 +30,7 @@ public CutoffController(IEpisodeCutoffService episodeCutoffService, [HttpGet] [Produces("application/json")] - public PagingResource<EpisodeResource> GetCutoffUnmetEpisodes([FromQuery] PagingRequestResource paging, bool monitored = true, [FromQuery] CutoffSubresource[]? includeSubresources = null) + public Ok<PagingResource<EpisodeResource>> GetCutoffUnmetEpisodes([FromQuery] PagingRequestResource paging, bool monitored = true, [FromQuery] CutoffSubresource[]? includeSubresources = null) { var pagingResource = new PagingResource<EpisodeResource>(paging); var pagingSpec = pagingResource.MapToPagingSpec<EpisodeResource, Episode>( @@ -56,6 +58,6 @@ public PagingResource<EpisodeResource> GetCutoffUnmetEpisodes([FromQuery] Paging var resource = pagingSpec.ApplyToPage(_episodeCutoffService.EpisodesWhereCutoffUnmet, v => MapToResource(v, includeSeries, includeEpisodeFile, includeImages)); - return resource; + return TypedResults.Ok(resource); } } diff --git a/src/Sonarr.Api.V5/Wanted/MissingController.cs b/src/Sonarr.Api.V5/Wanted/MissingController.cs index e63d0bef5..dfffae74c 100644 --- a/src/Sonarr.Api.V5/Wanted/MissingController.cs +++ b/src/Sonarr.Api.V5/Wanted/MissingController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.Datastore; @@ -24,7 +26,7 @@ public MissingController(IEpisodeService episodeService, [HttpGet] [Produces("application/json")] - public PagingResource<EpisodeResource> GetMissingEpisodes([FromQuery] PagingRequestResource paging, bool monitored = true, bool includeSpecials = true, [FromQuery] MissingSubresource[]? includeSubresources = null) + public Ok<PagingResource<EpisodeResource>> GetMissingEpisodes([FromQuery] PagingRequestResource paging, bool monitored = true, bool includeSpecials = true, [FromQuery] MissingSubresource[]? includeSubresources = null) { var pagingResource = new PagingResource<EpisodeResource>(paging); var pagingSpec = pagingResource.MapToPagingSpec<EpisodeResource, Episode>( @@ -51,6 +53,6 @@ public PagingResource<EpisodeResource> GetMissingEpisodes([FromQuery] PagingRequ var resource = pagingSpec.ApplyToPage(spec => _episodeService.EpisodesWithoutFiles(spec, includeSpecials), v => MapToResource(v, includeSeries, false, includeImages)); - return resource; + return TypedResults.Ok(resource); } } diff --git a/src/Sonarr.Http/REST/RestController.cs b/src/Sonarr.Http/REST/RestController.cs index ee32c6a09..e9da21178 100644 --- a/src/Sonarr.Http/REST/RestController.cs +++ b/src/Sonarr.Http/REST/RestController.cs @@ -4,6 +4,8 @@ using System.Reflection; using FluentValidation; using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Filters; @@ -50,15 +52,15 @@ protected RestController() [RestGetById] [Produces("application/json")] - public virtual ActionResult<TResource> GetResourceByIdWithErrorHandler(int id) + public virtual Results<Ok<TResource>, NotFound> GetResourceByIdWithErrorHandler(int id) { try { - return GetResourceById(id); + return TypedResults.Ok(GetResourceById(id)); } catch (ModelNotFoundException) { - return NotFound(); + return TypedResults.NotFound(); } } @@ -156,6 +158,20 @@ protected void ValidateResource(TResource resource, bool validateId = false, boo } } + protected Results<Accepted<TResource>, NotFound> TypedAccepted(int id) + { + var result = GetResourceById(id); + + return TypedResults.Accepted(Url.Action(nameof(GetResourceByIdWithErrorHandler), new { id }), result); + } + + protected Results<Created<TResource>, NotFound> TypedCreated(int id) + { + var result = GetResourceById(id); + + return TypedResults.Created(Url.Action(nameof(GetResourceByIdWithErrorHandler), new { id }), result); + } + protected ActionResult<TResource> Accepted(int id) { var result = GetResourceById(id); From 91c8d9fa50317778390a9c7dc580775b9fe428ed Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:25:55 +0300 Subject: [PATCH 094/110] Bump .NET to 10.0.7 --- global.json | 2 +- src/NzbDrone.Common/Sonarr.Common.csproj | 8 ++++---- src/NzbDrone.Core/Sonarr.Core.csproj | 10 +++++----- src/NzbDrone.Host/Sonarr.Host.csproj | 2 +- .../Sonarr.Integration.Test.csproj | 2 +- src/NzbDrone/Sonarr.csproj | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/global.json b/global.json index 5ee7b7cb0..d20023b35 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "10.0.202" + "version": "10.0.203" } } diff --git a/src/NzbDrone.Common/Sonarr.Common.csproj b/src/NzbDrone.Common/Sonarr.Common.csproj index 2e2eeb2b1..4f7298a2a 100644 --- a/src/NzbDrone.Common/Sonarr.Common.csproj +++ b/src/NzbDrone.Common/Sonarr.Common.csproj @@ -7,8 +7,8 @@ <PackageReference Include="Diacritical.Net" Version="1.0.5" /> <PackageReference Include="DryIoc.dll" Version="5.4.3" /> <PackageReference Include="IPAddressRange" Version="6.3.0" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" /> - <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.6" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" /> + <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.7" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="NLog" Version="5.5.1" /> <PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.5" /> @@ -18,9 +18,9 @@ <PackageReference Include="SharpZipLib" Version="1.4.2" /> <PackageReference Include="SourceGear.sqlite3" Version="3.50.4.5" /> <PackageReference Include="System.Data.SQLite" Version="2.0.3" /> - <PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.6" /> + <PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.7" /> <PackageReference Include="System.IO.FileSystem.AccessControl" Version="6.0.0-preview.5.21301.5" /> - <PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.6" /> + <PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.7" /> </ItemGroup> <ItemGroup> <Compile Update="EnsureThat\Resources\ExceptionMessages.Designer.cs"> diff --git a/src/NzbDrone.Core/Sonarr.Core.csproj b/src/NzbDrone.Core/Sonarr.Core.csproj index 5ae2e9858..075262c04 100644 --- a/src/NzbDrone.Core/Sonarr.Core.csproj +++ b/src/NzbDrone.Core/Sonarr.Core.csproj @@ -7,16 +7,16 @@ <PackageReference Include="Diacritical.Net" Version="1.0.5" /> <PackageReference Include="Equ" Version="2.3.0" /> <PackageReference Include="MailKit" Version="4.16.0" /> - <PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.6" /> + <PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.7" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" /> <PackageReference Include="MiniProfiler.AspNetCore" Version="4.5.4" /> <PackageReference Include="Openur.FFMpegCore" Version="5.4.0.31" /> <PackageReference Include="Openur.FFprobeStatic" Version="8.1.0.334" /> <PackageReference Include="Polly" Version="8.6.6" /> - <PackageReference Include="System.Drawing.Common" Version="10.0.6" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" /> - <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.6" /> - <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.6" /> + <PackageReference Include="System.Drawing.Common" Version="10.0.7" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" /> + <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.7" /> + <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.7" /> <PackageReference Include="FluentMigrator.Runner.Core" Version="8.0.1" /> <PackageReference Include="FluentMigrator.Runner.SQLite" Version="8.0.1" /> <PackageReference Include="FluentMigrator.Runner.Postgres" Version="8.0.1" /> diff --git a/src/NzbDrone.Host/Sonarr.Host.csproj b/src/NzbDrone.Host/Sonarr.Host.csproj index 1d2193f40..670d95172 100644 --- a/src/NzbDrone.Host/Sonarr.Host.csproj +++ b/src/NzbDrone.Host/Sonarr.Host.csproj @@ -7,7 +7,7 @@ <PackageReference Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" /> <PackageReference Include="NLog.Extensions.Logging" Version="5.5.0" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.7" /> - <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.6" /> + <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.7" /> <PackageReference Include="DryIoc.dll" Version="5.4.3" /> <PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" /> </ItemGroup> diff --git a/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj b/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj index 1598f1190..f97d4cf00 100644 --- a/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj +++ b/src/NzbDrone.Integration.Test/Sonarr.Integration.Test.csproj @@ -4,7 +4,7 @@ <OutputType>Library</OutputType> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.6" /> + <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\NzbDrone.Test.Common\Sonarr.Test.Common.csproj" /> diff --git a/src/NzbDrone/Sonarr.csproj b/src/NzbDrone/Sonarr.csproj index f7a64cee3..f9ce03ae5 100644 --- a/src/NzbDrone/Sonarr.csproj +++ b/src/NzbDrone/Sonarr.csproj @@ -8,7 +8,7 @@ <GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources> </PropertyGroup> <ItemGroup> - <PackageReference Include="System.Resources.Extensions" Version="10.0.6" /> + <PackageReference Include="System.Resources.Extensions" Version="10.0.7" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\NzbDrone.Host\Sonarr.Host.csproj" /> From b3e815e341ec78d1b69a273d51d85bb41e1e83c6 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:10:22 +0300 Subject: [PATCH 095/110] Default to `und` for audio streams with no language tag --- frontend/src/Episode/Summary/MediaInfo.tsx | 5 +- .../225_mediainfo_multiple_streamsFixture.cs | 57 +++++++++++++++++++ .../225_mediainfo_multiple_streams.cs | 22 ++++--- .../MediaInfo/MediaInfoFormatter.cs | 6 +- .../MediaInfo/VideoFileInfoReader.cs | 3 +- 5 files changed, 79 insertions(+), 14 deletions(-) diff --git a/frontend/src/Episode/Summary/MediaInfo.tsx b/frontend/src/Episode/Summary/MediaInfo.tsx index d9c80db69..72abcdb02 100644 --- a/frontend/src/Episode/Summary/MediaInfo.tsx +++ b/frontend/src/Episode/Summary/MediaInfo.tsx @@ -23,7 +23,10 @@ function MediaInfo(props: MediaInfoProps) { if (key === 'audioStreams') { return value.map((audioStream, index) => { - const language = getLanguageName(audioStream.language); + const language = + audioStream.language === 'und' + ? translate('Unknown') + : getLanguageName(audioStream.language); let line = `${language}`; diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/225_mediainfo_multiple_streamsFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/225_mediainfo_multiple_streamsFixture.cs index 180144d62..bf528e938 100644 --- a/src/NzbDrone.Core.Test/Datastore/Migration/225_mediainfo_multiple_streamsFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/Migration/225_mediainfo_multiple_streamsFixture.cs @@ -97,6 +97,63 @@ public void should_convert_non_empty_media_info() mediainfo.SubtitleStreams.Select(s => s.Language).Should().BeEquivalentTo("eng", "ger", "rum"); } + [Test] + public void should_convert_non_empty_media_info_with_empty_audio_languages() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("EpisodeFiles").Row(new + { + SeriesId = 1, + SeasonNumber = 1, + RelativePath = "Season 01/S01E05.mkv", + Size = 125.Megabytes(), + DateAdded = DateTime.UtcNow, + OriginalFilePath = "Series.Title.S01E05.720p.HDTV.x265-Sonarr.mkv", + ReleaseGroup = "Sonarr", + Quality = new QualityModel(Quality.HDTV720p).ToJson(), + Languages = "[1]", + MediaInfo = new + { + AudioFormat = "truehd", + AudioCodecID = "[0][0][0][0]", + AudioProfile = "Dolby TrueHD + Dolby Atmos", + AudioBitrate = 224000, + AudioChannels = 2, + AudioChannelPositions = "stereo", + AudioLanguages = new List<string>(), + Subtitles = new List<string> { "ger", "eng", "rum" }, + ScanType = "Progressive", + SchemaRevision = 13 + }.ToJson() + }); + }); + + var items = db.Query<EpisodeFile225>("SELECT \"Id\", \"RelativePath\", \"MediaInfo\" FROM \"EpisodeFiles\""); + + items.Should().HaveCount(1); + + var mediainfo = items.First().MediaInfo; + + mediainfo.AudioFormat.Should().BeNull(); + mediainfo.AudioCodecID.Should().BeNull(); + mediainfo.AudioProfile.Should().BeNull(); + mediainfo.AudioBitrate.Should().BeNull(); + mediainfo.AudioChannels.Should().BeNull(); + mediainfo.AudioChannelPositions.Should().BeNull(); + + mediainfo.AudioStreams.First().Format.Should().Be("truehd"); + mediainfo.AudioStreams.First().CodecId.Should().Be("[0][0][0][0]"); + mediainfo.AudioStreams.First().Profile.Should().Be("Dolby TrueHD + Dolby Atmos"); + mediainfo.AudioStreams.First().Bitrate.Should().Be(224000); + mediainfo.AudioStreams.First().Channels.Should().Be(2); + mediainfo.AudioStreams.First().ChannelPositions.Should().Be("stereo"); + mediainfo.AudioStreams.First().Language.Should().Be("und"); + + mediainfo.AudioStreams.Select(s => s.Language).Should().BeEquivalentTo("und"); + mediainfo.SubtitleStreams.Select(s => s.Language).Should().BeEquivalentTo("eng", "ger", "rum"); + } + [Test] public void should_convert_to_null_on_invalid_media_info() { diff --git a/src/NzbDrone.Core/Datastore/Migration/225_mediainfo_multiple_streams.cs b/src/NzbDrone.Core/Datastore/Migration/225_mediainfo_multiple_streams.cs index f743a88c0..b16117241 100644 --- a/src/NzbDrone.Core/Datastore/Migration/225_mediainfo_multiple_streams.cs +++ b/src/NzbDrone.Core/Datastore/Migration/225_mediainfo_multiple_streams.cs @@ -126,13 +126,19 @@ private static List<MediaInfoAudioStream225> MigrateAudioStreams(MediaInfo224 ol { Language = language, }) - .ToList(); - audioStreams?.FirstOrDefault()?.Format = old.AudioFormat; - audioStreams?.FirstOrDefault()?.CodecId = old.AudioCodecID; - audioStreams?.FirstOrDefault()?.Profile = old.AudioProfile; - audioStreams?.FirstOrDefault()?.Bitrate = old.AudioBitrate; - audioStreams?.FirstOrDefault()?.Channels = old.AudioChannels; - audioStreams?.FirstOrDefault()?.ChannelPositions = old.AudioChannelPositions; + .ToList() ?? []; + + if (audioStreams.Count == 0) + { + audioStreams.Add(new MediaInfoAudioStream225 { Language = "und" }); + } + + audioStreams.FirstOrDefault()?.Format = old.AudioFormat; + audioStreams.FirstOrDefault()?.CodecId = old.AudioCodecID; + audioStreams.FirstOrDefault()?.Profile = old.AudioProfile; + audioStreams.FirstOrDefault()?.Bitrate = old.AudioBitrate; + audioStreams.FirstOrDefault()?.Channels = old.AudioChannels; + audioStreams.FirstOrDefault()?.ChannelPositions = old.AudioChannelPositions; return audioStreams; } @@ -144,7 +150,7 @@ private static List<MediaInfoSubtitleStream225> MigrateSubtitleStreams(MediaInfo { Language = language, }) - .ToList(); + .ToList() ?? []; return subtitleStreams; } diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs index 66dde9cc1..277633ec5 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs @@ -31,7 +31,7 @@ public static decimal FormatAudioChannels(MediaInfoAudioStreamModel audioStream) public static string FormatAudioCodec(MediaInfoAudioStreamModel audioStream, string sceneName) { - if (audioStream.Format == null) + if (audioStream?.Format == null) { return null; } @@ -155,7 +155,7 @@ public static string FormatAudioCodec(MediaInfoAudioStreamModel audioStream, str public static string FormatVideoCodec(MediaInfoModel mediaInfo, string sceneName) { - if (mediaInfo.VideoFormat == null) + if (mediaInfo?.VideoFormat == null) { return null; } @@ -270,7 +270,7 @@ public static string FormatVideoCodec(MediaInfoModel mediaInfo, string sceneName private static decimal? FormatAudioChannelsFromAudioChannelPositions(MediaInfoAudioStreamModel audioStream) { - if (audioStream.ChannelPositions == null) + if (audioStream?.ChannelPositions == null) { return 0; } diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs index 073b510ce..32742bc96 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs @@ -85,13 +85,12 @@ public MediaInfoModel GetMediaInfo(string filename) mediaInfoModel.RawStreamData = string.Concat(analysis.OutputData); mediaInfoModel.AudioStreams = analysis.AudioStreams? - .Where(stream => stream.Language.IsNotNullOrWhiteSpace()) .OrderBy(stream => stream.Index) .Select(stream => { var model = new MediaInfoAudioStreamModel { - Language = stream.Language, + Language = stream.Language.IsNotNullOrWhiteSpace() ? stream.Language : "und", Format = stream.CodecName, CodecId = stream.CodecTagString, Profile = stream.Profile, From 581e118532191a487bf6c3d3f872806c891bbff8 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:59:45 +0300 Subject: [PATCH 096/110] Display 'None' for empty subtitles streams --- frontend/src/Episode/Summary/MediaInfo.tsx | 65 +++++++++++++--------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/frontend/src/Episode/Summary/MediaInfo.tsx b/frontend/src/Episode/Summary/MediaInfo.tsx index 72abcdb02..7b2e35b84 100644 --- a/frontend/src/Episode/Summary/MediaInfo.tsx +++ b/frontend/src/Episode/Summary/MediaInfo.tsx @@ -61,39 +61,50 @@ function MediaInfo(props: MediaInfoProps) { <DescriptionListItem key={key} title={translate('MediaInfoSubtitlesHeader')} - data={value.reduce( - (acc: React.ReactNode[] | null, subtitleStream, index) => { - const language = getLanguageName(subtitleStream.language); + data={ + value.length > 0 + ? value.reduce( + ( + acc: React.ReactNode[] | null, + subtitleStream, + index + ) => { + const language = getLanguageName( + subtitleStream.language + ); - let line = `${ - subtitleStream.format?.toUpperCase() || translate('Unknown') - }`; + let line = `${ + subtitleStream.format?.toUpperCase() || + translate('Unknown') + }`; - if ( - subtitleStream.title !== undefined && - subtitleStream.title !== language - ) { - line += ` | ${subtitleStream.title}`; - } + if ( + subtitleStream.title !== undefined && + subtitleStream.title !== language + ) { + line += ` | ${subtitleStream.title}`; + } - if (subtitleStream.forced) { - line += ` | ${translate('MediaInfoForced')}`; - } + if (subtitleStream.forced) { + line += ` | ${translate('MediaInfoForced')}`; + } - if (subtitleStream.hearingImpaired) { - line += ` | ${translate('MediaInfoHearingImpaired')}`; - } + if (subtitleStream.hearingImpaired) { + line += ` | ${translate('MediaInfoHearingImpaired')}`; + } - const curr = ( - <span key={index} title={line}> - {language} - </span> - ); + const curr = ( + <span key={index} title={line}> + {language} + </span> + ); - return acc === null ? [curr] : [acc, ' / ', curr]; - }, - null - )} + return acc === null ? [curr] : [acc, ' / ', curr]; + }, + null + ) + : translate('None') + } /> ); } From 970c0de62f9fbe6e9ab1a5625f67334d089a57b5 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:39:00 +0300 Subject: [PATCH 097/110] Fixed queue details counter --- .../Activity/Queue/Details/QueueDetailsProvider.tsx | 12 ++++++------ frontend/src/Activity/Queue/Queue.tsx | 2 +- frontend/src/Calendar/Agenda/AgendaEvent.tsx | 5 +---- .../Calendar/CalendarMissingEpisodeSearchButton.tsx | 4 +--- frontend/src/typings/Queue.ts | 6 ++---- 5 files changed, 11 insertions(+), 18 deletions(-) diff --git a/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx b/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx index ef6c50ab1..75976b452 100644 --- a/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx +++ b/frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx @@ -89,15 +89,15 @@ export function useQueueDetailsForSeries( return acc; } - if (seasonNumber != null && item.seasonNumber !== seasonNumber) { + if ( + seasonNumber != null && + !item.seasonNumbers?.includes(seasonNumber) + ) { return acc; } - acc.count++; - - if (item.episodeHasFile) { - acc.episodesWithFiles++; - } + acc.count += item.episodeIds.length; + acc.episodesWithFiles += item.episodesWithFilesCount; return acc; }, diff --git a/frontend/src/Activity/Queue/Queue.tsx b/frontend/src/Activity/Queue/Queue.tsx index 459ff196d..dd8259487 100644 --- a/frontend/src/Activity/Queue/Queue.tsx +++ b/frontend/src/Activity/Queue/Queue.tsx @@ -365,7 +365,7 @@ function QueueContent() { selectedIds.every((id: number) => { const item = records.find((i) => i.id === id); - return !!(item && item.seriesId && item.episodeId); + return !!(item && item.seriesId && item.episodeIds.length); }) } isPending={ diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.tsx b/frontend/src/Calendar/Agenda/AgendaEvent.tsx index 705fea29a..d0ab3e7fb 100644 --- a/frontend/src/Calendar/Agenda/AgendaEvent.tsx +++ b/frontend/src/Calendar/Agenda/AgendaEvent.tsx @@ -154,10 +154,7 @@ function AgendaEvent(props: AgendaEventProps) { {queueItem ? ( <span className={styles.statusIcon}> - <CalendarEventQueueDetails - seasonNumber={seasonNumber} - {...queueItem} - /> + <CalendarEventQueueDetails {...queueItem} /> </span> ) : null} diff --git a/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx b/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx index 91aa6d666..b206daa61 100644 --- a/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx +++ b/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx @@ -39,9 +39,7 @@ const useMissingEpisodeIdsSelector = () => { moment(airDateUtc).isAfter(start) && moment(airDateUtc).isBefore(end) && isBefore(episode.airDateUtc) && - !queueDetails.some( - (details) => !!details.episode && details.episode.id === episode.id - ) + !queueDetails.some((details) => details.episodeIds?.includes(episode.id)) ) { acc.push(episode.id); } diff --git a/frontend/src/typings/Queue.ts b/frontend/src/typings/Queue.ts index 885ceaa7d..b43a6adc7 100644 --- a/frontend/src/typings/Queue.ts +++ b/frontend/src/typings/Queue.ts @@ -42,15 +42,13 @@ interface Queue extends ModelBase { protocol: DownloadProtocol; downloadClient: string; outputPath: string; - episodeHasFile: boolean; + episodesWithFilesCount: number; seriesId?: number; - episodeId?: number; episodeIds: number[]; - seasonNumber?: number; seasonNumbers: number[]; downloadClientHasPostImportCategory: boolean; isFullSeason: boolean; - episode?: Episode; + episodes?: Episode[]; } export default Queue; From 8e258a36c134464da4cfa14522aefdb3ba27594a Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:22:08 +0300 Subject: [PATCH 098/110] Fix episode air date column styling in queue --- frontend/src/Activity/Queue/QueueRow.tsx | 48 ++++++++++++++++++------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/frontend/src/Activity/Queue/QueueRow.tsx b/frontend/src/Activity/Queue/QueueRow.tsx index a23c8af2a..7879b013a 100644 --- a/frontend/src/Activity/Queue/QueueRow.tsx +++ b/frontend/src/Activity/Queue/QueueRow.tsx @@ -29,6 +29,8 @@ import Queue, { QueueTrackedDownloadStatus, StatusMessage, } from 'typings/Queue'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; import formatBytes from 'Utilities/Number/formatBytes'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; import translate from 'Utilities/String/translate'; @@ -106,7 +108,7 @@ function QueueRow(props: QueueRowProps) { const series = useSingleSeries(seriesId); const episodes = useEpisodesWithIds(episodeIds); - const { showRelativeDates, shortDateFormat, timeFormat } = + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = useUiSettingsValues(); const { removeQueueItem, isRemoving } = useRemoveQueueItem(id); const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id); @@ -248,17 +250,39 @@ function QueueRow(props: QueueRowProps) { return ( <TableRowCell key={name}> - <RelativeDateCell - key={name} - component="span" - date={episodes[0].airDateUtc} - /> - {' - '} - <RelativeDateCell - key={name} - component="span" - date={episodes[episodes.length - 1].airDateUtc} - /> + <span + title={`${formatDateTime( + episodes[0].airDateUtc, + longDateFormat, + timeFormat, + { + includeRelativeDay: !showRelativeDates, + } + )} - ${formatDateTime( + episodes[episodes.length - 1].airDateUtc, + longDateFormat, + timeFormat, + { + includeRelativeDay: !showRelativeDates, + } + )}`} + > + {getRelativeDate({ + date: episodes[0].airDateUtc, + shortDateFormat, + showRelativeDates, + timeFormat, + timeForToday: true, + })} + {' - '} + {getRelativeDate({ + date: episodes[episodes.length - 1].airDateUtc, + shortDateFormat, + showRelativeDates, + timeFormat, + timeForToday: true, + })} + </span> </TableRowCell> ); } From 0826686dd7f14e8938cd8102d23f661594a63c2b Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:56:19 +0300 Subject: [PATCH 099/110] Fix loading Map objects on older browsers --- frontend/src/polyfills.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/polyfills.js b/frontend/src/polyfills.js index 74105388f..6f2a5e74d 100644 --- a/frontend/src/polyfills.js +++ b/frontend/src/polyfills.js @@ -47,3 +47,5 @@ if (!('contains' in String.prototype)) { if (!Object.groupBy) { import('core-js/actual/object/group-by'); } + +import 'core-js/actual/iterator'; From 710737f2e2319bdfc775f7d4cf89112ff4d3a5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20HERV=C3=89?= <45267274+thibaultherve@users.noreply.github.com> Date: Sat, 25 Apr 2026 03:47:12 +0200 Subject: [PATCH 100/110] Fixed: Scroll position not reset when navigating on small screens --- .../src/Components/Page/PageContentBody.tsx | 23 ++++++++---- .../src/Components/withScrollPosition.tsx | 31 ---------------- .../src/Helpers/Hooks/useScrollPosition.ts | 37 +++++++++++++++++++ frontend/src/Series/Index/SeriesIndex.tsx | 22 +++-------- 4 files changed, 58 insertions(+), 55 deletions(-) delete mode 100644 frontend/src/Components/withScrollPosition.tsx create mode 100644 frontend/src/Helpers/Hooks/useScrollPosition.ts diff --git a/frontend/src/Components/Page/PageContentBody.tsx b/frontend/src/Components/Page/PageContentBody.tsx index 9c3ffcd0a..89d756645 100644 --- a/frontend/src/Components/Page/PageContentBody.tsx +++ b/frontend/src/Components/Page/PageContentBody.tsx @@ -1,5 +1,6 @@ import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react'; import Scroller, { OnScroll } from 'Components/Scroller/Scroller'; +import useScrollPosition from 'Helpers/Hooks/useScrollPosition'; import { isLocked } from 'Utilities/scrollLock'; import styles from './PageContentBody.css'; @@ -7,7 +8,7 @@ interface PageContentBodyProps { className?: string; innerClassName?: string; children: ReactNode; - initialScrollTop?: number; + scrollPositionKey?: string; onScroll?: (payload: OnScroll) => void; } @@ -17,26 +18,32 @@ const PageContentBody = forwardRef( className = styles.contentBody, innerClassName = styles.innerContentBody, children, + scrollPositionKey, onScroll, - ...otherProps } = props; - const onScrollWrapper = useCallback( + const { initialScrollTop, onScroll: onScrollMemo } = + useScrollPosition(scrollPositionKey); + + const handleScroll = useCallback( (payload: OnScroll) => { - if (onScroll && !isLocked()) { - onScroll(payload); + if (isLocked()) { + return; } + + onScrollMemo(payload); + onScroll?.(payload); }, - [onScroll] + [onScroll, onScrollMemo] ); return ( <Scroller ref={ref} - {...otherProps} className={className} scrollDirection="vertical" - onScroll={onScrollWrapper} + initialScrollTop={initialScrollTop} + onScroll={handleScroll} > <div className={innerClassName}>{children}</div> </Scroller> diff --git a/frontend/src/Components/withScrollPosition.tsx b/frontend/src/Components/withScrollPosition.tsx deleted file mode 100644 index f688a6253..000000000 --- a/frontend/src/Components/withScrollPosition.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import scrollPositions from 'Store/scrollPositions'; - -interface WrappedComponentProps { - initialScrollTop: number; -} - -interface ScrollPositionProps { - history: RouteComponentProps['history']; - location: RouteComponentProps['location']; - match: RouteComponentProps['match']; -} - -function withScrollPosition( - WrappedComponent: React.FC<WrappedComponentProps>, - scrollPositionKey: string -) { - function ScrollPosition(props: ScrollPositionProps) { - const { history } = props; - - const initialScrollTop = - history.action === 'POP' ? scrollPositions[scrollPositionKey] : 0; - - return <WrappedComponent {...props} initialScrollTop={initialScrollTop} />; - } - - return ScrollPosition; -} - -export default withScrollPosition; diff --git a/frontend/src/Helpers/Hooks/useScrollPosition.ts b/frontend/src/Helpers/Hooks/useScrollPosition.ts new file mode 100644 index 000000000..25e5133e0 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useScrollPosition.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useHistory, useLocation } from 'react-router'; +import { OnScroll } from 'Components/Scroller/Scroller'; +import scrollPositions from 'Store/scrollPositions'; + +function useScrollPosition(key?: string) { + const { pathname } = useLocation(); + const { action } = useHistory(); + + // Reset window scroll on PUSH/REPLACE (mobile's scroll container). + // Reset the scroll position unless we're going back, this will allow the scroll + // position to reset when moving forward (PUSH/REPLACE) and restore when + // moving backwards (POP). + useEffect(() => { + if (action !== 'POP') { + window.scrollTo(0, 0); + } + }, [pathname, action]); + + const initialScrollTop = useMemo( + () => (key && action === 'POP' ? scrollPositions[key] ?? 0 : 0), + [key, action] + ); + + const onScroll = useCallback( + ({ scrollTop }: OnScroll) => { + if (key) { + scrollPositions[key] = scrollTop; + } + }, + [key] + ); + + return { initialScrollTop, onScroll }; +} + +export default useScrollPosition; diff --git a/frontend/src/Series/Index/SeriesIndex.tsx b/frontend/src/Series/Index/SeriesIndex.tsx index ea4ab3f76..083c438c4 100644 --- a/frontend/src/Series/Index/SeriesIndex.tsx +++ b/frontend/src/Series/Index/SeriesIndex.tsx @@ -14,7 +14,6 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import withScrollPosition from 'Components/withScrollPosition'; import { useCustomFiltersList } from 'Filters/useCustomFilters'; import { align, icons, kinds } from 'Helpers/Props'; import { DESCENDING } from 'Helpers/Props/sortDirections'; @@ -27,7 +26,6 @@ import { useSeriesOptions, } from 'Series/seriesOptionsStore'; import { FILTERS, useSeriesIndex } from 'Series/useSeries'; -import scrollPositions from 'Store/scrollPositions'; import { TableOptionsChangePayload } from 'typings/Table'; import translate from 'Utilities/String/translate'; import SeriesIndexFilterMenu from './Menus/SeriesIndexFilterMenu'; @@ -60,11 +58,7 @@ function getViewComponent(view: string) { return SeriesIndexTable; } -interface SeriesIndexProps { - initialScrollTop?: number; -} - -const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => { +function SeriesIndex() { const { isLoading: isFetching, isFetched, @@ -148,13 +142,9 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => { [setJumpToCharacter] ); - const onScroll = useCallback( - ({ scrollTop }: { scrollTop: number }) => { - setJumpToCharacter(undefined); - scrollPositions.seriesIndex = scrollTop; - }, - [setJumpToCharacter] - ); + const onScroll = useCallback(() => { + setJumpToCharacter(undefined); + }, [setJumpToCharacter]); const jumpBarItems: PageJumpBarItems = useMemo(() => { // Reset if not sorting by sortTitle @@ -296,7 +286,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore innerClassName={styles[`${view}InnerContentBody`]} - initialScrollTop={props.initialScrollTop} + scrollPositionKey="seriesIndex" onScroll={onScroll} > {isFetching && !isFetched ? <LoadingIndicator /> : null} @@ -353,6 +343,6 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => { </SelectProvider> </QueueDetailsProvider> ); -}, 'seriesIndex'); +} export default SeriesIndex; From 710a6ea078ec0f98c20fac55c60bb35361f345ba Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 20 Apr 2026 17:05:47 -0700 Subject: [PATCH 101/110] Auto close stale Draft PRs --- .github/workflows/close_stale_draft_prs.yml | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/close_stale_draft_prs.yml diff --git a/.github/workflows/close_stale_draft_prs.yml b/.github/workflows/close_stale_draft_prs.yml new file mode 100644 index 000000000..f5704686d --- /dev/null +++ b/.github/workflows/close_stale_draft_prs.yml @@ -0,0 +1,26 @@ +name: Close stale draft PRs + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + +jobs: + close-stale-drafts: + runs-on: ubuntu-latest + if: github.repository == 'Sonarr/Sonarr' + permissions: + pull-requests: write + steps: + - name: Close draft PRs inactive for 90+ days + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + cutoff=$(date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ) + gh pr list --draft --state open --limit 1000 \ + --json number,updatedAt \ + --jq ".[] | select(.updatedAt < \"$cutoff\") | .number" \ + | while read -r pr; do + gh pr close "$pr" --comment ":wave: This draft pull request has been closed automatically because it has not had any activity in 90 days. If you are still working on this, please update it and let us know so we can reopen it." + done From 6d3ef494b4cf2dac88ffc348f40567c363fa7620 Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 20 Apr 2026 17:16:39 -0700 Subject: [PATCH 102/110] Add useCombinedRefs hook --- frontend/src/Helpers/Hooks/useCombinedRefs.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 frontend/src/Helpers/Hooks/useCombinedRefs.ts diff --git a/frontend/src/Helpers/Hooks/useCombinedRefs.ts b/frontend/src/Helpers/Hooks/useCombinedRefs.ts new file mode 100644 index 000000000..00ee45d29 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useCombinedRefs.ts @@ -0,0 +1,39 @@ +import { ForwardedRef, useCallback, useRef } from 'react'; + +type OptionalRef<T> = ForwardedRef<T> | undefined; + +function setRef<T>(ref: OptionalRef<T>, value: T | null) { + if (typeof ref === 'function') { + ref(value); + } else if (ref) { + ref.current = value; + } +} + +function useCombinedRefs<T>(...refs: OptionalRef<T>[]) { + const previousRefs = useRef<OptionalRef<T>[]>([]); + + return useCallback((value: T | null) => { + let index = 0; + for (; index < refs.length; index++) { + const ref = refs[index]; + const prev = previousRefs.current[index]; + + if (prev !== ref) { + setRef(prev, null); + } + setRef(ref, value); + } + + for (; index < previousRefs.current.length; index++) { + const prev = previousRefs.current[index]; + setRef(prev, null); + } + + previousRefs.current = refs; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, refs); +} + +export default useCombinedRefs; From 04d98098e00ef95b42d50afe72e92768181d81ca Mon Sep 17 00:00:00 2001 From: Mark McDowall <mark@mcdowall.ca> Date: Mon, 20 Apr 2026 17:21:16 -0700 Subject: [PATCH 103/110] New: Ensure housekeeping task doesn't run while other tasks are running Closes #8544 --- src/NzbDrone.Core/Housekeeping/HousekeepingCommand.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/NzbDrone.Core/Housekeeping/HousekeepingCommand.cs b/src/NzbDrone.Core/Housekeeping/HousekeepingCommand.cs index ebf482ee5..937bf3794 100644 --- a/src/NzbDrone.Core/Housekeeping/HousekeepingCommand.cs +++ b/src/NzbDrone.Core/Housekeeping/HousekeepingCommand.cs @@ -4,5 +4,6 @@ namespace NzbDrone.Core.Housekeeping { public class HousekeepingCommand : Command { + public override bool IsExclusive => true; } } From 6214803a5a74dc62f071eeb10283abb625108b75 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Sat, 25 Apr 2026 04:48:10 +0300 Subject: [PATCH 104/110] Don't throw error on pushed releases with empty indexer or download client IDs --- src/NzbDrone.Core/Download/DownloadClientFactory.cs | 4 ++-- src/NzbDrone.Core/Indexers/IndexerFactory.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/NzbDrone.Core/Download/DownloadClientFactory.cs b/src/NzbDrone.Core/Download/DownloadClientFactory.cs index 1d98012c4..1906ffef3 100644 --- a/src/NzbDrone.Core/Download/DownloadClientFactory.cs +++ b/src/NzbDrone.Core/Download/DownloadClientFactory.cs @@ -60,9 +60,9 @@ public DownloadClientDefinition ResolveDownloadClient(int? id, string name) { var all = All(); var clientByName = name.IsNullOrWhiteSpace() ? null : all.FirstOrDefault(c => c.Name.EqualsIgnoreCase(name)); - var clientById = id.HasValue ? all.FirstOrDefault(c => c.Id == id.Value) : null; + var clientById = id is > 0 ? all.FirstOrDefault(c => c.Id == id.Value) : null; - if (id.HasValue && clientById == null) + if (id is > 0 && clientById == null) { throw new ResolveDownloadClientException("Download client with ID '{0}' could not be found", id.Value); } diff --git a/src/NzbDrone.Core/Indexers/IndexerFactory.cs b/src/NzbDrone.Core/Indexers/IndexerFactory.cs index 6fca9e540..b22e1b669 100644 --- a/src/NzbDrone.Core/Indexers/IndexerFactory.cs +++ b/src/NzbDrone.Core/Indexers/IndexerFactory.cs @@ -96,9 +96,9 @@ public IndexerDefinition ResolveIndexer(int? id, string name) { var all = All(); var clientByName = name.IsNullOrWhiteSpace() ? null : all.FirstOrDefault(c => c.Name.EqualsIgnoreCase(name)); - var clientById = id.HasValue ? all.FirstOrDefault(c => c.Id == id.Value) : null; + var clientById = id is > 0 ? all.FirstOrDefault(c => c.Id == id.Value) : null; - if (id.HasValue && clientById == null) + if (id is > 0 && clientById == null) { throw new ResolveIndexerException("Indexer with ID '{0}' could not be found", id.Value); } @@ -115,7 +115,7 @@ public IndexerDefinition ResolveIndexer(int? id, string name) if (clientByName != null && clientById != null && clientByName.Id != clientById.Id) { - throw new ResolveIndexerException("Indexer with name '{0}' does not match Indexerwith ID '{1}'", name, id.Value); + throw new ResolveIndexerException("Indexer with name '{0}' does not match indexer with ID '{1}'", name, id.Value); } return clientById ?? clientByName; From d8b16d79263668d26f3f35d019cf742ab6177e61 Mon Sep 17 00:00:00 2001 From: Colin Mackie <cdmackie@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:48:49 -0700 Subject: [PATCH 105/110] Wrap bulk UpdateMany/SetFields in a transaction --- src/NzbDrone.Core/Datastore/BasicRepository.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/Datastore/BasicRepository.cs b/src/NzbDrone.Core/Datastore/BasicRepository.cs index 59fb6afcd..cae2e8302 100644 --- a/src/NzbDrone.Core/Datastore/BasicRepository.cs +++ b/src/NzbDrone.Core/Datastore/BasicRepository.cs @@ -266,8 +266,10 @@ public void UpdateMany(IList<TModel> models) } using (var conn = _database.OpenConnection()) + using (var tran = conn.BeginTransaction(IsolationLevel.ReadCommitted)) { - UpdateFields(conn, null, models, _properties); + UpdateFields(conn, tran, models, _properties); + tran.Commit(); } } @@ -371,8 +373,10 @@ public void SetFields(IList<TModel> models, params Expression<Func<TModel, objec var propertiesToUpdate = properties.Select(x => x.GetMemberName()).ToList(); using (var conn = _database.OpenConnection()) + using (var tran = conn.BeginTransaction(IsolationLevel.ReadCommitted)) { - UpdateFields(conn, null, models, propertiesToUpdate); + UpdateFields(conn, tran, models, propertiesToUpdate); + tran.Commit(); } foreach (var model in models) From 53eb9d85456b0e65c6d50028298045f6b7dc9e64 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:40:55 +0300 Subject: [PATCH 106/110] Fix toggle monitored for seasons --- src/Sonarr.Api.V5/Series/SeriesController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sonarr.Api.V5/Series/SeriesController.cs b/src/Sonarr.Api.V5/Series/SeriesController.cs index a0c9ffafc..5eb93f035 100644 --- a/src/Sonarr.Api.V5/Series/SeriesController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesController.cs @@ -239,7 +239,7 @@ public Results<Ok<SeasonResource>, NotFound> UpdateSeasonMonitored([FromRoute] i _seriesService.UpdateSeries(series); - BroadcastResourceChange(ModelAction.Updated, series.ToResource()); + BroadcastResourceChange(ModelAction.Updated, GetSeriesResource(series, false)!); return TypedResults.Ok(season.ToResource()); } From 39f043a96c017c19e6a65d2475466be66d6beea2 Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:45:38 +0300 Subject: [PATCH 107/110] New: Filter series by episode release types --- .../Filter/Builder/FilterBuilderRow.tsx | 4 ++ .../ReleaseTypeFilterBuilderRowValue.tsx | 45 +++++++++++++++++++ .../Helpers/Props/filterBuilderValueTypes.ts | 2 + .../src/Series/Index/Table/SeriesIndexRow.css | 1 + .../Index/Table/SeriesIndexRow.css.d.ts | 1 + .../src/Series/Index/Table/SeriesIndexRow.tsx | 21 +++++++++ .../Index/Table/SeriesIndexTableHeader.css | 1 + .../Table/SeriesIndexTableHeader.css.d.ts | 1 + frontend/src/Series/Series.ts | 2 + frontend/src/Series/seriesOptionsStore.ts | 6 +++ frontend/src/Series/useSeries.ts | 12 +++++ src/NzbDrone.Core/Localization/Core/en.json | 1 + .../SeriesStats/SeasonStatistics.cs | 21 +++++++++ .../SeriesStats/SeriesStatistics.cs | 2 + .../SeriesStats/SeriesStatisticsRepository.cs | 3 ++ .../SeriesStats/SeriesStatisticsService.cs | 1 + .../Series/SeasonStatisticsResource.cs | 3 ++ .../Series/SeriesStatisticsResource.cs | 3 ++ 18 files changed, 130 insertions(+) create mode 100644 frontend/src/Components/Filter/Builder/ReleaseTypeFilterBuilderRowValue.tsx diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx b/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx index 00aa58936..0f437a642 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.tsx @@ -22,6 +22,7 @@ import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import QualityFilterBuilderRowValue from './QualityFilterBuilderRowValue'; import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue'; import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue'; +import ReleaseTypeFilterBuilderRowValue from './ReleaseTypeFilterBuilderRowValue'; import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue'; import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue'; import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue'; @@ -113,6 +114,9 @@ function getRowValueConnector<T>( case filterBuilderValueTypes.MONITORED_STATUS: return MonitoredStatusFilterBuilderRowValue; + case filterBuilderValueTypes.RELEASE_TYPES: + return ReleaseTypeFilterBuilderRowValue; + case filterBuilderValueTypes.SERIES: return SeriesFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/ReleaseTypeFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/ReleaseTypeFilterBuilderRowValue.tsx new file mode 100644 index 000000000..d76c28110 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/ReleaseTypeFilterBuilderRowValue.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue, { + FilterBuilderRowValueProps, +} from './FilterBuilderRowValue'; + +const releaseTypeList = [ + { + id: 'unknown', + get name() { + return translate('Unknown'); + }, + }, + { + id: 'singleEpisode', + get name() { + return translate('SingleEpisode'); + }, + }, + { + id: 'multiEpisode', + get name() { + return translate('MultiEpisode'); + }, + }, + { + id: 'seasonPack', + get name() { + return translate('SeasonPack'); + }, + }, +]; + +type ReleaseTypeFilterBuilderRowValueProps<T> = Omit< + FilterBuilderRowValueProps<T, string, string>, + 'tagList' +>; + +function ReleaseTypeFilterBuilderRowValue<T>( + props: ReleaseTypeFilterBuilderRowValueProps<T> +) { + return <FilterBuilderRowValue tagList={releaseTypeList} {...props} />; +} + +export default ReleaseTypeFilterBuilderRowValue; diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.ts b/frontend/src/Helpers/Props/filterBuilderValueTypes.ts index 4a12865c2..0110b76b1 100644 --- a/frontend/src/Helpers/Props/filterBuilderValueTypes.ts +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.ts @@ -10,6 +10,7 @@ export const QUALITY = 'quality'; export const QUALITY_PROFILE = 'qualityProfile'; export const QUEUE_STATUS = 'queueStatus'; export const MONITORED_STATUS = 'monitoredStatus'; +export const RELEASE_TYPES = 'releaseTypes'; export const SERIES = 'series'; export const SERIES_STATUS = 'seriesStatus'; export const SERIES_TYPES = 'seriesType'; @@ -28,6 +29,7 @@ export type FilterBuildValueType = | 'qualityProfile' | 'queueStatus' | 'monitoredStatus' + | 'releaseTypes' | 'series' | 'seriesStatus' | 'seriesType' diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css b/frontend/src/Series/Index/Table/SeriesIndexRow.css index 4b4706a01..414f536bc 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css @@ -75,6 +75,7 @@ } .releaseGroups, +.releaseTypes, .nextAiring, .previousAiring, .added, diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts index 93da75bbf..eb9fbee78 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css.d.ts @@ -27,6 +27,7 @@ interface CssExports { 'qualityProfileId': string; 'ratings': string; 'releaseGroups': string; + 'releaseTypes': string; 'seasonCount': string; 'seasonFolder': string; 'seriesType': string; diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx index 6a997af97..50faea08a 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx @@ -13,6 +13,7 @@ import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; import Column from 'Components/Table/Column'; +import getReleaseTypeName from 'Episode/getReleaseTypeName'; import { icons } from 'Helpers/Props'; import useCountryName from 'Internationalization/useCountryName'; import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; @@ -149,6 +150,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { totalEpisodeCount = 0, sizeOnDisk = 0, releaseGroups = [], + releaseTypes = [], episodeFileQualities = [], } = statistics; @@ -448,6 +450,25 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { ); } + if (name === 'releaseTypes') { + const joinedReleaseTypes = releaseTypes + .map(getReleaseTypeName) + .join(', '); + const truncatedReleaseTypes = + releaseTypes.length > 3 + ? `${releaseTypes + .slice(0, 3) + .map(getReleaseTypeName) + .join(', ')}...` + : joinedReleaseTypes; + + return ( + <VirtualTableRowCell key={name} className={styles[name]}> + <span title={joinedReleaseTypes}>{truncatedReleaseTypes}</span> + </VirtualTableRowCell> + ); + } + if (name === 'episodeFileQualities') { const joinedQualities = episodeFileQualities .map((q) => q.name) diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css index 0fac2d75f..a624b1ff4 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css @@ -39,6 +39,7 @@ } .releaseGroups, +.releaseTypes, .nextAiring, .previousAiring, .added, diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts index 63fd01bbe..8c1ff9e03 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css.d.ts @@ -22,6 +22,7 @@ interface CssExports { 'qualityProfileId': string; 'ratings': string; 'releaseGroups': string; + 'releaseTypes': string; 'seasonCount': string; 'seasonFolder': string; 'seriesType': string; diff --git a/frontend/src/Series/Series.ts b/frontend/src/Series/Series.ts index 9a7793abe..107614411 100644 --- a/frontend/src/Series/Series.ts +++ b/frontend/src/Series/Series.ts @@ -1,4 +1,5 @@ import ModelBase from 'App/ModelBase'; +import ReleaseType from 'InteractiveImport/ReleaseType'; import Language from 'Language/Language'; import Quality from 'Quality/Quality'; @@ -35,6 +36,7 @@ export interface Statistics { percentOfEpisodes: number; previousAiring?: Date; releaseGroups: string[]; + releaseTypes: ReleaseType[]; episodeFileQualities: Quality[]; sizeOnDisk: number; totalEpisodeCount: number; diff --git a/frontend/src/Series/seriesOptionsStore.ts b/frontend/src/Series/seriesOptionsStore.ts index 51a37e3e0..a833e9b5d 100644 --- a/frontend/src/Series/seriesOptionsStore.ts +++ b/frontend/src/Series/seriesOptionsStore.ts @@ -221,6 +221,12 @@ const { useOptions, useOption, setOptions, setOption, setSort, getOptions } = isSortable: false, isVisible: false, }, + { + name: 'releaseTypes', + label: () => translate('ReleaseTypes'), + isSortable: false, + isVisible: false, + }, { name: 'episodeFileQualities', label: () => translate('EpisodeFileQualities'), diff --git a/frontend/src/Series/useSeries.ts b/frontend/src/Series/useSeries.ts index 6e62301bb..6d1bff479 100644 --- a/frontend/src/Series/useSeries.ts +++ b/frontend/src/Series/useSeries.ts @@ -254,6 +254,12 @@ const FILTER_PREDICATES = { return predicate(releaseGroups, filterValue); }, + releaseTypes: (item: Series, filterValue: string[], type: FilterType) => { + const releaseTypes = item.statistics?.releaseTypes ?? []; + const predicate = getFilterTypePredicate(type); + return predicate(releaseTypes, filterValue); + }, + episodeFileQualities: ( item: Series, filterValue: number[], @@ -533,6 +539,12 @@ export const FILTER_BUILDER: FilterBuilderProp<Series>[] = [ label: () => translate('ReleaseGroups'), type: filterBuilderTypes.ARRAY, }, + { + name: 'releaseTypes', + label: () => translate('ReleaseTypes'), + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.RELEASE_TYPES, + }, { name: 'episodeFileQualities', label: () => translate('EpisodeFileQualities'), diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index c6d64ac6d..5921e10b8 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1776,6 +1776,7 @@ "ReleaseSource": "Release Source", "ReleaseTitle": "Release Title", "ReleaseType": "Release Type", + "ReleaseTypes": "Release Types", "Reload": "Reload", "RemotePath": "Remote Path", "RemotePathMappingBadDockerPathHealthCheckMessage": "You are using docker; download client {downloadClientName} places downloads in {path} but this is not a valid {osName} path. Review your remote path mappings and download client settings.", diff --git a/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs index bcca6a4d8..5fdf01519 100644 --- a/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs +++ b/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs @@ -4,6 +4,7 @@ using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; namespace NzbDrone.Core.SeriesStats @@ -22,6 +23,7 @@ public class SeasonStatistics : ResultSet public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public string ReleaseGroupsString { get; set; } + public string ReleaseTypesString { get; set; } public string EpisodeFileQualitiesString { get; set; } public DateTime? NextAiring @@ -113,6 +115,25 @@ public List<string> ReleaseGroups } } + public List<ReleaseType> ReleaseTypes + { + get + { + if (ReleaseTypesString.IsNullOrWhiteSpace()) + { + return []; + } + + return ReleaseTypesString + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(int.Parse) + .Distinct() + .Where(type => Enum.IsDefined(typeof(ReleaseType), type)) + .Select(type => (ReleaseType)type) + .ToList(); + } + } + public List<Quality> EpisodeFileQualities { get diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs index 41f637ee6..1d4d57902 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; namespace NzbDrone.Core.SeriesStats @@ -17,6 +18,7 @@ public class SeriesStatistics : ResultSet public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List<string> ReleaseGroups { get; set; } + public List<ReleaseType> ReleaseTypes { get; set; } public List<Quality> EpisodeFileQualities { get; set; } public List<SeasonStatistics> SeasonStatistics { get; set; } } diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs index c7098117d..bafb7f457 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs @@ -49,6 +49,7 @@ private List<SeasonStatistics> MapResults(List<SeasonStatistics> episodesResult, e.SizeOnDisk = file?.SizeOnDisk ?? 0; e.ReleaseGroupsString = file?.ReleaseGroupsString; + e.ReleaseTypesString = file?.ReleaseTypesString; e.EpisodeFileQualitiesString = file?.EpisodeFileQualitiesString; }); @@ -98,6 +99,7 @@ private SqlBuilder EpisodeFilesBuilder() ""SeasonNumber"", SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, GROUP_CONCAT(""ReleaseGroup"", '|') AS ReleaseGroupsString, + GROUP_CONCAT(""ReleaseType"", '|') AS ReleaseTypesString, GROUP_CONCAT(JSON_EXTRACT(""Quality"", '$.quality'), '|') AS EpisodeFileQualitiesString") .GroupBy<EpisodeFile>(x => x.SeriesId) .GroupBy<EpisodeFile>(x => x.SeasonNumber); @@ -108,6 +110,7 @@ private SqlBuilder EpisodeFilesBuilder() ""SeasonNumber"", SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, string_agg(""ReleaseGroup"", '|') AS ReleaseGroupsString, + string_agg(""ReleaseType""::text, '|') AS ReleaseTypesString, string_agg(""Quality""::json->>'quality', '|') AS EpisodeFileQualitiesString") .GroupBy<EpisodeFile>(x => x.SeriesId) .GroupBy<EpisodeFile>(x => x.SeasonNumber); diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs index 62d9ba9fa..693eab6e1 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs @@ -70,6 +70,7 @@ private SeriesStatistics MapSeriesStatistics(List<SeasonStatistics> seasonStatis MonitoredEpisodeCount = seasonStatistics.Sum(s => s.MonitoredEpisodeCount), SizeOnDisk = seasonStatistics.Sum(s => s.SizeOnDisk), ReleaseGroups = seasonStatistics.SelectMany(s => s.ReleaseGroups).Distinct().ToList(), + ReleaseTypes = seasonStatistics.SelectMany(s => s.ReleaseTypes).Distinct().OrderBy(s => s).ToList(), EpisodeFileQualities = SortQualities(seasonStatistics.SelectMany(s => s.EpisodeFileQualities).Distinct().ToList(), profile) }; diff --git a/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs b/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs index d7cb364f4..1387147a8 100644 --- a/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs +++ b/src/Sonarr.Api.V5/Series/SeasonStatisticsResource.cs @@ -1,3 +1,4 @@ +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.SeriesStats; @@ -13,6 +14,7 @@ public class SeasonStatisticsResource public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List<string>? ReleaseGroups { get; set; } + public List<ReleaseType>? ReleaseTypes { get; set; } public List<Quality>? EpisodeFileQualities { get; set; } public decimal PercentOfEpisodes @@ -43,6 +45,7 @@ public static SeasonStatisticsResource ToResource(this SeasonStatistics model) MonitoredEpisodeCount = model.MonitoredEpisodeCount, SizeOnDisk = model.SizeOnDisk, ReleaseGroups = model.ReleaseGroups, + ReleaseTypes = model.ReleaseTypes, EpisodeFileQualities = model.EpisodeFileQualities }; } diff --git a/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs b/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs index b9f58ff71..ffdca151d 100644 --- a/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs +++ b/src/Sonarr.Api.V5/Series/SeriesStatisticsResource.cs @@ -1,3 +1,4 @@ +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.SeriesStats; @@ -12,6 +13,7 @@ public class SeriesStatisticsResource public int MonitoredEpisodeCount { get; set; } public long SizeOnDisk { get; set; } public List<string>? ReleaseGroups { get; set; } + public List<ReleaseType>? ReleaseTypes { get; set; } public List<Quality>? EpisodeFileQualities { get; set; } public decimal PercentOfEpisodes @@ -41,6 +43,7 @@ public static SeriesStatisticsResource ToResource(this SeriesStatistics model, L MonitoredEpisodeCount = model.MonitoredEpisodeCount, SizeOnDisk = model.SizeOnDisk, ReleaseGroups = model.ReleaseGroups, + ReleaseTypes = model.ReleaseTypes, EpisodeFileQualities = model.EpisodeFileQualities }; } From 1c349778416c9abbc5ca06a8a68353a78d76715c Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:24:55 +0300 Subject: [PATCH 108/110] Produce distinct values in aggregated series stats from Postgres --- src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs index bafb7f457..2af4e57ce 100644 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs +++ b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs @@ -109,9 +109,9 @@ private SqlBuilder EpisodeFilesBuilder() .Select(@"""SeriesId"", ""SeasonNumber"", SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, - string_agg(""ReleaseGroup"", '|') AS ReleaseGroupsString, - string_agg(""ReleaseType""::text, '|') AS ReleaseTypesString, - string_agg(""Quality""::json->>'quality', '|') AS EpisodeFileQualitiesString") + string_agg(DISTINCT ""ReleaseGroup"", '|') AS ReleaseGroupsString, + string_agg(DISTINCT ""ReleaseType""::text, '|') AS ReleaseTypesString, + string_agg(DISTINCT ""Quality""::json->>'quality', '|') AS EpisodeFileQualitiesString") .GroupBy<EpisodeFile>(x => x.SeriesId) .GroupBy<EpisodeFile>(x => x.SeasonNumber); } From 510cbe54e8c761cd5602e5ebeb98a17cf0070ecc Mon Sep 17 00:00:00 2001 From: Bogdan <mynameisbogdan@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:42:48 +0300 Subject: [PATCH 109/110] Prevent duplicated items in state on create --- .../src/AddSeries/AddNewSeries/useAddSeries.ts | 14 ++++++-------- frontend/src/Commands/useCommands.ts | 11 +++++------ frontend/src/Helpers/Hooks/useApiMutation.ts | 14 ++++++++++++++ frontend/src/RootFolder/useRootFolders.ts | 9 +++++---- frontend/src/Settings/useProviderSettings.ts | 17 ++++------------- frontend/src/Tags/useTags.ts | 11 ++++------- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts b/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts index 086c031d9..016421e1f 100644 --- a/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts +++ b/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts @@ -1,7 +1,9 @@ import { useQueryClient } from '@tanstack/react-query'; import AddSeries from 'AddSeries/AddSeries'; import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore'; -import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import useApiMutation, { + addOrUpdateQueryClientItem, +} from 'Helpers/Hooks/useApiMutation'; import useApiQuery from 'Helpers/Hooks/useApiQuery'; import Series from 'Series/Series'; @@ -42,13 +44,9 @@ export const useAddSeries = () => { method: 'POST', mutationOptions: { onSuccess: (newSeries) => { - queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => { - if (!oldSeries) { - return [newSeries]; - } - - return [...oldSeries, newSeries]; - }); + queryClient.setQueryData<Series[]>(['/series'], (oldSeries = []) => + addOrUpdateQueryClientItem(oldSeries, newSeries, 'id') + ); }, }, } diff --git a/frontend/src/Commands/useCommands.ts b/frontend/src/Commands/useCommands.ts index f40579cb8..f4eed0752 100644 --- a/frontend/src/Commands/useCommands.ts +++ b/frontend/src/Commands/useCommands.ts @@ -2,7 +2,9 @@ import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useMemo, useRef } from 'react'; import { showMessage } from 'App/messagesStore'; import Command, { CommandBody, NewCommandBody } from 'Commands/Command'; -import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import useApiMutation, { + addOrUpdateQueryClientItem, +} from 'Helpers/Hooks/useApiMutation'; import useApiQuery from 'Helpers/Hooks/useApiQuery'; import { ERROR, @@ -45,11 +47,8 @@ export const useExecuteCommand = () => { path: '/command', mutationOptions: { onSuccess: (newCommand: Command) => { - queryClient.setQueryData<Command[]>( - ['/command'], - (oldCommands = []) => { - return [...oldCommands, newCommand]; - } + queryClient.setQueryData<Command[]>(['/command'], (oldCommands = []) => + addOrUpdateQueryClientItem(oldCommands, newCommand, 'id') ); }, }, diff --git a/frontend/src/Helpers/Hooks/useApiMutation.ts b/frontend/src/Helpers/Hooks/useApiMutation.ts index 641db3d50..36999380e 100644 --- a/frontend/src/Helpers/Hooks/useApiMutation.ts +++ b/frontend/src/Helpers/Hooks/useApiMutation.ts @@ -1,5 +1,6 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query'; import { useMemo } from 'react'; +import ModelBase from 'App/ModelBase'; import { ValidationFailures } from 'Store/Selectors/selectSettings'; import { ValidationError, @@ -71,3 +72,16 @@ export function getValidationFailures( } ); } + +export function addOrUpdateQueryClientItem< + T extends ModelBase, + K extends keyof T +>(oldData: T[] = [], newItem: T, key: K) { + const existingIndex = oldData.findIndex((item) => item[key] === newItem[key]); + + if (existingIndex === -1) { + return [...oldData, newItem]; + } + + return oldData.map((item) => (item[key] === newItem[key] ? newItem : item)); +} diff --git a/frontend/src/RootFolder/useRootFolders.ts b/frontend/src/RootFolder/useRootFolders.ts index 526dccf36..063c2fac4 100644 --- a/frontend/src/RootFolder/useRootFolders.ts +++ b/frontend/src/RootFolder/useRootFolders.ts @@ -1,6 +1,8 @@ import { useQueryClient } from '@tanstack/react-query'; import ModelBase from 'App/ModelBase'; -import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import useApiMutation, { + addOrUpdateQueryClientItem, +} from 'Helpers/Hooks/useApiMutation'; import useApiQuery from 'Helpers/Hooks/useApiQuery'; export interface UnmappedFolder { @@ -86,9 +88,8 @@ export const useAddRootFolder = () => { onSuccess: (newRootFolder) => { queryClient.setQueryData<RootFolder[]>( ['/rootFolder'], - (oldRootFolders = []) => { - return [...oldRootFolders, newRootFolder]; - } + (oldRootFolders = []) => + addOrUpdateQueryClientItem(oldRootFolders, newRootFolder, 'id') ); }, }, diff --git a/frontend/src/Settings/useProviderSettings.ts b/frontend/src/Settings/useProviderSettings.ts index 3bee87878..dced47a4b 100644 --- a/frontend/src/Settings/useProviderSettings.ts +++ b/frontend/src/Settings/useProviderSettings.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useCallback, useMemo, useRef, useState } from 'react'; import ModelBase from 'App/ModelBase'; import useApiMutation, { + addOrUpdateQueryClientItem, getValidationFailures, } from 'Helpers/Hooks/useApiMutation'; import useApiQuery, { QueryOptions } from 'Helpers/Hooks/useApiQuery'; @@ -121,19 +122,9 @@ export const useSaveProviderSettings = <T extends ModelBase>( }); }, onSuccess: (updatedSettings: T) => { - queryClient.setQueryData<T[]>([path], (oldData = []) => { - const existingIndex = oldData.findIndex( - (item) => item.id === updatedSettings.id - ); - - if (existingIndex === -1) { - return [...oldData, updatedSettings]; - } - - return oldData.map((item) => - item.id === updatedSettings.id ? updatedSettings : item - ); - }); + queryClient.setQueryData<T[]>([path], (oldData = []) => + addOrUpdateQueryClientItem(oldData, updatedSettings, 'id') + ); onSuccess?.(updatedSettings); }, onError, diff --git a/frontend/src/Tags/useTags.ts b/frontend/src/Tags/useTags.ts index 8e4f3da44..dddb9aa4e 100644 --- a/frontend/src/Tags/useTags.ts +++ b/frontend/src/Tags/useTags.ts @@ -2,6 +2,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; import ModelBase from 'App/ModelBase'; import useApiMutation, { + addOrUpdateQueryClientItem, getValidationFailures, } from 'Helpers/Hooks/useApiMutation'; import useApiQuery from 'Helpers/Hooks/useApiQuery'; @@ -56,13 +57,9 @@ export const useAddTag = (onTagCreated?: (tag: Tag) => void) => { setError(null); }, onSuccess: (data) => { - queryClient.setQueryData<Tag[]>(['tag'], (oldData) => { - if (!oldData) { - return oldData; - } - - return [...oldData, data]; - }); + queryClient.setQueryData<Tag[]>(['tag'], (oldData = []) => + addOrUpdateQueryClientItem(oldData, data, 'id') + ); onTagCreated?.(data); }, From bf5d48c76a6a793b7d2adc540bb11d9c51bcc3cb Mon Sep 17 00:00:00 2001 From: Mike Lonergan <MikeTheCanuck@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:15:01 -0700 Subject: [PATCH 110/110] Focus add series search input after clearing Closes #8487 --- .../AddSeries/AddNewSeries/AddNewSeries.tsx | 5 +- frontend/src/Components/Form/TextInput.tsx | 266 +++++++++--------- 2 files changed, 141 insertions(+), 130 deletions(-) diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx index 64b57dc4e..581023798 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import Alert from 'Components/Alert'; import TextInput from 'Components/Form/TextInput'; import Icon from 'Components/Icon'; @@ -22,6 +22,7 @@ function AddNewSeries() { const { term: initialTerm = '' } = useQueryParams<{ term: string }>(); const hasSeries = useHasSeries(); const [term, setTerm] = useState(initialTerm); + const searchInputRef = useRef<HTMLInputElement>(null); const [isFetching, setIsFetching] = useState(false); const query = useDebounce(term, term ? 300 : 0); @@ -36,6 +37,7 @@ function AddNewSeries() { const handleClearSeriesLookupPress = useCallback(() => { setTerm(''); setIsFetching(false); + searchInputRef.current?.focus(); }, []); const { isFetching: isFetchingApi, error, data } = useLookupSeries(query); @@ -57,6 +59,7 @@ function AddNewSeries() { </div> <TextInput + ref={searchInputRef} className={styles.searchInput} name="seriesLookup" value={term} diff --git a/frontend/src/Components/Form/TextInput.tsx b/frontend/src/Components/Form/TextInput.tsx index 9cc2666a3..d7bf800c5 100644 --- a/frontend/src/Components/Form/TextInput.tsx +++ b/frontend/src/Components/Form/TextInput.tsx @@ -2,11 +2,13 @@ import classNames from 'classnames'; import React, { ChangeEvent, FocusEvent, + forwardRef, SyntheticEvent, useCallback, useEffect, useRef, } from 'react'; +import useCombinedRefs from 'Helpers/Hooks/useCombinedRefs'; import { FileInputChanged, InputChanged } from 'typings/inputs'; import styles from './TextInput.css'; @@ -39,147 +41,153 @@ export interface FileInputProps extends CommonTextInputProps { onChange: (change: FileInputChanged) => void; } -function TextInput({ - className = styles.input, - type = 'text', - readOnly = false, - autoFocus = false, - placeholder, - name, - value = '', - hasError, - hasWarning, - hasButton, - step, - min, - max, - onBlur, - onFocus, - onCopy, - onChange, - onSelectionChange, -}: TextInputProps | FileInputProps): JSX.Element { - const inputRef = useRef<HTMLInputElement>(null); - const selectionTimeout = useRef<ReturnType<typeof setTimeout>>(); - const selectionStart = useRef<number | null>(); - const selectionEnd = useRef<number | null>(); - const isMouseTarget = useRef(false); +const TextInput = forwardRef<HTMLInputElement, TextInputProps | FileInputProps>( + ( + { + className = styles.input, + type = 'text', + readOnly = false, + autoFocus = false, + placeholder, + name, + value = '', + hasError, + hasWarning, + hasButton, + step, + min, + max, + onBlur, + onFocus, + onCopy, + onChange, + onSelectionChange, + }: TextInputProps | FileInputProps, + ref + ) => { + const inputRef = useRef<HTMLInputElement>(null); + const combinedRef = useCombinedRefs(ref, inputRef); + const selectionTimeout = useRef<ReturnType<typeof setTimeout>>(); + const selectionStart = useRef<number | null>(); + const selectionEnd = useRef<number | null>(); + const isMouseTarget = useRef(false); - const selectionChanged = useCallback(() => { - if (selectionTimeout.current) { - clearTimeout(selectionTimeout.current); - } - - selectionTimeout.current = setTimeout(() => { - if (!inputRef.current) { - return; + const selectionChanged = useCallback(() => { + if (selectionTimeout.current) { + clearTimeout(selectionTimeout.current); } - const start = inputRef.current.selectionStart; - const end = inputRef.current.selectionEnd; + selectionTimeout.current = setTimeout(() => { + if (!inputRef.current) { + return; + } - const selectionChanged = - selectionStart.current !== start || selectionEnd.current !== end; + const start = inputRef.current.selectionStart; + const end = inputRef.current.selectionEnd; - selectionStart.current = start; - selectionEnd.current = end; + const selectionChanged = + selectionStart.current !== start || selectionEnd.current !== end; - if (selectionChanged) { - onSelectionChange?.(start, end); + selectionStart.current = start; + selectionEnd.current = end; + + if (selectionChanged) { + onSelectionChange?.(start, end); + } + }, 10); + }, [onSelectionChange]); + + const handleChange = useCallback( + (event: ChangeEvent<HTMLInputElement>) => { + onChange({ + name, + value: event.target.value, + files: type === 'file' ? event.target.files : undefined, + }); + }, + [name, type, onChange] + ); + + const handleFocus = useCallback( + (event: FocusEvent<HTMLInputElement, Element>) => { + onFocus?.(event); + + selectionChanged(); + }, + [selectionChanged, onFocus] + ); + + const handleKeyUp = useCallback(() => { + selectionChanged(); + }, [selectionChanged]); + + const handleMouseDown = useCallback(() => { + isMouseTarget.current = true; + }, []); + + const handleMouseUp = useCallback(() => { + selectionChanged(); + }, [selectionChanged]); + + const handleWheel = useCallback(() => { + if (type === 'number') { + inputRef.current?.blur(); } - }, 10); - }, [onSelectionChange]); + }, [type]); - const handleChange = useCallback( - (event: ChangeEvent<HTMLInputElement>) => { - onChange({ - name, - value: event.target.value, - files: type === 'file' ? event.target.files : undefined, - }); - }, - [name, type, onChange] - ); + const handleDocumentMouseUp = useCallback(() => { + if (isMouseTarget.current) { + selectionChanged(); + } - const handleFocus = useCallback( - (event: FocusEvent<HTMLInputElement, Element>) => { - onFocus?.(event); + isMouseTarget.current = false; + }, [selectionChanged]); - selectionChanged(); - }, - [selectionChanged, onFocus] - ); + useEffect(() => { + window.addEventListener('mouseup', handleDocumentMouseUp); - const handleKeyUp = useCallback(() => { - selectionChanged(); - }, [selectionChanged]); + return () => { + window.removeEventListener('mouseup', handleDocumentMouseUp); + }; + }, [handleDocumentMouseUp]); - const handleMouseDown = useCallback(() => { - isMouseTarget.current = true; - }, []); + useEffect(() => { + return () => { + clearTimeout(selectionTimeout.current); + }; + }, []); - const handleMouseUp = useCallback(() => { - selectionChanged(); - }, [selectionChanged]); - - const handleWheel = useCallback(() => { - if (type === 'number') { - inputRef.current?.blur(); - } - }, [type]); - - const handleDocumentMouseUp = useCallback(() => { - if (isMouseTarget.current) { - selectionChanged(); - } - - isMouseTarget.current = false; - }, [selectionChanged]); - - useEffect(() => { - window.addEventListener('mouseup', handleDocumentMouseUp); - - return () => { - window.removeEventListener('mouseup', handleDocumentMouseUp); - }; - }, [handleDocumentMouseUp]); - - useEffect(() => { - return () => { - clearTimeout(selectionTimeout.current); - }; - }, []); - - return ( - <input - ref={inputRef} - type={type} - readOnly={readOnly} - autoFocus={autoFocus} - placeholder={placeholder} - className={classNames( - className, - readOnly && styles.readOnly, - hasError && styles.hasError, - hasWarning && styles.hasWarning, - hasButton && styles.hasButton - )} - name={name} - value={value} - step={step} - min={min} - max={max} - onChange={handleChange} - onFocus={handleFocus} - onBlur={onBlur} - onCopy={onCopy} - onCut={onCopy} - onKeyUp={handleKeyUp} - onMouseDown={handleMouseDown} - onMouseUp={handleMouseUp} - onWheel={handleWheel} - /> - ); -} + return ( + <input + ref={combinedRef} + type={type} + readOnly={readOnly} + autoFocus={autoFocus} + placeholder={placeholder} + className={classNames( + className, + readOnly && styles.readOnly, + hasError && styles.hasError, + hasWarning && styles.hasWarning, + hasButton && styles.hasButton + )} + name={name} + value={value} + step={step} + min={min} + max={max} + onChange={handleChange} + onFocus={handleFocus} + onBlur={onBlur} + onCopy={onCopy} + onCut={onCopy} + onKeyUp={handleKeyUp} + onMouseDown={handleMouseDown} + onMouseUp={handleMouseUp} + onWheel={handleWheel} + /> + ); + } +); export default TextInput;