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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -2078,6 +2078,10 @@
|
||||
"qualityProfile": "Профил за качество",
|
||||
"rootFolder": "Основна папка",
|
||||
"languageProfile": "Езиков профил",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Заявката е изпратена",
|
||||
"requestFailed": "Заявката се провали: ${error}",
|
||||
"requestsLoadFailed": "Неуспешно зареждане на опциите за заявка",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2068,6 +2068,10 @@
|
||||
"qualityProfile": "画質プロファイル",
|
||||
"rootFolder": "ルートフォルダ",
|
||||
"languageProfile": "言語プロファイル",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "リクエストを送信しました",
|
||||
"requestFailed": "リクエストに失敗しました: ${error}",
|
||||
"requestsLoadFailed": "リクエストオプションを読み込めませんでした",
|
||||
|
||||
@@ -2078,6 +2078,10 @@
|
||||
"qualityProfile": "Сапа профилі",
|
||||
"rootFolder": "Түпкі қапшық",
|
||||
"languageProfile": "Тіл профилі",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Сұрау жіберілді",
|
||||
"requestFailed": "Сұрау қатесі: ${error}",
|
||||
"requestsLoadFailed": "Параметрлерді жүктеу мүмкін болмады",
|
||||
|
||||
@@ -2068,6 +2068,10 @@
|
||||
"qualityProfile": "화질 프로파일",
|
||||
"rootFolder": "루트 폴더",
|
||||
"languageProfile": "언어 프로파일",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "요청을 제출했습니다",
|
||||
"requestFailed": "요청 실패: ${error}",
|
||||
"requestsLoadFailed": "요청 옵션을 불러올 수 없습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2098,6 +2098,10 @@
|
||||
"qualityProfile": "Профиль качества",
|
||||
"rootFolder": "Корневая папка",
|
||||
"languageProfile": "Языковой профиль",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "Запрос отправлен",
|
||||
"requestFailed": "Ошибка запроса: ${error}",
|
||||
"requestsLoadFailed": "Не удалось загрузить параметры запроса",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2068,6 +2068,10 @@
|
||||
"qualityProfile": "畫質設定檔(Quality Profile)",
|
||||
"rootFolder": "根目錄資料夾",
|
||||
"languageProfile": "語言設定檔(Language Profile)",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "請求已送出",
|
||||
"requestFailed": "請求失敗:${error}",
|
||||
"requestsLoadFailed": "無法載入請求選項",
|
||||
|
||||
@@ -2068,6 +2068,10 @@
|
||||
"qualityProfile": "画质配置",
|
||||
"rootFolder": "根目录",
|
||||
"languageProfile": "语言配置",
|
||||
"tags": "",
|
||||
"noTags": "",
|
||||
"defaultOption": "",
|
||||
"animeNote": "",
|
||||
"requestSubmitted": "请求已提交",
|
||||
"requestFailed": "请求失败:${error}",
|
||||
"requestsLoadFailed": "无法加载请求选项",
|
||||
|
||||
@@ -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<int>? tags;
|
||||
|
||||
const SeerrRequestPayload({
|
||||
required this.mediaType,
|
||||
required this.mediaId,
|
||||
@@ -76,6 +80,7 @@ class SeerrRequestPayload {
|
||||
this.profileId,
|
||||
this.rootFolder,
|
||||
this.languageProfileId,
|
||||
this.tags,
|
||||
});
|
||||
|
||||
Map<String, Object?> 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<int>? activeTags;
|
||||
final List<int>? 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<String, dynamic> 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<SeerrServiceProfile>? languageProfiles;
|
||||
final List<SeerrServiceTag>? 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<String, dynamic> json) => _$SeerrServiceDetailFromJson(json);
|
||||
}
|
||||
@@ -64,3 +81,14 @@ class SeerrRootFolder {
|
||||
|
||||
factory SeerrRootFolder.fromJson(Map<String, dynamic> 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<String, dynamic> json) => _$SeerrServiceTagFromJson(json);
|
||||
}
|
||||
|
||||
@@ -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<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
.toList(),
|
||||
activeAnimeTags: (json['activeAnimeTags'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
.toList(),
|
||||
);
|
||||
|
||||
SeerrServiceDetail _$SeerrServiceDetailFromJson(Map<String, dynamic> json) =>
|
||||
@@ -34,6 +44,9 @@ SeerrServiceDetail _$SeerrServiceDetailFromJson(Map<String, dynamic> json) =>
|
||||
languageProfiles: (json['languageProfiles'] as List<dynamic>?)
|
||||
?.map((e) => SeerrServiceProfile.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
tags: (json['tags'] as List<dynamic>?)
|
||||
?.map((e) => SeerrServiceTag.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
SeerrServiceProfile _$SeerrServiceProfileFromJson(Map<String, dynamic> json) =>
|
||||
@@ -47,3 +60,9 @@ SeerrRootFolder _$SeerrRootFolderFromJson(Map<String, dynamic> json) =>
|
||||
id: (json['id'] as num).toInt(),
|
||||
path: json['path'] as String?,
|
||||
);
|
||||
|
||||
SeerrServiceTag _$SeerrServiceTagFromJson(Map<String, dynamic> json) =>
|
||||
SeerrServiceTag(
|
||||
id: (json['id'] as num).toInt(),
|
||||
label: json['label'] as String?,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,6 +80,11 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
||||
|
||||
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<SeerrServiceInstance> _allServers = const [];
|
||||
@@ -90,6 +95,11 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
||||
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<int>? _tags;
|
||||
bool _tagsExpanded = false;
|
||||
|
||||
bool _submitting = false;
|
||||
String? _errorText;
|
||||
|
||||
@@ -159,6 +169,7 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
||||
|
||||
SeerrMediaInfo? mediaInfo;
|
||||
var seasons = const <SeerrSeason>[];
|
||||
var isAnime = false;
|
||||
if (_isMovie) {
|
||||
mediaInfo = (await client.getMovie(widget.tmdbId)).mediaInfo;
|
||||
} else {
|
||||
@@ -168,6 +179,7 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
||||
for (final season in tv.seasons ?? const <SeerrSeason>[])
|
||||
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<SeerrRequestSheet> {
|
||||
_settings = settings;
|
||||
_mediaInfo = mediaInfo;
|
||||
_seasons = seasons;
|
||||
_isAnime = isAnime;
|
||||
_allServers = servers;
|
||||
_loading = false;
|
||||
});
|
||||
@@ -195,13 +208,29 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
||||
_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<int>? _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<SeerrRequestSheet> {
|
||||
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<SeerrRequestSheet> {
|
||||
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<SeerrRequestSheet> {
|
||||
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<SeerrRequestSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<SeerrServiceTag> tags) {
|
||||
final selected = _tags ?? const <int>[];
|
||||
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<SeerrServiceTag> 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<Widget> _buildAdvancedSection(ThemeData theme) {
|
||||
final servers = _serversForVariant;
|
||||
final server = _serverDetail?.server ?? _server;
|
||||
final detail = _serverDetail;
|
||||
final profiles = detail?.profiles ?? const <SeerrServiceProfile>[];
|
||||
final folders = detail?.rootFolders ?? const <SeerrRootFolder>[];
|
||||
final languages = detail?.languageProfiles ?? const <SeerrServiceProfile>[];
|
||||
final tags = detail?.tags ?? const <SeerrServiceTag>[];
|
||||
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<SeerrRequestSheet> {
|
||||
_PickerTile<SeerrServiceProfile>(
|
||||
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<SeerrRequestSheet> {
|
||||
_PickerTile<SeerrRootFolder>(
|
||||
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<SeerrRequestSheet> {
|
||||
_PickerTile<SeerrServiceProfile>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <Map<String, dynamic>>[];
|
||||
final client = clientWith(
|
||||
MockClient((request) async {
|
||||
bodies.add(jsonDecode(request.body) as Map<String, dynamic>);
|
||||
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)),
|
||||
|
||||
@@ -106,6 +106,55 @@ Future<void> _pumpSheet(
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _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<String, dynamic> _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<String, dynamic> _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<String, dynamic>? 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': <int>[]}..remove('activeAnimeTags'),
|
||||
]);
|
||||
case '/api/v1/service/sonarr/0':
|
||||
return _json(_sonarrDetail());
|
||||
case '/api/v1/request':
|
||||
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
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<String, dynamic>? 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<String, dynamic>;
|
||||
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],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user