From e4f3888694706194329398afb04ff202bbbb2d7e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:05:07 +0200 Subject: [PATCH] fix(seerr): seed advanced requests from the Sonarr anime defaults and expose tags Requesting an anime series with REQUEST_ADVANCED routed it to the standard Sonarr profile and root folder. The sheet seeded its pickers from activeProfileId/activeDirectory only, then posted them as explicit overrides, which beat the anime defaults Seerr would otherwise apply at approval. A user without the permission sent no overrides and was routed correctly. The service DTOs now carry activeAnimeProfileId, activeAnimeDirectory, activeAnimeLanguageProfileId, activeTags, activeAnimeTags and the instance's tag list. The sheet detects the TMDB anime keyword on the TV details it already fetches and seeds each picker the way Seerr's web requester does: the anime value when the series is an anime and the instance configures one, else the standard value. Options carry the "(Default)" marker and an anime series shows the same note as the web UI. Tags are editable: an inline checklist under the advanced pickers, seeded from the instance's (anime) default tags and posted as `tags`. It is inline rather than a nested sheet page because the host builds only the top page, so a push would dispose the sheet and drop the season and picker selections on the way back. close #2215 --- lib/i18n/az.i18n.json | 4 + lib/i18n/bg.i18n.json | 4 + lib/i18n/da.i18n.json | 4 + lib/i18n/de.i18n.json | 4 + lib/i18n/en.i18n.json | 4 + lib/i18n/es.i18n.json | 4 + lib/i18n/fr.i18n.json | 4 + lib/i18n/hu.i18n.json | 4 + lib/i18n/it.i18n.json | 4 + lib/i18n/ja.i18n.json | 4 + lib/i18n/kk.i18n.json | 4 + lib/i18n/ko.i18n.json | 4 + lib/i18n/nb.i18n.json | 4 + lib/i18n/nl.i18n.json | 4 + lib/i18n/pl.i18n.json | 4 + lib/i18n/pt.i18n.json | 4 + lib/i18n/ru.i18n.json | 4 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 16 ++ lib/i18n/sv.i18n.json | 4 + lib/i18n/tr.i18n.json | 4 + lib/i18n/uz.i18n.json | 4 + lib/i18n/zh-Hant.i18n.json | 4 + lib/i18n/zh.i18n.json | 4 + lib/models/seerr/seerr_request.dart | 6 + lib/models/seerr/seerr_service.dart | 34 +++- lib/models/seerr/seerr_service.g.dart | 19 +++ lib/services/seerr/seerr_constants.dart | 5 + lib/widgets/seerr_request_sheet.dart | 132 ++++++++++++++-- test/services/seerr/seerr_client_test.dart | 52 ++++++ test/widgets/seerr_request_sheet_test.dart | 176 ++++++++++++++++++++- 31 files changed, 511 insertions(+), 19 deletions(-) diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index c7ef3ccab..1d8df1206 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Keyfiyyət profili", "rootFolder": "Kök qovluq", "languageProfile": "Dil profili", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Sorğu göndərildi", "requestFailed": "Sorğu uğursuz oldu: ${error}", "requestsLoadFailed": "Seçimlər yüklənə bilmədi", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 6968f7acd..e2b02cc5a 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Профил за качество", "rootFolder": "Основна папка", "languageProfile": "Езиков профил", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Заявката е изпратена", "requestFailed": "Заявката се провали: ${error}", "requestsLoadFailed": "Неуспешно зареждане на опциите за заявка", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index ca4fe3997..3ac133360 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Kvalitetsprofil", "rootFolder": "Rodmappe", "languageProfile": "Sprogprofil", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Anmodning sendt", "requestFailed": "Anmodning mislykkedes: ${error}", "requestsLoadFailed": "Kunne ikke indlæse anmodningsmuligheder", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 9ea3f79a7..e3651935a 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Qualitätsprofil", "rootFolder": "Stammordner", "languageProfile": "Sprachprofil", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Anfrage gesendet", "requestFailed": "Anfrage fehlgeschlagen: ${error}", "requestsLoadFailed": "Anfrageoptionen konnten nicht geladen werden", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index e3acbc5ed..c10d837b3 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Quality profile", "rootFolder": "Root folder", "languageProfile": "Language profile", + "tags": "Tags", + "noTags": "No tags", + "defaultOption": "${name} (Default)", + "animeNote": "This series is an anime.", "requestSubmitted": "Request submitted", "requestFailed": "Request failed: ${error}", "requestsLoadFailed": "Couldn't load request options", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 71317cc75..429d85822 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Perfil de calidad", "rootFolder": "Carpeta raíz", "languageProfile": "Perfil de idioma", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Solicitud enviada", "requestFailed": "La solicitud falló: ${error}", "requestsLoadFailed": "No se pudieron cargar las opciones de solicitud", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index a1b151315..28ee8f822 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Profil de qualité", "rootFolder": "Dossier racine", "languageProfile": "Profil de langue", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Demande envoyée", "requestFailed": "Échec de la demande : ${error}", "requestsLoadFailed": "Impossible de charger les options de demande", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 1bd42746a..2258dccd6 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Minőségi profil", "rootFolder": "Gyökérmappa", "languageProfile": "Nyelvi profil", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Igénylés elküldve", "requestFailed": "Az igénylés nem sikerült: ${error}", "requestsLoadFailed": "Nem sikerült betölteni az igénylési opciókat", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index f4206335b..31f85129c 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Profilo di qualità", "rootFolder": "Cartella radice", "languageProfile": "Profilo della lingua", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Richiesta inviata", "requestFailed": "Richiesta non riuscita: ${error}", "requestsLoadFailed": "Impossibile caricare le opzioni di richiesta", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index ec29b3783..71cefa40e 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -2068,6 +2068,10 @@ "qualityProfile": "画質プロファイル", "rootFolder": "ルートフォルダ", "languageProfile": "言語プロファイル", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "リクエストを送信しました", "requestFailed": "リクエストに失敗しました: ${error}", "requestsLoadFailed": "リクエストオプションを読み込めませんでした", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index ca1fcc016..57e7967e1 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Сапа профилі", "rootFolder": "Түпкі қапшық", "languageProfile": "Тіл профилі", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Сұрау жіберілді", "requestFailed": "Сұрау қатесі: ${error}", "requestsLoadFailed": "Параметрлерді жүктеу мүмкін болмады", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 85eb34ed1..885e60818 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -2068,6 +2068,10 @@ "qualityProfile": "화질 프로파일", "rootFolder": "루트 폴더", "languageProfile": "언어 프로파일", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "요청을 제출했습니다", "requestFailed": "요청 실패: ${error}", "requestsLoadFailed": "요청 옵션을 불러올 수 없습니다", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 79f06bd5f..d7c1c4bb2 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Kvalitetsprofil", "rootFolder": "Rotmappe", "languageProfile": "Språkprofil", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Forespørsel sendt", "requestFailed": "Forespørsel mislyktes: ${error}", "requestsLoadFailed": "Kunne ikke laste forespørselsalternativer", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 86a6c1a39..5ebd83f41 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Kwaliteitsprofiel", "rootFolder": "Hoofdmap", "languageProfile": "Taalprofiel", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Aanvraag verzonden", "requestFailed": "Aanvraag mislukt: ${error}", "requestsLoadFailed": "Aanvraagopties konden niet worden geladen", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index c91786584..57ebd5c0e 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -2098,6 +2098,10 @@ "qualityProfile": "Profil jakości", "rootFolder": "Folder główny", "languageProfile": "Profil językowy", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Zamówienie wysłane", "requestFailed": "Zamówienie nie powiodło się: ${error}", "requestsLoadFailed": "Nie udało się wczytać opcji zamówienia", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index cb74621fd..3389f58fa 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Perfil de qualidade", "rootFolder": "Pasta raiz", "languageProfile": "Perfil de idioma", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Solicitação enviada", "requestFailed": "Falha na solicitação: ${error}", "requestsLoadFailed": "Não foi possível carregar as opções de solicitação", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 66c6a0916..8a7041423 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -2098,6 +2098,10 @@ "qualityProfile": "Профиль качества", "rootFolder": "Корневая папка", "languageProfile": "Языковой профиль", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Запрос отправлен", "requestFailed": "Ошибка запроса: ${error}", "requestsLoadFailed": "Не удалось загрузить параметры запроса", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index aeecd1481..4ff0c45d3 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 43751 (1988 per locale) +/// Strings: 43755 (1988 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 02a258713..eac493c77 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -5225,6 +5225,18 @@ class Translations$seerr$en { /// en: 'Language profile' String get languageProfile => 'Language profile'; + /// en: 'Tags' + String get tags => 'Tags'; + + /// en: 'No tags' + String get noTags => 'No tags'; + + /// en: '${name} (Default)' + String defaultOption({required Object name}) => '${name} (Default)'; + + /// en: 'This series is an anime.' + String get animeNote => 'This series is an anime.'; + /// en: 'Request submitted' String get requestSubmitted => 'Request submitted'; @@ -8852,6 +8864,10 @@ extension on Translations { 'seerr.qualityProfile' => 'Quality profile', 'seerr.rootFolder' => 'Root folder', 'seerr.languageProfile' => 'Language profile', + 'seerr.tags' => 'Tags', + 'seerr.noTags' => 'No tags', + 'seerr.defaultOption' => ({required Object name}) => '${name} (Default)', + 'seerr.animeNote' => 'This series is an anime.', 'seerr.requestSubmitted' => 'Request submitted', 'seerr.requestFailed' => ({required Object error}) => 'Request failed: ${error}', 'seerr.requestsLoadFailed' => 'Couldn\'t load request options', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index e4cd5b622..f2048f6da 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Kvalitetsprofil", "rootFolder": "Rotmapp", "languageProfile": "Språkprofil", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Begäran skickad", "requestFailed": "Begäran kunde inte genomföras: ${error}", "requestsLoadFailed": "Det gick inte att läsa in alternativ för begäran", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index 0feb9938c..a08735494 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Kalite profili", "rootFolder": "Kök klasör", "languageProfile": "Dil profili", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "İstek gönderildi", "requestFailed": "İstek başarısız oldu: ${error}", "requestsLoadFailed": "İstek seçenekleri yüklenemedi", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index fa1cc7bde..822d37bc9 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -2078,6 +2078,10 @@ "qualityProfile": "Sifat profili", "rootFolder": "Asosiy jild", "languageProfile": "Til profili", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "Soʻrov yuborildi", "requestFailed": "Soʻrov xatoligi: ${error}", "requestsLoadFailed": "Parametrlarni yuklab boʻlmadi", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 5fd528632..08bea2c45 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -2068,6 +2068,10 @@ "qualityProfile": "畫質設定檔(Quality Profile)", "rootFolder": "根目錄資料夾", "languageProfile": "語言設定檔(Language Profile)", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "請求已送出", "requestFailed": "請求失敗:${error}", "requestsLoadFailed": "無法載入請求選項", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 5fb5847d1..c6dafae59 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -2068,6 +2068,10 @@ "qualityProfile": "画质配置", "rootFolder": "根目录", "languageProfile": "语言配置", + "tags": "", + "noTags": "", + "defaultOption": "", + "animeNote": "", "requestSubmitted": "请求已提交", "requestFailed": "请求失败:${error}", "requestsLoadFailed": "无法加载请求选项", diff --git a/lib/models/seerr/seerr_request.dart b/lib/models/seerr/seerr_request.dart index 7d84c3257..68227cb15 100644 --- a/lib/models/seerr/seerr_request.dart +++ b/lib/models/seerr/seerr_request.dart @@ -67,6 +67,10 @@ class SeerrRequestPayload { final String? rootFolder; final int? languageProfileId; + /// Arr tag ids. An empty list is a real override (no tags); null leaves + /// the instance defaults in place. + final List? tags; + const SeerrRequestPayload({ required this.mediaType, required this.mediaId, @@ -76,6 +80,7 @@ class SeerrRequestPayload { this.profileId, this.rootFolder, this.languageProfileId, + this.tags, }); Map toJson() => { @@ -87,5 +92,6 @@ class SeerrRequestPayload { if (profileId != null) 'profileId': profileId, if (rootFolder != null) 'rootFolder': rootFolder, if (languageProfileId != null) 'languageProfileId': languageProfileId, + if (tags != null) 'tags': tags, }; } diff --git a/lib/models/seerr/seerr_service.dart b/lib/models/seerr/seerr_service.dart index 86594c150..967de699a 100644 --- a/lib/models/seerr/seerr_service.dart +++ b/lib/models/seerr/seerr_service.dart @@ -15,6 +15,17 @@ class SeerrServiceInstance { /// Sonarr only. final int? activeLanguageProfileId; + /// Sonarr only: the defaults Seerr routes anime series to instead of the + /// standard ones. Each is absent when the instance has no anime override. + final String? activeAnimeDirectory; + final int? activeAnimeProfileId; + final int? activeAnimeLanguageProfileId; + + /// Tag ids applied by default. Only the detail endpoint reports the real + /// values; the list endpoint reports `[]` for `activeTags` on Sonarr. + final List? activeTags; + final List? activeAnimeTags; + const SeerrServiceInstance({ required this.id, this.name, @@ -23,13 +34,18 @@ class SeerrServiceInstance { this.activeDirectory, this.activeProfileId, this.activeLanguageProfileId, + this.activeAnimeDirectory, + this.activeAnimeProfileId, + this.activeAnimeLanguageProfileId, + this.activeTags, + this.activeAnimeTags, }); factory SeerrServiceInstance.fromJson(Map json) => _$SeerrServiceInstanceFromJson(json); } -/// Quality profile / root folder / language profile options of one instance -/// (`GET /service/radarr|sonarr/{id}`). +/// Quality profile / root folder / language profile / tag options of one +/// instance (`GET /service/radarr|sonarr/{id}`). @JsonSerializable(createToJson: false) class SeerrServiceDetail { final SeerrServiceInstance? server; @@ -38,8 +54,9 @@ class SeerrServiceDetail { /// Sonarr v3 only; absent on Radarr and newer Sonarr. final List? languageProfiles; + final List? tags; - const SeerrServiceDetail({this.server, this.profiles, this.rootFolders, this.languageProfiles}); + const SeerrServiceDetail({this.server, this.profiles, this.rootFolders, this.languageProfiles, this.tags}); factory SeerrServiceDetail.fromJson(Map json) => _$SeerrServiceDetailFromJson(json); } @@ -64,3 +81,14 @@ class SeerrRootFolder { factory SeerrRootFolder.fromJson(Map json) => _$SeerrRootFolderFromJson(json); } + +/// Arr tag option `{id, label}`. +@JsonSerializable(createToJson: false) +class SeerrServiceTag { + final int id; + final String? label; + + const SeerrServiceTag({required this.id, this.label}); + + factory SeerrServiceTag.fromJson(Map json) => _$SeerrServiceTagFromJson(json); +} diff --git a/lib/models/seerr/seerr_service.g.dart b/lib/models/seerr/seerr_service.g.dart index 6a9a2ab3b..5a8eba737 100644 --- a/lib/models/seerr/seerr_service.g.dart +++ b/lib/models/seerr/seerr_service.g.dart @@ -16,6 +16,16 @@ SeerrServiceInstance _$SeerrServiceInstanceFromJson( activeDirectory: json['activeDirectory'] as String?, activeProfileId: (json['activeProfileId'] as num?)?.toInt(), activeLanguageProfileId: (json['activeLanguageProfileId'] as num?)?.toInt(), + activeAnimeDirectory: json['activeAnimeDirectory'] as String?, + activeAnimeProfileId: (json['activeAnimeProfileId'] as num?)?.toInt(), + activeAnimeLanguageProfileId: (json['activeAnimeLanguageProfileId'] as num?) + ?.toInt(), + activeTags: (json['activeTags'] as List?) + ?.map((e) => (e as num).toInt()) + .toList(), + activeAnimeTags: (json['activeAnimeTags'] as List?) + ?.map((e) => (e as num).toInt()) + .toList(), ); SeerrServiceDetail _$SeerrServiceDetailFromJson(Map json) => @@ -34,6 +44,9 @@ SeerrServiceDetail _$SeerrServiceDetailFromJson(Map json) => languageProfiles: (json['languageProfiles'] as List?) ?.map((e) => SeerrServiceProfile.fromJson(e as Map)) .toList(), + tags: (json['tags'] as List?) + ?.map((e) => SeerrServiceTag.fromJson(e as Map)) + .toList(), ); SeerrServiceProfile _$SeerrServiceProfileFromJson(Map json) => @@ -47,3 +60,9 @@ SeerrRootFolder _$SeerrRootFolderFromJson(Map json) => id: (json['id'] as num).toInt(), path: json['path'] as String?, ); + +SeerrServiceTag _$SeerrServiceTagFromJson(Map json) => + SeerrServiceTag( + id: (json['id'] as num).toInt(), + label: json['label'] as String?, + ); diff --git a/lib/services/seerr/seerr_constants.dart b/lib/services/seerr/seerr_constants.dart index a42787ae7..c910353ff 100644 --- a/lib/services/seerr/seerr_constants.dart +++ b/lib/services/seerr/seerr_constants.dart @@ -16,6 +16,11 @@ abstract final class SeerrConstants { /// Quick Connect poll cadence, matching the Seerr web UI's fixed 2000ms. static const Duration quickConnectPollInterval = Duration(seconds: 2); + + /// TMDB keyword Seerr treats as "this series is an anime" + /// (server/api/themoviedb/constants.ts `ANIME_KEYWORD_ID`). Seerr routes + /// such series to a Sonarr instance's anime defaults. + static const int animeKeywordId = 210024; } /// Seerr `MediaServerType` values (server/constants/server.ts), sent as diff --git a/lib/widgets/seerr_request_sheet.dart b/lib/widgets/seerr_request_sheet.dart index 5143dcb4d..a3be1ac72 100644 --- a/lib/widgets/seerr_request_sheet.dart +++ b/lib/widgets/seerr_request_sheet.dart @@ -80,6 +80,11 @@ class _SeerrRequestSheetState extends State { bool _is4k = false; + /// TV only: the TMDB anime keyword is present, so Seerr routes the series + /// to the Sonarr instance's anime defaults (profile, folder, language, + /// tags) and the sheet must seed its overrides from those. + bool _isAnime = false; + /// Advanced options (REQUEST_ADVANCED): all configured instances of the /// matching service; the pickers filter by the 4K toggle. List _allServers = const []; @@ -90,6 +95,11 @@ class _SeerrRequestSheetState extends State { String? _rootFolder; int? _languageProfileId; + /// Null until the service detail reports the instance's default tags; a + /// null is omitted from the payload so Seerr applies its own defaults. + List? _tags; + bool _tagsExpanded = false; + bool _submitting = false; String? _errorText; @@ -159,6 +169,7 @@ class _SeerrRequestSheetState extends State { SeerrMediaInfo? mediaInfo; var seasons = const []; + var isAnime = false; if (_isMovie) { mediaInfo = (await client.getMovie(widget.tmdbId)).mediaInfo; } else { @@ -168,6 +179,7 @@ class _SeerrRequestSheetState extends State { for (final season in tv.seasons ?? const []) if (season.seasonNumber > 0 && (season.episodeCount ?? 0) > 0) season, ]; + isAnime = tv.keywords?.any((k) => k.id == SeerrConstants.animeKeywordId) ?? false; } if (!mounted) return; _replaceSeasonFocusNodes(seasons); @@ -175,6 +187,7 @@ class _SeerrRequestSheetState extends State { _settings = settings; _mediaInfo = mediaInfo; _seasons = seasons; + _isAnime = isAnime; _allServers = servers; _loading = false; }); @@ -195,13 +208,29 @@ class _SeerrRequestSheetState extends State { _adoptServer(next); } + /// The instance default the web requester preselects: the anime value when + /// the series is an anime and the instance configures one, else the + /// standard value. + int? _defaultProfileId(SeerrServiceInstance? server) => + (_isAnime ? server?.activeAnimeProfileId : null) ?? server?.activeProfileId; + + String? _defaultRootFolder(SeerrServiceInstance? server) => + (_isAnime ? server?.activeAnimeDirectory : null) ?? server?.activeDirectory; + + int? _defaultLanguageProfileId(SeerrServiceInstance? server) => + (_isAnime ? server?.activeAnimeLanguageProfileId : null) ?? server?.activeLanguageProfileId; + + List? _defaultTags(SeerrServiceInstance? server) => _isAnime ? server?.activeAnimeTags : server?.activeTags; + void _adoptServer(SeerrServiceInstance? server) { setState(() { _server = server; _serverDetail = null; - _profileId = server?.activeProfileId; - _rootFolder = server?.activeDirectory; - _languageProfileId = server?.activeLanguageProfileId; + _profileId = _defaultProfileId(server); + _rootFolder = _defaultRootFolder(server); + _languageProfileId = _defaultLanguageProfileId(server); + // The list endpoint reports no usable tags; wait for the detail. + _tags = null; }); if (server != null && _advancedAllowed) unawaited(_loadServerDetail(server)); } @@ -215,9 +244,10 @@ class _SeerrRequestSheetState extends State { setState(() { _serverDetail = detail; _serverDetailLoading = false; - _profileId ??= detail.server?.activeProfileId; - _rootFolder ??= detail.server?.activeDirectory; - _languageProfileId ??= detail.server?.activeLanguageProfileId; + _profileId ??= _defaultProfileId(detail.server); + _rootFolder ??= _defaultRootFolder(detail.server); + _languageProfileId ??= _defaultLanguageProfileId(detail.server); + if (detail.tags != null) _tags = [...?_defaultTags(detail.server)]; }); } catch (e) { // Advanced pickers degrade to server defaults; the request still works. @@ -338,6 +368,7 @@ class _SeerrRequestSheetState extends State { profileId: advanced ? _profileId : null, rootFolder: advanced ? _rootFolder : null, languageProfileId: advanced ? _languageProfileId : null, + tags: advanced ? _tags : null, ); try { await widget.source.client.createRequest(payload); @@ -368,7 +399,8 @@ class _SeerrRequestSheetState extends State { return _serversForVariant.length > 1 || (detail?.profiles?.isNotEmpty ?? false) || (detail?.rootFolders?.isNotEmpty ?? false) || - (detail?.languageProfiles?.isNotEmpty ?? false); + (detail?.languageProfiles?.isNotEmpty ?? false) || + (detail?.tags?.isNotEmpty ?? false); } void _focusRequestButton() { @@ -537,12 +569,53 @@ class _SeerrRequestSheetState extends State { ); } + /// Option label, marking the instance default the way the web requester + /// does (`Anime (Default)`). + String _describeOption(String name, {required bool isDefault}) => + isDefault ? t.seerr.defaultOption(name: name) : name; + + String _tagsSummary(List tags) { + final selected = _tags ?? const []; + final labels = [ + for (final tag in tags) + if (selected.contains(tag.id)) tag.label ?? '#${tag.id}', + ]; + return labels.isEmpty ? t.seerr.noTags : labels.join(', '); + } + + void _toggleTag(List tags, int id, bool checked) { + final selected = {...?_tags}; + if (checked) { + selected.add(id); + } else { + selected.remove(id); + } + // Keep the arr's tag order so the payload is stable across toggles. + setState(() { + _tags = [ + for (final tag in tags) + if (selected.contains(tag.id)) tag.id, + ]; + }); + } + List _buildAdvancedSection(ThemeData theme) { final servers = _serversForVariant; + final server = _serverDetail?.server ?? _server; final detail = _serverDetail; final profiles = detail?.profiles ?? const []; final folders = detail?.rootFolders ?? const []; final languages = detail?.languageProfiles ?? const []; + final tags = detail?.tags ?? const []; + final defaultProfileId = _defaultProfileId(server); + final defaultRootFolder = _defaultRootFolder(server); + final defaultLanguageProfileId = _defaultLanguageProfileId(server); + String describeProfile(SeerrServiceProfile p) => + _describeOption(p.name ?? '#${p.id}', isDefault: p.id == defaultProfileId); + String describeFolder(SeerrRootFolder f) => + _describeOption(f.path ?? '#${f.id}', isDefault: f.path == defaultRootFolder); + String describeLanguage(SeerrServiceProfile p) => + _describeOption(p.name ?? '#${p.id}', isDefault: p.id == defaultLanguageProfileId); return [ const SizedBox(height: 8), Row( @@ -566,9 +639,9 @@ class _SeerrRequestSheetState extends State { _PickerTile( icon: Symbols.high_quality_rounded, label: t.seerr.qualityProfile, - value: profiles.firstWhereOrNull((p) => p.id == _profileId)?.name ?? '', + value: profiles.where((p) => p.id == _profileId).map(describeProfile).firstOrNull ?? '', options: profiles, - describe: (p) => p.name ?? '#${p.id}', + describe: describeProfile, isSelected: (p) => p.id == _profileId, enabled: !_submitting, onSelected: (p) => setState(() => _profileId = p.id), @@ -577,9 +650,9 @@ class _SeerrRequestSheetState extends State { _PickerTile( icon: Symbols.folder_rounded, label: t.seerr.rootFolder, - value: _rootFolder ?? '', + value: folders.where((f) => f.path == _rootFolder).map(describeFolder).firstOrNull ?? _rootFolder ?? '', options: folders, - describe: (f) => f.path ?? '#${f.id}', + describe: describeFolder, isSelected: (f) => f.path == _rootFolder, enabled: !_submitting, onSelected: (f) => setState(() => _rootFolder = f.path), @@ -588,13 +661,46 @@ class _SeerrRequestSheetState extends State { _PickerTile( icon: Symbols.language_rounded, label: t.seerr.languageProfile, - value: languages.firstWhereOrNull((p) => p.id == _languageProfileId)?.name ?? '', + value: languages.where((p) => p.id == _languageProfileId).map(describeLanguage).firstOrNull ?? '', options: languages, - describe: (p) => p.name ?? '#${p.id}', + describe: describeLanguage, isSelected: (p) => p.id == _languageProfileId, enabled: !_submitting, onSelected: (p) => setState(() => _languageProfileId = p.id), ), + // Tags stay inline rather than on a nested sheet page: the host builds + // only the top page, so pushing one would dispose this state and drop + // the season/4K/picker selections on the way back. + if (tags.isNotEmpty) ...[ + FocusableListTile( + leading: const AppIcon(Symbols.label_rounded, fill: 1), + title: Text(t.seerr.tags), + subtitle: Text(_tagsSummary(tags), maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: AppIcon(_tagsExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, fill: 1), + contentPadding: EdgeInsets.zero, + enabled: !_submitting, + onTap: () => setState(() => _tagsExpanded = !_tagsExpanded), + ), + if (_tagsExpanded) + for (final tag in tags) + FocusableCheckboxListTile( + value: _tags?.contains(tag.id) ?? false, + onChanged: _submitting ? null : (checked) => _toggleTag(tags, tag.id, checked ?? false), + title: Text(tag.label ?? '#${tag.id}'), + contentPadding: const EdgeInsetsDirectional.only(start: 40), + controlAffinity: ListTileControlAffinity.leading, + ), + ], + if (_isAnime) ...[ + const SizedBox(height: 8), + Text( + t.seerr.animeNote, + style: theme.textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], ]; } } diff --git a/test/services/seerr/seerr_client_test.dart b/test/services/seerr/seerr_client_test.dart index 718c4075e..17c0d95f3 100644 --- a/test/services/seerr/seerr_client_test.dart +++ b/test/services/seerr/seerr_client_test.dart @@ -670,6 +670,58 @@ void main() { expect(bodies[1]['profileId'], 6); }); + test('createRequest posts tags only when set, keeping an empty list as a real override', () async { + final bodies = >[]; + final client = clientWith( + MockClient((request) async { + bodies.add(jsonDecode(request.body) as Map); + return _json({'id': 11, 'status': 2}); + }), + ); + await client.createRequest(const SeerrRequestPayload(mediaType: 'tv', mediaId: 1396)); + await client.createRequest(const SeerrRequestPayload(mediaType: 'tv', mediaId: 1396, tags: [])); + await client.createRequest(const SeerrRequestPayload(mediaType: 'tv', mediaId: 1396, tags: [5, 9])); + expect(bodies[0].containsKey('tags'), isFalse); + expect(bodies[1]['tags'], isEmpty); + expect(bodies[2]['tags'], [5, 9]); + }); + + test('getSonarrService parses the anime defaults and tag options', () async { + final client = clientWith( + MockClient( + (request) async => _json({ + 'server': { + 'id': 0, + 'name': 'Sonarr', + 'is4k': false, + 'isDefault': true, + 'activeProfileId': 1, + 'activeDirectory': '/tv', + 'activeAnimeProfileId': 2, + 'activeAnimeDirectory': '/anime', + 'activeAnimeLanguageProfileId': 3, + 'activeTags': [7], + 'activeAnimeTags': [5, 6], + }, + 'profiles': [], + 'rootFolders': [], + 'languageProfiles': null, + 'tags': [ + {'id': 5, 'label': 'anime'}, + ], + }), + ), + ); + final detail = await client.getSonarrService(0); + final server = detail.server!; + expect(server.activeAnimeProfileId, 2); + expect(server.activeAnimeDirectory, '/anime'); + expect(server.activeAnimeLanguageProfileId, 3); + expect(server.activeTags, [7]); + expect(server.activeAnimeTags, [5, 6]); + expect(detail.tags?.single.label, 'anime'); + }); + test('API errors carry the server message', () async { final client = clientWith( MockClient((request) async => _json({'message': 'Request quota exceeded'}, status: 429)), diff --git a/test/widgets/seerr_request_sheet_test.dart b/test/widgets/seerr_request_sheet_test.dart index 69822e840..5de2a6a07 100644 --- a/test/widgets/seerr_request_sheet_test.dart +++ b/test/widgets/seerr_request_sheet_test.dart @@ -106,6 +106,55 @@ Future _pumpSheet( await tester.pumpAndSettle(); } +Map _sonarrServer() => { + 'id': 0, + 'name': 'Sonarr Main', + 'is4k': false, + 'isDefault': true, + 'activeProfileId': 1, + 'activeDirectory': '/tv', + 'activeLanguageProfileId': 1, + 'activeAnimeProfileId': 2, + 'activeAnimeDirectory': '/anime', + 'activeAnimeLanguageProfileId': 2, + 'activeTags': [7], + 'activeAnimeTags': [5], +}; + +Map _sonarrDetail() => { + 'server': _sonarrServer(), + 'profiles': [ + {'id': 1, 'name': 'TV'}, + {'id': 2, 'name': 'Anime'}, + ], + 'rootFolders': [ + {'id': 1, 'path': '/tv'}, + {'id': 2, 'path': '/anime'}, + ], + 'languageProfiles': [ + {'id': 1, 'name': 'English'}, + {'id': 2, 'name': 'Japanese'}, + ], + 'tags': [ + {'id': 5, 'label': 'anime'}, + {'id': 7, 'label': 'tv'}, + {'id': 9, 'label': 'uhd'}, + ], +}; + +Map _tvDetails({required bool anime}) => { + 'id': 46260, + 'name': 'Naruto', + 'keywords': [ + {'id': 9715, 'name': 'superhero'}, + if (anime) {'id': 210024, 'name': 'anime'}, + ], + 'seasons': [ + {'seasonNumber': 1, 'episodeCount': 57, 'name': 'Season 1'}, + ], + 'mediaInfo': {'status': 1, 'status4k': 1, 'seasons': [], 'requests': []}, +}; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -404,10 +453,12 @@ void main() { await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 550, title: 'Fight Club'); // Single server: no server picker, but profile/folder pickers show - // the instance defaults. + // the instance defaults, marked the way Seerr's web requester does. expect(find.text('Destination server'), findsNothing); expect(find.text('Quality profile'), findsOneWidget); - expect(find.text('1080p'), findsOneWidget); + expect(find.text('1080p (Default)'), findsOneWidget); + // No `tags` in the detail: nothing to pick, nothing to override. + expect(find.text('Tags'), findsNothing); await tester.tap(find.widgetWithText(FilledButton, 'Request')); await tester.pumpAndSettle(); @@ -421,4 +472,125 @@ void main() { 'rootFolder': '/movies', }); }); + + testWidgets('an anime series seeds the advanced pickers from the Sonarr anime defaults', (tester) async { + Map? postedBody; + final mock = MockClient((request) async { + switch (request.url.path) { + case '/api/v1/settings/public': + return _json(_publicSettings()); + case '/api/v1/tv/46260': + return _json(_tvDetails(anime: true)); + case '/api/v1/service/sonarr': + // The list endpoint reports no usable tags (`activeTags: []`). + return _json([ + {..._sonarrServer(), 'activeTags': []}..remove('activeAnimeTags'), + ]); + case '/api/v1/service/sonarr/0': + return _json(_sonarrDetail()); + case '/api/v1/request': + postedBody = jsonDecode(request.body) as Map; + return _json({'id': 12, 'status': 2}, status: 201); + } + fail('unexpected request ${request.url.path}'); + }); + final source = _source(mock, permissions: SeerrPermission.request | SeerrPermission.requestAdvanced); + + // Season, 4K-less advanced pickers, tags, note, and the button need + // more than the default 600px test viewport's 75% sheet cap. + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await _pumpSheet(tester, source: source, kind: MediaKind.show, tmdbId: 46260, title: 'Naruto'); + + expect(find.text('Anime (Default)'), findsOneWidget); + expect(find.text('/anime (Default)'), findsOneWidget); + expect(find.text('Japanese (Default)'), findsOneWidget); + expect(find.text('anime'), findsOneWidget); + expect(find.text('This series is an anime.'), findsOneWidget); + + await tester.tap(find.text('Season 1')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, 'Request')); + await tester.pumpAndSettle(); + + expect(postedBody, { + 'mediaType': 'tv', + 'mediaId': 46260, + 'seasons': [1], + 'is4k': false, + 'serverId': 0, + 'profileId': 2, + 'rootFolder': '/anime', + 'languageProfileId': 2, + 'tags': [5], + }); + }); + + testWidgets('a non-anime series keeps the standard defaults and lets the user edit tags', (tester) async { + Map? postedBody; + final mock = MockClient((request) async { + switch (request.url.path) { + case '/api/v1/settings/public': + return _json(_publicSettings()); + case '/api/v1/tv/46260': + return _json(_tvDetails(anime: false)); + case '/api/v1/service/sonarr': + return _json([_sonarrServer()]); + case '/api/v1/service/sonarr/0': + return _json(_sonarrDetail()); + case '/api/v1/request': + postedBody = jsonDecode(request.body) as Map; + return _json({'id': 12, 'status': 2}, status: 201); + } + fail('unexpected request ${request.url.path}'); + }); + final source = _source(mock, permissions: SeerrPermission.request | SeerrPermission.requestAdvanced); + + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await _pumpSheet(tester, source: source, kind: MediaKind.show, tmdbId: 46260, title: 'Naruto'); + + expect(find.text('TV (Default)'), findsOneWidget); + expect(find.text('/tv (Default)'), findsOneWidget); + expect(find.text('This series is an anime.'), findsNothing); + expect(find.text('tv'), findsOneWidget); + // Collapsed until the row is tapped: only the season checkboxes exist. + expect(find.byType(CheckboxListTile), findsNWidgets(2)); + + // Selections made before the tag list opens must survive it: the tag + // list is inline, so no page swap can rebuild the sheet from scratch. + await tester.tap(find.text('Season 1')); + await tester.pump(); + + await tester.tap(find.text('Tags')); + await tester.pumpAndSettle(); + expect(find.byType(CheckboxListTile), findsNWidgets(5)); + await tester.tap(find.text('uhd')); + await tester.pump(); // summary is now "tv, uhd", so 'tv' names only the row + await tester.tap(find.text('tv')); + await tester.pumpAndSettle(); + // Summary reflects the edit ('tv' now names only the deselected row). + expect(find.text('uhd'), findsNWidgets(2)); + await tester.tap(find.text('Tags')); + await tester.pumpAndSettle(); + expect(find.byType(CheckboxListTile), findsNWidgets(2)); + expect(find.text('uhd'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Request')); + await tester.pumpAndSettle(); + + expect(postedBody, { + 'mediaType': 'tv', + 'mediaId': 46260, + 'seasons': [1], + 'is4k': false, + 'serverId': 0, + 'profileId': 1, + 'rootFolder': '/tv', + 'languageProfileId': 1, + 'tags': [9], + }); + }); }