From 716fe9b51b6fa34d0dacddeea326a485625e900c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:32:40 +0200 Subject: [PATCH] feat(watchlist): add watchlist toggle to library context menus Watchlist membership was only reachable from Explore cards and the detail screen's action row, which drops the bookmark first on narrow screens with no fallback in the overflow menu. Add an entry to MediaContextMenu for movies and shows whenever a connected catalog source can hold the item, covering card long-press everywhere and the detail screen's overflow. External-id resolution is session-cached per item on CatalogSourcesProvider and shared with the detail screen. A cold cache labels the entry "Add to Watchlist" and always adds (idempotent), so a press can never turn into a surprise removal; "Remove" is offered once cached membership proves it. Several capable sources open the same per-source chooser the detail screen uses. close #1822 --- lib/i18n/az.i18n.json | 3 + lib/i18n/bg.i18n.json | 3 + lib/i18n/da.i18n.json | 3 + lib/i18n/de.i18n.json | 3 + lib/i18n/en.i18n.json | 3 + lib/i18n/es.i18n.json | 3 + lib/i18n/fr.i18n.json | 3 + lib/i18n/hu.i18n.json | 3 + lib/i18n/it.i18n.json | 3 + lib/i18n/ja.i18n.json | 3 + lib/i18n/kk.i18n.json | 3 + lib/i18n/ko.i18n.json | 3 + lib/i18n/nb.i18n.json | 3 + lib/i18n/nl.i18n.json | 3 + lib/i18n/pl.i18n.json | 3 + lib/i18n/pt.i18n.json | 3 + lib/i18n/ru.i18n.json | 3 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 20 +- lib/i18n/sv.i18n.json | 3 + lib/i18n/tr.i18n.json | 3 + lib/i18n/uz.i18n.json | 3 + lib/i18n/zh-Hant.i18n.json | 3 + lib/i18n/zh.i18n.json | 3 + lib/providers/catalog_sources_provider.dart | 57 +++- lib/screens/media_detail/action_buttons.dart | 27 +- lib/screens/media_detail_screen.dart | 44 +--- .../catalog/library_watchlist_candidates.dart | 63 +++++ lib/widgets/catalog_context_menu.dart | 18 +- lib/widgets/media_context_menu.dart | 118 +++++++++ lib/widgets/watchlist_source_chooser.dart | 43 +++ .../catalog_sources_provider_test.dart | 135 ++++++++++ .../library_watchlist_candidates_test.dart | 168 ++++++++++++ test/widgets/media_context_menu_test.dart | 247 ++++++++++++++++++ 34 files changed, 936 insertions(+), 72 deletions(-) create mode 100644 lib/services/catalog/library_watchlist_candidates.dart create mode 100644 lib/widgets/watchlist_source_chooser.dart create mode 100644 test/services/catalog/library_watchlist_candidates_test.dart diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 2a876a96..88cca02c 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Personajlar", "addToWatchlist": "İzləmə siyahısına əlavə et", "removeFromWatchlist": "İzləmə siyahısından sil", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "İzləmə siyahısı yenilənə bilmədi", + "watchlistNoMatch": "", "notInLibrary": "Kitabxananızda yoxdur", "inTheseLibraries": "Bu kitabxanalarda var", "checkingLibrary": "Kitabxananız yoxlanılır...", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 2a447bcd..d937b4e0 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Герои", "addToWatchlist": "Добави в списъка за гледане", "removeFromWatchlist": "Премахни от списъка за гледане", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Неуспешно обновяване на списъка за гледане", + "watchlistNoMatch": "", "notInLibrary": "Не е в твоята библиотека", "inTheseLibraries": "В тези библиотеки", "checkingLibrary": "Проверка на твоята библиотека...", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 5f729374..e6ece5a8 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Figurer", "addToWatchlist": "Føj til ønskeliste", "removeFromWatchlist": "Fjern fra ønskeliste", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Kunne ikke opdatere ønskelisten", + "watchlistNoMatch": "", "notInLibrary": "Ikke i dit bibliotek", "inTheseLibraries": "I disse biblioteker", "checkingLibrary": "Tjekker dit bibliotek...", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index cd2cee48..e58d524a 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Charaktere", "addToWatchlist": "Zur Merkliste hinzufügen", "removeFromWatchlist": "Von Merkliste entfernen", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Merkliste konnte nicht aktualisiert werden", + "watchlistNoMatch": "", "notInLibrary": "Nicht in deiner Mediathek", "inTheseLibraries": "In diesen Mediatheken", "checkingLibrary": "Deine Mediathek wird überprüft …", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 12cbaf34..e2c56a44 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Characters", "addToWatchlist": "Add to Watchlist", "removeFromWatchlist": "Remove from Watchlist", + "addedToWatchlist": "Added to watchlist", + "removedFromWatchlist": "Removed from watchlist", "watchlistUpdateFailed": "Couldn't update watchlist", + "watchlistNoMatch": "Couldn't match this item to a watchlist", "notInLibrary": "Not in your library", "inTheseLibraries": "In these libraries", "checkingLibrary": "Checking your library...", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 6a36e10a..e50ccc48 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Personajes", "addToWatchlist": "Añadir a la lista de seguimiento", "removeFromWatchlist": "Quitar de la lista de seguimiento", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "No se pudo actualizar la lista de seguimiento", + "watchlistNoMatch": "", "notInLibrary": "No está en tu biblioteca", "inTheseLibraries": "En estas bibliotecas", "checkingLibrary": "Comprobando tu biblioteca...", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 6ab765db..c5ffcb69 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Personnages", "addToWatchlist": "Ajouter à la liste de suivi", "removeFromWatchlist": "Retirer de la liste de suivi", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Impossible de mettre à jour la liste de suivi", + "watchlistNoMatch": "", "notInLibrary": "Absent de votre bibliothèque", "inTheseLibraries": "Dans ces bibliothèques", "checkingLibrary": "Vérification de votre bibliothèque...", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 0fe514c7..e0d33838 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Karakterek", "addToWatchlist": "Hozzáadás a Néznivalókhoz", "removeFromWatchlist": "Eltávolítás a Néznivalókból", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Nem sikerült a Néznivalók frissítése", + "watchlistNoMatch": "", "notInLibrary": "Nincs a könyvtáradban", "inTheseLibraries": "Ezekben a könyvtárakban", "checkingLibrary": "Könyvtár ellenőrzése...", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 09c471a5..a8d6ddb7 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Personaggi", "addToWatchlist": "Aggiungi alla lista da guardare", "removeFromWatchlist": "Rimuovi dalla lista da guardare", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Impossibile aggiornare la lista da guardare", + "watchlistNoMatch": "", "notInLibrary": "Non è nella tua libreria", "inTheseLibraries": "In queste librerie", "checkingLibrary": "Ricerca nella tua libreria...", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index e3e191f4..47917137 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -1075,7 +1075,10 @@ "characters": "キャラクター", "addToWatchlist": "ウォッチリストに追加", "removeFromWatchlist": "ウォッチリストから削除", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "ウォッチリストを更新できませんでした", + "watchlistNoMatch": "", "notInLibrary": "ライブラリにありません", "inTheseLibraries": "これらのライブラリにあります", "checkingLibrary": "ライブラリを確認中…", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 8bdb7429..d3937dae 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Кейіпкерлер", "addToWatchlist": "Көру тізіміне қосу", "removeFromWatchlist": "Көру тізімінен өшіру", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Көру тізімін жаңарту мүмкін болмады", + "watchlistNoMatch": "", "notInLibrary": "Кітапханаңызда жоқ", "inTheseLibraries": "Осы кітапханаларда бар", "checkingLibrary": "Кітапхана тексерілуде...", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index e1b11cda..6e4a4da3 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -1075,7 +1075,10 @@ "characters": "캐릭터", "addToWatchlist": "관심 목록에 추가", "removeFromWatchlist": "관심 목록에서 제거", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "관심 목록을 업데이트하지 못했습니다", + "watchlistNoMatch": "", "notInLibrary": "라이브러리에 없음", "inTheseLibraries": "이 라이브러리에 있음", "checkingLibrary": "라이브러리 확인 중...", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index f83c5840..37b67b2d 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Figurer", "addToWatchlist": "Legg til i ønskeliste", "removeFromWatchlist": "Fjern fra ønskeliste", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Kunne ikke oppdatere ønskelisten", + "watchlistNoMatch": "", "notInLibrary": "Ikke i biblioteket ditt", "inTheseLibraries": "I disse bibliotekene", "checkingLibrary": "Sjekker biblioteket ditt...", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 6bcac7c8..deb2c5c1 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Personages", "addToWatchlist": "Toevoegen aan kijklijst", "removeFromWatchlist": "Verwijderen uit kijklijst", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Kon kijklijst niet bijwerken", + "watchlistNoMatch": "", "notInLibrary": "Niet in je bibliotheek", "inTheseLibraries": "In deze bibliotheken", "checkingLibrary": "Je bibliotheek controleren...", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index a19ac353..09fef314 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -1090,7 +1090,10 @@ "characters": "Postacie", "addToWatchlist": "Dodaj do listy do obejrzenia", "removeFromWatchlist": "Usuń z listy do obejrzenia", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Nie udało się zaktualizować listy do obejrzenia", + "watchlistNoMatch": "", "notInLibrary": "Nie ma tego w Twojej bibliotece", "inTheseLibraries": "W tych bibliotekach", "checkingLibrary": "Sprawdzanie Twojej biblioteki...", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index ab5af90a..5dc52c84 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Personagens", "addToWatchlist": "Adicionar à lista para assistir", "removeFromWatchlist": "Remover da lista para assistir", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Não foi possível atualizar a lista para assistir", + "watchlistNoMatch": "", "notInLibrary": "Não está na sua biblioteca", "inTheseLibraries": "Nestas bibliotecas", "checkingLibrary": "Verificando sua biblioteca...", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index d4f67089..53b538a9 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -1090,7 +1090,10 @@ "characters": "Персонажи", "addToWatchlist": "Добавить в список для просмотра", "removeFromWatchlist": "Удалить из списка для просмотра", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Не удалось обновить список для просмотра", + "watchlistNoMatch": "", "notInLibrary": "Нет в вашей библиотеке", "inTheseLibraries": "В этих библиотеках", "checkingLibrary": "Проверка вашей библиотеки...", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 69f325a4..16020f1d 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: 38356 (1743 per locale) +/// Strings: 38359 (1743 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 1032acf9..6aef4b94 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -3054,9 +3054,18 @@ class Translations$explore$en { /// en: 'Remove from Watchlist' String get removeFromWatchlist => 'Remove from Watchlist'; + /// en: 'Added to watchlist' + String get addedToWatchlist => 'Added to watchlist'; + + /// en: 'Removed from watchlist' + String get removedFromWatchlist => 'Removed from watchlist'; + /// en: 'Couldn't update watchlist' String get watchlistUpdateFailed => 'Couldn\'t update watchlist'; + /// en: 'Couldn't match this item to a watchlist' + String get watchlistNoMatch => 'Couldn\'t match this item to a watchlist'; + /// en: 'Not in your library' String get notInLibrary => 'Not in your library'; @@ -7065,7 +7074,10 @@ extension on Translations { 'explore.characters' => 'Characters', 'explore.addToWatchlist' => 'Add to Watchlist', 'explore.removeFromWatchlist' => 'Remove from Watchlist', + 'explore.addedToWatchlist' => 'Added to watchlist', + 'explore.removedFromWatchlist' => 'Removed from watchlist', 'explore.watchlistUpdateFailed' => 'Couldn\'t update watchlist', + 'explore.watchlistNoMatch' => 'Couldn\'t match this item to a watchlist', 'explore.notInLibrary' => 'Not in your library', 'explore.inTheseLibraries' => 'In these libraries', 'explore.checkingLibrary' => 'Checking your library...', @@ -7102,11 +7114,11 @@ extension on Translations { 'explore.stats.viewersWeek' => ({required Object n}) => '${n} watched this week', 'explore.stats.viewersMonth' => ({required Object n}) => '${n} watched this month', 'explore.stats.viewersYear' => ({required Object n}) => '${n} watched this year', + _ => null, + } ?? switch (path) { 'explore.stats.viewersAllTime' => ({required Object n}) => '${n} viewers', 'explore.stats.planning' => ({required Object n}) => '${n} planning to watch', 'explore.stats.favorited' => ({required Object n}) => '${n} favorites', - _ => null, - } ?? switch (path) { 'explore.stats.dropRate' => ({required Object percent}) => '${percent} dropped it', 'explore.stats.comments' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} comment', other: '${n} comments', ), 'explore.stats.votes' => ({required Object n}) => '${n} votes', @@ -7616,11 +7628,11 @@ extension on Translations { 'performanceOverlay.player' => 'Player', 'performanceOverlay.memory' => 'Memory', 'performanceOverlay.uiFps' => 'UI FPS', + _ => null, + } ?? switch (path) { 'externalPlayer.title' => 'External Player', 'externalPlayer.useExternalPlayer' => 'Use External Player', 'externalPlayer.useExternalPlayerDescription' => 'Open videos in another app', - _ => null, - } ?? switch (path) { 'externalPlayer.selectPlayer' => 'Select Player', 'externalPlayer.customPlayers' => 'Custom Players', 'externalPlayer.systemDefault' => 'System Default', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 3a91d551..7b1a071a 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Karaktärer", "addToWatchlist": "Lägg till i bevakningslista", "removeFromWatchlist": "Ta bort från bevakningslista", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Det gick inte att uppdatera bevakningslistan", + "watchlistNoMatch": "", "notInLibrary": "Finns inte i ditt bibliotek", "inTheseLibraries": "I dessa bibliotek", "checkingLibrary": "Kontrollerar ditt bibliotek...", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index 34bf24ad..1d2d5ad1 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Karakterler", "addToWatchlist": "İzleme Listesine Ekle", "removeFromWatchlist": "İzleme Listesinden Kaldır", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "İzleme listesi güncellenemedi", + "watchlistNoMatch": "", "notInLibrary": "Kitaplığınızda yok", "inTheseLibraries": "Bu kitaplıklarda var", "checkingLibrary": "Kitaplığınız kontrol ediliyor...", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 5e8740e9..c5940cd3 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -1080,7 +1080,10 @@ "characters": "Qahramonlar", "addToWatchlist": "Tomosha roʻyxatiga qoʻshish", "removeFromWatchlist": "Tomosha roʻyxatidan oʻchirish", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "Tomosha roʻyxatini yangilab boʻlmadi", + "watchlistNoMatch": "", "notInLibrary": "Kutubxonangizda yoʻq", "inTheseLibraries": "Ushbu kutubxonalarda bor", "checkingLibrary": "Kutubxona tekshirilmoqda...", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index c849fe91..c9bd979e 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -1075,7 +1075,10 @@ "characters": "角色", "addToWatchlist": "新增至待看清單", "removeFromWatchlist": "從待看清單移除", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "無法更新待看清單", + "watchlistNoMatch": "", "notInLibrary": "不在您的媒體庫中", "inTheseLibraries": "在這些媒體庫中", "checkingLibrary": "正在檢查您的媒體庫…", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index ea709b2c..7b9a88bc 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -1075,7 +1075,10 @@ "characters": "角色", "addToWatchlist": "添加到想看列表", "removeFromWatchlist": "从想看列表移除", + "addedToWatchlist": "", + "removedFromWatchlist": "", "watchlistUpdateFailed": "无法更新想看列表", + "watchlistNoMatch": "", "notInLibrary": "不在你的媒体库中", "inTheseLibraries": "在这些媒体库中", "checkingLibrary": "正在检查你的媒体库…", diff --git a/lib/providers/catalog_sources_provider.dart b/lib/providers/catalog_sources_provider.dart index 1f67d9a8..e17466ad 100644 --- a/lib/providers/catalog_sources_provider.dart +++ b/lib/providers/catalog_sources_provider.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; import '../connection/connection_registry.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; import '../models/catalog/catalog_item.dart'; @@ -11,6 +13,7 @@ import '../profiles/active_profile_provider.dart'; import '../profiles/profile_connection_registry.dart'; import '../profiles/profile.dart'; import '../services/base_shared_preferences_service.dart'; +import '../services/catalog/library_watchlist_candidates.dart'; import '../services/catalog/catalog_source.dart'; import '../services/catalog/anilist_catalog_source.dart'; import '../services/catalog/mal_catalog_source.dart'; @@ -18,6 +21,7 @@ import '../services/catalog/plex_catalog_source.dart'; import '../services/catalog/seerr_catalog_source.dart'; import '../services/catalog/simkl_catalog_source.dart'; import '../services/catalog/trakt_catalog_source.dart'; +import '../services/trackers/future_coalescer.dart'; import '../services/plex_discover_client.dart'; import '../services/seerr/seerr_client.dart'; import '../services/trackers/anilist/anilist_client.dart'; @@ -115,6 +119,14 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie CatalogSourceId? _preferredSourceId; String _activeUserUuid = ''; + // Per-item watchlist candidates (see [watchlistCandidatesFor]): the future + // cache dedups/retries loads, the results map gives menu surfaces + // synchronous access for labeling, and the generation guard keeps a load + // that outlives a source rebind from repopulating disposed sources. + final KeyedFutureCache> _watchlistCandidateLoads = KeyedFutureCache(); + final Map> _watchlistCandidateResults = {}; + int _watchlistCandidateGeneration = 0; + List get connectedSources => [ ?_trakt.source, ?_mal.source, @@ -160,6 +172,40 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie return watchlistCapableSource; } + static String _watchlistItemKey(MediaItem item) => '${item.serverId ?? ''}/${item.id}'; + + /// Session-cached [resolveWatchlistCandidates] for a library item: every + /// watchlist-capable source the item exists in, with its per-source ids. + /// Shared by the detail screen and card context menus so an item resolves + /// its external ids at most once per session. Failures are not cached (the + /// next call retries); a null [client] (server offline) resolves to + /// nothing without caching the miss. Invalidated when sources rebind. + Future> watchlistCandidatesFor(MediaItem item, {required MediaServerClient? client}) { + final cached = _watchlistCandidateResults[_watchlistItemKey(item)]; + if (cached != null) return Future.value(cached); + if (client == null) return Future.value(const []); + final generation = _watchlistCandidateGeneration; + return _watchlistCandidateLoads.run(_watchlistItemKey(item), () async { + final candidates = await resolveWatchlistCandidates(client: client, item: item, sources: watchlistCapableSources); + if (!isDisposed && generation == _watchlistCandidateGeneration) { + _watchlistCandidateResults[_watchlistItemKey(item)] = candidates; + } + return candidates; + }); + } + + /// The already-resolved candidates for [item], or null when never resolved + /// this session. Menu surfaces label from this without awaiting; an empty + /// list means no connected source can hold the item. + List? cachedWatchlistCandidatesFor(MediaItem item) => + _watchlistCandidateResults[_watchlistItemKey(item)]; + + void _invalidateWatchlistCandidates() { + _watchlistCandidateGeneration++; + _watchlistCandidateLoads.clear(); + _watchlistCandidateResults.clear(); + } + /// Hydrate the per-profile active-source preference and Plex session. Future onActiveProfileChanged(String? userUuid) async { final generation = ++_profileBindingGeneration; @@ -196,6 +242,7 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie expectedProfileGeneration == _profileBindingGeneration && sessionGeneration == _plexSessionGeneration && _plex.update(null)) { + _invalidateWatchlistCandidates(); safeNotifyListeners(); } return; @@ -205,7 +252,10 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie sessionGeneration != _plexSessionGeneration) { return; } - if (_plex.update(session)) safeNotifyListeners(); + if (_plex.update(session)) { + _invalidateWatchlistCandidates(); + safeNotifyListeners(); + } } Future setActiveSource(CatalogSourceId id) async { @@ -225,7 +275,10 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie changed = _anilist.update(trackers.anilistCatalogClient) || changed; changed = _simkl.update(trackers.simklCatalogClient) || changed; changed = _seerr.update(seerr.catalogClient) || changed; - if (changed) safeNotifyListeners(); + if (changed) { + _invalidateWatchlistCandidates(); + safeNotifyListeners(); + } } @override diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index bcbaf7c2..b0fbcc3b 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -207,7 +207,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { // Membership reads each source's session snapshot — no per-open API // call. Filled when the item is on ANY source's watchlist; with several // candidates the press opens a source chooser. - // Not in the compact tiers: it drops away first on narrow screens. + // Not in the compact tiers: on narrow screens it drops away first and + // stays reachable through the ⋮ menu's watchlist entry. final watchlistStates = [ for (final candidate in _watchlistCandidates) candidate.source.isOnWatchlist(metadata.kind, candidate.ids), ]; @@ -343,24 +344,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { // shows that source's current membership. final renderBox = _watchlistButtonKey.currentContext?.findRenderObject() as RenderBox?; if (renderBox == null) return; - final choice = await showAppMenu( + final choice = await showWatchlistSourceChooser( context, + kind: metadata.kind, + candidates: candidates, anchorRect: renderBox.localToGlobal(Offset.zero) & renderBox.size, focusFirstItem: true, - entries: [ - for (final candidate in candidates) - AppMenuItem( - value: candidate, - leading: CatalogSourceLogo(candidate.source.id), - label: candidate.source.displayName, - subtitle: (candidate.source.isOnWatchlist(metadata.kind, candidate.ids) ?? false) - ? t.explore.removeFromWatchlist - : t.explore.addToWatchlist, - trailing: (candidate.source.isOnWatchlist(metadata.kind, candidate.ids) ?? false) - ? const AppIcon(Symbols.bookmark_added_rounded, fill: 1) - : const AppIcon(Symbols.bookmark_add_rounded), - ), - ], ); if (choice == null || !mounted) return; await _toggleWatchlistOn(metadata, choice); @@ -373,11 +362,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { try { // Optimistic inside the source; the row/screens listening to // watchlistChanges (including this one) rebuild immediately. - if (current) { - await candidate.source.removeFromWatchlist(metadata.kind, candidate.ids); - } else { - await candidate.source.addToWatchlist(metadata.kind, candidate.ids); - } + await mutateWatchlistMembership(metadata.kind, candidate, add: !current); } catch (_) { if (mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed); } finally { diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 5eb69322..1e6a4d57 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -58,12 +58,12 @@ import '../services/settings_service.dart'; import '../services/watch_actions.dart'; import '../widgets/settings_builder.dart'; import '../utils/layout_constants.dart'; -import '../models/catalog/catalog_item.dart'; import '../providers/catalog_sources_provider.dart'; import '../providers/download_provider.dart'; import '../providers/offline_watch_provider.dart'; import '../providers/watch_state_store.dart'; import '../services/catalog/catalog_source.dart'; +import '../services/catalog/library_watchlist_candidates.dart'; import '../utils/app_logger.dart'; import '../utils/formatters.dart'; import '../utils/scroll_utils.dart'; @@ -71,12 +71,11 @@ import '../utils/dialogs.dart'; import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/app_bar_back_button.dart'; -import '../widgets/app_menu.dart'; -import '../widgets/catalog_source_logo.dart'; import '../widgets/desktop_app_bar.dart'; import '../utils/desktop_window_padding.dart'; import '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/media_context_menu.dart'; +import '../widgets/watchlist_source_chooser.dart'; import 'libraries/state_messages.dart'; import '../widgets/overlay_sheet.dart'; import '../widgets/placeholder_container.dart'; @@ -118,10 +117,6 @@ const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId'; enum _SyncRuleAction { edit, remove, delete } -/// A watchlist-capable catalog source paired with this item's ids in that -/// source's terms (see `_resolveWatchlistIds`). -typedef WatchlistCandidate = ({CatalogSource source, CatalogItemIds ids}); - class _SeasonEpisodePager { final Map> _states = {}; final Set _firstPageLoadsInFlight = {}; @@ -685,37 +680,26 @@ class _MediaDetailScreenState extends State /// and for non-movie/show kinds. void _initWatchlistState() { if (widget.isOffline || (!_metadata.isMovie && !_metadata.isShow)) return; - final sources = Provider.of(context, listen: false)?.watchlistCapableSources; - if (sources == null || sources.isEmpty) return; + final catalogSources = Provider.of(context, listen: false); + final sources = catalogSources?.watchlistCapableSources ?? const []; + if (catalogSources == null || sources.isEmpty) return; _watchlistListenedSources = sources; for (final source in sources) { source.watchlistChanges.addListener(_onWatchlistSourceChanged); unawaited(source.ensureWatchlistLoaded()); } - unawaited(_resolveWatchlistIds(sources)); + unawaited(_resolveWatchlistIds(catalogSources)); } - Future _resolveWatchlistIds(List sources) async { + Future _resolveWatchlistIds(CatalogSourcesProvider catalogSources) async { try { - final ids = await _getMediaClientForMetadata(context)?.fetchExternalIds(_metadata.id); - if (!mounted || ids == null || !ids.hasAny) return; - // Sources can require their own id forms (MAL maps external ids to an - // anime id via Fribb); null means the item is outside that source's - // domain. The action shows for the sources that resolved; with more - // than one, the toggle opens a source chooser. - final candidates = []; - for (final source in sources) { - try { - final resolved = await source.resolveItemIds(_metadata.kind, ids); - if (resolved != null) candidates.add((source: source, ids: resolved)); - } catch (e, stackTrace) { - appLogger.d( - 'Watchlist external-id resolution failed for ${source.id.name}', - error: e, - stackTrace: stackTrace, - ); - } - } + // Session-cached on the provider and shared with the card context + // menus; null means the item is outside a source's domain and the + // action shows for the sources that resolved. + final candidates = await catalogSources.watchlistCandidatesFor( + _metadata, + client: _getMediaClientForMetadata(context), + ); if (!mounted || candidates.isEmpty) return; setState(() => _watchlistCandidates = candidates); } catch (e) { diff --git a/lib/services/catalog/library_watchlist_candidates.dart b/lib/services/catalog/library_watchlist_candidates.dart new file mode 100644 index 00000000..5d0278c6 --- /dev/null +++ b/lib/services/catalog/library_watchlist_candidates.dart @@ -0,0 +1,63 @@ +import '../../media/media_item.dart'; +import '../../media/media_kind.dart'; +import '../../media/media_server_client.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../utils/app_logger.dart'; +import 'catalog_source.dart'; + +/// A watchlist-capable catalog source paired with a library item's ids in +/// that source's terms (see [resolveWatchlistCandidates]). +typedef WatchlistCandidate = ({CatalogSource source, CatalogItemIds ids}); + +/// Resolve which of [sources] can hold [item] on their watchlist, with the +/// ids each source needs for membership/mutation. +/// +/// The item's external ids come from its owning server ([client]); each +/// source then maps them to its own id forms (MAL and AniList go through +/// Fribb; Plex Discover matches server-side). A source that cannot hold the +/// item (non-anime on MAL, no usable ids) is simply absent from the result. +/// Per-source resolution failures are logged and skipped so one flaky +/// provider does not hide the rest; a failed external-id fetch throws. +Future> resolveWatchlistCandidates({ + required MediaServerClient? client, + required MediaItem item, + required List sources, +}) async { + if (client == null || sources.isEmpty) return const []; + final ids = await client.fetchExternalIds(item.id); + if (!ids.hasAny) return const []; + final candidates = []; + for (final source in sources) { + try { + final resolved = await source.resolveItemIds(item.kind, ids); + if (resolved != null) candidates.add((source: source, ids: resolved)); + } catch (e, stackTrace) { + appLogger.d('Watchlist external-id resolution failed for ${source.id.name}', error: e, stackTrace: stackTrace); + } + } + return candidates; +} + +/// Watchlist mutations keyed by source+item so surfaces without a per-screen +/// guard (context menus, which outlive their tap) can't double-fire while +/// one is still in flight. +final Set _watchlistMutationsInFlight = {}; + +/// Add or remove [candidate]'s item on its source's watchlist. +/// +/// Returns false when the identical mutation is already in flight (nothing +/// ran). API failures propagate to the caller, which owns user feedback. +Future mutateWatchlistMembership(MediaKind kind, WatchlistCandidate candidate, {required bool add}) async { + final key = '${candidate.source.id.name}/${kind.id}/${candidate.ids.canonicalKey ?? ''}'; + if (!_watchlistMutationsInFlight.add(key)) return false; + try { + if (add) { + await candidate.source.addToWatchlist(kind, candidate.ids); + } else { + await candidate.source.removeFromWatchlist(kind, candidate.ids); + } + return true; + } finally { + _watchlistMutationsInFlight.remove(key); + } +} diff --git a/lib/widgets/catalog_context_menu.dart b/lib/widgets/catalog_context_menu.dart index ef10bfb3..49b7fa89 100644 --- a/lib/widgets/catalog_context_menu.dart +++ b/lib/widgets/catalog_context_menu.dart @@ -8,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../i18n/strings.g.dart'; import '../models/catalog/catalog_item.dart'; import '../providers/catalog_sources_provider.dart'; +import '../services/catalog/library_watchlist_candidates.dart'; import '../utils/catalog_navigation_helper.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; @@ -25,11 +26,6 @@ class _CatalogMenuAction { static const toggleWatchlist = _CatalogMenuAction(_CatalogMenuActionType.toggleWatchlist); } -/// Watchlist mutations keyed by source+item so a re-opened menu can't -/// double-fire while one is still in flight (the detail screens keep their -/// own per-screen guards). -final Set _watchlistMutationsInFlight = {}; - /// Context menu for catalog stand-in cards (Explore tab). Replaces /// [MediaContextMenu], whose entries are all server-backed and would break on /// items with no server id. @@ -87,18 +83,12 @@ Future showCatalogItemMenu(BuildContext context, CatalogItem item, {Offset // Re-read membership: it can have changed while the menu was open // (snapshot load, another surface's toggle). final current = source!.isOnWatchlist(item.kind, item.ids) ?? onWatchlist ?? false; - final mutationKey = '${source.id.name}/${item.kind.id}/${item.ids.canonicalKey ?? item.title}'; - if (!_watchlistMutationsInFlight.add(mutationKey)) return; try { - if (current) { - await source.removeFromWatchlist(item.kind, item.ids); - } else { - await source.addToWatchlist(item.kind, item.ids); - } + // The shared guard keys by source+item so a re-opened menu can't + // double-fire while one mutation is still in flight. + await mutateWatchlistMembership(item.kind, (source: source, ids: item.ids), add: !current); } catch (_) { if (context.mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed); - } finally { - _watchlistMutationsInFlight.remove(mutationKey); } case _CatalogMenuActionType.openUrl: final url = action.url; diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 6abbf970..4a62f9ce 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -23,6 +23,7 @@ import '../services/music/music_playback_service.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/playlist_items_loader.dart'; import '../services/watch_actions.dart'; +import '../services/catalog/library_watchlist_candidates.dart'; import '../models/transcode_quality_preset.dart'; import '../utils/content_utils.dart'; import '../utils/delete_impact.dart'; @@ -34,6 +35,7 @@ import '../utils/global_key_utils.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/offline_mode_provider.dart'; +import '../providers/catalog_sources_provider.dart'; import '../profiles/active_profile_provider.dart'; import '../profiles/profile.dart'; import '../utils/provider_extensions.dart'; @@ -62,6 +64,7 @@ import '../widgets/file_info_bottom_sheet.dart'; import 'pill_input_decoration.dart'; import '../widgets/focusable_list_tile.dart'; import '../widgets/overlay_sheet.dart'; +import 'watchlist_source_chooser.dart'; import '../widgets/rating_bottom_sheet.dart'; import '../i18n/strings.g.dart'; @@ -315,6 +318,40 @@ class MediaContextMenuState extends State { final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false; final canEditMetadata = isAdmin && supportsMetadataEdit(mediaClient, mediaKind); + // Watchlist (movies and shows), backed by the connected catalog sources + // (Trakt, Plex, MAL, ...). Whether THIS item resolves in a source needs + // its external ids, which load lazily: the entry defaults to "Add" — an + // idempotent no-op when the item turns out to be listed already — and + // only offers "Remove" once cached candidates prove membership, so a + // cold cache can never turn a press into a surprise removal. Opening + // the menu warms both caches for the tap and the next open. + final catalogSources = Provider.of(context, listen: false); + var showWatchlistEntry = false; + var watchlistRemoveOffered = false; + if (mediaItem != null && + (mediaKind == MediaKind.movie || mediaKind == MediaKind.show) && + catalogSources != null && + catalogSources.watchlistCapableSources.isNotEmpty && + itemServerOnline && + !context.read().isOffline) { + final cachedCandidates = catalogSources.cachedWatchlistCandidatesFor(mediaItem); + // Resolved-and-empty means no connected source can hold this item. + showWatchlistEntry = cachedCandidates == null || cachedCandidates.isNotEmpty; + watchlistRemoveOffered = + cachedCandidates?.any((c) => c.source.isOnWatchlist(mediaItem.kind, c.ids) == true) ?? false; + if (showWatchlistEntry) { + unawaited( + catalogSources.watchlistCandidatesFor(mediaItem, client: mediaClient).catchError((Object e, StackTrace st) { + appLogger.d('Watchlist candidate warm-up failed', error: e, stackTrace: st); + return const []; + }), + ); + for (final source in catalogSources.watchlistCapableSources) { + unawaited(source.ensureWatchlistLoaded()); + } + } + } + // Deletion is the one gate that asks the server per item; see // [isMediaDeletionAllowed]. Only kinds that can actually be deleted pay // for the round trip, and only on a backend that answers it. @@ -620,6 +657,16 @@ class MediaContextMenuState extends State { } } + if (showWatchlistEntry) { + menuActions.add( + _MenuAction( + value: 'toggle_watchlist', + icon: watchlistRemoveOffered ? Symbols.bookmark_remove_rounded : Symbols.bookmark_add_rounded, + label: watchlistRemoveOffered ? t.explore.removeFromWatchlist : t.explore.addToWatchlist, + ), + ); + } + // Add to... (for episodes, movies, shows, and seasons). Plex-only — // uses `buildMetadataUri` + `addToPlaylist` / `addToCollection`. The // MediaBrowser item-add APIs are different and not wired here yet. @@ -833,6 +880,18 @@ class MediaContextMenuState extends State { await _showAddToSubmenu(context); break; + case 'toggle_watchlist': + await _handleWatchlistToggle( + context, + mediaItem!, + catalogSources!, + mediaClient, + removeOffered: watchlistRemoveOffered, + position: position, + openedFromKeyboard: openedFromKeyboard, + ); + break; + case 'shuffle_play': await _handleShufflePlayWithQueue(context); break; @@ -944,6 +1003,65 @@ class MediaContextMenuState extends State { ]; } + /// Resolve-then-mutate for the watchlist entry. [removeOffered] pins the + /// intent the user saw: an entry labeled "Add" always adds (idempotent + /// when the item was already listed) — resolution finishing after the + /// menu was built must not flip a press into a removal. With several + /// capable sources a chooser opens and the picked source toggles by its + /// own (by now resolved) membership, mirroring the detail screen. + Future _handleWatchlistToggle( + BuildContext context, + MediaItem item, + CatalogSourcesProvider catalogSources, + MediaServerClient? client, { + required bool removeOffered, + required Offset? position, + required bool openedFromKeyboard, + }) async { + List candidates; + try { + candidates = await catalogSources.watchlistCandidatesFor(item, client: client); + } catch (e, st) { + appLogger.w('Watchlist candidate resolution failed', error: e, stackTrace: st); + if (context.mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed); + return; + } + if (!context.mounted) return; + if (candidates.isEmpty) { + showAppSnackBar(context, t.explore.watchlistNoMatch); + return; + } + + final WatchlistCandidate candidate; + final bool add; + if (candidates.length == 1) { + candidate = candidates.single; + add = !removeOffered; + } else { + final choice = await showWatchlistSourceChooser( + context, + kind: item.kind, + candidates: candidates, + position: position, + focusFirstItem: openedFromKeyboard, + ); + if (choice == null || !context.mounted) return; + candidate = choice; + add = !(choice.source.isOnWatchlist(item.kind, choice.ids) ?? false); + } + + try { + // Membership updates optimistically inside the source; Explore rows + // and open detail screens listening to watchlistChanges follow. + if (!await mutateWatchlistMembership(item.kind, candidate, add: add)) return; + if (!context.mounted) return; + showSuccessSnackBar(context, add ? t.explore.addedToWatchlist : t.explore.removedFromWatchlist); + } catch (e, st) { + appLogger.w('Watchlist update failed', error: e, stackTrace: st); + if (context.mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed); + } + } + /// Execute an action with error handling and refresh Future _executeAction(BuildContext context, Future Function() action, String successMessage) async { try { diff --git a/lib/widgets/watchlist_source_chooser.dart b/lib/widgets/watchlist_source_chooser.dart new file mode 100644 index 00000000..91f2528c --- /dev/null +++ b/lib/widgets/watchlist_source_chooser.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../i18n/strings.g.dart'; +import '../media/media_kind.dart'; +import '../services/catalog/library_watchlist_candidates.dart'; +import 'app_icon.dart'; +import 'app_menu.dart'; +import 'catalog_source_logo.dart'; + +/// Anchored chooser for an item that resolves in several watchlist-capable +/// sources: one entry per source, subtitled with what selecting it would do +/// given that source's current membership. Returns the picked candidate, or +/// null on dismiss. +Future showWatchlistSourceChooser( + BuildContext context, { + required MediaKind kind, + required List candidates, + Rect? anchorRect, + Offset? position, + bool focusFirstItem = false, +}) { + return showAppMenu( + context, + anchorRect: anchorRect, + position: position, + focusFirstItem: focusFirstItem, + entries: [ + for (final candidate in candidates) + AppMenuItem( + value: candidate, + leading: CatalogSourceLogo(candidate.source.id), + label: candidate.source.displayName, + subtitle: (candidate.source.isOnWatchlist(kind, candidate.ids) ?? false) + ? t.explore.removeFromWatchlist + : t.explore.addToWatchlist, + trailing: (candidate.source.isOnWatchlist(kind, candidate.ids) ?? false) + ? const AppIcon(Symbols.bookmark_added_rounded, fill: 1) + : const AppIcon(Symbols.bookmark_add_rounded), + ), + ], + ); +} diff --git a/test/providers/catalog_sources_provider_test.dart b/test/providers/catalog_sources_provider_test.dart index 3fbbc162..e8f3bad7 100644 --- a/test/providers/catalog_sources_provider_test.dart +++ b/test/providers/catalog_sources_provider_test.dart @@ -1,8 +1,15 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; import 'package:plezy/providers/catalog_sources_provider.dart'; +import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/services/plex_discover_client.dart'; +import 'package:plezy/utils/external_ids.dart'; +import '../test_helpers/media_items.dart'; import '../test_helpers/prefs.dart'; void main() { @@ -78,4 +85,132 @@ void main() { expect(calls, 3); expect(provider.connectedSources, isEmpty); }); + + group('watchlist candidate cache', () { + final item = testMediaItem(id: 'movie-1', serverId: 'server-1'); + + test('resolves an item once per session and hands menus the cached result', () async { + final source = _FakeWatchlistSource(CatalogSourceId.trakt); + final provider = _FakeSourcesProvider([source]); + addTearDown(provider.dispose); + final client = _ExternalIdsClient(const ExternalIds(imdb: 'tt1')); + + expect(provider.cachedWatchlistCandidatesFor(item), isNull); + final first = await provider.watchlistCandidatesFor(item, client: client); + final second = await provider.watchlistCandidatesFor(item, client: client); + + expect(client.calls, 1); + expect(first.single.source, same(source)); + expect(second, same(first)); + expect(provider.cachedWatchlistCandidatesFor(item), same(first)); + }); + + test('concurrent loads for the same item coalesce into one resolution', () async { + final provider = _FakeSourcesProvider([_FakeWatchlistSource(CatalogSourceId.trakt)]); + addTearDown(provider.dispose); + final gate = Completer(); + final client = _ExternalIdsClient(const ExternalIds(imdb: 'tt1'), gate: gate); + + final first = provider.watchlistCandidatesFor(item, client: client); + final second = provider.watchlistCandidatesFor(item, client: client); + gate.complete(); + + expect(await second, same(await first)); + expect(client.calls, 1); + }); + + test('a failed resolution is retried instead of cached', () async { + final provider = _FakeSourcesProvider([_FakeWatchlistSource(CatalogSourceId.trakt)]); + addTearDown(provider.dispose); + final client = _ExternalIdsClient(const ExternalIds(imdb: 'tt1'), error: StateError('unreachable')); + + await expectLater(provider.watchlistCandidatesFor(item, client: client), throwsStateError); + expect(provider.cachedWatchlistCandidatesFor(item), isNull); + + client.error = null; + final candidates = await provider.watchlistCandidatesFor(item, client: client); + expect(candidates, hasLength(1)); + expect(client.calls, 2); + }); + + test('a null client resolves to nothing without caching the miss', () async { + final provider = _FakeSourcesProvider([_FakeWatchlistSource(CatalogSourceId.trakt)]); + addTearDown(provider.dispose); + + expect(await provider.watchlistCandidatesFor(item, client: null), isEmpty); + expect(provider.cachedWatchlistCandidatesFor(item), isNull); + + final candidates = await provider.watchlistCandidatesFor( + item, + client: _ExternalIdsClient(const ExternalIds(imdb: 'tt1')), + ); + expect(candidates, hasLength(1)); + }); + + test('a source rebind invalidates cached candidates', () async { + PlexDiscoverSession? session = const PlexDiscoverSession(accessToken: 'a', clientIdentifier: 'client-id'); + final provider = _FakeSourcesProvider([ + _FakeWatchlistSource(CatalogSourceId.trakt), + ], plexSessionSupplier: () async => session); + addTearDown(provider.dispose); + await provider.onActiveProfileChanged('profile-1'); + + await provider.watchlistCandidatesFor(item, client: _ExternalIdsClient(const ExternalIds(imdb: 'tt1'))); + expect(provider.cachedWatchlistCandidatesFor(item), isNotNull); + + await provider.onProfileBindingStateChanged(true); + session = const PlexDiscoverSession(accessToken: 'b', clientIdentifier: 'client-id'); + await provider.onProfileBindingStateChanged(false); + + expect(provider.cachedWatchlistCandidatesFor(item), isNull); + }); + }); +} + +class _ExternalIdsClient implements MediaServerClient { + _ExternalIdsClient(this.ids, {this.error, this.gate}); + + final ExternalIds ids; + Object? error; + final Completer? gate; + int calls = 0; + + @override + Future fetchExternalIds(String itemId) async { + calls++; + await gate?.future; + if (error != null) throw error!; + return ids; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeWatchlistSource implements CatalogSource { + _FakeWatchlistSource(this.id); + + @override + final CatalogSourceId id; + + @override + bool get supportsWatchlist => true; + + @override + Future resolveItemIds(MediaKind kind, ExternalIds external) async => + CatalogItemIds(imdb: external.imdb); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Overrides the bound-client source list so the cache can be exercised with +/// fake sources; the invalidation paths (Plex session rebinds) stay real. +class _FakeSourcesProvider extends CatalogSourcesProvider { + _FakeSourcesProvider(this.sources, {super.plexSessionSupplier}); + + final List sources; + + @override + List get connectedSources => sources; } diff --git a/test/services/catalog/library_watchlist_candidates_test.dart b/test/services/catalog/library_watchlist_candidates_test.dart new file mode 100644 index 00000000..d3283652 --- /dev/null +++ b/test/services/catalog/library_watchlist_candidates_test.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/services/catalog/catalog_source.dart'; +import 'package:plezy/services/catalog/library_watchlist_candidates.dart'; +import 'package:plezy/utils/external_ids.dart'; + +import '../../test_helpers/media_items.dart'; + +class _ExternalIdsClient implements MediaServerClient { + _ExternalIdsClient(this.ids, {this.error}); + + ExternalIds ids; + Object? error; + int calls = 0; + + @override + Future fetchExternalIds(String itemId) async { + calls++; + if (error != null) throw error!; + return ids; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeWatchlistSource implements CatalogSource { + _FakeWatchlistSource(this.id, {this.resolveTo, this.resolveError, this.mutationGate, this.mutationError}); + + @override + final CatalogSourceId id; + + final CatalogItemIds? resolveTo; + final Object? resolveError; + final Completer? mutationGate; + final Object? mutationError; + + int resolveCalls = 0; + final List<({MediaKind kind, CatalogItemIds ids, bool add})> mutations = []; + + @override + bool get supportsWatchlist => true; + + @override + Future resolveItemIds(MediaKind kind, ExternalIds external) async { + resolveCalls++; + if (resolveError != null) throw resolveError!; + return resolveTo; + } + + @override + Future addToWatchlist(MediaKind kind, CatalogItemIds ids) => _mutate(kind, ids, add: true); + + @override + Future removeFromWatchlist(MediaKind kind, CatalogItemIds ids) => _mutate(kind, ids, add: false); + + Future _mutate(MediaKind kind, CatalogItemIds ids, {required bool add}) async { + await mutationGate?.future; + if (mutationError != null) throw mutationError!; + mutations.add((kind: kind, ids: ids, add: add)); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + final item = testMediaItem(id: 'movie-1', serverId: 'server-1'); + + group('resolveWatchlistCandidates', () { + test('pairs each source with its resolved ids, skipping out-of-domain and failing sources', () async { + final trakt = _FakeWatchlistSource(CatalogSourceId.trakt, resolveTo: const CatalogItemIds(imdb: 'tt1')); + final mal = _FakeWatchlistSource(CatalogSourceId.mal); // resolves null: not in domain + final simkl = _FakeWatchlistSource(CatalogSourceId.simkl, resolveError: StateError('down')); + + final candidates = await resolveWatchlistCandidates( + client: _ExternalIdsClient(const ExternalIds(imdb: 'tt1')), + item: item, + sources: [trakt, mal, simkl], + ); + + expect(candidates.map((c) => c.source.id), [CatalogSourceId.trakt]); + expect(candidates.single.ids.imdb, 'tt1'); + expect(mal.resolveCalls, 1); + expect(simkl.resolveCalls, 1); + }); + + test('an item without external ids resolves to nothing without asking any source', () async { + final trakt = _FakeWatchlistSource(CatalogSourceId.trakt, resolveTo: const CatalogItemIds(imdb: 'tt1')); + + final candidates = await resolveWatchlistCandidates( + client: _ExternalIdsClient(const ExternalIds()), + item: item, + sources: [trakt], + ); + + expect(candidates, isEmpty); + expect(trakt.resolveCalls, 0); + }); + + test('a null client (server offline) resolves to nothing', () async { + final candidates = await resolveWatchlistCandidates( + client: null, + item: item, + sources: [_FakeWatchlistSource(CatalogSourceId.trakt)], + ); + expect(candidates, isEmpty); + }); + + test('a failed external-id fetch propagates to the caller', () { + expect( + resolveWatchlistCandidates( + client: _ExternalIdsClient(const ExternalIds(), error: StateError('unreachable')), + item: item, + sources: [_FakeWatchlistSource(CatalogSourceId.trakt)], + ), + throwsStateError, + ); + }); + }); + + group('mutateWatchlistMembership', () { + const ids = CatalogItemIds(imdb: 'tt1'); + + test('dispatches add and remove to the candidate source', () async { + final source = _FakeWatchlistSource(CatalogSourceId.trakt); + + expect(await mutateWatchlistMembership(MediaKind.movie, (source: source, ids: ids), add: true), isTrue); + expect(await mutateWatchlistMembership(MediaKind.movie, (source: source, ids: ids), add: false), isTrue); + + expect(source.mutations.map((m) => m.add), [true, false]); + expect(source.mutations.first.ids.imdb, 'tt1'); + }); + + test('an identical mutation already in flight is refused instead of double-firing', () async { + final gate = Completer(); + final source = _FakeWatchlistSource(CatalogSourceId.trakt, mutationGate: gate); + final candidate = (source: source, ids: ids); + + final first = mutateWatchlistMembership(MediaKind.movie, candidate, add: true); + expect(await mutateWatchlistMembership(MediaKind.movie, candidate, add: true), isFalse); + expect(source.mutations, isEmpty); + + gate.complete(); + expect(await first, isTrue); + expect(source.mutations, hasLength(1)); + + // The guard releases with the mutation. + expect(await mutateWatchlistMembership(MediaKind.movie, candidate, add: false), isTrue); + expect(source.mutations, hasLength(2)); + }); + + test('a failed mutation rethrows and releases the guard', () async { + final failing = _FakeWatchlistSource(CatalogSourceId.trakt, mutationError: StateError('api down')); + final candidate = (source: failing, ids: ids); + + await expectLater(mutateWatchlistMembership(MediaKind.movie, candidate, add: true), throwsStateError); + + final working = _FakeWatchlistSource(CatalogSourceId.trakt); + expect(await mutateWatchlistMembership(MediaKind.movie, (source: working, ids: ids), add: true), isTrue); + expect(working.mutations, hasLength(1)); + }); + }); +} diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 1dba58c9..dc631a00 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -27,9 +27,11 @@ import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/metadata_edit/metadata_edit_adapters.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/models/plex/plex_config.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/providers/download_provider.dart'; +import 'package:plezy/providers/catalog_sources_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/offline_mode_provider.dart'; import 'package:plezy/providers/playback_state_provider.dart'; @@ -43,7 +45,9 @@ import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/utils/deletion_notifier.dart'; +import 'package:plezy/utils/external_ids.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/media_server_timeouts.dart'; @@ -1195,6 +1199,111 @@ void main() { expect(harness.client.fileInfoRequests, isEmpty); }); }); + + group('watchlist entry', () { + testWidgets('cold open offers Add and adds to the single capable source', (tester) async { + final source = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1')); + final harness = await _pumpWatchlistMenu(tester, sources: [source], guids: ['imdb://tt1']); + + harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target'))); + await tester.pumpAndSettle(); + + expect(find.text(t.explore.addToWatchlist), findsOneWidget); + expect(find.text(t.explore.removeFromWatchlist), findsNothing); + + await tester.tap(find.text(t.explore.addToWatchlist)); + await tester.pumpAndSettle(); + + expect(source.mutations.map((m) => m.add), [true]); + expect(source.mutations.single.ids.imdb, 'tt1'); + expect(find.text(t.explore.addedToWatchlist), findsOneWidget); + // Opening also kicked the membership snapshot load for the next open. + expect(source.ensureLoadedCalls, greaterThan(0)); + }); + + testWidgets('offers Remove once cached membership is known, and removes', (tester) async { + final source = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1')) + ..membership = true; + final harness = await _pumpWatchlistMenu(tester, sources: [source]); + await harness.catalogSources.watchlistCandidatesFor( + harness.item, + client: _SeedIdsClient(const ExternalIds(imdb: 'tt1')), + ); + + harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target'))); + await tester.pumpAndSettle(); + + expect(find.text(t.explore.removeFromWatchlist), findsOneWidget); + + await tester.tap(find.text(t.explore.removeFromWatchlist)); + await tester.pumpAndSettle(); + + expect(source.mutations.map((m) => m.add), [false]); + expect(find.text(t.explore.removedFromWatchlist), findsOneWidget); + }); + + testWidgets('hides the entry when the item resolved in no capable source', (tester) async { + final source = _MenuWatchlistSource(CatalogSourceId.mal, 'MAL'); // resolves null: out of domain + final harness = await _pumpWatchlistMenu(tester, sources: [source]); + await harness.catalogSources.watchlistCandidatesFor( + harness.item, + client: _SeedIdsClient(const ExternalIds(imdb: 'tt1')), + ); + + harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target'))); + await tester.pumpAndSettle(); + + expect(find.text(t.mediaMenu.markAsWatched), findsOneWidget); + expect(find.text(t.explore.addToWatchlist), findsNothing); + expect(find.text(t.explore.removeFromWatchlist), findsNothing); + }); + + testWidgets('reports when the tapped item matches no watchlist', (tester) async { + final source = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1')); + // The metadata answer carries no Guid entries: no external ids. + final harness = await _pumpWatchlistMenu(tester, sources: [source]); + + harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text(t.explore.addToWatchlist)); + await tester.pumpAndSettle(); + + expect(source.mutations, isEmpty); + expect(find.text(t.explore.watchlistNoMatch), findsOneWidget); + }); + + testWidgets('several capable sources open a per-source chooser', (tester) async { + final trakt = _MenuWatchlistSource(CatalogSourceId.trakt, 'Trakt', resolveTo: const CatalogItemIds(imdb: 'tt1')); + final simkl = _MenuWatchlistSource(CatalogSourceId.simkl, 'Simkl', resolveTo: const CatalogItemIds(imdb: 'tt1')) + ..membership = true; + final harness = await _pumpWatchlistMenu(tester, sources: [trakt, simkl]); + await harness.catalogSources.watchlistCandidatesFor( + harness.item, + client: _SeedIdsClient(const ExternalIds(imdb: 'tt1')), + ); + + harness.menuKey.currentState!.showContextMenu(tester.element(find.text('watchlist target'))); + await tester.pumpAndSettle(); + + // Membership known-true on one source labels the entry Remove. + await tester.tap(find.text(t.explore.removeFromWatchlist)); + await tester.pumpAndSettle(); + + // The chooser names each source with its own pending action. + expect(find.text('Trakt'), findsOneWidget); + expect(find.text('Simkl'), findsOneWidget); + expect(find.text(t.explore.addToWatchlist), findsOneWidget); + expect(find.text(t.explore.removeFromWatchlist), findsOneWidget); + + await tester.tap(find.text('Simkl')); + await tester.pumpAndSettle(); + + expect(simkl.mutations.map((m) => m.add), [false]); + expect(trakt.mutations, isEmpty); + expect(find.text(t.explore.removedFromWatchlist), findsOneWidget); + }); + }); } Future> _pumpPlexMovieMenu( @@ -1761,3 +1870,141 @@ JellyfinConnection _jellyfinConnection() { createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); } + +class _MenuWatchlistSource implements CatalogSource { + _MenuWatchlistSource(this.id, this.displayName, {this.resolveTo}); + + @override + final CatalogSourceId id; + + @override + final String displayName; + + final CatalogItemIds? resolveTo; + bool? membership; + int ensureLoadedCalls = 0; + final List<({MediaKind kind, CatalogItemIds ids, bool add})> mutations = []; + + @override + bool get supportsWatchlist => true; + + @override + Future ensureWatchlistLoaded() async { + ensureLoadedCalls++; + } + + @override + bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => membership; + + @override + Future resolveItemIds(MediaKind kind, ExternalIds external) async => resolveTo; + + @override + Future addToWatchlist(MediaKind kind, CatalogItemIds ids) async => + mutations.add((kind: kind, ids: ids, add: true)); + + @override + Future removeFromWatchlist(MediaKind kind, CatalogItemIds ids) async => + mutations.add((kind: kind, ids: ids, add: false)); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _WatchlistSourcesProvider extends CatalogSourcesProvider { + _WatchlistSourcesProvider(this.sources); + + final List sources; + + @override + List get connectedSources => sources; +} + +class _SeedIdsClient implements MediaServerClient { + _SeedIdsClient(this.ids); + + final ExternalIds ids; + + @override + Future fetchExternalIds(String itemId) async => ids; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Pumps a Plex movie's context menu with [sources] connected as catalog +/// sources. The Plex MockClient answers the external-id metadata fetch with +/// [guids] (empty: the item carries no external ids). +Future<({GlobalKey menuKey, MediaItem item, CatalogSourcesProvider catalogSources})> +_pumpWatchlistMenu(WidgetTester tester, {required List sources, List guids = const []}) async { + LocaleSettings.setLocaleSync(AppLocale.en); + TvDetectionService.debugSetAppleTVOverride(true); + addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + final client = testPlexClient( + serverId: ServerId('plex-1'), + httpClient: MockClient((request) async { + if (request.url.path == '/library/metadata/movie-1') { + return jsonResponse({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'movie-1', + 'type': 'movie', + 'title': 'Movie', + if (guids.isNotEmpty) + 'Guid': [ + for (final guid in guids) {'id': guid}, + ], + }, + ], + }, + }); + } + return http.Response('not found', 404); + }), + ); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = testMultiServerProvider(manager); + final offlineMode = OfflineModeProvider(manager); + final catalogSources = _WatchlistSourcesProvider(sources); + final stack = await ProfileStack.create(db: db, withStorage: false); + addTearDown(() async { + await stack.dispose(); + catalogSources.dispose(); + offlineMode.dispose(); + multiServerProvider.dispose(); + manager.dispose(); + await db.close(); + }); + + final menuKey = GlobalKey(); + final item = testMediaItem(id: 'movie-1', kind: MediaKind.movie, title: 'Movie', serverId: 'plex-1'); + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: stack.active), + ChangeNotifierProvider.value(value: offlineMode), + ChangeNotifierProvider.value(value: catalogSources), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: Center( + child: MediaContextMenu( + key: menuKey, + item: item, + child: const SizedBox(width: 120, height: 80, child: Text('watchlist target')), + ), + ), + ), + ), + ), + ), + ); + return (menuKey: menuKey, item: item, catalogSources: catalogSources); +}