From eda676f9a29565e1ed002d048dd60685fb8f5f87 Mon Sep 17 00:00:00 2001 From: Weblate Date: Sat, 28 Feb 2026 14:26:18 +0000 Subject: [PATCH 01/95] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: Deleted User Co-authored-by: Havok Dan Co-authored-by: Jonas Co-authored-by: Weblate Co-authored-by: fordas Co-authored-by: jeff20001204 Co-authored-by: lalafei524 Co-authored-by: ugyes 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 Date: Mon, 23 Feb 2026 00:25:52 +0000 Subject: [PATCH 02/95] 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 Date: Sun, 1 Mar 2026 18:02:13 +0100 Subject: [PATCH 03/95] 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 + { + 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.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.Spanish }, + Episodes = new List { new Episode() }, + Path = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.Spanish.XviD-OSiTV.avi" + }; + + Mocker.GetMock() + .Setup(s => s.ParseCustomFormat(It.IsAny(), It.Is(x => x.Contains("English")))) + .Returns([_englishCustomFormat]); + + Mocker.GetMock() + .Setup(s => s.ParseCustomFormat(It.IsAny(), It.Is(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() + .Setup(s => s.BuildFileName(It.IsAny>(), It.IsAny(), It.IsAny(), "", 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() + .Setup(s => s.BuildFileName(It.IsAny>(), It.IsAny(), It.IsAny(), "", 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.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() + .Setup(s => s.DownloadPropersAndRepacks) + .Returns(ProperDownloadTypes.DoNotPrefer); + + Mocker.GetMock() + .Setup(s => s.ParseCustomFormat(episodeFile)) + .Returns(episodeFileCustomFormats); + + _localEpisode.Quality = new QualityModel(Quality.Bluray1080p); + _localEpisode.CustomFormats = Builder.CreateListOfSize(1).Build().ToList(); + _localEpisode.CustomFormatScore = 20; + _localEpisode.OriginalFileNameCustomFormats = Builder.CreateListOfSize(1).Build().ToList(); + _localEpisode.OriginalFileNameCustomFormatScore = 60; + + _localEpisode.Episodes = Builder.CreateListOfSize(1) + .All() + .With(e => e.EpisodeFileId = 1) + .With(e => e.EpisodeFile = new LazyLoaded(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 ParseCustomFormat(EpisodeFile episodeFile); List ParseCustomFormat(Blocklist blocklist, Series series); List ParseCustomFormat(EpisodeHistory history, Series series); - List ParseCustomFormat(LocalEpisode localEpisode); + List ParseCustomFormat(LocalEpisode localEpisode, string fileName); } public class CustomFormatCalculationService : ICustomFormatCalculationService @@ -114,12 +114,12 @@ public List ParseCustomFormat(EpisodeHistory history, Series serie return ParseCustomFormat(input); } - public List ParseCustomFormat(LocalEpisode localEpisode) + public List 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 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 Import(List 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 Import(List 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 Import(List 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 Import(List 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 specifications, @@ -39,7 +38,7 @@ public ImportDecisionMaker(IEnumerable 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 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 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, 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(); - Languages = new List(); - CustomFormats = new List(); - } - 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 Episodes { get; set; } + public List Episodes { get; set; } = new(); public List OldFiles { get; set; } public QualityModel Quality { get; set; } - public List Languages { get; set; } + public List 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 CustomFormats { get; set; } + public List CustomFormats { get; set; } = new(); public int CustomFormatScore { get; set; } + public List 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 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 Date: Mon, 16 Feb 2026 13:37:59 -0800 Subject: [PATCH 04/95] 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 configValues) _cache.Clear(); var allWithDefaults = GetConfigDictionary(); + var hasUpdated = false; foreach (var configValue in configValues) { @@ -155,11 +156,15 @@ public void SaveConfigDictionary(Dictionary 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 AllWithDefaults() public void SaveConfigDictionary(Dictionary configValues) { var allWithDefaults = AllWithDefaults(); + var hasUpdated = false; foreach (var configValue in configValues) { @@ -68,11 +69,15 @@ public void SaveConfigDictionary(Dictionary 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 { - 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 : RestController 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 SaveConfig([FromBody] TResource resource) + public virtual ActionResult 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 { - 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 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 +public class UpdateSettingsController : SettingsController { - 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 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 Date: Mon, 16 Feb 2026 14:24:08 -0800 Subject: [PATCH 05/95] 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 IsValidCertificate(this IRuleBuilder 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 +{ + 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 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 Date: Mon, 16 Feb 2026 14:24:54 -0800 Subject: [PATCH 06/95] 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 & { @@ -52,10 +51,6 @@ export interface DownloadClientOptionsAppState extends AppSectionItemState, AppSectionSaveState {} -export interface GeneralAppState - extends AppSectionItemState, - AppSectionSaveState {} - export interface ImportListAppState extends AppSectionState, 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 ( @@ -91,7 +73,7 @@ export default function AuthenticationRequiredModalContent() { {translate('AuthenticationRequiredWarning')} - {isPopulated && !error ? ( + {isFetched && !error ? (
{translate('AuthenticationMethod')} @@ -177,7 +159,7 @@ export default function AuthenticationRequiredModalContent() {
) : null} - {!isPopulated && !error ? : null} + {!isFetched && !error ? : null} 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['analyticsEnabled']; + analyticsEnabled: PendingSection['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['backupFolder']; - backupInterval: PendingSection['backupInterval']; - backupRetention: PendingSection['backupRetention']; + backupFolder: PendingSection['backupFolder']; + backupInterval: PendingSection['backupInterval']; + backupRetention: PendingSection['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() { /> - {isFetching && !isPopulated ? : null} + {isFetching && !isFetched ? : null} {!isFetching && error ? ( @@ -140,7 +117,7 @@ function GeneralSettings() { ) : null} - {hasSettings && isPopulated && !error ? ( + {settings && isFetched && !error ? (
['bindAddress']; - port: PendingSection['port']; - urlBase: PendingSection['urlBase']; - instanceName: PendingSection['instanceName']; - applicationUrl: PendingSection['applicationUrl']; - enableSsl: PendingSection['enableSsl']; - sslPort: PendingSection['sslPort']; - sslKeyPath: PendingSection['sslKeyPath']; - sslCertPath: PendingSection['sslCertPath']; - sslCertPassword: PendingSection['sslCertPassword']; - launchBrowser: PendingSection['launchBrowser']; + bindAddress: PendingSection['bindAddress']; + port: PendingSection['port']; + urlBase: PendingSection['urlBase']; + instanceName: PendingSection['instanceName']; + applicationUrl: PendingSection['applicationUrl']; + enableSsl: PendingSection['enableSsl']; + sslPort: PendingSection['sslPort']; + sslKeyPath: PendingSection['sslKeyPath']; + sslCertPath: PendingSection['sslCertPath']; + sslCertPassword: PendingSection['sslCertPassword']; + launchBrowser: PendingSection['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[] = [ { @@ -33,8 +33,8 @@ const logLevelOptions: EnhancedSelectInputValue[] = [ ]; interface LoggingSettingsProps { - logLevel: PendingSection['logLevel']; - logSizeLimit: PendingSection['logSizeLimit']; + logLevel: PendingSection['logLevel']; + logSizeLimit: PendingSection['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['proxyEnabled']; - proxyType: PendingSection['proxyType']; - proxyHostname: PendingSection['proxyHostname']; - proxyPort: PendingSection['proxyPort']; - proxyUsername: PendingSection['proxyUsername']; - proxyPassword: PendingSection['proxyPassword']; - proxyBypassFilter: PendingSection['proxyBypassFilter']; - proxyBypassLocalAddresses: PendingSection['proxyBypassLocalAddresses']; + proxyEnabled: PendingSection['proxyEnabled']; + proxyType: PendingSection['proxyType']; + proxyHostname: PendingSection['proxyHostname']; + proxyPort: PendingSection['proxyPort']; + proxyUsername: PendingSection['proxyUsername']; + proxyPassword: PendingSection['proxyPassword']; + proxyBypassFilter: PendingSection['proxyBypassFilter']; + proxyBypassLocalAddresses: PendingSection['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[] = [ { @@ -85,13 +85,13 @@ const certificateValidationOptions: EnhancedSelectInputValue[] = [ ]; interface SecuritySettingsProps { - authenticationMethod: PendingSection['authenticationMethod']; - authenticationRequired: PendingSection['authenticationRequired']; - username: PendingSection['username']; - password: PendingSection['password']; - passwordConfirmation: PendingSection['passwordConfirmation']; - apiKey: PendingSection['apiKey']; - certificateValidation: PendingSection['certificateValidation']; + authenticationMethod: PendingSection['authenticationMethod']; + authenticationRequired: PendingSection['authenticationRequired']; + username: PendingSection['username']; + password: PendingSection['password']; + passwordConfirmation: PendingSection['passwordConfirmation']; + apiKey: PendingSection['apiKey']; + certificateValidation: PendingSection['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['branch']; - updateAutomatically: PendingSection['updateAutomatically']; - updateMechanism: PendingSection['updateMechanism']; - updateScriptPath: PendingSection['updateScriptPath']; + branch: PendingSection['branch']; + updateAutomatically: PendingSection['updateAutomatically']; + updateMechanism: PendingSection['updateMechanism']; + updateScriptPath: PendingSection['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(PATH); +}; + +export const useManageGeneralSettings = () => { + return useManageSettings(PATH); +}; + +export const useSaveGeneralSettings = () => { + return useSaveSettings(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 ( From bcceb22512b2a94bf8456425a80279d0a1aa45aa Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 16 Feb 2026 14:53:14 -0800 Subject: [PATCH 07/95] 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 +{ + 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 +{ + public override List UpdateModel(IndexerBulkResource resource, List 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 +{ + 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 GetAll() + { + return Enum.GetValues(typeof(IndexerFlags)).Cast().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 +{ + 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 +{ + 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 Date: Mon, 16 Feb 2026 15:48:47 -0800 Subject: [PATCH 08/95] 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, AppSectionSaveState {} -export interface IndexerAppState - extends AppSectionState, - AppSectionDeleteState, - AppSectionSaveState, - AppSectionSchemaState> { - isTestingAll: boolean; -} - export interface CustomFormatAppState extends AppSectionState, 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 = Omit< function IndexerFilterBuilderRowValue( props: IndexerFilterBuilderRowValueProps ) { - 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 ; } 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 ( ( } ); }; + +const updateQueryClientItem = ( + queryClient: ReturnType, + 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 = ( + queryClient: ReturnType, + 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 ( @@ -70,7 +65,7 @@ function IndexerSettings() { 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 (
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 ( {translate('AddIndexer')} @@ -66,7 +58,7 @@ function AddIndexerModalContent({ {translate('AddIndexerError')} ) : null} - {isSchemaPopulated && !schemaError ? ( + {isSchemaFetched && !schemaError ? (
{translate('SupportedIndexers')}
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 { +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 ( 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 ( - - + + ); } 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('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) => { + 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({ - {isFetching ? : null} + + + {translate('Name')} - {!isFetching && error ? ( - {translate('AddIndexerError')} - ) : null} + + - {!isFetching && !error ? ( - - - {translate('Name')} + + {translate('EnableRss')} - + + + + {translate('EnableAutomaticSearch')} + + + + + + {translate('EnableInteractiveSearch')} + + + + + {fields?.map((field) => { + return ( + - + ); + })} - - {translate('EnableRss')} + + {translate('IndexerPriority')} - - + + - - {translate('EnableAutomaticSearch')} + + {translate('MaximumSingleEpisodeAge')} - - + + - - {translate('EnableInteractiveSearch')} + + {translate('DownloadClient')} - - + + - {fields?.map((field) => { - return ( - - ); - })} + + {translate('Tags')} - - {translate('IndexerPriority')} - - - - - - {translate('MaximumSingleEpisodeAge')} - - - - - - {translate('DownloadClient')} - - - - - - {translate('Tags')} - - - - - ) : null} + + + 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( - 'settings.indexers', - sortByProp('name') - ) - ); + const { isFetching, isFetched, data, error } = useSortedIndexers(); const [isAddIndexerModalOpen, setIsAddIndexerModalOpen] = useState(false); const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false); + const [cloneIndexerId, setCloneIndexerId] = useState(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 (
- {items.map((item) => { + {data.map((item) => { return ( 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(); + } = useSelect(); - 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 ?
{errorMessage}
: null} - {isPopulated && !error && !items.length ? ( + {isFetched && !error && !data.length ? ( {translate('NoIndexersFound')} ) : null} - {isPopulated && !!items.length && !isFetching && !isFetching ? ( + {isFetched && !!data.length && !isFetching && !isFetching ? ( - {items.map((item) => { + {data.map((item) => { return ( 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(); + const { toggleSelected, useIsSelected } = useSelect(); 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[] = [ + { + 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([]); @@ -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[] = [ - { - key: 'add', - get value() { - return translate('Add'); - }, - }, - { - key: 'remove', - get value() { - return translate('Remove'); - }, - }, - { - key: 'replace', - get value() { - return translate('Replace'); - }, - }, - ]; - return ( {translate('Tags')} 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({ + path: PATH, + }); +}; + +export const useManageIndexer = ( + id: number | undefined, + cloneId: number | undefined, + selectedSchema?: SelectedSchema +) => { + const schema = useSelectedSchema(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( + id, + defaultProvider, + PATH + ); + + return manage; +}; + +export const useDeleteIndexer = (id: number) => { + const result = useDeleteProvider(id, PATH); + + return { + ...result, + deleteIndexer: result.deleteProvider, + }; +}; + +export const useIndexerSchema = (enabled: boolean = true) => { + return useProviderSchema(PATH, enabled); +}; + +export const useTestIndexer = ( + onSuccess?: () => void, + onError?: (error: ApiError) => void +) => { + const { mutate, isPending, error } = useApiMutation({ + 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({ + 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([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([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( + '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 ( - + ); 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) => 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 (
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 +interface BaseManageProviderSettings extends Omit>, 'settings'> { item: PendingSection; updateValue: (key: K, value: T[K]) => void; @@ -19,9 +19,17 @@ interface ManageProviderSettings saveError: ApiError | null; testProvider: () => void; isTesting: boolean; - updateFieldValue?: (fieldProperties: Record) => void; } +interface ManageProviderSettingsWithFields + extends BaseManageProviderSettings { + updateFieldValue: (fieldProperties: Record) => void; +} + +type ManageProviderSettings = T extends Provider + ? ManageProviderSettingsWithFields + : BaseManageProviderSettings; + const isProviderWithFields = (provider: unknown): provider is Provider => { return ( typeof provider === 'object' && @@ -296,10 +304,10 @@ export const useManageProviderSettings = ( return { ...baseReturn, updateFieldValue, - }; + } as ManageProviderSettings; } - return baseReturn; + return baseReturn as ManageProviderSettings; }; export const useDeleteProvider = ( 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 (
Date: Mon, 16 Feb 2026 16:29:38 -0800 Subject: [PATCH 09/95] 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( + 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 = ( 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 Date: Sun, 1 Mar 2026 18:03:10 +0100 Subject: [PATCH 10/95] 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 Date: Sat, 21 Feb 2026 18:34:55 +0200 Subject: [PATCH 11/95] 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 Date: Sat, 21 Feb 2026 12:34:12 -0800 Subject: [PATCH 12/95] 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() .Verify(v => v.FindByTvdbId(It.IsAny()), 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() + .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() + .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 Date: Sun, 22 Feb 2026 20:27:21 -0800 Subject: [PATCH 13/95] 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 = { + 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({ 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) { ) : null} + {originalCountryName ? ( + + ) : null} + {network ? (
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 50/95] 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 51/95] 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 52/95] 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 53/95] 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 54/95] 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 55/95] 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 56/95] 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 57/95] 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 58/95] 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 59/95] 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 60/95] 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 61/95] 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 62/95] 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 63/95] 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 64/95] 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 65/95] 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 66/95] 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 67/95] 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 68/95] 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 69/95] 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 70/95] 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 71/95] 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 72/95] 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 73/95] 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 74/95] 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 75/95] 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 76/95] 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 77/95] 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 78/95] 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 79/95] 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 80/95] 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 81/95] 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 82/95] 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 83/95] 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 84/95] 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 85/95] 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 86/95] 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 87/95] 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 88/95] 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 89/95] 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 90/95] 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 91/95] 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 92/95] 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 93/95] 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 94/95] 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 95/95] 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;