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