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], + }); + }); }