diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 15653b44..b72d5b63 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "Плътност на библиотеката", "compact": "Компактна", "comfortable": "Удобна", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "Режим на изглед", "gridView": "Мрежа", "listView": "Списък", @@ -872,6 +874,7 @@ "trendingShows": "Актуални сериали", "popularMovies": "Популярни филми", "popularShows": "Популярни сериали", + "trendingAnime": "Актуални аниме", "suggestedAnime": "Препоръчани аниме", "airingAnime": "Топ излъчвани аниме", "popularAnime": "Най-популярни аниме", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 3fd9e667..acb97ffe 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "Bibliotekstæthed", "compact": "Kompakt", "comfortable": "Komfortabel", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "Visningstilstand", "gridView": "Gitter", "listView": "Liste", @@ -872,6 +874,7 @@ "trendingShows": "Trending serier", "popularMovies": "Populære film", "popularShows": "Populære serier", + "trendingAnime": "Trending anime", "suggestedAnime": "Foreslået anime", "airingAnime": "Top igangværende anime", "popularAnime": "Mest populære anime", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index bb35046b..4519634e 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -874,6 +874,7 @@ "trendingShows": "Angesagte Serien", "popularMovies": "Beliebte Filme", "popularShows": "Beliebte Serien", + "trendingAnime": "Angesagte Anime", "suggestedAnime": "Vorgeschlagene Anime", "airingAnime": "Top laufende Anime", "popularAnime": "Beliebteste Anime", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 2745ac1b..c626fa51 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -874,6 +874,7 @@ "trendingShows": "Trending Shows", "popularMovies": "Popular Movies", "popularShows": "Popular Shows", + "trendingAnime": "Trending Anime", "suggestedAnime": "Suggested Anime", "airingAnime": "Top Airing Anime", "popularAnime": "Most Popular Anime", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index d14a4491..d6e47cda 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "Densidad de Biblioteca", "compact": "Compacto", "comfortable": "Cómodo", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "Modo de Vista", "gridView": "Cuadrícula", "listView": "Lista", @@ -872,6 +874,7 @@ "trendingShows": "Series en tendencia", "popularMovies": "Películas populares", "popularShows": "Series populares", + "trendingAnime": "Anime en tendencia", "suggestedAnime": "Anime sugerido", "airingAnime": "Mejor anime en emisión", "popularAnime": "Anime más popular", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 893de761..afc14f69 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "Densité des bibliothèques", "compact": "Compact", "comfortable": "Confortable", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "Mode d'affichage", "gridView": "Grille", "listView": "Liste", @@ -872,6 +874,7 @@ "trendingShows": "Show TV tendance", "popularMovies": "Films populaires", "popularShows": "Show TV populaires", + "trendingAnime": "Anime tendance", "suggestedAnime": "Anime suggérés", "airingAnime": "Meilleurs anime en diffusion", "popularAnime": "Anime les plus populaires", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index e78d9fcf..81320981 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "Densità libreria", "compact": "Compatta", "comfortable": "Comoda", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "Modalità di visualizzazione", "gridView": "Griglia", "listView": "Elenco", @@ -872,6 +874,7 @@ "trendingShows": "Serie TV di tendenza", "popularMovies": "Film popolari", "popularShows": "Serie TV popolari", + "trendingAnime": "Anime di tendenza", "suggestedAnime": "Anime suggeriti", "airingAnime": "Migliori anime in onda", "popularAnime": "Anime più popolari", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 33040297..a8ed261f 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "ライブラリの密度", "compact": "コンパクト", "comfortable": "ゆったり", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "表示モード", "gridView": "グリッド", "listView": "リスト", @@ -871,6 +873,7 @@ "trendingShows": "トレンドのテレビ番組", "popularMovies": "人気の映画", "popularShows": "人気のテレビ番組", + "trendingAnime": "トレンドのアニメ", "suggestedAnime": "おすすめのアニメ", "airingAnime": "放送中の注目アニメ", "popularAnime": "人気のアニメ", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index ea137df9..3cd202fa 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "라이브러리 표시 밀도", "compact": "좁게", "comfortable": "넓게", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "보기 모드", "gridView": "그리드 보기", "listView": "목록 보기", @@ -871,6 +873,7 @@ "trendingShows": "지금 뜨는 TV 프로그램", "popularMovies": "인기 영화", "popularShows": "인기 TV 프로그램", + "trendingAnime": "지금 뜨는 애니메이션", "suggestedAnime": "추천 애니메이션", "airingAnime": "방영 중인 인기 애니메이션", "popularAnime": "가장 인기 있는 애니메이션", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 885b4f38..7314b2e3 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "Bibliotekets tetthet", "compact": "Kompakt", "comfortable": "Komfortabel", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "Visningsmodus", "gridView": "Rutenett", "listView": "Liste", @@ -872,6 +874,7 @@ "trendingShows": "Trending serier", "popularMovies": "Populære filmer", "popularShows": "Populære serier", + "trendingAnime": "Anime som trender", "suggestedAnime": "Foreslått anime", "airingAnime": "Topp pågående anime", "popularAnime": "Mest populære anime", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index ac6dcd90..231c0714 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "Bibliotheek dichtheid", "compact": "Compact", "comfortable": "Comfortabel", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "Weergavemodus", "gridView": "Raster", "listView": "Lijst", @@ -872,6 +874,7 @@ "trendingShows": "Trending series", "popularMovies": "Populaire films", "popularShows": "Populaire series", + "trendingAnime": "Trending anime", "suggestedAnime": "Voorgestelde anime", "airingAnime": "Top lopende anime", "popularAnime": "Populairste anime", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 096e8e02..0ddedd87 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "Gęstość biblioteki", "compact": "Kompaktowy", "comfortable": "Wygodny", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "Tryb widoku", "gridView": "Siatka", "listView": "Lista", @@ -874,6 +876,7 @@ "trendingShows": "Seriale na czasie", "popularMovies": "Popularne filmy", "popularShows": "Popularne seriale", + "trendingAnime": "Anime na czasie", "suggestedAnime": "Sugerowane anime", "airingAnime": "Najlepsze emitowane anime", "popularAnime": "Najpopularniejsze anime", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 50267537..526c8226 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "Densidade da Biblioteca", "compact": "Compacto", "comfortable": "Confortável", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "Modo de Visualização", "gridView": "Grade", "listView": "Lista", @@ -872,6 +874,7 @@ "trendingShows": "Séries em alta", "popularMovies": "Filmes populares", "popularShows": "Séries populares", + "trendingAnime": "Anime em alta", "suggestedAnime": "Anime sugerido", "airingAnime": "Melhor anime em exibição", "popularAnime": "Anime mais popular", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 31a9011f..a0c86792 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "Плотность библиотеки", "compact": "Компактный", "comfortable": "Комфортный", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "Режим просмотра", "gridView": "Сетка", "listView": "Список", @@ -874,6 +876,7 @@ "trendingShows": "Сериалы в тренде", "popularMovies": "Популярные фильмы", "popularShows": "Популярные сериалы", + "trendingAnime": "Аниме в тренде", "suggestedAnime": "Рекомендуемые аниме", "airingAnime": "Топ выходящих аниме", "popularAnime": "Самые популярные аниме", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 8ce829a4..4883af24 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 22855 (1428 per locale) +/// Strings: 22899 (1431 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 4d4f6575..d9d8e5e9 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsBg extends TranslationsSettingsEn { @override String get libraryDensity => 'Плътност на библиотеката'; @override String get compact => 'Компактна'; @override String get comfortable => 'Удобна'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => 'Режим на изглед'; @override String get gridView => 'Мрежа'; @override String get listView => 'Списък'; @@ -1934,6 +1936,7 @@ class _TranslationsExploreRowsBg extends TranslationsExploreRowsEn { @override String get trendingShows => 'Актуални сериали'; @override String get popularMovies => 'Популярни филми'; @override String get popularShows => 'Популярни сериали'; + @override String get trendingAnime => 'Актуални аниме'; @override String get suggestedAnime => 'Препоръчани аниме'; @override String get airingAnime => 'Топ излъчвани аниме'; @override String get popularAnime => 'Най-популярни аниме'; @@ -2221,6 +2224,8 @@ extension on TranslationsBg { 'settings.libraryDensity' => 'Плътност на библиотеката', 'settings.compact' => 'Компактна', 'settings.comfortable' => 'Удобна', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => 'Режим на изглед', 'settings.gridView' => 'Мрежа', 'settings.listView' => 'Списък', @@ -2633,10 +2638,10 @@ extension on TranslationsBg { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('bg'))(n, one: 'Автоматично премахнато ${n} гледано изтегляне', other: 'Автоматично премахнати ${n} гледани изтегляния', ), 'messages.removedFromContinueWatching' => 'Премахнато от продължаване на гледането', 'messages.errorLoading' => ({required Object error}) => 'Грешка: ${error}', - 'messages.streamInterrupted' => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.', - 'messages.liveStreamInterrupted' => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.', + 'messages.liveStreamInterrupted' => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.', 'messages.fileInfoNotAvailable' => 'Информацията за файла не е налична', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Грешка при зареждане на информация за файла: ${error}', 'messages.errorLoadingSeries' => 'Грешка при зареждане на сериала', @@ -2922,6 +2927,7 @@ extension on TranslationsBg { 'explore.rows.trendingShows' => 'Актуални сериали', 'explore.rows.popularMovies' => 'Популярни филми', 'explore.rows.popularShows' => 'Популярни сериали', + 'explore.rows.trendingAnime' => 'Актуални аниме', 'explore.rows.suggestedAnime' => 'Препоръчани аниме', 'explore.rows.airingAnime' => 'Топ излъчвани аниме', 'explore.rows.popularAnime' => 'Най-популярни аниме', @@ -3146,11 +3152,11 @@ extension on TranslationsBg { 'watchTogether.removeRoom' => 'Премахни', 'watchTogether.guestSwitchUnavailable' => 'Превключването не е възможно — сървърът е недостъпен за синхронизация', 'watchTogether.guestSwitchFailed' => 'Превключването не е възможно — съдържанието не е намерено на този сървър', + _ => null, + } ?? switch (path) { 'downloads.title' => 'Изтегляния', 'downloads.manage' => 'Управление', 'downloads.tvShows' => 'ТВ сериали', - _ => null, - } ?? switch (path) { 'downloads.movies' => 'Филми', 'downloads.music' => 'Музика', 'downloads.tracksQueued' => ({required Object count}) => '${count} песни в опашката за изтегляне', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 96ffac33..0fa4d401 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsDa extends TranslationsSettingsEn { @override String get libraryDensity => 'Bibliotekstæthed'; @override String get compact => 'Kompakt'; @override String get comfortable => 'Komfortabel'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => 'Visningstilstand'; @override String get gridView => 'Gitter'; @override String get listView => 'Liste'; @@ -1934,6 +1936,7 @@ class _TranslationsExploreRowsDa extends TranslationsExploreRowsEn { @override String get trendingShows => 'Trending serier'; @override String get popularMovies => 'Populære film'; @override String get popularShows => 'Populære serier'; + @override String get trendingAnime => 'Trending anime'; @override String get suggestedAnime => 'Foreslået anime'; @override String get airingAnime => 'Top igangværende anime'; @override String get popularAnime => 'Mest populære anime'; @@ -2221,6 +2224,8 @@ extension on TranslationsDa { 'settings.libraryDensity' => 'Bibliotekstæthed', 'settings.compact' => 'Kompakt', 'settings.comfortable' => 'Komfortabel', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => 'Visningstilstand', 'settings.gridView' => 'Gitter', 'settings.listView' => 'Liste', @@ -2633,10 +2638,10 @@ extension on TranslationsDa { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('da'))(n, one: 'Fjernede automatisk ${n} set download', other: 'Fjernede automatisk ${n} sete downloads', ), 'messages.removedFromContinueWatching' => 'Fjernet fra Fortsæt med at se', 'messages.errorLoading' => ({required Object error}) => 'Fejl: ${error}', - 'messages.streamInterrupted' => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.', - 'messages.liveStreamInterrupted' => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.', + 'messages.liveStreamInterrupted' => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.', 'messages.fileInfoNotAvailable' => 'Filinfo ikke tilgængelig', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fejl ved indlæsning af filinfo: ${error}', 'messages.errorLoadingSeries' => 'Fejl ved indlæsning af serie', @@ -2922,6 +2927,7 @@ extension on TranslationsDa { 'explore.rows.trendingShows' => 'Trending serier', 'explore.rows.popularMovies' => 'Populære film', 'explore.rows.popularShows' => 'Populære serier', + 'explore.rows.trendingAnime' => 'Trending anime', 'explore.rows.suggestedAnime' => 'Foreslået anime', 'explore.rows.airingAnime' => 'Top igangværende anime', 'explore.rows.popularAnime' => 'Mest populære anime', @@ -3146,11 +3152,11 @@ extension on TranslationsDa { 'watchTogether.removeRoom' => 'Fjern', 'watchTogether.guestSwitchUnavailable' => 'Kunne ikke skifte — server ikke tilgængelig for synkronisering', 'watchTogether.guestSwitchFailed' => 'Kunne ikke skifte — indhold blev ikke fundet på denne server', + _ => null, + } ?? switch (path) { 'downloads.title' => 'Downloads', 'downloads.manage' => 'Administrer', 'downloads.tvShows' => 'TV-serier', - _ => null, - } ?? switch (path) { 'downloads.movies' => 'Film', 'downloads.music' => 'Musik', 'downloads.tracksQueued' => ({required Object count}) => '${count} numre i kø til download', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 07db9aba..9bf2dd36 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -1936,6 +1936,7 @@ class _TranslationsExploreRowsDe extends TranslationsExploreRowsEn { @override String get trendingShows => 'Angesagte Serien'; @override String get popularMovies => 'Beliebte Filme'; @override String get popularShows => 'Beliebte Serien'; + @override String get trendingAnime => 'Angesagte Anime'; @override String get suggestedAnime => 'Vorgeschlagene Anime'; @override String get airingAnime => 'Top laufende Anime'; @override String get popularAnime => 'Beliebteste Anime'; @@ -2926,6 +2927,7 @@ extension on TranslationsDe { 'explore.rows.trendingShows' => 'Angesagte Serien', 'explore.rows.popularMovies' => 'Beliebte Filme', 'explore.rows.popularShows' => 'Beliebte Serien', + 'explore.rows.trendingAnime' => 'Angesagte Anime', 'explore.rows.suggestedAnime' => 'Vorgeschlagene Anime', 'explore.rows.airingAnime' => 'Top laufende Anime', 'explore.rows.popularAnime' => 'Beliebteste Anime', @@ -3150,9 +3152,9 @@ extension on TranslationsDe { 'watchTogether.removeRoom' => 'Entfernen', 'watchTogether.guestSwitchUnavailable' => 'Wechsel fehlgeschlagen — Server nicht für Synchronisierung verfügbar', 'watchTogether.guestSwitchFailed' => 'Wechsel fehlgeschlagen — Inhalt auf diesem Server nicht gefunden', - 'downloads.title' => 'Downloads', _ => null, } ?? switch (path) { + 'downloads.title' => 'Downloads', 'downloads.manage' => 'Verwalten', 'downloads.tvShows' => 'Serien', 'downloads.movies' => 'Filme', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index c4054df4..cc885635 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -4602,6 +4602,9 @@ class TranslationsExploreRowsEn { /// en: 'Popular Shows' String get popularShows => 'Popular Shows'; + /// en: 'Trending Anime' + String get trendingAnime => 'Trending Anime'; + /// en: 'Suggested Anime' String get suggestedAnime => 'Suggested Anime'; @@ -5787,6 +5790,7 @@ extension on Translations { 'explore.rows.trendingShows' => 'Trending Shows', 'explore.rows.popularMovies' => 'Popular Movies', 'explore.rows.popularShows' => 'Popular Shows', + 'explore.rows.trendingAnime' => 'Trending Anime', 'explore.rows.suggestedAnime' => 'Suggested Anime', 'explore.rows.airingAnime' => 'Top Airing Anime', 'explore.rows.popularAnime' => 'Most Popular Anime', @@ -6011,9 +6015,9 @@ extension on Translations { 'watchTogether.removeRoom' => 'Remove', 'watchTogether.guestSwitchUnavailable' => 'Couldn\'t switch — server unavailable for sync', 'watchTogether.guestSwitchFailed' => 'Couldn\'t switch — content not found on this server', - 'downloads.title' => 'Downloads', _ => null, } ?? switch (path) { + 'downloads.title' => 'Downloads', 'downloads.manage' => 'Manage', 'downloads.tvShows' => 'TV Shows', 'downloads.movies' => 'Movies', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 1ef9a586..04b0edfb 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsEs extends TranslationsSettingsEn { @override String get libraryDensity => 'Densidad de Biblioteca'; @override String get compact => 'Compacto'; @override String get comfortable => 'Cómodo'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => 'Modo de Vista'; @override String get gridView => 'Cuadrícula'; @override String get listView => 'Lista'; @@ -1934,6 +1936,7 @@ class _TranslationsExploreRowsEs extends TranslationsExploreRowsEn { @override String get trendingShows => 'Series en tendencia'; @override String get popularMovies => 'Películas populares'; @override String get popularShows => 'Series populares'; + @override String get trendingAnime => 'Anime en tendencia'; @override String get suggestedAnime => 'Anime sugerido'; @override String get airingAnime => 'Mejor anime en emisión'; @override String get popularAnime => 'Anime más popular'; @@ -2221,6 +2224,8 @@ extension on TranslationsEs { 'settings.libraryDensity' => 'Densidad de Biblioteca', 'settings.compact' => 'Compacto', 'settings.comfortable' => 'Cómodo', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => 'Modo de Vista', 'settings.gridView' => 'Cuadrícula', 'settings.listView' => 'Lista', @@ -2633,10 +2638,10 @@ extension on TranslationsEs { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('es'))(n, one: 'Se eliminó automáticamente ${n} descarga vista', other: 'Se eliminaron automáticamente ${n} descargas vistas', ), 'messages.removedFromContinueWatching' => 'Eliminado de Seguir Viendo', 'messages.errorLoading' => ({required Object error}) => 'Error: ${error}', - 'messages.streamInterrupted' => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.', - 'messages.liveStreamInterrupted' => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.', + 'messages.liveStreamInterrupted' => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.', 'messages.fileInfoNotAvailable' => 'Información de archivo no disponible', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error al cargar info de archivo: ${error}', 'messages.errorLoadingSeries' => 'Error al cargar la serie', @@ -2922,6 +2927,7 @@ extension on TranslationsEs { 'explore.rows.trendingShows' => 'Series en tendencia', 'explore.rows.popularMovies' => 'Películas populares', 'explore.rows.popularShows' => 'Series populares', + 'explore.rows.trendingAnime' => 'Anime en tendencia', 'explore.rows.suggestedAnime' => 'Anime sugerido', 'explore.rows.airingAnime' => 'Mejor anime en emisión', 'explore.rows.popularAnime' => 'Anime más popular', @@ -3146,11 +3152,11 @@ extension on TranslationsEs { 'watchTogether.removeRoom' => 'Eliminar', 'watchTogether.guestSwitchUnavailable' => 'No se pudo cambiar — servidor no disponible para sincronización', 'watchTogether.guestSwitchFailed' => 'No se pudo cambiar — contenido no encontrado en este servidor', + _ => null, + } ?? switch (path) { 'downloads.title' => 'Descargas', 'downloads.manage' => 'Gestionar', 'downloads.tvShows' => 'Series de TV', - _ => null, - } ?? switch (path) { 'downloads.movies' => 'Películas', 'downloads.music' => 'Música', 'downloads.tracksQueued' => ({required Object count}) => '${count} canciones en cola para descargar', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 872d8b30..b960cdd5 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsFr extends TranslationsSettingsEn { @override String get libraryDensity => 'Densité des bibliothèques'; @override String get compact => 'Compact'; @override String get comfortable => 'Confortable'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => 'Mode d\'affichage'; @override String get gridView => 'Grille'; @override String get listView => 'Liste'; @@ -1934,6 +1936,7 @@ class _TranslationsExploreRowsFr extends TranslationsExploreRowsEn { @override String get trendingShows => 'Show TV tendance'; @override String get popularMovies => 'Films populaires'; @override String get popularShows => 'Show TV populaires'; + @override String get trendingAnime => 'Anime tendance'; @override String get suggestedAnime => 'Anime suggérés'; @override String get airingAnime => 'Meilleurs anime en diffusion'; @override String get popularAnime => 'Anime les plus populaires'; @@ -2221,6 +2224,8 @@ extension on TranslationsFr { 'settings.libraryDensity' => 'Densité des bibliothèques', 'settings.compact' => 'Compact', 'settings.comfortable' => 'Confortable', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => 'Mode d\'affichage', 'settings.gridView' => 'Grille', 'settings.listView' => 'Liste', @@ -2633,10 +2638,10 @@ extension on TranslationsFr { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('fr'))(n, one: '${n} téléchargement vu supprimé automatiquement', other: '${n} téléchargements vus supprimés automatiquement', ), 'messages.removedFromContinueWatching' => 'Supprimer de "Continuer à regarder"', 'messages.errorLoading' => ({required Object error}) => 'Erreur: ${error}', - 'messages.streamInterrupted' => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.', - 'messages.liveStreamInterrupted' => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.', + 'messages.liveStreamInterrupted' => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.', 'messages.fileInfoNotAvailable' => 'Informations sur le fichier non disponibles', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erreur lors du chargement des informations sur le fichier: ${error}', 'messages.errorLoadingSeries' => 'Erreur lors du chargement de la série', @@ -2922,6 +2927,7 @@ extension on TranslationsFr { 'explore.rows.trendingShows' => 'Show TV tendance', 'explore.rows.popularMovies' => 'Films populaires', 'explore.rows.popularShows' => 'Show TV populaires', + 'explore.rows.trendingAnime' => 'Anime tendance', 'explore.rows.suggestedAnime' => 'Anime suggérés', 'explore.rows.airingAnime' => 'Meilleurs anime en diffusion', 'explore.rows.popularAnime' => 'Anime les plus populaires', @@ -3146,11 +3152,11 @@ extension on TranslationsFr { 'watchTogether.removeRoom' => 'Supprimer', 'watchTogether.guestSwitchUnavailable' => 'Impossible de changer — serveur indisponible pour la synchronisation', 'watchTogether.guestSwitchFailed' => 'Impossible de changer — contenu introuvable sur ce serveur', + _ => null, + } ?? switch (path) { 'downloads.title' => 'Téléchargements', 'downloads.manage' => 'Gérer', 'downloads.tvShows' => 'Show TV', - _ => null, - } ?? switch (path) { 'downloads.movies' => 'Films', 'downloads.music' => 'Musique', 'downloads.tracksQueued' => ({required Object count}) => '${count} titres en file d\'attente de téléchargement', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 5b76f901..bfe09188 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsIt extends TranslationsSettingsEn { @override String get libraryDensity => 'Densità libreria'; @override String get compact => 'Compatta'; @override String get comfortable => 'Comoda'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => 'Modalità di visualizzazione'; @override String get gridView => 'Griglia'; @override String get listView => 'Elenco'; @@ -1934,6 +1936,7 @@ class _TranslationsExploreRowsIt extends TranslationsExploreRowsEn { @override String get trendingShows => 'Serie TV di tendenza'; @override String get popularMovies => 'Film popolari'; @override String get popularShows => 'Serie TV popolari'; + @override String get trendingAnime => 'Anime di tendenza'; @override String get suggestedAnime => 'Anime suggeriti'; @override String get airingAnime => 'Migliori anime in onda'; @override String get popularAnime => 'Anime più popolari'; @@ -2221,6 +2224,8 @@ extension on TranslationsIt { 'settings.libraryDensity' => 'Densità libreria', 'settings.compact' => 'Compatta', 'settings.comfortable' => 'Comoda', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => 'Modalità di visualizzazione', 'settings.gridView' => 'Griglia', 'settings.listView' => 'Elenco', @@ -2633,10 +2638,10 @@ extension on TranslationsIt { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('it'))(n, one: 'Rimosso automaticamente ${n} download già visto', other: 'Rimossi automaticamente ${n} download già visti', ), 'messages.removedFromContinueWatching' => 'Rimosso da Continua a guardare', 'messages.errorLoading' => ({required Object error}) => 'Errore: ${error}', - 'messages.streamInterrupted' => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.', - 'messages.liveStreamInterrupted' => 'La diretta si è interrotta. Premi Riproduci per riprovare.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.', + 'messages.liveStreamInterrupted' => 'La diretta si è interrotta. Premi Riproduci per riprovare.', 'messages.fileInfoNotAvailable' => 'Informazioni sul file non disponibili', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Errore caricamento informazioni sul file: ${error}', 'messages.errorLoadingSeries' => 'Errore caricamento serie', @@ -2922,6 +2927,7 @@ extension on TranslationsIt { 'explore.rows.trendingShows' => 'Serie TV di tendenza', 'explore.rows.popularMovies' => 'Film popolari', 'explore.rows.popularShows' => 'Serie TV popolari', + 'explore.rows.trendingAnime' => 'Anime di tendenza', 'explore.rows.suggestedAnime' => 'Anime suggeriti', 'explore.rows.airingAnime' => 'Migliori anime in onda', 'explore.rows.popularAnime' => 'Anime più popolari', @@ -3146,11 +3152,11 @@ extension on TranslationsIt { 'watchTogether.removeRoom' => 'Rimuovi', 'watchTogether.guestSwitchUnavailable' => 'Impossibile cambiare — server non disponibile per la sincronizzazione', 'watchTogether.guestSwitchFailed' => 'Impossibile cambiare — contenuto non trovato su questo server', + _ => null, + } ?? switch (path) { 'downloads.title' => 'Download', 'downloads.manage' => 'Gestisci', 'downloads.tvShows' => 'Serie TV', - _ => null, - } ?? switch (path) { 'downloads.movies' => 'Film', 'downloads.music' => 'Musica', 'downloads.tracksQueued' => ({required Object count}) => '${count} brani in coda per il download', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 39aca32b..eeed16a1 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsJa extends TranslationsSettingsEn { @override String get libraryDensity => 'ライブラリの密度'; @override String get compact => 'コンパクト'; @override String get comfortable => 'ゆったり'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => '表示モード'; @override String get gridView => 'グリッド'; @override String get listView => 'リスト'; @@ -1931,6 +1933,7 @@ class _TranslationsExploreRowsJa extends TranslationsExploreRowsEn { @override String get trendingShows => 'トレンドのテレビ番組'; @override String get popularMovies => '人気の映画'; @override String get popularShows => '人気のテレビ番組'; + @override String get trendingAnime => 'トレンドのアニメ'; @override String get suggestedAnime => 'おすすめのアニメ'; @override String get airingAnime => '放送中の注目アニメ'; @override String get popularAnime => '人気のアニメ'; @@ -2218,6 +2221,8 @@ extension on TranslationsJa { 'settings.libraryDensity' => 'ライブラリの密度', 'settings.compact' => 'コンパクト', 'settings.comfortable' => 'ゆったり', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => '表示モード', 'settings.gridView' => 'グリッド', 'settings.listView' => 'リスト', @@ -2630,10 +2635,10 @@ extension on TranslationsJa { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ja'))(n, other: '視聴済みダウンロードを${n}件自動削除しました', ), 'messages.removedFromContinueWatching' => '視聴中から削除しました', 'messages.errorLoading' => ({required Object error}) => 'エラー: ${error}', - 'messages.streamInterrupted' => 'ストリームが中断されました。再生を押すかシークして再試行してください。', - 'messages.liveStreamInterrupted' => 'ライブストリームが中断されました。再生を押して再試行してください。', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'ストリームが中断されました。再生を押すかシークして再試行してください。', + 'messages.liveStreamInterrupted' => 'ライブストリームが中断されました。再生を押して再試行してください。', 'messages.fileInfoNotAvailable' => 'ファイル情報が利用できません', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'ファイル情報の読み込みエラー: ${error}', 'messages.errorLoadingSeries' => 'シリーズの読み込みエラー', @@ -2919,6 +2924,7 @@ extension on TranslationsJa { 'explore.rows.trendingShows' => 'トレンドのテレビ番組', 'explore.rows.popularMovies' => '人気の映画', 'explore.rows.popularShows' => '人気のテレビ番組', + 'explore.rows.trendingAnime' => 'トレンドのアニメ', 'explore.rows.suggestedAnime' => 'おすすめのアニメ', 'explore.rows.airingAnime' => '放送中の注目アニメ', 'explore.rows.popularAnime' => '人気のアニメ', @@ -3143,11 +3149,11 @@ extension on TranslationsJa { 'watchTogether.removeRoom' => '削除', 'watchTogether.guestSwitchUnavailable' => '切り替えできません — サーバーが同期できません', 'watchTogether.guestSwitchFailed' => '切り替えできません — このサーバーにコンテンツが見つかりません', + _ => null, + } ?? switch (path) { 'downloads.title' => 'ダウンロード', 'downloads.manage' => '管理', 'downloads.tvShows' => 'テレビ番組', - _ => null, - } ?? switch (path) { 'downloads.movies' => '映画', 'downloads.music' => '音楽', 'downloads.tracksQueued' => ({required Object count}) => '${count} 曲をダウンロード待機中', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 25cec588..a9416d6d 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsKo extends TranslationsSettingsEn { @override String get libraryDensity => '라이브러리 표시 밀도'; @override String get compact => '좁게'; @override String get comfortable => '넓게'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => '보기 모드'; @override String get gridView => '그리드 보기'; @override String get listView => '목록 보기'; @@ -1931,6 +1933,7 @@ class _TranslationsExploreRowsKo extends TranslationsExploreRowsEn { @override String get trendingShows => '지금 뜨는 TV 프로그램'; @override String get popularMovies => '인기 영화'; @override String get popularShows => '인기 TV 프로그램'; + @override String get trendingAnime => '지금 뜨는 애니메이션'; @override String get suggestedAnime => '추천 애니메이션'; @override String get airingAnime => '방영 중인 인기 애니메이션'; @override String get popularAnime => '가장 인기 있는 애니메이션'; @@ -2218,6 +2221,8 @@ extension on TranslationsKo { 'settings.libraryDensity' => '라이브러리 표시 밀도', 'settings.compact' => '좁게', 'settings.comfortable' => '넓게', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => '보기 모드', 'settings.gridView' => '그리드 보기', 'settings.listView' => '목록 보기', @@ -2630,10 +2635,10 @@ extension on TranslationsKo { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ko'))(n, other: '시청한 다운로드 ${n}개를 자동 삭제했습니다', ), 'messages.removedFromContinueWatching' => '계속 시청 목록에서 제거됨', 'messages.errorLoading' => ({required Object error}) => '오류: ${error}', - 'messages.streamInterrupted' => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.', - 'messages.liveStreamInterrupted' => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.', + 'messages.liveStreamInterrupted' => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.', 'messages.fileInfoNotAvailable' => '파일 정보가 없습니다', 'messages.errorLoadingFileInfo' => ({required Object error}) => '파일 정보 로딩 중 오류: ${error}', 'messages.errorLoadingSeries' => '시리즈 로딩 중 오류', @@ -2919,6 +2924,7 @@ extension on TranslationsKo { 'explore.rows.trendingShows' => '지금 뜨는 TV 프로그램', 'explore.rows.popularMovies' => '인기 영화', 'explore.rows.popularShows' => '인기 TV 프로그램', + 'explore.rows.trendingAnime' => '지금 뜨는 애니메이션', 'explore.rows.suggestedAnime' => '추천 애니메이션', 'explore.rows.airingAnime' => '방영 중인 인기 애니메이션', 'explore.rows.popularAnime' => '가장 인기 있는 애니메이션', @@ -3143,11 +3149,11 @@ extension on TranslationsKo { 'watchTogether.removeRoom' => '제거', 'watchTogether.guestSwitchUnavailable' => '전환할 수 없음 — 동기화 서버를 사용할 수 없습니다', 'watchTogether.guestSwitchFailed' => '전환할 수 없음 — 이 서버에서 콘텐츠를 찾을 수 없습니다', + _ => null, + } ?? switch (path) { 'downloads.title' => '다운로드', 'downloads.manage' => '관리', 'downloads.tvShows' => 'TV 프로그램', - _ => null, - } ?? switch (path) { 'downloads.movies' => '영화', 'downloads.music' => '음악', 'downloads.tracksQueued' => ({required Object count}) => '${count}곡 다운로드 대기 중', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index bb96f62d..94f4da0f 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsNb extends TranslationsSettingsEn { @override String get libraryDensity => 'Bibliotekets tetthet'; @override String get compact => 'Kompakt'; @override String get comfortable => 'Komfortabel'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => 'Visningsmodus'; @override String get gridView => 'Rutenett'; @override String get listView => 'Liste'; @@ -1934,6 +1936,7 @@ class _TranslationsExploreRowsNb extends TranslationsExploreRowsEn { @override String get trendingShows => 'Trending serier'; @override String get popularMovies => 'Populære filmer'; @override String get popularShows => 'Populære serier'; + @override String get trendingAnime => 'Anime som trender'; @override String get suggestedAnime => 'Foreslått anime'; @override String get airingAnime => 'Topp pågående anime'; @override String get popularAnime => 'Mest populære anime'; @@ -2221,6 +2224,8 @@ extension on TranslationsNb { 'settings.libraryDensity' => 'Bibliotekets tetthet', 'settings.compact' => 'Kompakt', 'settings.comfortable' => 'Komfortabel', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => 'Visningsmodus', 'settings.gridView' => 'Rutenett', 'settings.listView' => 'Liste', @@ -2633,10 +2638,10 @@ extension on TranslationsNb { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nb'))(n, one: 'Fjernet automatisk ${n} sett nedlasting', other: 'Fjernet automatisk ${n} sette nedlastinger', ), 'messages.removedFromContinueWatching' => 'Fjernet fra Fortsett å se', 'messages.errorLoading' => ({required Object error}) => 'Feil: ${error}', - 'messages.streamInterrupted' => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.', - 'messages.liveStreamInterrupted' => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.', + 'messages.liveStreamInterrupted' => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.', 'messages.fileInfoNotAvailable' => 'Filinformasjon ikke tilgjengelig', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Feil ved lasting av filinformasjon: ${error}', 'messages.errorLoadingSeries' => 'Feil ved lasting av serie', @@ -2922,6 +2927,7 @@ extension on TranslationsNb { 'explore.rows.trendingShows' => 'Trending serier', 'explore.rows.popularMovies' => 'Populære filmer', 'explore.rows.popularShows' => 'Populære serier', + 'explore.rows.trendingAnime' => 'Anime som trender', 'explore.rows.suggestedAnime' => 'Foreslått anime', 'explore.rows.airingAnime' => 'Topp pågående anime', 'explore.rows.popularAnime' => 'Mest populære anime', @@ -3146,11 +3152,11 @@ extension on TranslationsNb { 'watchTogether.removeRoom' => 'Fjern', 'watchTogether.guestSwitchUnavailable' => 'Kunne ikke bytte — server ikke tilgjengelig for synkronisering', 'watchTogether.guestSwitchFailed' => 'Kunne ikke bytte — innhold ble ikke funnet på denne serveren', + _ => null, + } ?? switch (path) { 'downloads.title' => 'Nedlastinger', 'downloads.manage' => 'Administrer', 'downloads.tvShows' => 'TV-serier', - _ => null, - } ?? switch (path) { 'downloads.movies' => 'Filmer', 'downloads.music' => 'Musikk', 'downloads.tracksQueued' => ({required Object count}) => '${count} spor i nedlastingskø', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index ed874682..6654a0b5 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsNl extends TranslationsSettingsEn { @override String get libraryDensity => 'Bibliotheek dichtheid'; @override String get compact => 'Compact'; @override String get comfortable => 'Comfortabel'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => 'Weergavemodus'; @override String get gridView => 'Raster'; @override String get listView => 'Lijst'; @@ -1934,6 +1936,7 @@ class _TranslationsExploreRowsNl extends TranslationsExploreRowsEn { @override String get trendingShows => 'Trending series'; @override String get popularMovies => 'Populaire films'; @override String get popularShows => 'Populaire series'; + @override String get trendingAnime => 'Trending anime'; @override String get suggestedAnime => 'Voorgestelde anime'; @override String get airingAnime => 'Top lopende anime'; @override String get popularAnime => 'Populairste anime'; @@ -2221,6 +2224,8 @@ extension on TranslationsNl { 'settings.libraryDensity' => 'Bibliotheek dichtheid', 'settings.compact' => 'Compact', 'settings.comfortable' => 'Comfortabel', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => 'Weergavemodus', 'settings.gridView' => 'Raster', 'settings.listView' => 'Lijst', @@ -2633,10 +2638,10 @@ extension on TranslationsNl { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nl'))(n, one: 'Automatisch ${n} bekeken download verwijderd', other: 'Automatisch ${n} bekeken downloads verwijderd', ), 'messages.removedFromContinueWatching' => 'Verwijderd uit Doorgaan met kijken', 'messages.errorLoading' => ({required Object error}) => 'Fout: ${error}', - 'messages.streamInterrupted' => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.', - 'messages.liveStreamInterrupted' => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.', + 'messages.liveStreamInterrupted' => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.', 'messages.fileInfoNotAvailable' => 'Bestand informatie niet beschikbaar', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fout bij laden bestand info: ${error}', 'messages.errorLoadingSeries' => 'Fout bij laden serie', @@ -2922,6 +2927,7 @@ extension on TranslationsNl { 'explore.rows.trendingShows' => 'Trending series', 'explore.rows.popularMovies' => 'Populaire films', 'explore.rows.popularShows' => 'Populaire series', + 'explore.rows.trendingAnime' => 'Trending anime', 'explore.rows.suggestedAnime' => 'Voorgestelde anime', 'explore.rows.airingAnime' => 'Top lopende anime', 'explore.rows.popularAnime' => 'Populairste anime', @@ -3146,11 +3152,11 @@ extension on TranslationsNl { 'watchTogether.removeRoom' => 'Verwijderen', 'watchTogether.guestSwitchUnavailable' => 'Kon niet schakelen — server niet beschikbaar voor synchronisatie', 'watchTogether.guestSwitchFailed' => 'Kon niet schakelen — inhoud niet gevonden op deze server', + _ => null, + } ?? switch (path) { 'downloads.title' => 'Downloads', 'downloads.manage' => 'Beheren', 'downloads.tvShows' => 'Series', - _ => null, - } ?? switch (path) { 'downloads.movies' => 'Films', 'downloads.music' => 'Muziek', 'downloads.tracksQueued' => ({required Object count}) => '${count} nummers in wachtrij voor download', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 1c18e63f..6f88b2ff 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsPl extends TranslationsSettingsEn { @override String get libraryDensity => 'Gęstość biblioteki'; @override String get compact => 'Kompaktowy'; @override String get comfortable => 'Wygodny'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => 'Tryb widoku'; @override String get gridView => 'Siatka'; @override String get listView => 'Lista'; @@ -1940,6 +1942,7 @@ class _TranslationsExploreRowsPl extends TranslationsExploreRowsEn { @override String get trendingShows => 'Seriale na czasie'; @override String get popularMovies => 'Popularne filmy'; @override String get popularShows => 'Popularne seriale'; + @override String get trendingAnime => 'Anime na czasie'; @override String get suggestedAnime => 'Sugerowane anime'; @override String get airingAnime => 'Najlepsze emitowane anime'; @override String get popularAnime => 'Najpopularniejsze anime'; @@ -2227,6 +2230,8 @@ extension on TranslationsPl { 'settings.libraryDensity' => 'Gęstość biblioteki', 'settings.compact' => 'Kompaktowy', 'settings.comfortable' => 'Wygodny', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => 'Tryb widoku', 'settings.gridView' => 'Siatka', 'settings.listView' => 'Lista', @@ -2639,10 +2644,10 @@ extension on TranslationsPl { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pl'))(n, one: 'Automatycznie usunięto ${n} obejrzane pobranie', few: 'Automatycznie usunięto ${n} obejrzane pobrania', many: 'Automatycznie usunięto ${n} obejrzanych pobrań', other: 'Automatycznie usunięto ${n} obejrzanego pobrania', ), 'messages.removedFromContinueWatching' => 'Usunięto z kontynuowania oglądania', 'messages.errorLoading' => ({required Object error}) => 'Błąd: ${error}', - 'messages.streamInterrupted' => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.', - 'messages.liveStreamInterrupted' => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.', + 'messages.liveStreamInterrupted' => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.', 'messages.fileInfoNotAvailable' => 'Informacje o pliku niedostępne', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Błąd ładowania informacji o pliku: ${error}', 'messages.errorLoadingSeries' => 'Błąd ładowania serialu', @@ -2928,6 +2933,7 @@ extension on TranslationsPl { 'explore.rows.trendingShows' => 'Seriale na czasie', 'explore.rows.popularMovies' => 'Popularne filmy', 'explore.rows.popularShows' => 'Popularne seriale', + 'explore.rows.trendingAnime' => 'Anime na czasie', 'explore.rows.suggestedAnime' => 'Sugerowane anime', 'explore.rows.airingAnime' => 'Najlepsze emitowane anime', 'explore.rows.popularAnime' => 'Najpopularniejsze anime', @@ -3152,11 +3158,11 @@ extension on TranslationsPl { 'watchTogether.removeRoom' => 'Usuń', 'watchTogether.guestSwitchUnavailable' => 'Nie można przełączyć — serwer niedostępny do synchronizacji', 'watchTogether.guestSwitchFailed' => 'Nie można przełączyć — nie znaleziono treści na tym serwerze', + _ => null, + } ?? switch (path) { 'downloads.title' => 'Pobrania', 'downloads.manage' => 'Zarządzaj', 'downloads.tvShows' => 'Seriale TV', - _ => null, - } ?? switch (path) { 'downloads.movies' => 'Filmy', 'downloads.music' => 'Muzyka', 'downloads.tracksQueued' => ({required Object count}) => '${count} utworów w kolejce do pobrania', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 1010e209..1c3c7782 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsPt extends TranslationsSettingsEn { @override String get libraryDensity => 'Densidade da Biblioteca'; @override String get compact => 'Compacto'; @override String get comfortable => 'Confortável'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => 'Modo de Visualização'; @override String get gridView => 'Grade'; @override String get listView => 'Lista'; @@ -1934,6 +1936,7 @@ class _TranslationsExploreRowsPt extends TranslationsExploreRowsEn { @override String get trendingShows => 'Séries em alta'; @override String get popularMovies => 'Filmes populares'; @override String get popularShows => 'Séries populares'; + @override String get trendingAnime => 'Anime em alta'; @override String get suggestedAnime => 'Anime sugerido'; @override String get airingAnime => 'Melhor anime em exibição'; @override String get popularAnime => 'Anime mais popular'; @@ -2221,6 +2224,8 @@ extension on TranslationsPt { 'settings.libraryDensity' => 'Densidade da Biblioteca', 'settings.compact' => 'Compacto', 'settings.comfortable' => 'Confortável', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => 'Modo de Visualização', 'settings.gridView' => 'Grade', 'settings.listView' => 'Lista', @@ -2633,10 +2638,10 @@ extension on TranslationsPt { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pt'))(n, one: 'Removido automaticamente ${n} download assistido', other: 'Removidos automaticamente ${n} downloads assistidos', ), 'messages.removedFromContinueWatching' => 'Removido de Continuar Assistindo', 'messages.errorLoading' => ({required Object error}) => 'Erro: ${error}', - 'messages.streamInterrupted' => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.', - 'messages.liveStreamInterrupted' => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.', + 'messages.liveStreamInterrupted' => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.', 'messages.fileInfoNotAvailable' => 'Informações do arquivo não disponíveis', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erro ao carregar info do arquivo: ${error}', 'messages.errorLoadingSeries' => 'Erro ao carregar série', @@ -2922,6 +2927,7 @@ extension on TranslationsPt { 'explore.rows.trendingShows' => 'Séries em alta', 'explore.rows.popularMovies' => 'Filmes populares', 'explore.rows.popularShows' => 'Séries populares', + 'explore.rows.trendingAnime' => 'Anime em alta', 'explore.rows.suggestedAnime' => 'Anime sugerido', 'explore.rows.airingAnime' => 'Melhor anime em exibição', 'explore.rows.popularAnime' => 'Anime mais popular', @@ -3146,11 +3152,11 @@ extension on TranslationsPt { 'watchTogether.removeRoom' => 'Remover', 'watchTogether.guestSwitchUnavailable' => 'Não foi possível trocar — servidor indisponível para sincronização', 'watchTogether.guestSwitchFailed' => 'Não foi possível trocar — conteúdo não encontrado neste servidor', + _ => null, + } ?? switch (path) { 'downloads.title' => 'Downloads', 'downloads.manage' => 'Gerenciar', 'downloads.tvShows' => 'Séries de TV', - _ => null, - } ?? switch (path) { 'downloads.movies' => 'Filmes', 'downloads.music' => 'Música', 'downloads.tracksQueued' => ({required Object count}) => '${count} faixas na fila para download', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 3194167e..a318d439 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsRu extends TranslationsSettingsEn { @override String get libraryDensity => 'Плотность библиотеки'; @override String get compact => 'Компактный'; @override String get comfortable => 'Комфортный'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => 'Режим просмотра'; @override String get gridView => 'Сетка'; @override String get listView => 'Список'; @@ -1940,6 +1942,7 @@ class _TranslationsExploreRowsRu extends TranslationsExploreRowsEn { @override String get trendingShows => 'Сериалы в тренде'; @override String get popularMovies => 'Популярные фильмы'; @override String get popularShows => 'Популярные сериалы'; + @override String get trendingAnime => 'Аниме в тренде'; @override String get suggestedAnime => 'Рекомендуемые аниме'; @override String get airingAnime => 'Топ выходящих аниме'; @override String get popularAnime => 'Самые популярные аниме'; @@ -2227,6 +2230,8 @@ extension on TranslationsRu { 'settings.libraryDensity' => 'Плотность библиотеки', 'settings.compact' => 'Компактный', 'settings.comfortable' => 'Комфортный', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => 'Режим просмотра', 'settings.gridView' => 'Сетка', 'settings.listView' => 'Список', @@ -2639,10 +2644,10 @@ extension on TranslationsRu { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ru'))(n, one: 'Автоматически удалена ${n} просмотренная загрузка', few: 'Автоматически удалены ${n} просмотренные загрузки', many: 'Автоматически удалено ${n} просмотренных загрузок', other: 'Автоматически удалено ${n} просмотренной загрузки', ), 'messages.removedFromContinueWatching' => 'Удалено из «Продолжить просмотр»', 'messages.errorLoading' => ({required Object error}) => 'Ошибка: ${error}', - 'messages.streamInterrupted' => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.', - 'messages.liveStreamInterrupted' => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.', + 'messages.liveStreamInterrupted' => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.', 'messages.fileInfoNotAvailable' => 'Информация о файле недоступна', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Ошибка загрузки информации о файле: ${error}', 'messages.errorLoadingSeries' => 'Ошибка загрузки сериала', @@ -2928,6 +2933,7 @@ extension on TranslationsRu { 'explore.rows.trendingShows' => 'Сериалы в тренде', 'explore.rows.popularMovies' => 'Популярные фильмы', 'explore.rows.popularShows' => 'Популярные сериалы', + 'explore.rows.trendingAnime' => 'Аниме в тренде', 'explore.rows.suggestedAnime' => 'Рекомендуемые аниме', 'explore.rows.airingAnime' => 'Топ выходящих аниме', 'explore.rows.popularAnime' => 'Самые популярные аниме', @@ -3152,11 +3158,11 @@ extension on TranslationsRu { 'watchTogether.removeRoom' => 'Удалить', 'watchTogether.guestSwitchUnavailable' => 'Не удалось переключиться — сервер недоступен для синхронизации', 'watchTogether.guestSwitchFailed' => 'Не удалось переключиться — содержимое не найдено на этом сервере', + _ => null, + } ?? switch (path) { 'downloads.title' => 'Загрузки', 'downloads.manage' => 'Управление', 'downloads.tvShows' => 'Сериалы', - _ => null, - } ?? switch (path) { 'downloads.movies' => 'Фильмы', 'downloads.music' => 'Музыка', 'downloads.tracksQueued' => ({required Object count}) => '${count} треков в очереди на загрузку', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index eed8cc89..f8a36915 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsSv extends TranslationsSettingsEn { @override String get libraryDensity => 'Biblioteksdensitet'; @override String get compact => 'Kompakt'; @override String get comfortable => 'Bekväm'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => 'Visningsläge'; @override String get gridView => 'Rutnät'; @override String get listView => 'Lista'; @@ -1934,6 +1936,7 @@ class _TranslationsExploreRowsSv extends TranslationsExploreRowsEn { @override String get trendingShows => 'Trendande serier'; @override String get popularMovies => 'Populära filmer'; @override String get popularShows => 'Populära serier'; + @override String get trendingAnime => 'Trendande anime'; @override String get suggestedAnime => 'Föreslagna anime'; @override String get airingAnime => 'Bästa pågående anime'; @override String get popularAnime => 'Mest populära anime'; @@ -2221,6 +2224,8 @@ extension on TranslationsSv { 'settings.libraryDensity' => 'Biblioteksdensitet', 'settings.compact' => 'Kompakt', 'settings.comfortable' => 'Bekväm', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => 'Visningsläge', 'settings.gridView' => 'Rutnät', 'settings.listView' => 'Lista', @@ -2633,10 +2638,10 @@ extension on TranslationsSv { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('sv'))(n, one: 'Tog automatiskt bort ${n} sedd nedladdning', other: 'Tog automatiskt bort ${n} sedda nedladdningar', ), 'messages.removedFromContinueWatching' => 'Borttagen från Fortsätt titta', 'messages.errorLoading' => ({required Object error}) => 'Fel: ${error}', - 'messages.streamInterrupted' => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.', - 'messages.liveStreamInterrupted' => 'Livestreamen avbröts. Tryck på play för att försöka igen.', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.', + 'messages.liveStreamInterrupted' => 'Livestreamen avbröts. Tryck på play för att försöka igen.', 'messages.fileInfoNotAvailable' => 'Filinformation inte tillgänglig', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fel vid laddning av filinformation: ${error}', 'messages.errorLoadingSeries' => 'Fel vid laddning av serie', @@ -2922,6 +2927,7 @@ extension on TranslationsSv { 'explore.rows.trendingShows' => 'Trendande serier', 'explore.rows.popularMovies' => 'Populära filmer', 'explore.rows.popularShows' => 'Populära serier', + 'explore.rows.trendingAnime' => 'Trendande anime', 'explore.rows.suggestedAnime' => 'Föreslagna anime', 'explore.rows.airingAnime' => 'Bästa pågående anime', 'explore.rows.popularAnime' => 'Mest populära anime', @@ -3146,11 +3152,11 @@ extension on TranslationsSv { 'watchTogether.removeRoom' => 'Ta bort', 'watchTogether.guestSwitchUnavailable' => 'Kunde inte byta — server inte tillgänglig för synkronisering', 'watchTogether.guestSwitchFailed' => 'Kunde inte byta — innehåll hittades inte på denna server', + _ => null, + } ?? switch (path) { 'downloads.title' => 'Nedladdningar', 'downloads.manage' => 'Hantera', 'downloads.tvShows' => 'TV-serier', - _ => null, - } ?? switch (path) { 'downloads.movies' => 'Filmer', 'downloads.music' => 'Musik', 'downloads.tracksQueued' => ({required Object count}) => '${count} låtar i nedladdningskö', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 500b0458..67cc9432 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -239,6 +239,8 @@ class _TranslationsSettingsZh extends TranslationsSettingsEn { @override String get libraryDensity => '媒体库密度'; @override String get compact => '紧凑'; @override String get comfortable => '舒适'; + @override String get tvCornerSpotlightBackdrop => ''; + @override String get tvCornerSpotlightBackdropDescription => ''; @override String get viewMode => '视图模式'; @override String get gridView => '网格视图'; @override String get listView => '列表视图'; @@ -1931,6 +1933,7 @@ class _TranslationsExploreRowsZh extends TranslationsExploreRowsEn { @override String get trendingShows => '趋势剧集'; @override String get popularMovies => '热门电影'; @override String get popularShows => '热门剧集'; + @override String get trendingAnime => '热门动画'; @override String get suggestedAnime => '推荐动画'; @override String get airingAnime => '热门连载动画'; @override String get popularAnime => '最受欢迎动画'; @@ -2218,6 +2221,8 @@ extension on TranslationsZh { 'settings.libraryDensity' => '媒体库密度', 'settings.compact' => '紧凑', 'settings.comfortable' => '舒适', + 'settings.tvCornerSpotlightBackdrop' => '', + 'settings.tvCornerSpotlightBackdropDescription' => '', 'settings.viewMode' => '视图模式', 'settings.gridView' => '网格视图', 'settings.listView' => '列表视图', @@ -2630,10 +2635,10 @@ extension on TranslationsZh { 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('zh'))(n, other: '已自动移除 ${n} 个看过的下载', ), 'messages.removedFromContinueWatching' => '已从继续观看中移除', 'messages.errorLoading' => ({required Object error}) => '错误: ${error}', - 'messages.streamInterrupted' => '视频流已中断。按播放键或拖动进度条重试。', - 'messages.liveStreamInterrupted' => '直播流已中断。按播放键重试。', _ => null, } ?? switch (path) { + 'messages.streamInterrupted' => '视频流已中断。按播放键或拖动进度条重试。', + 'messages.liveStreamInterrupted' => '直播流已中断。按播放键重试。', 'messages.fileInfoNotAvailable' => '文件信息不可用', 'messages.errorLoadingFileInfo' => ({required Object error}) => '加载文件信息时出错: ${error}', 'messages.errorLoadingSeries' => '加载系列时出错', @@ -2919,6 +2924,7 @@ extension on TranslationsZh { 'explore.rows.trendingShows' => '趋势剧集', 'explore.rows.popularMovies' => '热门电影', 'explore.rows.popularShows' => '热门剧集', + 'explore.rows.trendingAnime' => '热门动画', 'explore.rows.suggestedAnime' => '推荐动画', 'explore.rows.airingAnime' => '热门连载动画', 'explore.rows.popularAnime' => '最受欢迎动画', @@ -3143,11 +3149,11 @@ extension on TranslationsZh { 'watchTogether.removeRoom' => '移除', 'watchTogether.guestSwitchUnavailable' => '无法切换 — 服务器无法同步', 'watchTogether.guestSwitchFailed' => '无法切换 — 在此服务器上未找到内容', + _ => null, + } ?? switch (path) { 'downloads.title' => '下载', 'downloads.manage' => '管理', 'downloads.tvShows' => '电视剧', - _ => null, - } ?? switch (path) { 'downloads.movies' => '电影', 'downloads.music' => '音乐', 'downloads.tracksQueued' => ({required Object count}) => '${count} 首曲目已加入下载队列', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 186176df..1c92d9e5 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "Biblioteksdensitet", "compact": "Kompakt", "comfortable": "Bekväm", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "Visningsläge", "gridView": "Rutnät", "listView": "Lista", @@ -872,6 +874,7 @@ "trendingShows": "Trendande serier", "popularMovies": "Populära filmer", "popularShows": "Populära serier", + "trendingAnime": "Trendande anime", "suggestedAnime": "Föreslagna anime", "airingAnime": "Bästa pågående anime", "popularAnime": "Mest populära anime", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index d653b57b..fffb5df1 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -108,6 +108,8 @@ "libraryDensity": "媒体库密度", "compact": "紧凑", "comfortable": "舒适", + "tvCornerSpotlightBackdrop": "", + "tvCornerSpotlightBackdropDescription": "", "viewMode": "视图模式", "gridView": "网格视图", "listView": "列表视图", @@ -871,6 +873,7 @@ "trendingShows": "趋势剧集", "popularMovies": "热门电影", "popularShows": "热门剧集", + "trendingAnime": "热门动画", "suggestedAnime": "推荐动画", "airingAnime": "热门连载动画", "popularAnime": "最受欢迎动画", diff --git a/lib/models/anilist/anilist_media.dart b/lib/models/anilist/anilist_media.dart new file mode 100644 index 00000000..66d7e3ef --- /dev/null +++ b/lib/models/anilist/anilist_media.dart @@ -0,0 +1,161 @@ +import '../../utils/json_utils.dart'; + +/// Anime metadata returned by the exact field selections in [AnilistClient]. +/// +/// This is intentionally hand-written: AniList is GraphQL, so absent fields +/// are expected whenever a query requests a smaller shape (for example the +/// planning-list membership snapshot). +class AnilistMedia { + final int? id; + final int? idMal; + final String? titleEnglish; + final String? titleRomaji; + final String? titleUserPreferred; + final String? format; + final String? status; + final int? episodes; + final int? duration; + final String? description; + final int? averageScore; + final String? season; + final int? seasonYear; + final int? startYear; + final List? genres; + final bool isAdult; + final String? coverImageExtraLarge; + final String? coverImageLarge; + final String? bannerImage; + final List? mainStudios; + final String? trailerId; + final String? trailerSite; + + const AnilistMedia({ + this.id, + this.idMal, + this.titleEnglish, + this.titleRomaji, + this.titleUserPreferred, + this.format, + this.status, + this.episodes, + this.duration, + this.description, + this.averageScore, + this.season, + this.seasonYear, + this.startYear, + this.genres, + this.isAdult = false, + this.coverImageExtraLarge, + this.coverImageLarge, + this.bannerImage, + this.mainStudios, + this.trailerId, + this.trailerSite, + }); + + factory AnilistMedia.fromJson(Map json) { + final title = json['title']; + final coverImage = json['coverImage']; + final startDate = json['startDate']; + final studios = json['studios']; + final trailer = json['trailer']; + + return AnilistMedia( + id: flexibleInt(json['id']), + idMal: flexibleInt(json['idMal']), + titleEnglish: title is Map ? title['english'] as String? : null, + titleRomaji: title is Map ? title['romaji'] as String? : null, + titleUserPreferred: title is Map ? title['userPreferred'] as String? : null, + format: json['format'] as String?, + status: json['status'] as String?, + episodes: flexibleInt(json['episodes']), + duration: flexibleInt(json['duration']), + description: stripHtml(json['description'] as String?), + averageScore: flexibleInt(json['averageScore']), + season: json['season'] as String?, + seasonYear: flexibleInt(json['seasonYear']), + startYear: startDate is Map ? flexibleInt(startDate['year']) : null, + genres: _stringList(json['genres']), + isAdult: json['isAdult'] == true, + coverImageExtraLarge: coverImage is Map ? coverImage['extraLarge'] as String? : null, + coverImageLarge: coverImage is Map ? coverImage['large'] as String? : null, + bannerImage: json['bannerImage'] as String?, + mainStudios: studios is Map ? _studioNames(studios['nodes']) : null, + trailerId: trailer is Map ? trailer['id'] as String? : null, + trailerSite: trailer is Map ? trailer['site'] as String? : null, + ); + } + + String get displayTitle => _nonEmpty(titleEnglish) ?? _nonEmpty(titleUserPreferred) ?? _nonEmpty(titleRomaji) ?? ''; + + int? get year => seasonYear ?? startYear; + + String? get posterUrl => _nonEmpty(coverImageExtraLarge) ?? _nonEmpty(coverImageLarge); + + String? get backdropUrl => _nonEmpty(bannerImage); + + double? get rating { + final score = averageScore; + return score == null || score <= 0 ? null : score / 10; + } + + int? get votes => null; + + int? get runtimeMinutes => duration == null || duration! <= 0 ? null : duration; + + String? get network { + for (final studio in mainStudios ?? const []) { + final name = _nonEmpty(studio); + if (name != null) return name; + } + return null; + } + + String? get trailerUrl { + final id = _nonEmpty(trailerId); + if (id == null || trailerSite?.toLowerCase() != 'youtube') return null; + return 'https://www.youtube.com/watch?v=$id'; + } + + bool get isMovie => format == 'MOVIE'; + + /// Convert AniList's small HTML subset to plain display text. + static String? stripHtml(String? value) { + if (value == null || value.isEmpty) return null; + final plain = value + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<[^>]+>'), '') + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'") + .trim(); + return plain.isEmpty ? null : plain; + } + + static String? _nonEmpty(String? value) => value == null || value.isEmpty ? null : value; + + static List? _stringList(Object? value) { + if (value is! List) return null; + final strings = [ + for (final item in value) + if (item is String && item.isNotEmpty) item, + ]; + return strings.isEmpty ? null : strings; + } + + static List? _studioNames(Object? value) { + if (value is! List) return null; + final names = [ + for (final node in value) + if (node is Map && node['name'] is String && (node['name'] as String).isNotEmpty) node['name'] as String, + ]; + return names.isEmpty ? null : names; + } +} + +typedef AnilistPage = ({List items, bool hasMore}); diff --git a/lib/models/catalog/catalog_item.dart b/lib/models/catalog/catalog_item.dart index addc8ee8..1d9ef47c 100644 --- a/lib/models/catalog/catalog_item.dart +++ b/lib/models/catalog/catalog_item.dart @@ -4,7 +4,7 @@ import '../../media/media_kind.dart'; import '../../utils/external_ids.dart'; /// External catalog providers that can back the Explore tab. -enum CatalogSourceId { trakt, mal, seerr } +enum CatalogSourceId { plex, trakt, mal, anilist, simkl, seerr } /// Normalized airing/production status across providers (Trakt `status`, /// MAL `status`). Null when unknown or uninteresting (released movies). @@ -12,21 +12,43 @@ enum CatalogAirStatus { airing, ended, canceled, upcoming } /// External ids identifying a catalog item across providers and media /// servers. A superset of [ExternalIds] that also carries provider-native -/// ids (Trakt id/slug, MAL id today). +/// ids (Plex rating key, Trakt id/slug, MAL, AniList, and Simkl). class CatalogItemIds { + final String? plex; final int? trakt; final String? slug; final int? mal; + final int? anilist; + final int? simkl; final String? imdb; final int? tmdb; final int? tvdb; - const CatalogItemIds({this.trakt, this.slug, this.mal, this.imdb, this.tmdb, this.tvdb}); + const CatalogItemIds({ + this.plex, + this.trakt, + this.slug, + this.mal, + this.anilist, + this.simkl, + this.imdb, + this.tmdb, + this.tvdb, + }); factory CatalogItemIds.fromExternal(ExternalIds ids) => CatalogItemIds(imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb); - bool get hasAny => imdb != null || tmdb != null || tvdb != null || trakt != null || slug != null || mal != null; + bool get hasAny => + imdb != null || + tmdb != null || + tvdb != null || + mal != null || + anilist != null || + simkl != null || + plex != null || + trakt != null || + slug != null; /// Stable identity key preferring globally-unique ids. Callers must /// namespace it by [MediaKind] (tmdb movie/show ids can collide). @@ -35,6 +57,9 @@ class CatalogItemIds { if (tmdb != null) return 'tmdb:$tmdb'; if (tvdb != null) return 'tvdb:$tvdb'; if (mal != null) return 'mal:$mal'; + if (anilist != null) return 'anilist:$anilist'; + if (simkl != null) return 'simkl:$simkl'; + if (plex != null) return 'plex:$plex'; if (trakt != null) return 'trakt:$trakt'; if (slug != null) return 'slug:$slug'; return null; @@ -48,6 +73,9 @@ class CatalogItemIds { if (tmdb != null) 'tmdb:$tmdb', if (tvdb != null) 'tvdb:$tvdb', if (mal != null) 'mal:$mal', + if (anilist != null) 'anilist:$anilist', + if (simkl != null) 'simkl:$simkl', + if (plex != null) 'plex:$plex', if (trakt != null) 'trakt:$trakt', if (slug != null) 'slug:$slug', ]; @@ -55,18 +83,24 @@ class CatalogItemIds { ExternalIds toExternalIds() => ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb); Map toJson() => { + if (plex != null) 'plex': plex, if (trakt != null) 'trakt': trakt, if (slug != null) 'slug': slug, if (mal != null) 'mal': mal, + if (anilist != null) 'anilist': anilist, + if (simkl != null) 'simkl': simkl, if (imdb != null) 'imdb': imdb, if (tmdb != null) 'tmdb': tmdb, if (tvdb != null) 'tvdb': tvdb, }; factory CatalogItemIds.fromJson(Map json) => CatalogItemIds( + plex: json['plex'] as String?, trakt: json['trakt'] as int?, slug: json['slug'] as String?, mal: json['mal'] as int?, + anilist: json['anilist'] as int?, + simkl: json['simkl'] as int?, imdb: json['imdb'] as String?, tmdb: json['tmdb'] as int?, tvdb: json['tvdb'] as int?, diff --git a/lib/models/simkl/simkl_all_items_entry.dart b/lib/models/simkl/simkl_all_items_entry.dart new file mode 100644 index 00000000..97ef6d3d --- /dev/null +++ b/lib/models/simkl/simkl_all_items_entry.dart @@ -0,0 +1,76 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../utils/json_utils.dart'; +import 'simkl_ids.dart'; + +part 'simkl_all_items_entry.g.dart'; + +@JsonSerializable(createToJson: false) +class SimklAllItemsMedia { + final String? title; + @JsonKey(fromJson: flexibleInt) + final int? year; + final String? poster; + final String? fanart; + @JsonKey(fromJson: flexibleInt) + final int? runtime; + final String? overview; + final List? genres; + final String? network; + final String? status; + final SimklIds ids; + + const SimklAllItemsMedia({ + this.title, + this.year, + this.poster, + this.fanart, + this.runtime, + this.overview, + this.genres, + this.network, + this.status, + required this.ids, + }); + + factory SimklAllItemsMedia.fromJson(Map json) => _$SimklAllItemsMediaFromJson(json); +} + +@JsonSerializable(createToJson: false) +class SimklAllItemsEntry { + /// User-list status (`plantowatch`, `watching`, ...), not production state. + final String? status; + final SimklAllItemsMedia? show; + final SimklAllItemsMedia? movie; + @JsonKey(name: 'anime_type') + final String? animeType; + @JsonKey(name: 'total_episodes_count', fromJson: flexibleInt) + final int? totalEpisodes; + + const SimklAllItemsEntry({this.status, this.show, this.movie, this.animeType, this.totalEpisodes}); + + SimklAllItemsMedia? get media => movie ?? show; + + bool get isShow => show != null; + + factory SimklAllItemsEntry.fromJson(Map json) => _$SimklAllItemsEntryFromJson(json); +} + +class SimklAllItems { + final List shows; + final List movies; + final List anime; + + const SimklAllItems({this.shows = const [], this.movies = const [], this.anime = const []}); + + factory SimklAllItems.fromJson(Map json) => + SimklAllItems(shows: _entries(json['shows']), movies: _entries(json['movies']), anime: _entries(json['anime'])); + + static List _entries(Object? value) { + if (value is! List) return const []; + return [ + for (final entry in value) + if (entry is Map) SimklAllItemsEntry.fromJson(entry), + ]; + } +} diff --git a/lib/models/simkl/simkl_all_items_entry.g.dart b/lib/models/simkl/simkl_all_items_entry.g.dart new file mode 100644 index 00000000..97d46e91 --- /dev/null +++ b/lib/models/simkl/simkl_all_items_entry.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'simkl_all_items_entry.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SimklAllItemsMedia _$SimklAllItemsMediaFromJson(Map json) => + SimklAllItemsMedia( + title: json['title'] as String?, + year: flexibleInt(json['year']), + poster: json['poster'] as String?, + fanart: json['fanart'] as String?, + runtime: flexibleInt(json['runtime']), + overview: json['overview'] as String?, + genres: (json['genres'] as List?) + ?.map((e) => e as String) + .toList(), + network: json['network'] as String?, + status: json['status'] as String?, + ids: SimklIds.fromJson(json['ids'] as Map), + ); + +SimklAllItemsEntry _$SimklAllItemsEntryFromJson(Map json) => + SimklAllItemsEntry( + status: json['status'] as String?, + show: json['show'] == null + ? null + : SimklAllItemsMedia.fromJson(json['show'] as Map), + movie: json['movie'] == null + ? null + : SimklAllItemsMedia.fromJson(json['movie'] as Map), + animeType: json['anime_type'] as String?, + totalEpisodes: flexibleInt(json['total_episodes_count']), + ); diff --git a/lib/models/simkl/simkl_best_item.dart b/lib/models/simkl/simkl_best_item.dart new file mode 100644 index 00000000..f7d29590 --- /dev/null +++ b/lib/models/simkl/simkl_best_item.dart @@ -0,0 +1,21 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../utils/json_utils.dart'; +import 'simkl_ids.dart'; +import 'simkl_rating.dart'; + +part 'simkl_best_item.g.dart'; + +@JsonSerializable(createToJson: false) +class SimklBestItem { + final String? title; + @JsonKey(fromJson: flexibleInt) + final int? year; + final String? poster; + final SimklIds ids; + final SimklRatings? ratings; + + const SimklBestItem({this.title, this.year, this.poster, required this.ids, this.ratings}); + + factory SimklBestItem.fromJson(Map json) => _$SimklBestItemFromJson(json); +} diff --git a/lib/models/simkl/simkl_best_item.g.dart b/lib/models/simkl/simkl_best_item.g.dart new file mode 100644 index 00000000..fb294e37 --- /dev/null +++ b/lib/models/simkl/simkl_best_item.g.dart @@ -0,0 +1,18 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'simkl_best_item.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SimklBestItem _$SimklBestItemFromJson(Map json) => + SimklBestItem( + title: json['title'] as String?, + year: flexibleInt(json['year']), + poster: json['poster'] as String?, + ids: SimklIds.fromJson(json['ids'] as Map), + ratings: json['ratings'] == null + ? null + : SimklRatings.fromJson(json['ratings'] as Map), + ); diff --git a/lib/models/simkl/simkl_ids.dart b/lib/models/simkl/simkl_ids.dart new file mode 100644 index 00000000..1829e22b --- /dev/null +++ b/lib/models/simkl/simkl_ids.dart @@ -0,0 +1,51 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../utils/json_utils.dart'; +import '../catalog/catalog_item.dart'; + +part 'simkl_ids.g.dart'; + +Object? _readSimklId(Map json, String key) => json['simkl'] ?? json['simkl_id']; + +/// Simkl's identifier block. CDN feeds call the native key `simkl_id`, while +/// API responses and mutation bodies use `simkl`. +@JsonSerializable(includeIfNull: false) +class SimklIds { + @JsonKey(readValue: _readSimklId, fromJson: flexibleInt) + final int? simkl; + + /// Response-only according to Simkl's current API contract. + @JsonKey(includeToJson: false) + final String? slug; + + final String? imdb; + @JsonKey(fromJson: flexibleInt) + final int? tmdb; + @JsonKey(fromJson: flexibleInt) + final int? tvdb; + @JsonKey(fromJson: flexibleInt) + final int? mal; + @JsonKey(fromJson: flexibleInt) + final int? anilist; + + const SimklIds({this.simkl, this.slug, this.imdb, this.tmdb, this.tvdb, this.mal, this.anilist}); + + bool get hasAny => simkl != null || imdb != null || tmdb != null || tvdb != null || mal != null || anilist != null; + + CatalogItemIds toCatalogItemIds() => + CatalogItemIds(simkl: simkl, slug: slug, imdb: imdb, tmdb: tmdb, tvdb: tvdb, mal: mal, anilist: anilist); + + factory SimklIds.fromCatalogItemIds(CatalogItemIds ids) => SimklIds( + simkl: ids.simkl, + slug: ids.slug, + imdb: ids.imdb, + tmdb: ids.tmdb, + tvdb: ids.tvdb, + mal: ids.mal, + anilist: ids.anilist, + ); + + Map toJson() => _$SimklIdsToJson(this); + + factory SimklIds.fromJson(Map json) => _$SimklIdsFromJson(json); +} diff --git a/lib/models/simkl/simkl_ids.g.dart b/lib/models/simkl/simkl_ids.g.dart new file mode 100644 index 00000000..ba6e9bd0 --- /dev/null +++ b/lib/models/simkl/simkl_ids.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'simkl_ids.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SimklIds _$SimklIdsFromJson(Map json) => SimklIds( + simkl: flexibleInt(_readSimklId(json, 'simkl')), + slug: json['slug'] as String?, + imdb: json['imdb'] as String?, + tmdb: flexibleInt(json['tmdb']), + tvdb: flexibleInt(json['tvdb']), + mal: flexibleInt(json['mal']), + anilist: flexibleInt(json['anilist']), +); + +Map _$SimklIdsToJson(SimklIds instance) => { + 'simkl': ?instance.simkl, + 'imdb': ?instance.imdb, + 'tmdb': ?instance.tmdb, + 'tvdb': ?instance.tvdb, + 'mal': ?instance.mal, + 'anilist': ?instance.anilist, +}; diff --git a/lib/models/simkl/simkl_images.dart b/lib/models/simkl/simkl_images.dart new file mode 100644 index 00000000..a58f7cc7 --- /dev/null +++ b/lib/models/simkl/simkl_images.dart @@ -0,0 +1,9 @@ +String? simklPosterUrl(String? hash) { + if (hash == null || hash.isEmpty) return null; + return 'https://simkl.in/posters/${hash}_m.webp'; +} + +String? simklFanartUrl(String? hash) { + if (hash == null || hash.isEmpty) return null; + return 'https://simkl.in/fanart/${hash}_medium.webp'; +} diff --git a/lib/models/simkl/simkl_rating.dart b/lib/models/simkl/simkl_rating.dart new file mode 100644 index 00000000..1d59c160 --- /dev/null +++ b/lib/models/simkl/simkl_rating.dart @@ -0,0 +1,30 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../utils/json_utils.dart'; + +part 'simkl_rating.g.dart'; + +@JsonSerializable(createToJson: false) +class SimklRating { + @JsonKey(fromJson: flexibleDouble) + final double? rating; + @JsonKey(fromJson: flexibleInt) + final int? votes; + + const SimklRating({this.rating, this.votes}); + + factory SimklRating.fromJson(Map json) => _$SimklRatingFromJson(json); +} + +@JsonSerializable(createToJson: false) +class SimklRatings { + final SimklRating? simkl; + final SimklRating? imdb; + final SimklRating? mal; + + const SimklRatings({this.simkl, this.imdb, this.mal}); + + SimklRating? get primary => simkl ?? mal ?? imdb; + + factory SimklRatings.fromJson(Map json) => _$SimklRatingsFromJson(json); +} diff --git a/lib/models/simkl/simkl_rating.g.dart b/lib/models/simkl/simkl_rating.g.dart new file mode 100644 index 00000000..25b18615 --- /dev/null +++ b/lib/models/simkl/simkl_rating.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'simkl_rating.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SimklRating _$SimklRatingFromJson(Map json) => SimklRating( + rating: flexibleDouble(json['rating']), + votes: flexibleInt(json['votes']), +); + +SimklRatings _$SimklRatingsFromJson(Map json) => SimklRatings( + simkl: json['simkl'] == null + ? null + : SimklRating.fromJson(json['simkl'] as Map), + imdb: json['imdb'] == null + ? null + : SimklRating.fromJson(json['imdb'] as Map), + mal: json['mal'] == null + ? null + : SimklRating.fromJson(json['mal'] as Map), +); diff --git a/lib/models/simkl/simkl_recommendation.dart b/lib/models/simkl/simkl_recommendation.dart new file mode 100644 index 00000000..6d4a1bc3 --- /dev/null +++ b/lib/models/simkl/simkl_recommendation.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../utils/json_utils.dart'; +import 'simkl_ids.dart'; + +part 'simkl_recommendation.g.dart'; + +@JsonSerializable(createToJson: false) +class SimklRecommendation { + final String? title; + @JsonKey(fromJson: flexibleInt) + final int? year; + final String? poster; + final String? type; + final SimklIds ids; + + const SimklRecommendation({this.title, this.year, this.poster, this.type, required this.ids}); + + factory SimklRecommendation.fromJson(Map json) => _$SimklRecommendationFromJson(json); +} diff --git a/lib/models/simkl/simkl_recommendation.g.dart b/lib/models/simkl/simkl_recommendation.g.dart new file mode 100644 index 00000000..bac2e01d --- /dev/null +++ b/lib/models/simkl/simkl_recommendation.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'simkl_recommendation.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SimklRecommendation _$SimklRecommendationFromJson(Map json) => + SimklRecommendation( + title: json['title'] as String?, + year: flexibleInt(json['year']), + poster: json['poster'] as String?, + type: json['type'] as String?, + ids: SimklIds.fromJson(json['ids'] as Map), + ); diff --git a/lib/models/simkl/simkl_search_result.dart b/lib/models/simkl/simkl_search_result.dart new file mode 100644 index 00000000..268c93a6 --- /dev/null +++ b/lib/models/simkl/simkl_search_result.dart @@ -0,0 +1,39 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../utils/json_utils.dart'; +import 'simkl_ids.dart'; +import 'simkl_rating.dart'; + +part 'simkl_search_result.g.dart'; + +@JsonSerializable(createToJson: false) +class SimklSearchResult { + final String? title; + @JsonKey(fromJson: flexibleInt) + final int? year; + + /// Anime format (`tv`, `movie`, `ova`, ...). + final String? type; + @JsonKey(name: 'endpoint_type') + final String? endpointType; + final String? poster; + final SimklIds ids; + @JsonKey(name: 'ep_count', fromJson: flexibleInt) + final int? episodeCount; + final String? status; + final SimklRatings? ratings; + + const SimklSearchResult({ + this.title, + this.year, + this.type, + this.endpointType, + this.poster, + required this.ids, + this.episodeCount, + this.status, + this.ratings, + }); + + factory SimklSearchResult.fromJson(Map json) => _$SimklSearchResultFromJson(json); +} diff --git a/lib/models/simkl/simkl_search_result.g.dart b/lib/models/simkl/simkl_search_result.g.dart new file mode 100644 index 00000000..63ce64e5 --- /dev/null +++ b/lib/models/simkl/simkl_search_result.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'simkl_search_result.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SimklSearchResult _$SimklSearchResultFromJson(Map json) => + SimklSearchResult( + title: json['title'] as String?, + year: flexibleInt(json['year']), + type: json['type'] as String?, + endpointType: json['endpoint_type'] as String?, + poster: json['poster'] as String?, + ids: SimklIds.fromJson(json['ids'] as Map), + episodeCount: flexibleInt(json['ep_count']), + status: json['status'] as String?, + ratings: json['ratings'] == null + ? null + : SimklRatings.fromJson(json['ratings'] as Map), + ); diff --git a/lib/models/simkl/simkl_trending_item.dart b/lib/models/simkl/simkl_trending_item.dart new file mode 100644 index 00000000..cdc000ec --- /dev/null +++ b/lib/models/simkl/simkl_trending_item.dart @@ -0,0 +1,68 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../utils/json_utils.dart'; +import 'simkl_ids.dart'; +import 'simkl_rating.dart'; + +part 'simkl_trending_item.g.dart'; + +@JsonSerializable(createToJson: false) +class SimklTrendingItem { + final String? title; + final String? url; + final String? poster; + final String? fanart; + @JsonKey(name: 'release_date') + final String? releaseDate; + final String? runtime; + final String? status; + final List? genres; + final String? trailer; + final String? overview; + final SimklRatings? ratings; + @JsonKey(name: 'total_episodes', fromJson: flexibleInt) + final int? totalEpisodes; + final String? network; + @JsonKey(name: 'anime_type') + final String? animeType; + final SimklIds ids; + + const SimklTrendingItem({ + this.title, + this.url, + this.poster, + this.fanart, + this.releaseDate, + this.runtime, + this.status, + this.genres, + this.trailer, + this.overview, + this.ratings, + this.totalEpisodes, + this.network, + this.animeType, + required this.ids, + }); + + int? get year { + final parts = releaseDate?.split('/'); + if (parts == null || parts.length != 3) return null; + return int.tryParse(parts[2]); + } + + int? get runtimeMinutes { + final value = runtime?.trim(); + if (value == null || value.isEmpty) return null; + final match = RegExp(r'^(?:(\d+)\s*h)?(?:\s*(\d+)\s*m)?$', caseSensitive: false).firstMatch(value); + if (match == null || (match.group(1) == null && match.group(2) == null)) return null; + final hours = int.tryParse(match.group(1) ?? '') ?? 0; + final minutes = int.tryParse(match.group(2) ?? '') ?? 0; + final total = hours * 60 + minutes; + return total > 0 ? total : null; + } + + String? get trailerUrl => trailer == null || trailer!.isEmpty ? null : 'https://www.youtube.com/watch?v=$trailer'; + + factory SimklTrendingItem.fromJson(Map json) => _$SimklTrendingItemFromJson(json); +} diff --git a/lib/models/simkl/simkl_trending_item.g.dart b/lib/models/simkl/simkl_trending_item.g.dart new file mode 100644 index 00000000..8e439ace --- /dev/null +++ b/lib/models/simkl/simkl_trending_item.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'simkl_trending_item.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SimklTrendingItem _$SimklTrendingItemFromJson(Map json) => + SimklTrendingItem( + title: json['title'] as String?, + url: json['url'] as String?, + poster: json['poster'] as String?, + fanart: json['fanart'] as String?, + releaseDate: json['release_date'] as String?, + runtime: json['runtime'] as String?, + status: json['status'] as String?, + genres: (json['genres'] as List?) + ?.map((e) => e as String) + .toList(), + trailer: json['trailer'] as String?, + overview: json['overview'] as String?, + ratings: json['ratings'] == null + ? null + : SimklRatings.fromJson(json['ratings'] as Map), + totalEpisodes: flexibleInt(json['total_episodes']), + network: json['network'] as String?, + animeType: json['anime_type'] as String?, + ids: SimklIds.fromJson(json['ids'] as Map), + ); diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart index bd998d73..82aee36b 100644 --- a/lib/navigation/profile_session_screen.dart +++ b/lib/navigation/profile_session_screen.dart @@ -35,6 +35,16 @@ import '../watch_together/providers/watch_together_provider.dart'; import '../widgets/music/mini_player.dart'; import 'profile_navigation_scope.dart'; +CatalogSourcesProvider _createCatalogSourcesProvider(BuildContext context) { + return CatalogSourcesProvider( + plexSessionSupplier: () => resolvePlexDiscoverSession( + activeProfile: context.read(), + connections: context.read(), + profileConnections: context.read(), + ), + ); +} + /// Root route for an active profile session. /// /// The root app navigator owns setup/auth/profile-picking. This route owns the @@ -164,14 +174,15 @@ class _ProfileSessionScreenState extends State { return provider; }, ), - ChangeNotifierProxyProvider3< + ChangeNotifierProxyProvider4< TraktAccountProvider, TrackersProvider, SeerrAccountProvider, + ActiveProfileProvider, CatalogSourcesProvider >( create: (context) { - final provider = CatalogSourcesProvider(); + final provider = _createCatalogSourcesProvider(context); unawaited( provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) { appLogger.w('Catalog sources profile hydrate failed', error: e, stackTrace: s); @@ -179,9 +190,10 @@ class _ProfileSessionScreenState extends State { ); return provider; }, - update: (_, trakt, trackers, seerr, previous) { - final provider = previous ?? CatalogSourcesProvider(); + update: (context, trakt, trackers, seerr, activeProfile, previous) { + final provider = previous ?? _createCatalogSourcesProvider(context); provider.update(trakt, trackers, seerr); + unawaited(provider.onProfileBindingStateChanged(activeProfile.isBinding)); return provider; }, ), diff --git a/lib/providers/catalog_sources_provider.dart b/lib/providers/catalog_sources_provider.dart index fc77f6c1..8549e1d7 100644 --- a/lib/providers/catalog_sources_provider.dart +++ b/lib/providers/catalog_sources_provider.dart @@ -1,49 +1,131 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import '../connection/connection_registry.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; import '../models/catalog/catalog_item.dart'; +import '../profiles/active_plex_identity.dart'; +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/catalog_source.dart'; +import '../services/catalog/anilist_catalog_source.dart'; import '../services/catalog/mal_catalog_source.dart'; +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/plex_discover_client.dart'; import '../services/seerr/seerr_client.dart'; +import '../services/trackers/anilist/anilist_client.dart'; import '../services/trackers/mal/mal_client.dart'; +import '../services/trackers/simkl/simkl_client.dart'; import '../services/trakt/trakt_client.dart'; import 'seerr_account_provider.dart'; import 'trakt_account_provider.dart'; import 'trackers_provider.dart'; +import '../utils/app_logger.dart'; + +typedef PlexDiscoverSessionSupplier = Future Function(); + +/// Resolve the active Plex/Home profile credentials used by the cloud +/// Discover provider. Home-user tokens take precedence over the account token +/// so each profile sees and mutates its own universal watchlist. +Future resolvePlexDiscoverSession({ + required ActiveProfileProvider activeProfile, + required ConnectionRegistry connections, + required ProfileConnectionRegistry profileConnections, +}) async { + final identity = await resolveActivePlexIdentity( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + ); + if (identity == null) return null; + + var token = identity.account.accountToken; + final profile = activeProfile.active; + if (profile != null) { + final profileConnection = await profileConnections.get(profile.id, identity.account.id); + if (profileConnection?.hasToken ?? false) token = profileConnection!.userToken!; + } + final session = PlexDiscoverSession(accessToken: token, clientIdentifier: identity.account.clientIdentifier); + return session.isUsable ? session : null; +} + +/// Owns one client/source pair and applies the shared rebind/dispose contract. +class _CatalogSourceBinding { + _CatalogSourceBinding(this._create, {bool Function(Client? previous, Client? next)? equals}) + : _equals = equals ?? ((previous, next) => identical(previous, next)); + + final Source Function(Client client) _create; + final bool Function(Client? previous, Client? next) _equals; + Client? _client; + Source? source; + + bool update(Client? next) { + if (_equals(_client, next)) return false; + final replacement = next == null ? null : _create(next); + source?.dispose(); + _client = next; + source = replacement; + return true; + } + + void dispose() { + source?.dispose(); + source = null; + _client = null; + } +} /// Enumerates the connected [CatalogSource]s for the active profile and owns /// which one the Explore tab shows. /// -/// Profile-scoped; rebuilt through a `ChangeNotifierProxyProvider3` on -/// [TraktAccountProvider], [TrackersProvider] (MAL), and -/// [SeerrAccountProvider] so sources appear and -/// disappear live when a provider is connected or disconnected mid-session +/// Profile-scoped. Plex Discover credentials hydrate with the active profile; +/// tracker/Seerr sources are rebuilt through the proxy-provider update hook so +/// every source appears and disappears with its owning account connection /// (which also drives the Explore tab's visibility). class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifierMixin { - static const String _activeSourceBaseKey = 'catalog_active_source'; + CatalogSourcesProvider({this.plexSessionSupplier}); - TraktCatalogSource? _trakt; - TraktClient? _lastTraktClient; - MalCatalogSource? _mal; - MalClient? _lastMalClient; - SeerrCatalogSource? _seerr; - SeerrClient? _lastSeerrClient; + final PlexDiscoverSessionSupplier? plexSessionSupplier; + final _CatalogSourceBinding _plex = _CatalogSourceBinding( + (session) => PlexCatalogSource(PlexDiscoverClient(session)), + equals: (previous, next) => previous == next, + ); + final _CatalogSourceBinding _trakt = _CatalogSourceBinding(TraktCatalogSource.new); + final _CatalogSourceBinding _mal = _CatalogSourceBinding(MalCatalogSource.new); + final _CatalogSourceBinding _anilist = _CatalogSourceBinding( + AnilistCatalogSource.new, + ); + final _CatalogSourceBinding _simkl = _CatalogSourceBinding(SimklCatalogSource.new); + final _CatalogSourceBinding _seerr = _CatalogSourceBinding(SeerrCatalogSource.new); + int _profileBindingGeneration = 0; + int _plexSessionGeneration = 0; + bool? _lastProfileBindingState; + static const String _activeSourceBaseKey = 'catalog_active_source'; CatalogSourceId? _preferredSourceId; String _activeUserUuid = ''; - List get connectedSources => [?_trakt, ?_mal, ?_seerr]; + List get connectedSources => [ + ?_trakt.source, + ?_mal.source, + ?_anilist.source, + ?_simkl.source, + ?_plex.source, + ?_seerr.source, + ]; - bool get hasAnySource => _trakt != null || _mal != null || _seerr != null; + bool get hasAnySource => connectedSources.isNotEmpty; /// The connected Seerr source, for the request surfaces (detail-screen /// Request action and sheet) that need Seerr's client beyond the /// [CatalogSource] interface. - SeerrCatalogSource? get seerrSource => _seerr; + SeerrCatalogSource? get seerrSource => _seerr.source; /// The source whose rows the Explore tab shows: the user's persisted pick /// when it is still connected, otherwise the first connected source. @@ -74,14 +156,52 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie return watchlistCapableSource; } - /// Hydrate the per-profile active-source preference. + /// Hydrate the per-profile active-source preference and Plex session. Future onActiveProfileChanged(String? userUuid) async { + final generation = ++_profileBindingGeneration; _activeUserUuid = userUuid ?? ''; final prefs = await BaseSharedPreferencesService.sharedCache(); final raw = prefs.getString(profileScopedPrefsKey(_activeUserUuid, _activeSourceBaseKey)); - if (isDisposed) return; + if (isDisposed || generation != _profileBindingGeneration) return; + _preferredSourceId = CatalogSourceId.values.asNameMap()[raw]; safeNotifyListeners(); + await _refreshPlexSession(profileGeneration: generation, clearOnFailure: true); + } + + /// Refresh Plex Discover credentials after a same-profile server rebind. + /// The active-profile binder announces start/finish even when the profile id + /// stays unchanged, which is the connection-add/remove seam. + Future onProfileBindingStateChanged(bool isBinding) async { + final previous = _lastProfileBindingState; + _lastProfileBindingState = isBinding; + if (isBinding && previous != true) _plexSessionGeneration++; + if (previous == true && !isBinding) await _refreshPlexSession(); + } + + Future _refreshPlexSession({int? profileGeneration, bool clearOnFailure = false}) async { + final expectedProfileGeneration = profileGeneration ?? _profileBindingGeneration; + final sessionGeneration = ++_plexSessionGeneration; + PlexDiscoverSession? session; + try { + session = await plexSessionSupplier?.call(); + } catch (error, stackTrace) { + appLogger.w('Plex Discover session hydrate failed', error: error, stackTrace: stackTrace); + if (clearOnFailure && + !isDisposed && + expectedProfileGeneration == _profileBindingGeneration && + sessionGeneration == _plexSessionGeneration && + _plex.update(null)) { + safeNotifyListeners(); + } + return; + } + if (isDisposed || + expectedProfileGeneration != _profileBindingGeneration || + sessionGeneration != _plexSessionGeneration) { + return; + } + if (_plex.update(session)) safeNotifyListeners(); } Future setActiveSource(CatalogSourceId id) async { @@ -96,42 +216,22 @@ class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifie /// was rebound (connect/disconnect/profile switch). void update(TraktAccountProvider trakt, TrackersProvider trackers, SeerrAccountProvider seerr) { var changed = false; - - final traktClient = trakt.catalogClient; - if (!identical(traktClient, _lastTraktClient)) { - _lastTraktClient = traktClient; - _trakt?.dispose(); - _trakt = traktClient == null ? null : TraktCatalogSource(traktClient); - changed = true; - } - - final malClient = trackers.malCatalogClient; - if (!identical(malClient, _lastMalClient)) { - _lastMalClient = malClient; - _mal?.dispose(); - _mal = malClient == null ? null : MalCatalogSource(malClient); - changed = true; - } - - final seerrClient = seerr.catalogClient; - if (!identical(seerrClient, _lastSeerrClient)) { - _lastSeerrClient = seerrClient; - _seerr?.dispose(); - _seerr = seerrClient == null ? null : SeerrCatalogSource(seerrClient); - changed = true; - } - + changed = _trakt.update(trakt.catalogClient) || changed; + changed = _mal.update(trackers.malCatalogClient) || changed; + changed = _anilist.update(trackers.anilistCatalogClient) || changed; + changed = _simkl.update(trackers.simklCatalogClient) || changed; + changed = _seerr.update(seerr.catalogClient) || changed; if (changed) safeNotifyListeners(); } @override void dispose() { - _trakt?.dispose(); - _trakt = null; - _mal?.dispose(); - _mal = null; - _seerr?.dispose(); - _seerr = null; + _plex.dispose(); + _trakt.dispose(); + _mal.dispose(); + _anilist.dispose(); + _simkl.dispose(); + _seerr.dispose(); super.dispose(); } } diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 3698c34c..796960ff 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -13,12 +13,20 @@ import 'catalog_sources_provider.dart'; enum ExploreLoadState { initial, loading, loaded, error } -/// A row of the Explore tab: the catalog row id plus its rendering hub. -typedef ExploreRowHub = ({CatalogRowId row, MediaHub hub}); +/// One rendered Explore shelf, backed by either a fixed catalog row or a +/// provider-defined hub. +class ExploreRowHub { + final CatalogRowId? row; + final String? providerHubId; + final MediaHub hub; -/// Owns the Explore tab's data: one [CatalogPage] per row of the active -/// [CatalogSource], converted to [MediaHub]s so the existing shelf stack -/// renders them. + const ExploreRowHub.catalogRow({required CatalogRowId this.row, required this.hub}) : providerHubId = null; + + const ExploreRowHub.providerHub({required String this.providerHubId, required this.hub}) : row = null; +} + +/// Owns the Explore tab's fixed rows and provider-defined hubs, converted to +/// [MediaHub]s so the existing shelf stack renders them. /// /// Lives inside the profile-keyed provider subtree. Listens to /// [CatalogSourcesProvider] for the active source (connect/disconnect/switch) @@ -45,6 +53,7 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin CatalogSource? _source; Map _rows = {}; + List _providerHubs = const []; ExploreLoadState _state = ExploreLoadState.initial; String? _errorMessage; DateTime? _loadedAt; @@ -58,6 +67,7 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin // timer alone can lose the race when the user navigates back quickly. int _watchlistMutationEpoch = 0; int _watchlistRowFetchedEpoch = 0; + int _watchlistHubRetryEpoch = -1; final FutureCoalescer _watchlistRefreshCoalescer = FutureCoalescer(); List? _hubsCache; @@ -84,7 +94,7 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin for (final row in source.supportedRows) if (_rows[row] case final CatalogPage page) if (page.items.isNotEmpty) - ( + ExploreRowHub.catalogRow( row: row, hub: MediaHub( id: 'explore:${source.id.name}:${row.name}', @@ -96,6 +106,20 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin more: page.hasMore, ), ), + for (final providerHub in _providerHubs) + if (providerHub.page.items.isNotEmpty) + ExploreRowHub.providerHub( + providerHubId: providerHub.id, + hub: MediaHub( + id: 'explore:${source.id.name}:hub:${providerHub.id}', + identifier: 'explore.hub.${providerHub.id}', + title: providerHub.title, + type: 'mixed', + items: [for (final item in providerHub.page.items) item.toMediaItem()], + size: providerHub.page.items.length, + more: providerHub.page.hasMore, + ), + ), ]; _hubsCache = hubs; _hubsCacheKey = key; @@ -110,6 +134,7 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin CatalogRowId.trendingShows => t.explore.rows.trendingShows, CatalogRowId.popularMovies => t.explore.rows.popularMovies, CatalogRowId.popularShows => t.explore.rows.popularShows, + CatalogRowId.trendingAnime => t.explore.rows.trendingAnime, CatalogRowId.suggestedAnime => t.explore.rows.suggestedAnime, CatalogRowId.airingAnime => t.explore.rows.airingAnime, CatalogRowId.popularAnime => t.explore.rows.popularAnime, @@ -155,41 +180,54 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin _errorMessage = null; safeNotifyListeners(); - final rows = source.supportedRows; + final fetched = {}; + List? fetchedProviderHubs; Object? firstError; - final results = await Future.wait([ - for (final row in rows) - source.fetchRow(row, limit: rowLimit).then((page) => page).catchError((Object e) { - appLogger.w('Explore: ${source.id.name} row ${row.name} failed', error: e); - firstError ??= e; - return null; - }), + final CatalogHubSource? hubSource = source is CatalogHubSource ? source as CatalogHubSource : null; + await Future.wait([ + for (final row in source.supportedRows) + () async { + try { + fetched[row] = await source.fetchRow(row, limit: rowLimit); + } catch (e) { + appLogger.w('Explore: ${source.id.name} row ${row.name} failed', error: e); + firstError ??= e; + } + }(), + if (hubSource != null) + () async { + try { + fetchedProviderHubs = await hubSource.fetchHubs(limit: rowLimit); + } catch (e) { + appLogger.w('Explore: ${source.id.name} provider hubs failed', error: e); + firstError ??= e; + } + }(), ]); if (isDisposed || generation != _generation) return; - final fetched = { - for (var i = 0; i < rows.length; i++) - if (results[i] case final CatalogPage page) rows[i]: page, - }; - // A debounced watchlist-row refresh that landed while this load was in - // flight covered later mutations than our page — keep the fresher one. + // A debounced watchlist refresh that landed while this load was in + // flight covered later mutations than both the watchlist page and Plex's + // watchlist-derived hubs — keep the fresher versions. if (_watchlistRowFetchedEpoch > mutationEpochAtStart) { fetched.remove(CatalogRowId.watchlist); + fetchedProviderHubs = null; } - if (fetched.isEmpty) { + if (fetched.isEmpty && (fetchedProviderHubs == null || (fetchedProviderHubs!.isEmpty && firstError != null))) { // Nothing succeeded: keep stale rows if any (they beat an error flash), // otherwise surface the failure. A null message falls back to the // localized empty-state text in the screen. - if (_rows.isEmpty) { + if (_rows.isEmpty && _providerHubs.isEmpty) { _state = ExploreLoadState.error; _errorMessage = firstError?.toString(); } else { _state = ExploreLoadState.loaded; } } else { - // Failed rows keep their previous page. + // Failed rows and hubs keep their previous content. _rows = {..._rows, ...fetched}; + if (fetchedProviderHubs case final hubs?) _providerHubs = hubs; _state = ExploreLoadState.loaded; _loadedAt = DateTime.now(); _rowsEpoch++; @@ -206,18 +244,32 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin safeNotifyListeners(); } - /// Full item list for a row's View All grid, paging past the shelf cap. + /// Full item list for a fixed row's View All grid. Future> loadAllForRow(CatalogRowId row) async { final source = _source; if (source == null) return const []; + return _loadAllPages(row.name, (page) => source.fetchRow(row, page: page, limit: viewAllPageLimit)); + } + + /// Full item list for either a fixed row or a provider-defined hub. + Future> loadAllForHub(ExploreRowHub rowHub) async { + if (rowHub.row case final row?) return loadAllForRow(row); + final source = _source; + final hubId = rowHub.providerHubId; + if (source is! CatalogHubSource || hubId == null) return rowHub.hub.items; + final hubSource = source as CatalogHubSource; + return _loadAllPages(hubId, (page) => hubSource.fetchHub(hubId, page: page, limit: viewAllPageLimit)); + } + + Future> _loadAllPages(String label, Future Function(int page) fetchPage) async { final items = []; var page = 1; while (true) { - final res = await source.fetchRow(row, page: page, limit: viewAllPageLimit); - items.addAll([for (final item in res.items) item.toMediaItem()]); - if (!res.hasMore) break; + final result = await fetchPage(page); + items.addAll([for (final item in result.items) item.toMediaItem()]); + if (!result.hasMore) break; if (page >= viewAllMaxPages) { - appLogger.w('Explore: ${row.name} View All truncated at ${items.length} items ($page pages)'); + appLogger.w('Explore: $label View All truncated at ${items.length} items ($page pages)'); break; } page++; @@ -240,7 +292,9 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin _watchlistRefreshCoalescer.reset(); _watchlistMutationEpoch = 0; _watchlistRowFetchedEpoch = 0; + _watchlistHubRetryEpoch = -1; _rows = {}; + _providerHubs = const []; _loadedAt = null; _errorMessage = null; _state = ExploreLoadState.initial; @@ -269,24 +323,57 @@ class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (source == null || isDisposed) return; final generation = _generation; final coveredEpoch = _watchlistMutationEpoch; - try { - final page = await source.fetchRow(CatalogRowId.watchlist, limit: rowLimit); - if (isDisposed || generation != _generation) return; + CatalogPage? fetchedPage; + List? fetchedProviderHubs; + final CatalogHubSource? hubSource = source is CatalogHubSource ? source as CatalogHubSource : null; + var providerHubRefreshFailed = false; + await Future.wait([ + () async { + try { + fetchedPage = await source.fetchRow(CatalogRowId.watchlist, limit: rowLimit); + } catch (e) { + appLogger.w('Explore: watchlist row refresh failed', error: e); + } + }(), + if (hubSource != null) + () async { + try { + fetchedProviderHubs = await hubSource.fetchHubs(limit: rowLimit); + } catch (e) { + appLogger.w('Explore: ${source.id.name} provider hub refresh failed', error: e); + } + }(), + ]); + providerHubRefreshFailed = hubSource != null && fetchedProviderHubs == null; + if (isDisposed || generation != _generation) return; + if (fetchedPage == null && fetchedProviderHubs == null) return; + + if (fetchedPage case final page?) { _rows = {..._rows, CatalogRowId.watchlist: page}; - _rowsEpoch++; - _watchlistRowFetchedEpoch = coveredEpoch; - safeNotifyListeners(); - if (_watchlistMutationEpoch > coveredEpoch) { - // Mutations that arrived while this pass was in flight coalesced - // into it but aren't reflected in its page — go around once more. - _scheduleWatchlistRefresh(); - } else { - // Fully caught up: a still-pending debounce would only refetch the - // same state. - _watchlistRefreshTimer?.cancel(); + if (!providerHubRefreshFailed) { + _watchlistRowFetchedEpoch = coveredEpoch; + _watchlistHubRetryEpoch = -1; } - } catch (e) { - appLogger.w('Explore: watchlist row refresh failed', error: e); + } + if (fetchedProviderHubs case final hubs?) _providerHubs = hubs; + _rowsEpoch++; + safeNotifyListeners(); + + if (fetchedPage == null) return; + if (providerHubRefreshFailed) { + // One automatic retry per mutation epoch avoids leaving derived hubs + // stale without hammering an unavailable endpoint indefinitely. + if (_watchlistHubRetryEpoch != coveredEpoch) { + _watchlistHubRetryEpoch = coveredEpoch; + _scheduleWatchlistRefresh(); + } + } else if (_watchlistMutationEpoch > coveredEpoch) { + // Mutations that arrived during this pass need one more refresh. + _scheduleWatchlistRefresh(); + } else { + // Fully caught up: a still-pending debounce would only refetch the same + // state. + _watchlistRefreshTimer?.cancel(); } } diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index d4cdbe37..28b0445a 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -66,6 +66,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// every rebind is followed by a notify, so proxy consumers track identity. MalClient? get malCatalogClient => _mal == null ? null : MalTracker.instance.client; + /// Live AniList and Simkl clients for Explore. Like [malCatalogClient], + /// these are gated on this provider's profile-bound sessions so a fresh + /// profile subtree cannot observe clients still bound to the prior profile. + AnilistClient? get anilistCatalogClient => _anilist == null ? null : AnilistTracker.instance.client; + SimklClient? get simklCatalogClient => _simkl == null ? null : SimklTracker.instance.client; + String? get malUsername => _mal?.username; String? get anilistUsername => _anilist?.username; String? get simklUsername => _simkl?.username; diff --git a/lib/screens/catalog_item_detail_screen.dart b/lib/screens/catalog_item_detail_screen.dart index 76a434db..eb3d9b6e 100644 --- a/lib/screens/catalog_item_detail_screen.dart +++ b/lib/screens/catalog_item_detail_screen.dart @@ -408,7 +408,9 @@ class _CatalogItemDetailScreenState extends State { crossAxisAlignment: .start, children: [ Text( - widget.item.source == CatalogSourceId.mal ? t.explore.characters : t.explore.cast, + const {CatalogSourceId.mal, CatalogSourceId.anilist}.contains(widget.item.source) + ? t.explore.characters + : t.explore.cast, style: theme.textTheme.titleMedium, ), const SizedBox(height: 4), diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart index 6679d5f7..ff859f83 100644 --- a/lib/screens/explore_screen.dart +++ b/lib/screens/explore_screen.dart @@ -127,13 +127,15 @@ class ExploreScreenState extends State MainScreenFocusScope.focusSidebarOf(context); } - static IconData _rowIcon(CatalogRowId row) => switch (row) { + static IconData _rowIcon(CatalogRowId? row) => switch (row) { + null => Symbols.thumb_up_rounded, CatalogRowId.watchlist => Symbols.bookmark_rounded, CatalogRowId.recommendedMovies || CatalogRowId.recommendedShows || CatalogRowId.suggestedAnime => Symbols.thumb_up_rounded, CatalogRowId.trendingMovies || CatalogRowId.trendingShows || + CatalogRowId.trendingAnime || CatalogRowId.airingAnime || CatalogRowId.trending => Symbols.trending_up_rounded, CatalogRowId.popularMovies || CatalogRowId.popularShows || CatalogRowId.popularAnime => Symbols.whatshot_rounded, @@ -301,7 +303,7 @@ class ExploreScreenState extends State key: _orderedHubKeys[i], hub: rowHubs[i].hub, icon: _rowIcon(rowHubs[i].row), - loadMoreItems: rowHubs[i].hub.more ? () => _explore.loadAllForRow(rowHubs[i].row) : null, + loadMoreItems: rowHubs[i].hub.more ? () => _explore.loadAllForHub(rowHubs[i]) : null, onVerticalNavigation: (isUp) => _handleVerticalNavigation(i, isUp), onNavigateUp: i == 0 ? () => _actionBarKey.currentState?.requestFocusOnFirst() : null, onNavigateToSidebar: _navigateToSidebar, @@ -317,9 +319,9 @@ class ExploreScreenState extends State ); } - CatalogRowId? _rowForHub(MediaHub hub) { + ExploreRowHub? _rowForHub(MediaHub hub) { for (final rowHub in _explore.rowHubs) { - if (rowHub.hub.id == hub.id) return rowHub.row; + if (rowHub.hub.id == hub.id) return rowHub; } return null; } @@ -429,11 +431,11 @@ class ExploreScreenState extends State child: TvBrowseRail( key: _tvBrowseRailKey, hubs: tvHubs, - iconForHub: (hub, _) => _rowIcon(_rowForHub(hub) ?? CatalogRowId.watchlist), + iconForHub: (hub, _) => _rowIcon(_rowForHub(hub)?.row), onFocusedItemChanged: _setSpotlightItem, loadMoreItems: (hub) { - final row = _rowForHub(hub); - return row == null ? Future.value(hub.items) : _explore.loadAllForRow(row); + final rowHub = _rowForHub(hub); + return rowHub == null ? Future.value(hub.items) : _explore.loadAllForHub(rowHub); }, onNavigateUp: _actionBarKey.currentState?.requestFocusOnFirst, onNavigateToSidebar: _navigateToSidebar, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 1203d2d3..7ccfebba 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -718,8 +718,16 @@ class _MediaDetailScreenState extends State // than one, the toggle opens a source chooser. final candidates = []; for (final source in sources) { - final resolved = await source.resolveItemIds(_metadata.kind, ids); - if (resolved != null) candidates.add((source: source, ids: resolved)); + 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, + ); + } } if (!mounted || candidates.isEmpty) return; setState(() => _watchlistCandidates = candidates); diff --git a/lib/services/catalog/anilist_catalog_source.dart b/lib/services/catalog/anilist_catalog_source.dart new file mode 100644 index 00000000..b481bcf4 --- /dev/null +++ b/lib/services/catalog/anilist_catalog_source.dart @@ -0,0 +1,251 @@ +import 'package:collection/collection.dart'; + +import '../../media/media_kind.dart'; +import '../../models/anilist/anilist_media.dart'; +import '../../models/catalog/catalog_cast_member.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../models/trackers/fribb_mapping_row.dart'; +import '../../utils/external_ids.dart'; +import '../trackers/anilist/anilist_client.dart'; +import '../trackers/fribb_mapping_store.dart'; +import '../trackers/future_coalescer.dart'; +import 'catalog_source.dart'; +import 'catalog_watchlist_machinery.dart'; + +/// [CatalogSource] backed by AniList's GraphQL API. +/// +/// The client is owned and rebound by `TrackersProvider`; this source only +/// borrows it. Fribb bridges AniList/MAL identities to media-server IDs. +class AnilistCatalogSource with CatalogWatchlistMachinery implements CatalogSource { + final AnilistClient _client; + final FribbMappingLookup _fribb; + final FutureCoalescer _viewerIdLoad = FutureCoalescer(); + int? _viewerId; + + AnilistCatalogSource(this._client, {FribbMappingLookup? fribb}) : _fribb = fribb ?? FribbMappingStore.instance; + + @override + CatalogSourceId get id => CatalogSourceId.anilist; + + @override + String get displayName => 'AniList'; + + @override + List get supportedRows => const [ + CatalogRowId.watchlist, + CatalogRowId.trendingAnime, + CatalogRowId.airingAnime, + CatalogRowId.popularAnime, + ]; + + @override + bool get supportsWatchlist => true; + + @override + String get watchlistLogLabel => 'AniList: Planning'; + + @override + int get watchlistPageLimit => 500; + + @override + int get watchlistMaxPages => 4; + + @override + Future fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async { + final AnilistPage result; + switch (row) { + case CatalogRowId.watchlist: + result = await _client.getPlanningPage(await _getViewerId(), chunk: page, perChunk: limit); + case CatalogRowId.trendingAnime: + result = await _client.getTrendingAnime(page: page, limit: limit); + case CatalogRowId.airingAnime: + final current = currentAnimeSeason(DateTime.now()); + result = await _client.getSeasonalAnime(current.season, current.year, page: page, limit: limit); + case CatalogRowId.popularAnime: + result = await _client.getPopularAnime(page: page, limit: limit); + case CatalogRowId.recommendedMovies: + case CatalogRowId.recommendedShows: + case CatalogRowId.trendingMovies: + case CatalogRowId.trendingShows: + case CatalogRowId.popularMovies: + case CatalogRowId.popularShows: + case CatalogRowId.suggestedAnime: + case CatalogRowId.trending: + case CatalogRowId.upcomingMovies: + case CatalogRowId.upcomingShows: + throw ArgumentError('AniList does not serve ${row.name}'); + } + return CatalogPage(items: await _toCatalogItems(result.items), hasMore: result.hasMore); + } + + @override + Future> search(String query, {int limit = 30}) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + final result = await _client.searchAnime(trimmed, limit: limit); + return _toCatalogItems(result.items); + } + + static ({String season, int year}) currentAnimeSeason(DateTime date) => switch (date.month) { + 12 => (season: 'WINTER', year: date.year + 1), + 1 || 2 => (season: 'WINTER', year: date.year), + >= 3 && <= 5 => (season: 'SPRING', year: date.year), + >= 6 && <= 8 => (season: 'SUMMER', year: date.year), + _ => (season: 'FALL', year: date.year), + }; + + static CatalogAirStatus? airStatusFor(AnilistMedia anime) => switch (anime.status) { + 'RELEASING' => CatalogAirStatus.airing, + 'FINISHED' => anime.isMovie ? null : CatalogAirStatus.ended, + 'NOT_YET_RELEASED' => CatalogAirStatus.upcoming, + 'CANCELLED' => CatalogAirStatus.canceled, + _ => null, + }; + + Future> _toCatalogItems(List anime) async { + final valid = [ + for (final entry in anime) + if (entry.id != null) entry, + ]; + final rows = await Future.wait([ + for (final entry in valid) + if (entry.idMal case final int malId) _fribb.lookupByMal(malId) else Future.value(), + ]); + return [for (var i = 0; i < valid.length; i++) _toCatalogItem(valid[i], rows[i])]; + } + + CatalogItem _toCatalogItem(AnilistMedia anime, FribbMappingRow? row) => CatalogItem( + source: CatalogSourceId.anilist, + kind: anime.isMovie ? MediaKind.movie : MediaKind.show, + title: anime.displayTitle, + year: anime.year, + overview: anime.description, + runtimeMinutes: anime.runtimeMinutes, + rating: anime.rating, + votes: anime.votes, + genres: anime.genres, + trailerUrl: anime.trailerUrl, + airStatus: airStatusFor(anime), + episodeCount: anime.isMovie || (anime.episodes ?? 0) <= 0 ? null : anime.episodes, + network: anime.network, + ids: CatalogItemIds( + anilist: anime.id, + mal: anime.idMal, + imdb: row?.imdbIds?.firstOrNull, + tmdb: row?.tmdbIds?.firstOrNull, + tvdb: row?.tvdbId, + ), + posterUrl: anime.posterUrl, + backdropUrl: anime.backdropUrl, + ); + + @override + Future> fetchCast(CatalogItem item, {int limit = 20}) async { + final anilistId = item.ids.anilist; + if (anilistId == null) return const []; + final characters = await _client.getAnimeCharacters(anilistId, limit: limit); + return [ + for (final character in characters) + if (character.name.isNotEmpty) + CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl), + ]; + } + + @override + Future> fetchRelated(CatalogItem item, {int limit = 20}) async { + final anilistId = item.ids.anilist; + if (anilistId == null) return const []; + return _toCatalogItems(await _client.getAnimeRecommendations(anilistId, limit: limit)); + } + + @override + Future resolveItemIds(MediaKind kind, ExternalIds external) async { + if (!external.hasAny) return null; + final rows = await _fribb.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb); + final row = _pickRow(kind, rows); + if (row?.anilistId == null) return null; + return CatalogItemIds( + anilist: row!.anilistId, + mal: row.malId, + imdb: external.imdb, + tmdb: external.tmdb, + tvdb: external.tvdb, + ); + } + + static FribbMappingRow? _pickRow(MediaKind kind, List rows) { + final withAnilist = [ + for (final row in rows) + if (row.anilistId != null) row, + ]; + if (withAnilist.isEmpty) return null; + if (kind == MediaKind.movie) { + return withAnilist.firstWhereOrNull((row) => row.isMovie) ?? withAnilist.first; + } + return withAnilist.firstWhereOrNull((row) => row.tvdbSeason == 1 || row.tmdbSeason == 1) ?? withAnilist.first; + } + + static List _identityKeys(CatalogItemIds ids) => [ + if (ids.anilist case final int id) 'anilist:$id', + if (ids.mal case final int id) 'mal:$id', + ]; + + @override + List membershipKeysFor(MediaKind kind, CatalogItemIds ids) => _identityKeys(ids); + + @override + Future fetchWatchlistKeyPage(int page, int limit) async { + final result = await _client.getPlanningIdsPage(await _getViewerId(), chunk: page, perChunk: limit); + return ( + groups: [ + for (final anime in result.items) + for (final keys in [_identityKeys(CatalogItemIds(anilist: anime.id, mal: anime.idMal))]) + if (keys.isNotEmpty) keys, + ], + hasMore: result.hasMore, + ); + } + + Future _getViewerId() { + final cached = _viewerId; + if (cached != null) return Future.value(cached); + return _viewerIdLoad.run(() async { + final id = await _client.getViewerId(); + _viewerId = id; + return id; + }); + } + + @override + Future resolveWatchlistMutationIds(MediaKind kind, CatalogItemIds ids) async { + if (ids.anilist != null) return ids; + + if (ids.mal case final int malId) { + final row = await _fribb.lookupByMal(malId); + if (row?.anilistId case final int anilistId) { + return CatalogItemIds(anilist: anilistId, mal: malId, imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb); + } + } + + final resolved = await resolveItemIds(kind, ids.toExternalIds()); + if (resolved?.anilist == null) { + throw StateError('AniList: no anime mapping for ${ids.canonicalKey ?? 'item'}'); + } + return resolved!; + } + + @override + Future performWatchlistMutation(MediaKind kind, CatalogItemIds ids, {required bool add}) async { + final anilistId = ids.anilist!; + if (add) { + await _client.setMediaListStatus(mediaId: anilistId, status: 'PLANNING'); + } else { + await _client.deleteMediaListEntry(anilistId); + } + } + + @override + void dispose() { + disposeWatchlistMachinery(); + } +} diff --git a/lib/services/catalog/catalog_source.dart b/lib/services/catalog/catalog_source.dart index 1d019c0f..98ec128b 100644 --- a/lib/services/catalog/catalog_source.dart +++ b/lib/services/catalog/catalog_source.dart @@ -15,6 +15,7 @@ enum CatalogRowId { popularMovies, popularShows, // Anime rows (MAL has no movie/show split). + trendingAnime, suggestedAnime, airingAnime, popularAnime, @@ -50,6 +51,27 @@ class CatalogPage { const CatalogPage({required this.items, this.hasMore = false}); } +/// One provider-defined Explore shelf. Unlike [CatalogRowId], hubs are +/// discovered at runtime and retain the provider's title and stable id. +class CatalogHub { + final String id; + final String title; + final CatalogPage page; + + const CatalogHub({required this.id, required this.title, required this.page}); +} + +/// Optional capability for catalog providers that expose dynamic hub rows. +/// +/// [CatalogSource] stays fixed-row by default. Providers such as Plex can +/// implement this alongside it without forcing every source to grow no-op +/// methods. +abstract interface class CatalogHubSource { + Future> fetchHubs({int limit = 25}); + + Future fetchHub(String id, {int page = 1, int limit = 25}); +} + /// A pluggable external catalog provider backing the Explore tab (Trakt /// today; Overseerr/Jellyfin or MAL later). /// diff --git a/lib/services/catalog/catalog_watchlist_machinery.dart b/lib/services/catalog/catalog_watchlist_machinery.dart index b82d3a71..366f68db 100644 --- a/lib/services/catalog/catalog_watchlist_machinery.dart +++ b/lib/services/catalog/catalog_watchlist_machinery.dart @@ -34,9 +34,11 @@ mixin CatalogWatchlistMachinery { int get watchlistPageLimit; int get watchlistMaxPages; - /// Every membership key form of [ids] under [kind]'s namespace rules. - /// Empty when [ids] carries no form this source can key on. - List membershipKeysFor(MediaKind kind, CatalogItemIds ids); + /// Every membership key form of [ids], namespaced by [kind]. Sources with + /// different identity rules (MAL and AniList) override this. + List membershipKeysFor(MediaKind kind, CatalogItemIds ids) => [ + for (final key in ids.allKeys) '${kind.id}/$key', + ]; /// One page of the snapshot as key groups (one group per entry). Future fetchWatchlistKeyPage(int page, int limit); diff --git a/lib/services/catalog/mal_catalog_source.dart b/lib/services/catalog/mal_catalog_source.dart index 701f9a08..8390ff1d 100644 --- a/lib/services/catalog/mal_catalog_source.dart +++ b/lib/services/catalog/mal_catalog_source.dart @@ -69,6 +69,7 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource { CatalogRowId.trendingShows || CatalogRowId.popularMovies || CatalogRowId.popularShows || + CatalogRowId.trendingAnime || CatalogRowId.trending || CatalogRowId.upcomingMovies || CatalogRowId.upcomingShows => throw ArgumentError('MAL does not serve ${row.name}'), diff --git a/lib/services/catalog/plex_catalog_source.dart b/lib/services/catalog/plex_catalog_source.dart new file mode 100644 index 00000000..7a2d41c9 --- /dev/null +++ b/lib/services/catalog/plex_catalog_source.dart @@ -0,0 +1,237 @@ +import '../../media/media_kind.dart'; +import '../../models/catalog/catalog_cast_member.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../utils/external_ids.dart'; +import '../../utils/json_utils.dart'; +import '../plex_discover_client.dart'; +import 'catalog_source.dart'; +import 'catalog_watchlist_machinery.dart'; + +/// [CatalogSource] backed by the active Plex profile's universal watchlist +/// and its provider-defined recommendation hubs. +class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource, CatalogHubSource { + final PlexDiscoverClient _client; + final Map _hubKeys = {}; + + PlexCatalogSource(this._client); + + @override + CatalogSourceId get id => CatalogSourceId.plex; + + @override + String get displayName => 'Plex'; + + @override + List get supportedRows => const [CatalogRowId.watchlist]; + + @override + bool get supportsWatchlist => true; + + @override + String get watchlistLogLabel => 'Plex: watchlist'; + + @override + int get watchlistPageLimit => 500; + + @override + int get watchlistMaxPages => 10; + + @override + Future fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async { + if (row != CatalogRowId.watchlist) throw ArgumentError('Plex does not serve ${row.name}'); + final response = await _client.getWatchlist(page: page, limit: limit); + return CatalogPage(items: _fromMetadata(response.items), hasMore: response.hasMore); + } + + @override + Future> fetchHubs({int limit = 25}) async { + final fetched = await _client.getRecommendedHubs(limit: limit); + final keys = {}; + final result = []; + for (final hub in fetched) { + final items = _fromMetadata(hub.page.items); + if (items.isEmpty) continue; + keys[hub.id] = hub.key; + result.add( + CatalogHub( + id: hub.id, + title: hub.title, + page: CatalogPage(items: items, hasMore: hub.page.hasMore), + ), + ); + } + _hubKeys + ..clear() + ..addAll(keys); + return result; + } + + @override + Future fetchHub(String id, {int page = 1, int limit = 25}) async { + final key = _hubKeys[id]; + if (key == null) return const CatalogPage(items: []); + final response = await _client.getHub(key, page: page, limit: limit); + return CatalogPage(items: _fromMetadata(response.items), hasMore: response.hasMore); + } + + @override + Future> search(String query, {int limit = 30}) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + return _fromMetadata(await _client.search(trimmed, limit: limit)); + } + + @override + Future resolveItemIds(MediaKind kind, ExternalIds external) async { + if (!external.hasAny) return null; + final metadata = await _client.match(external); + final matchedKind = metadata == null ? null : _kindFor(metadata['type']); + if (metadata == null || matchedKind != kind) return null; + final ids = _idsFor(metadata); + if (ids.plex == null) return null; + return CatalogItemIds( + plex: ids.plex, + imdb: ids.imdb ?? external.imdb, + tmdb: ids.tmdb ?? external.tmdb, + tvdb: ids.tvdb ?? external.tvdb, + ); + } + + @override + Future resolveWatchlistMutationIds(MediaKind kind, CatalogItemIds ids) async { + if (ids.plex != null && ids.plex!.isNotEmpty) return ids; + final resolved = await resolveItemIds(kind, ids.toExternalIds()); + if (resolved?.plex == null || resolved!.plex!.isEmpty) { + throw StateError('Plex: no rating key for ${ids.canonicalKey ?? 'item'}'); + } + return resolved; + } + + @override + Future> fetchCast(CatalogItem item, {int limit = 20}) async { + final ratingKey = item.ids.plex; + if (ratingKey == null) return const []; + final metadata = await _client.getMetadata(ratingKey); + if (metadata == null) return const []; + return [ + for (final role in flexibleMapList(metadata['Role']).take(limit)) + if (_nonEmptyString(role['tag'] ?? role['name']) case final String name) + CatalogCastMember( + name: name, + secondary: _nonEmptyString(role['role']), + imageUrl: _nonEmptyString(role['thumb']), + ), + ]; + } + + @override + Future> fetchRelated(CatalogItem item, {int limit = 20}) async { + final ratingKey = item.ids.plex; + if (ratingKey == null) return const []; + return _fromMetadata(await _client.getRelated(ratingKey)).take(limit).toList(); + } + + @override + Future fetchWatchlistKeyPage(int page, int limit) async { + final response = await _client.getWatchlist(page: page, limit: limit); + return ( + groups: [for (final item in _fromMetadata(response.items)) membershipKeysFor(item.kind, item.ids)], + hasMore: response.hasMore, + ); + } + + @override + Future performWatchlistMutation(MediaKind kind, CatalogItemIds ids, {required bool add}) async { + final ratingKey = ids.plex; + if (ratingKey == null || ratingKey.isEmpty) { + throw ArgumentError('Plex watchlist mutations require a Plex rating key'); + } + await _client.setWatchlisted(ratingKey, add: add); + } + + List _fromMetadata(List> metadata) { + final items = []; + final seen = {}; + for (final value in metadata) { + final item = _toCatalogItem(value); + if (item != null && seen.add(item.identityKey)) items.add(item); + } + return items; + } + + CatalogItem? _toCatalogItem(Map metadata) { + final kind = _kindFor(metadata['type']); + final title = _nonEmptyString(metadata['title']); + final ids = _idsFor(metadata); + if (kind == null || title == null || ids.plex == null) return null; + + final genres = [ + for (final genre in flexibleMapList(metadata['Genre'])) + if (_nonEmptyString(genre['tag']) case final String name) name, + ]; + final durationMs = flexibleInt(metadata['duration']); + return CatalogItem( + source: CatalogSourceId.plex, + kind: kind, + title: title, + year: flexibleInt(metadata['year']), + overview: _nonEmptyString(metadata['summary']), + runtimeMinutes: durationMs == null ? null : Duration(milliseconds: durationMs).inMinutes, + rating: flexibleDouble(metadata['rating'] ?? metadata['audienceRating']), + genres: genres.isEmpty ? null : genres, + certification: _nonEmptyString(metadata['contentRating']), + episodeCount: kind == MediaKind.show ? flexibleInt(metadata['leafCount']) : null, + network: _nonEmptyString(metadata['studio'] ?? metadata['network']), + ids: ids, + posterUrl: _nonEmptyString(metadata['thumb']), + backdropUrl: _nonEmptyString(metadata['art']), + ); + } + + static MediaKind? _kindFor(Object? type) => switch (type) { + 'movie' => MediaKind.movie, + 'show' => MediaKind.show, + _ => null, + }; + + static CatalogItemIds _idsFor(Map metadata) { + String? imdb; + int? tmdb; + int? tvdb; + + void consumeGuid(Object? value) { + final guid = _nonEmptyString(value); + if (guid == null) return; + final separator = guid.indexOf('://'); + if (separator <= 0) return; + final provider = guid.substring(0, separator).toLowerCase(); + final id = guid.substring(separator + 3); + switch (provider) { + case 'imdb': + imdb ??= id; + case 'tmdb': + tmdb ??= int.tryParse(id); + case 'tvdb': + tvdb ??= int.tryParse(id); + } + } + + consumeGuid(metadata['guid']); + for (final guid in flexibleMapList(metadata['Guid'])) { + consumeGuid(guid['id']); + } + + return CatalogItemIds(plex: _nonEmptyString(metadata['ratingKey']), imdb: imdb, tmdb: tmdb, tvdb: tvdb); + } + + static String? _nonEmptyString(Object? value) { + final string = value?.toString().trim(); + return string == null || string.isEmpty ? null : string; + } + + @override + void dispose() { + disposeWatchlistMachinery(); + _client.dispose(); + } +} diff --git a/lib/services/catalog/seerr_catalog_source.dart b/lib/services/catalog/seerr_catalog_source.dart index bdd900d7..3eaa32be 100644 --- a/lib/services/catalog/seerr_catalog_source.dart +++ b/lib/services/catalog/seerr_catalog_source.dart @@ -66,6 +66,7 @@ class SeerrCatalogSource implements CatalogSource { CatalogRowId.recommendedShows || CatalogRowId.trendingMovies || CatalogRowId.trendingShows || + CatalogRowId.trendingAnime || CatalogRowId.suggestedAnime || CatalogRowId.airingAnime || CatalogRowId.popularAnime => throw ArgumentError('Seerr does not serve ${row.name}'), diff --git a/lib/services/catalog/simkl_catalog_source.dart b/lib/services/catalog/simkl_catalog_source.dart new file mode 100644 index 00000000..f312e9d8 --- /dev/null +++ b/lib/services/catalog/simkl_catalog_source.dart @@ -0,0 +1,359 @@ +import '../../media/media_kind.dart'; +import '../../models/catalog/catalog_cast_member.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../models/simkl/simkl_all_items_entry.dart'; +import '../../models/simkl/simkl_best_item.dart'; +import '../../models/simkl/simkl_ids.dart'; +import '../../models/simkl/simkl_images.dart'; +import '../../models/simkl/simkl_recommendation.dart'; +import '../../models/simkl/simkl_search_result.dart'; +import '../../models/simkl/simkl_trending_item.dart'; +import '../../utils/external_ids.dart'; +import '../trackers/simkl/simkl_client.dart'; +import '../trackers/simkl/simkl_constants.dart'; +import '../trackers/tracker_exceptions.dart'; +import '../trackers/future_coalescer.dart'; +import 'catalog_source.dart'; +import 'catalog_watchlist_machinery.dart'; + +/// [CatalogSource] backed by Simkl's REST API and public discovery CDN. +/// +/// The client is owned and rebound by `TrackersProvider`; this source only +/// borrows it. Simkl accepts the media server's native external IDs directly, +/// so library-item resolution does not require Fribb. +class SimklCatalogSource with CatalogWatchlistMachinery implements CatalogSource { + static const Duration _rowCacheTtl = Duration(minutes: 15); + + final SimklClient _client; + final Map> _rowCache = {}; + final Map _rowCacheLoadedAt = {}; + final KeyedFutureCoalescer> _rowLoads = KeyedFutureCoalescer(); + final FutureCoalescer _watchlistLoad = FutureCoalescer(); + SimklAllItems? _watchlistCache; + int _watchlistCacheGeneration = 0; + + SimklCatalogSource(this._client); + + @override + CatalogSourceId get id => CatalogSourceId.simkl; + + @override + String get displayName => 'Simkl'; + + @override + List get supportedRows => const [ + CatalogRowId.watchlist, + CatalogRowId.trendingMovies, + CatalogRowId.trendingShows, + CatalogRowId.trendingAnime, + CatalogRowId.popularShows, + CatalogRowId.popularAnime, + ]; + + @override + bool get supportsWatchlist => true; + + @override + String get watchlistLogLabel => 'Simkl: Plan to Watch'; + + /// Simkl's plan-to-watch snapshot is one unpaginated response. + @override + int get watchlistPageLimit => 1; + + @override + int get watchlistMaxPages => 1; + + @override + Future fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async { + if (!supportedRows.contains(row)) { + throw ArgumentError('Simkl does not serve ${row.name}'); + } + + final normalizedPage = page < 1 ? 1 : page; + final normalizedLimit = limit < 1 ? 1 : limit; + var cached = _rowCache[row]; + final loadedAt = _rowCacheLoadedAt[row]; + final expired = normalizedPage == 1 && loadedAt != null && DateTime.now().difference(loadedAt) >= _rowCacheTtl; + if (cached == null || expired) { + cached = await _rowLoads.run(row, () async { + final current = _rowCache[row]; + final currentLoadedAt = _rowCacheLoadedAt[row]; + final currentExpired = + normalizedPage == 1 && + currentLoadedAt != null && + DateTime.now().difference(currentLoadedAt) >= _rowCacheTtl; + if (current != null && !currentExpired) return current; + if (row == CatalogRowId.watchlist && currentExpired) _invalidateWatchlistCache(); + final fetched = await _fetchUnpaginatedRow(row); + _rowCache[row] = fetched; + _rowCacheLoadedAt[row] = DateTime.now(); + return fetched; + }); + } + + final start = (normalizedPage - 1) * normalizedLimit; + if (start >= cached.length) return const CatalogPage(items: []); + final end = (start + normalizedLimit).clamp(0, cached.length); + return CatalogPage(items: cached.sublist(start, end), hasMore: end < cached.length); + } + + Future> _fetchUnpaginatedRow(CatalogRowId row) async => switch (row) { + CatalogRowId.watchlist => _fetchWatchlistRow(), + // Simkl requires visible attribution wherever the CDN trending data is + // displayed. The source switcher logo/name supplies that attribution. + CatalogRowId.trendingMovies => _fetchTrending(SimklCatalogType.movies), + CatalogRowId.trendingShows => _fetchTrending(SimklCatalogType.tv), + CatalogRowId.trendingAnime => _fetchTrending(SimklCatalogType.anime), + CatalogRowId.popularShows => _fetchBest(SimklCatalogType.tv), + CatalogRowId.popularAnime => _fetchBest(SimklCatalogType.anime), + CatalogRowId.recommendedMovies || + CatalogRowId.recommendedShows || + CatalogRowId.popularMovies || + CatalogRowId.suggestedAnime || + CatalogRowId.airingAnime || + CatalogRowId.trending || + CatalogRowId.upcomingMovies || + CatalogRowId.upcomingShows => throw ArgumentError('Simkl does not serve ${row.name}'), + }; + + Future> _fetchTrending(SimklCatalogType type) async => [ + for (final item in await _client.getTrending(type)) + if (item.ids.hasAny) _catalogItemFromTrending(item, type), + ]; + + Future> _fetchBest(SimklCatalogType type) async => [ + for (final item in await _client.getBest(type)) + if (item.ids.hasAny) _catalogItemFromBest(item, type), + ]; + + Future> _fetchWatchlistRow() async { + final response = await _getWatchlistItems(); + return [ + for (final entry in response.movies) + if (entry.media?.ids.hasAny == true) _catalogItemFromAllItems(entry, SimklCatalogType.movies), + for (final entry in response.shows) + if (entry.media?.ids.hasAny == true) _catalogItemFromAllItems(entry, SimklCatalogType.tv), + for (final entry in response.anime) + if (entry.media?.ids.hasAny == true) _catalogItemFromAllItems(entry, SimklCatalogType.anime), + ]; + } + + Future _getWatchlistItems() { + final cached = _watchlistCache; + if (cached != null) return Future.value(cached); + final generation = _watchlistCacheGeneration; + return _watchlistLoad.run(() async { + final response = await _client.getAllItems(extended: 'full'); + if (generation == _watchlistCacheGeneration) _watchlistCache = response; + return response; + }); + } + + @override + Future> search(String query, {int limit = 30}) async { + final trimmed = query.trim(); + if (trimmed.isEmpty || limit <= 0) return const []; + final perType = (limit / 3).ceil().clamp(1, 50); + final pages = await Future.wait([ + _client.searchCatalog(SimklCatalogType.movies, trimmed, limit: perType), + _client.searchCatalog(SimklCatalogType.tv, trimmed, limit: perType), + _client.searchCatalog(SimklCatalogType.anime, trimmed, limit: perType), + ]); + final types = const [SimklCatalogType.movies, SimklCatalogType.tv, SimklCatalogType.anime]; + return [ + for (var i = 0; i < pages.length; i++) + for (final item in pages[i].items) + if (item.ids.hasAny) _catalogItemFromSearch(item, types[i]), + ].take(limit).toList(); + } + + static CatalogAirStatus? airStatusFor(String? status, MediaKind kind) => switch (status?.toLowerCase()) { + 'airing' || 'ongoing' => CatalogAirStatus.airing, + 'ended' => kind == MediaKind.movie ? null : CatalogAirStatus.ended, + 'tba' => CatalogAirStatus.upcoming, + _ => null, + }; + + static MediaKind _kindFor(SimklCatalogType type, {String? animeType}) { + if (type == SimklCatalogType.movies) return MediaKind.movie; + if (type == SimklCatalogType.anime && animeType?.toLowerCase() == 'movie') return MediaKind.movie; + return MediaKind.show; + } + + CatalogItem _catalogItemFromTrending(SimklTrendingItem item, SimklCatalogType type) { + final kind = _kindFor(type, animeType: item.animeType); + final rating = item.ratings?.primary; + return CatalogItem( + source: CatalogSourceId.simkl, + kind: kind, + title: item.title ?? '', + year: item.year, + overview: item.overview, + runtimeMinutes: item.runtimeMinutes, + rating: rating?.rating, + votes: rating?.votes, + genres: item.genres, + trailerUrl: item.trailerUrl, + airStatus: airStatusFor(item.status, kind), + episodeCount: kind == MediaKind.movie || (item.totalEpisodes ?? 0) <= 0 ? null : item.totalEpisodes, + network: item.network, + ids: item.ids.toCatalogItemIds(), + posterUrl: simklPosterUrl(item.poster), + backdropUrl: simklFanartUrl(item.fanart), + ); + } + + CatalogItem _catalogItemFromSearch(SimklSearchResult item, SimklCatalogType type) { + final kind = _kindFor(type, animeType: item.type); + final rating = item.ratings?.primary; + return CatalogItem( + source: CatalogSourceId.simkl, + kind: kind, + title: item.title ?? '', + year: item.year, + rating: rating?.rating, + votes: rating?.votes, + airStatus: airStatusFor(item.status, kind), + episodeCount: kind == MediaKind.movie || (item.episodeCount ?? 0) <= 0 ? null : item.episodeCount, + ids: item.ids.toCatalogItemIds(), + posterUrl: simklPosterUrl(item.poster), + ); + } + + CatalogItem _catalogItemFromBest(SimklBestItem item, SimklCatalogType type) { + final kind = _kindFor(type); + final rating = item.ratings?.primary; + return CatalogItem( + source: CatalogSourceId.simkl, + kind: kind, + title: item.title ?? '', + year: item.year, + rating: rating?.rating, + votes: rating?.votes, + ids: item.ids.toCatalogItemIds(), + posterUrl: simklPosterUrl(item.poster), + ); + } + + CatalogItem _catalogItemFromAllItems(SimklAllItemsEntry entry, SimklCatalogType type) { + final media = entry.media!; + final kind = _kindFor(type, animeType: entry.animeType); + return CatalogItem( + source: CatalogSourceId.simkl, + kind: kind, + title: media.title ?? '', + year: media.year, + overview: media.overview, + runtimeMinutes: media.runtime, + genres: media.genres, + airStatus: airStatusFor(media.status, kind), + episodeCount: kind == MediaKind.movie || (entry.totalEpisodes ?? 0) <= 0 ? null : entry.totalEpisodes, + network: media.network, + ids: media.ids.toCatalogItemIds(), + posterUrl: simklPosterUrl(media.poster), + backdropUrl: simklFanartUrl(media.fanart), + ); + } + + CatalogItem _catalogItemFromRecommendation(SimklRecommendation item) { + final kind = item.type == 'movie' ? MediaKind.movie : MediaKind.show; + return CatalogItem( + source: CatalogSourceId.simkl, + kind: kind, + title: item.title ?? '', + year: item.year, + ids: item.ids.toCatalogItemIds(), + posterUrl: simklPosterUrl(item.poster), + ); + } + + @override + Future> fetchCast(CatalogItem item, {int limit = 20}) async => const []; + + @override + Future> fetchRelated(CatalogItem item, {int limit = 20}) async { + final simklId = item.ids.simkl; + if (simklId == null) return const []; + final primary = item.kind == MediaKind.movie ? SimklCatalogType.movies : SimklCatalogType.tv; + List recommendations; + try { + recommendations = await _client.getRecommendations(primary, simklId); + // Current Simkl detail endpoints return `[]` (HTTP 200), not 404, for + // an ID belonging to a different media bucket. Anime is therefore the + // fallback for both an explicit 404 and an empty primary response. + if (recommendations.isEmpty) { + recommendations = await _client.getRecommendations(SimklCatalogType.anime, simklId); + } + } on TrackerApiException catch (error) { + if (error.statusCode != 404) rethrow; + recommendations = await _client.getRecommendations(SimklCatalogType.anime, simklId); + } + return [ + for (final recommendation in recommendations.take(limit)) + if (recommendation.ids.hasAny) _catalogItemFromRecommendation(recommendation), + ]; + } + + @override + Future resolveItemIds(MediaKind kind, ExternalIds external) async => + external.hasAny ? CatalogItemIds.fromExternal(external) : null; + + @override + Future fetchWatchlistKeyPage(int page, int limit) async { + final response = await _getWatchlistItems(); + return ( + groups: [ + for (final entry in response.movies) + if (entry.media case final media?) membershipKeysFor(MediaKind.movie, media.ids.toCatalogItemIds()), + for (final entry in response.shows) + if (entry.media case final media?) membershipKeysFor(MediaKind.show, media.ids.toCatalogItemIds()), + for (final entry in response.anime) + if (entry.media case final media?) + [ + ...membershipKeysFor(MediaKind.movie, media.ids.toCatalogItemIds()), + ...membershipKeysFor(MediaKind.show, media.ids.toCatalogItemIds()), + ], + ], + hasMore: false, + ); + } + + @override + Future performWatchlistMutation(MediaKind kind, CatalogItemIds ids, {required bool add}) async { + final bucket = kind == MediaKind.show ? 'shows' : 'movies'; + final mutationIds = SimklIds.fromCatalogItemIds(ids).toJson(); + if (add) { + await _client.addToList({ + bucket: [ + {'to': 'plantowatch', 'ids': mutationIds}, + ], + }); + } else { + // Simkl has no remove-from-list endpoint. A bare-IDs history removal drops + // the title from the user's library entirely; for a Plan to Watch entry, + // that is the documented and intended removal behavior. + await _client.removeFromHistory({ + bucket: [ + {'ids': mutationIds}, + ], + }); + } + _invalidateWatchlistCache(); + } + + void _invalidateWatchlistCache() { + _watchlistCacheGeneration++; + _watchlistCache = null; + _watchlistLoad.reset(); + _rowCache.remove(CatalogRowId.watchlist); + _rowCacheLoadedAt.remove(CatalogRowId.watchlist); + } + + @override + void dispose() { + _invalidateWatchlistCache(); + _rowCache.clear(); + _rowCacheLoadedAt.clear(); + disposeWatchlistMachinery(); + } +} diff --git a/lib/services/catalog/trakt_catalog_source.dart b/lib/services/catalog/trakt_catalog_source.dart index 51f8fa86..a74f546f 100644 --- a/lib/services/catalog/trakt_catalog_source.dart +++ b/lib/services/catalog/trakt_catalog_source.dart @@ -81,6 +81,7 @@ class TraktCatalogSource with CatalogWatchlistMachinery implements CatalogSource case CatalogRowId.popularShows: final res = await _client.getPopular(TraktCatalogType.shows, page: page, limit: limit); return CatalogPage(items: _fromMedia(res.items, MediaKind.show), hasMore: res.hasMore); + case CatalogRowId.trendingAnime: case CatalogRowId.suggestedAnime: case CatalogRowId.airingAnime: case CatalogRowId.popularAnime: diff --git a/lib/services/plex_discover_client.dart b/lib/services/plex_discover_client.dart new file mode 100644 index 00000000..15cb715b --- /dev/null +++ b/lib/services/plex_discover_client.dart @@ -0,0 +1,265 @@ +import 'dart:async'; + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../utils/external_ids.dart'; +import '../utils/json_utils.dart'; + +/// Credentials for Plex's cloud Discover provider. The access token is scoped +/// to the active Plex/Home profile; it must never be logged or persisted here. +class PlexDiscoverSession { + final String accessToken; + final String clientIdentifier; + + const PlexDiscoverSession({required this.accessToken, required this.clientIdentifier}); + + bool get isUsable => accessToken.isNotEmpty && clientIdentifier.isNotEmpty; + + @override + bool operator ==(Object other) => + other is PlexDiscoverSession && other.accessToken == accessToken && other.clientIdentifier == clientIdentifier; + + @override + int get hashCode => Object.hash(accessToken, clientIdentifier); +} + +class PlexDiscoverPage { + final List> items; + final bool hasMore; + + const PlexDiscoverPage({required this.items, this.hasMore = false}); +} + +class PlexDiscoverHub { + final String id; + final String key; + final String title; + final PlexDiscoverPage page; + + const PlexDiscoverHub({required this.id, required this.key, required this.title, required this.page}); +} + +class PlexDiscoverException implements Exception { + final int statusCode; + final String message; + + const PlexDiscoverException(this.statusCode, this.message); + + @override + String toString() => 'PlexDiscoverException($statusCode): $message'; +} + +/// Minimal client for the Plex cloud catalog/watchlist API advertised by +/// `https://discover.provider.plex.tv/`. +class PlexDiscoverClient { + static final Uri _baseUri = Uri.parse('https://discover.provider.plex.tv'); + + final PlexDiscoverSession session; + final http.Client _http; + final Duration requestTimeout; + + PlexDiscoverClient(this.session, {http.Client? httpClient, this.requestTimeout = const Duration(seconds: 20)}) + : _http = httpClient ?? http.Client(); + + Future getWatchlist({int page = 1, int limit = 25}) async { + final safePage = page < 1 ? 1 : page; + final safeLimit = limit.clamp(1, 500); + final offset = (safePage - 1) * safeLimit; + final data = await _request( + 'GET', + '/library/sections/watchlist/all', + query: {'X-Plex-Container-Start': offset, 'X-Plex-Container-Size': safeLimit, 'includeMeta': 1}, + ); + final container = _mediaContainer(data!); + final items = flexibleMapList(container['Metadata']); + final total = flexibleInt(container['totalSize']) ?? flexibleInt(container['size']) ?? items.length; + return PlexDiscoverPage(items: items, hasMore: offset + items.length < total); + } + + /// Provider-defined recommendation shelves derived from the active + /// profile's universal watchlist. + Future> getRecommendedHubs({int limit = 25}) async { + final safeLimit = limit.clamp(1, 100); + final data = await _request('GET', '/hubs/sections/watchlist', query: {'count': safeLimit + 1, 'includeMeta': 1}); + final container = _mediaContainer(data!); + final result = []; + final seen = {}; + for (final hub in flexibleMapList(container['Hub'])) { + final key = _nonEmptyString(hub['key'] ?? hub['hubKey']); + final id = _nonEmptyString(hub['hubIdentifier']) ?? key; + final title = _nonEmptyString(hub['title']); + if (key == null || id == null || title == null || !seen.add(id)) continue; + final rawItems = flexibleMapList(hub['Metadata']); + final items = rawItems.take(safeLimit).toList(); + final total = flexibleInt(hub['totalSize']) ?? flexibleInt(hub['size']) ?? rawItems.length; + result.add( + PlexDiscoverHub( + id: id, + key: key, + title: title, + page: PlexDiscoverPage( + items: items, + hasMore: flexibleBool(hub['more']) == true || rawItems.length > safeLimit || total > items.length, + ), + ), + ); + } + return result; + } + + Future getHub(String key, {int page = 1, int limit = 100}) async { + final safePage = page < 1 ? 1 : page; + final safeLimit = limit.clamp(1, 500); + final offset = (safePage - 1) * safeLimit; + final data = await _request( + 'GET', + key, + query: {'X-Plex-Container-Start': offset, 'X-Plex-Container-Size': safeLimit, 'includeMeta': 1}, + ); + final container = _mediaContainer(data!); + final hub = firstFlexibleMap(container['Hub']); + final containerItems = flexibleMapList(container['Metadata']); + final rawItems = containerItems.isNotEmpty ? containerItems : flexibleMapList(hub?['Metadata']); + final items = rawItems.take(safeLimit).toList(); + final total = + flexibleInt(container['totalSize']) ?? + flexibleInt(hub?['totalSize']) ?? + flexibleInt(container['size']) ?? + flexibleInt(hub?['size']) ?? + rawItems.length; + return PlexDiscoverPage( + items: items, + hasMore: flexibleBool(hub?['more']) == true || rawItems.length > safeLimit || offset + items.length < total, + ); + } + + Future>> search(String query, {int limit = 30}) async { + final data = await _request( + 'GET', + '/library/search', + query: { + 'query': query, + 'limit': limit.clamp(1, 100), + 'searchTypes': 'movies,tv', + 'searchProviders': 'discover', + 'includeMetadata': 1, + 'filterPeople': 1, + }, + ); + final container = _mediaContainer(data!); + return [ + for (final group in flexibleMapList(container['SearchResults'])) + for (final result in flexibleMapList(group['SearchResult'])) + if (firstFlexibleMap(result['Metadata']) case final Map metadata) metadata, + ]; + } + + Future?> match(ExternalIds ids) async { + final guid = switch (ids) { + ExternalIds(imdb: final String imdb) => 'imdb://$imdb', + ExternalIds(tmdb: final int tmdb) => 'tmdb://$tmdb', + ExternalIds(tvdb: final int tvdb) => 'tvdb://$tvdb', + _ => null, + }; + if (guid == null) return null; + final data = await _request('GET', '/library/metadata/matches', query: {'guid': guid}, allowNotFound: true); + if (data == null) return null; + return firstFlexibleMap(_mediaContainer(data)['Metadata']); + } + + Future?> getMetadata(String ratingKey) async { + final data = await _request( + 'GET', + '/library/metadata/${Uri.encodeComponent(ratingKey)}', + query: {'includeGuids': 1}, + allowNotFound: true, + ); + if (data == null) return null; + return firstFlexibleMap(_mediaContainer(data)['Metadata']); + } + + Future>> getRelated(String ratingKey) async { + final data = await _request( + 'GET', + '/library/metadata/${Uri.encodeComponent(ratingKey)}/related', + allowNotFound: true, + ); + if (data == null) return const []; + final container = _mediaContainer(data); + return [ + for (final hub in flexibleMapList(container['Hub'])) ...flexibleMapList(hub['Metadata']), + if (container['Hub'] == null) ...flexibleMapList(container['Metadata']), + ]; + } + + Future setWatchlisted(String ratingKey, {required bool add}) async { + await _request( + 'PUT', + add ? '/actions/addToWatchlist' : '/actions/removeFromWatchlist', + query: {'ratingKey': ratingKey}, + ); + } + + Future?> _request( + String method, + String path, { + Map? query, + bool allowNotFound = false, + }) async { + final relative = Uri.parse(path); + if (relative.hasScheme || relative.host.isNotEmpty || !relative.path.startsWith('/')) { + throw ArgumentError.value(path, 'path', 'Plex Discover paths must stay on the provider host'); + } + final uri = _baseUri.replace( + path: relative.path, + queryParameters: { + ...relative.queryParameters, + for (final entry in query?.entries ?? const >[]) + if (entry.value != null) entry.key: entry.value.toString(), + }, + ); + final headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Plex-Token': session.accessToken, + 'X-Plex-Client-Identifier': session.clientIdentifier, + 'X-Plex-Product': 'Plezy', + 'X-Plex-Version': '2', + }; + final request = switch (method) { + 'GET' => _http.get(uri, headers: headers), + 'PUT' => _http.put(uri, headers: headers), + _ => throw ArgumentError.value(method, 'method', 'Unsupported Plex Discover method'), + }; + final response = await request.timeout(requestTimeout); + if (allowNotFound && response.statusCode == 404) return null; + if (response.statusCode < 200 || response.statusCode >= 300) { + throw PlexDiscoverException(response.statusCode, _errorMessage(response.body)); + } + if (response.body.isEmpty) return const {}; + final decoded = jsonDecode(response.body); + return decoded is Map ? decoded : const {}; + } + + static Map _mediaContainer(Map data) => + firstFlexibleMap(data['MediaContainer']) ?? const {}; + + static String? _nonEmptyString(Object? value) { + final string = value?.toString().trim(); + return string == null || string.isEmpty ? null : string; + } + + static String _errorMessage(String body) { + try { + final decoded = jsonDecode(body); + final error = decoded is Map ? firstFlexibleMap(decoded['Error']) : null; + return error?['message']?.toString() ?? error?['error']?.toString() ?? 'Request failed'; + } catch (_) { + return 'Request failed'; + } + } + + void dispose() => _http.close(); +} diff --git a/lib/services/trackers/anilist/anilist_client.dart b/lib/services/trackers/anilist/anilist_client.dart index 8e3bdfee..0508be33 100644 --- a/lib/services/trackers/anilist/anilist_client.dart +++ b/lib/services/trackers/anilist/anilist_client.dart @@ -1,7 +1,10 @@ +import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; +import '../../../models/anilist/anilist_media.dart'; + import '../../../utils/json_utils.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; @@ -10,11 +13,50 @@ import '../tracker_http_client.dart'; import '../tracker_session.dart'; import 'anilist_constants.dart'; +typedef AnilistCharacter = ({String name, String? role, String? imageUrl}); + /// GraphQL client for AniList. /// /// No refresh endpoint — on 401 the session is terminal and /// [onSessionInvalidated] clears it so the user re-auths. class AnilistClient implements DisposableTrackerClient { + static const String catalogMediaFields = ''' + id + idMal + title { + english + romaji + userPreferred + } + format + status + episodes + duration + description + averageScore + season + seasonYear + startDate { + year + } + genres + isAdult + coverImage { + extraLarge + large + } + bannerImage + studios(isMain: true) { + nodes { + name + } + } + trailer { + id + site + } + '''; + final TrackerSession _session; final TrackerHttpClient _http; final void Function() onSessionInvalidated; @@ -122,10 +164,241 @@ class AnilistClient implements DisposableTrackerClient { return count != null && count > 0 ? count : null; } + Future getTrendingAnime({int page = 1, int limit = 25}) => + _getAnimePage(sort: 'TRENDING_DESC', page: page, limit: limit); + + Future getPopularAnime({int page = 1, int limit = 25}) => + _getAnimePage(sort: 'POPULARITY_DESC', page: page, limit: limit); + + Future getSeasonalAnime(String season, int seasonYear, {int page = 1, int limit = 25}) => + _getAnimePage(sort: 'POPULARITY_DESC', season: season, seasonYear: seasonYear, page: page, limit: limit); + + Future searchAnime(String search, {int page = 1, int limit = 30}) => + _getAnimePage(sort: 'SEARCH_MATCH', search: search, page: page, limit: limit); + + Future _getAnimePage({ + required String sort, + String? search, + String? season, + int? seasonYear, + required int page, + required int limit, + }) async { + final mediaQuery = + ''' + query( + \$page: Int + \$perPage: Int + \$sort: [MediaSort!] + \$search: String + \$season: MediaSeason + \$seasonYear: Int + ) { + Page(page: \$page, perPage: \$perPage) { + pageInfo { + hasNextPage + } + media( + type: ANIME + isAdult: false + sort: \$sort + search: \$search + season: \$season + seasonYear: \$seasonYear + ) { + $catalogMediaFields + } + } + } + '''; + final data = await query( + mediaQuery, + variables: { + 'page': page < 1 ? 1 : page, + 'perPage': limit.clamp(1, 50).toInt(), + 'sort': [sort], + 'search': ?search, + 'season': ?season, + 'seasonYear': ?seasonYear, + }, + ); + final result = data['Page']; + if (result is! Map) return (items: const [], hasMore: false); + final media = result['media']; + final pageInfo = result['pageInfo']; + return ( + items: [ + if (media is List) + for (final node in media) + if (node is Map) AnilistMedia.fromJson(node), + ], + hasMore: pageInfo is Map && pageInfo['hasNextPage'] == true, + ); + } + + Future getPlanningPage(int userId, {int chunk = 1, int perChunk = 500}) => + _getPlanningPage(userId, chunk: chunk, perChunk: perChunk, idsOnly: false); + + Future getPlanningIdsPage(int userId, {int chunk = 1, int perChunk = 500}) => + _getPlanningPage(userId, chunk: chunk, perChunk: perChunk, idsOnly: true); + + Future _getPlanningPage( + int userId, { + required int chunk, + required int perChunk, + required bool idsOnly, + }) async { + final fields = idsOnly ? 'id idMal' : catalogMediaFields; + final listQuery = + ''' + query(\$userId: Int!, \$chunk: Int, \$perChunk: Int) { + MediaListCollection( + userId: \$userId + type: ANIME + status: PLANNING + sort: [ADDED_TIME_DESC] + chunk: \$chunk + perChunk: \$perChunk + ) { + hasNextChunk + lists { + isCustomList + entries { + media { + $fields + } + } + } + } + } + '''; + final data = await query( + listQuery, + variables: {'userId': userId, 'chunk': chunk < 1 ? 1 : chunk, 'perChunk': perChunk.clamp(1, 500).toInt()}, + ); + final collection = data['MediaListCollection']; + if (collection is! Map) return (items: const [], hasMore: false); + + final seen = {}; + final items = []; + final lists = collection['lists']; + if (lists is List) { + for (final list in lists) { + if (list is! Map || list['isCustomList'] == true) continue; + final entries = list['entries']; + if (entries is! List) continue; + for (final entry in entries) { + if (entry is! Map) continue; + final media = entry['media']; + if (media is! Map) continue; + final parsed = AnilistMedia.fromJson(media); + final id = parsed.id; + if (id != null && seen.add(id)) items.add(parsed); + } + } + } + return (items: items, hasMore: collection['hasNextChunk'] == true); + } + + Future getViewerId() async { + final data = await query('query { Viewer { id } }'); + final viewer = data['Viewer']; + final id = viewer is Map ? flexibleInt(viewer['id']) : null; + if (id == null) throw StateError('AniList: Viewer response did not include an id'); + return id; + } + + /// Set list status without touching progress. Watchlist-add must not reuse + /// [saveMediaListEntry], whose required progress would reset active entries. + Future setMediaListStatus({required int mediaId, required String status}) async { + const mutation = ''' + mutation(\$mediaId: Int, \$status: MediaListStatus) { + SaveMediaListEntry(mediaId: \$mediaId, status: \$status) { + id + } + } + '''; + await query(mutation, variables: {'mediaId': mediaId, 'status': status}); + } + + Future> getAnimeCharacters(int id, {int limit = 20}) async { + const characterQuery = ''' + query(\$id: Int!, \$perPage: Int) { + Media(id: \$id, type: ANIME) { + characters(page: 1, perPage: \$perPage) { + edges { + role + node { + name { + full + } + image { + large + medium + } + } + } + } + } + } + '''; + final data = await query(characterQuery, variables: {'id': id, 'perPage': limit.clamp(1, 50).toInt()}); + final media = data['Media']; + final characters = media is Map ? media['characters'] : null; + final edges = characters is Map ? characters['edges'] : null; + if (edges is! List) return const []; + return [ + for (final edge in edges) + if (edge is Map) + if (edge['node'] case final Map node) + if (node['name'] case final Map name) + if (name['full'] case final String full) + if (full.isNotEmpty) + ( + name: full, + role: edge['role'] as String?, + imageUrl: node['image'] is Map + ? ((node['image'] as Map)['large'] as String? ?? (node['image'] as Map)['medium'] as String?) + : null, + ), + ]; + } + + Future> getAnimeRecommendations(int id, {int limit = 20}) async { + final recommendationQuery = + ''' + query(\$id: Int!, \$perPage: Int) { + Media(id: \$id, type: ANIME) { + recommendations(page: 1, perPage: \$perPage) { + nodes { + mediaRecommendation { + $catalogMediaFields + } + } + } + } + } + '''; + final data = await query(recommendationQuery, variables: {'id': id, 'perPage': limit.clamp(1, 50).toInt()}); + final media = data['Media']; + final recommendations = media is Map ? media['recommendations'] : null; + final nodes = recommendations is Map ? recommendations['nodes'] : null; + if (nodes is! List) return const []; + final items = []; + for (final node in nodes) { + if (node is! Map) continue; + final recommendation = node['mediaRecommendation']; + if (recommendation is! Map) continue; + final item = AnilistMedia.fromJson(recommendation); + if (!item.isAdult) items.add(item); + } + return items; + } + Future> query(String query, {Map? variables}) async { final uri = Uri.parse(AnilistConstants.apiBase); final headers = AnilistConstants.headers(accessToken: _session.accessToken); - final res = await _http.sendJson( + Future send() => _http.sendJson( 'POST', uri, headers: headers, @@ -133,6 +406,15 @@ class AnilistClient implements DisposableTrackerClient { allowedMethods: const {'POST'}, ); + final res = await send(); + + if (res.statusCode == 429) { + throw TrackerRateLimitException( + service: TrackerService.anilist, + retryAfterSeconds: int.tryParse(res.headers['retry-after'] ?? ''), + ); + } + if (res.statusCode == 401) { onSessionInvalidated(); throw const TrackerAuthException( diff --git a/lib/services/trackers/simkl/simkl_auth_service.dart b/lib/services/trackers/simkl/simkl_auth_service.dart index b3b4dc8d..c4ada422 100644 --- a/lib/services/trackers/simkl/simkl_auth_service.dart +++ b/lib/services/trackers/simkl/simkl_auth_service.dart @@ -13,18 +13,18 @@ import 'simkl_constants.dart'; /// Simkl OAuth PIN (device-code) flow. /// -/// `GET /oauth/pin?client_id=...&redirect=` returns a PIN the -/// user enters at https://simkl.com/pin. After entry Simkl redirects the -/// browser to the relay's static "signed in" page. The app polls -/// `/oauth/pin/?client_id=...` until `result == "OK"`. +/// `GET /oauth/pin` with Simkl's required app identity parameters and a +/// relay redirect returns a PIN the user enters at https://simkl.com/pin. +/// After entry Simkl redirects the browser to the relay's static "signed in" +/// page. The app polls `/oauth/pin/` with the same identity. class SimklAuthService extends DeviceCodeAuthServiceBase { SimklAuthService({super.httpClient}); @override Future createDeviceCode() async { - final uri = Uri.parse(SimklConstants.pinUrl).replace( - queryParameters: {'client_id': SimklConstants.clientId, 'redirect': '${OAuthProxyClient.baseUrl}/auth/done'}, - ); + final uri = Uri.parse( + SimklConstants.pinUrl, + ).replace(queryParameters: SimklConstants.queryParameters({'redirect': '${OAuthProxyClient.baseUrl}/auth/done'})); final res = await sendAbortableHttpRequest( httpClient, 'GET', @@ -52,7 +52,7 @@ class SimklAuthService extends DeviceCodeAuthServiceBase { Future probe(DeviceCode code) async { final pollUri = Uri.parse( SimklConstants.pinPollUrl(code.userCode), - ).replace(queryParameters: {'client_id': SimklConstants.clientId}); + ).replace(queryParameters: SimklConstants.queryParameters()); final http.Response res; try { res = await sendAbortableHttpRequest( diff --git a/lib/services/trackers/simkl/simkl_client.dart b/lib/services/trackers/simkl/simkl_client.dart index 18f1a0ab..42db9b65 100644 --- a/lib/services/trackers/simkl/simkl_client.dart +++ b/lib/services/trackers/simkl/simkl_client.dart @@ -2,6 +2,13 @@ import 'dart:async'; import 'package:http/http.dart' as http; +import '../../../models/simkl/simkl_all_items_entry.dart'; +import '../../../models/simkl/simkl_best_item.dart'; +import '../../../models/simkl/simkl_recommendation.dart'; +import '../../../models/simkl/simkl_search_result.dart'; +import '../../../models/simkl/simkl_trending_item.dart'; +import '../../trakt/trakt_page.dart'; + import '../tracker.dart'; import '../tracker_constants.dart'; import '../tracker_exceptions.dart'; @@ -50,12 +57,104 @@ class SimklClient implements DisposableTrackerClient { return const []; } - Future _request(String method, String path, {Map? body}) async { - final uri = Uri.parse('${SimklConstants.apiBase}$path'); - final headers = SimklConstants.headers(accessToken: session.accessToken); - final res = await _http.sendJson(method, uri, headers: headers, body: body, allowedMethods: const {'GET', 'POST'}); + // --- Catalog endpoints (Explore tab) --- - if (res.statusCode == 401) { + Future> getTrending(SimklCatalogType type) async { + final decoded = await _request( + 'GET', + '/discover/trending/${type.name}/week_100.json', + baseOverride: SimklConstants.dataBase, + ); + if (decoded is! List) return const []; + return [ + for (final item in decoded) + if (item is Map) SimklTrendingItem.fromJson(item), + ]; + } + + Future> searchCatalog( + SimklCatalogType type, + String search, { + int page = 1, + int limit = 10, + }) async { + final response = await _requestResponse( + 'GET', + '/search/${type.searchPath}', + query: {'q': search, 'page': '${page < 1 ? 1 : page}', 'limit': '${limit.clamp(1, 50)}', 'extended': 'full'}, + ); + final decoded = TrackerHttpClient.decodeJson(response.body); + final items = [ + if (decoded is List) + for (final item in decoded) + if (item is Map) SimklSearchResult.fromJson(item), + ]; + return TraktPage.fromResponse(response, items); + } + + Future> getBest(SimklCatalogType type, {String filter = 'watched'}) async { + if (type == SimklCatalogType.movies) { + throw ArgumentError.value(type, 'type', 'Simkl has no supported best-movies catalog'); + } + final decoded = await _request('GET', '/${type.name}/best/$filter'); + if (decoded is! List) return const []; + return [ + for (final item in decoded) + if (item is Map) SimklBestItem.fromJson(item), + ]; + } + + Future getAllItems({String type = 'all', String status = 'plantowatch', String? extended}) async { + final decoded = await _request('GET', '/sync/all-items/$type/$status', query: {'extended': ?extended}); + return decoded is Map ? SimklAllItems.fromJson(decoded) : const SimklAllItems(); + } + + Future addToList(Map body) async { + await _request('POST', '/sync/add-to-list', body: body); + } + + Future> getRecommendations(SimklCatalogType urlType, int simklId) async { + final decoded = await _request('GET', '/${urlType.detailPath}/$simklId'); + if (decoded is! Map) return const []; + final recommendations = decoded['users_recommendations']; + if (recommendations is! List) return const []; + return [ + for (final item in recommendations) + if (item is Map) SimklRecommendation.fromJson(item), + ]; + } + + Future _request( + String method, + String path, { + Map? body, + Map? query, + String? baseOverride, + }) async { + final response = await _requestResponse(method, path, body: body, query: query, baseOverride: baseOverride); + return TrackerHttpClient.decodeJson(response.body); + } + + Future _requestResponse( + String method, + String path, { + Map? body, + Map? query, + String? baseOverride, + }) async { + final base = baseOverride ?? SimklConstants.apiBase; + final uri = Uri.parse('$base$path').replace(queryParameters: SimklConstants.queryParameters(query)); + final mainApiHost = uri.host == Uri.parse(SimklConstants.apiBase).host; + final headers = SimklConstants.headers(accessToken: mainApiHost ? session.accessToken : null); + final response = await _http.sendJson( + method, + uri, + headers: headers, + body: body, + allowedMethods: const {'GET', 'POST'}, + ); + + if (mainApiHost && response.statusCode == 401) { onSessionInvalidated(); throw const TrackerAuthException( service: TrackerService.simkl, @@ -64,9 +163,9 @@ class SimklClient implements DisposableTrackerClient { isPermanent: true, ); } - if (res.statusCode < 200 || res.statusCode >= 300) { - throw TrackerApiException(service: TrackerService.simkl, statusCode: res.statusCode, body: res.body); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw TrackerApiException(service: TrackerService.simkl, statusCode: response.statusCode, body: response.body); } - return TrackerHttpClient.decodeJson(res.body); + return response; } } diff --git a/lib/services/trackers/simkl/simkl_constants.dart b/lib/services/trackers/simkl/simkl_constants.dart index 22ebd7ce..60fc0be3 100644 --- a/lib/services/trackers/simkl/simkl_constants.dart +++ b/lib/services/trackers/simkl/simkl_constants.dart @@ -1,3 +1,11 @@ +enum SimklCatalogType { movies, tv, anime } + +extension SimklCatalogTypeApi on SimklCatalogType { + String get searchPath => this == SimklCatalogType.movies ? 'movie' : name; + + String get detailPath => this == SimklCatalogType.movies ? 'movies' : name; +} + /// Bundled Simkl API credentials and endpoints. /// /// Register at https://simkl.com/settings/developer — app type should be @@ -11,6 +19,9 @@ class SimklConstants { static const String clientId = 'ac97718a469c33eab948b63f92226106157e58fdcdd70c1b5857f1779b1d3a6a'; static const String apiBase = 'https://api.simkl.com'; + static const String dataBase = 'https://data.simkl.in'; + static const String appName = 'plezy'; + static const String appVersion = '2'; // OAuth (device-code / PIN) endpoints static const String pinUrl = '$apiBase/oauth/pin'; @@ -21,11 +32,20 @@ class SimklConstants { /// Web page the user visits to enter the code. static const String verificationUrl = 'https://simkl.com/pin'; - /// Headers on every Simkl request. `simkl-api-key` is required on all - /// endpoints, authed or not. + static Map queryParameters([Map? query]) => { + ...?query, + 'client_id': clientId, + 'app-name': appName, + 'app-version': appVersion, + }; + + /// Required identity headers on every Simkl request. Authenticated API + /// calls additionally carry the bearer token; CDN requests deliberately do + /// not receive it. static Map headers({String? accessToken}) => { 'Content-Type': 'application/json', 'Accept': 'application/json', + 'User-Agent': '$appName/$appVersion', 'simkl-api-key': clientId, if (accessToken != null) 'Authorization': 'Bearer $accessToken', }; diff --git a/lib/widgets/catalog_source_logo.dart b/lib/widgets/catalog_source_logo.dart index 911a0809..c0ad7e8d 100644 --- a/lib/widgets/catalog_source_logo.dart +++ b/lib/widgets/catalog_source_logo.dart @@ -3,11 +3,11 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../models/catalog/catalog_item.dart'; -/// Brand mark of a catalog source (Trakt circle, MAL square) — or any -/// service SVG via [CatalogSourceLogo.asset] — tinted with the ambient icon -/// color. Uses [SvgTheme.currentColor] so SVGs with multiple explicit fills -/// (AniList keeps its brand-blue L while the A follows the theme) render -/// correctly alongside single-color wordmarks. +/// Brand mark of a catalog source — or any service SVG via +/// [CatalogSourceLogo.asset] — tinted with the ambient icon color. Uses +/// [SvgTheme.currentColor] so SVGs with multiple explicit fills (AniList +/// keeps its brand-blue L while the A follows the theme) render correctly +/// alongside single-color wordmarks. class CatalogSourceLogo extends StatelessWidget { final CatalogSourceId? id; final String? assetPath; @@ -15,7 +15,7 @@ class CatalogSourceLogo extends StatelessWidget { const CatalogSourceLogo(CatalogSourceId this.id, {super.key, this.size = 20}) : assetPath = null; - /// For services that aren't catalog sources (AniList, Simkl). + /// For services that do not participate in the Explore catalog. const CatalogSourceLogo.asset(String this.assetPath, {super.key, this.size = 20}) : id = null; @override @@ -23,8 +23,11 @@ class CatalogSourceLogo extends StatelessWidget { final asset = assetPath ?? switch (id!) { + CatalogSourceId.plex => 'assets/plex_chevron.svg', CatalogSourceId.trakt => 'assets/trakt_circlemark.svg', CatalogSourceId.mal => 'assets/mal_mark.svg', + CatalogSourceId.anilist => 'assets/anilist_mark.svg', + CatalogSourceId.simkl => 'assets/simkl_mark.svg', CatalogSourceId.seerr => 'assets/seerr_mark.svg', }; final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface; diff --git a/test/models/catalog_item_test.dart b/test/models/catalog_item_test.dart new file mode 100644 index 00000000..76b82570 --- /dev/null +++ b/test/models/catalog_item_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; + +void main() { + group('CatalogItemIds', () { + test('round-trips provider-native ids through JSON', () { + const ids = CatalogItemIds( + plex: 'plex-4', + trakt: 8, + slug: 'title', + mal: 5, + anilist: 6, + simkl: 7, + imdb: 'tt123', + tmdb: 2, + tvdb: 3, + ); + + final json = ids.toJson(); + final decoded = CatalogItemIds.fromJson(json); + + expect(json, { + 'plex': 'plex-4', + 'trakt': 8, + 'slug': 'title', + 'mal': 5, + 'anilist': 6, + 'simkl': 7, + 'imdb': 'tt123', + 'tmdb': 2, + 'tvdb': 3, + }); + expect(decoded.plex, 'plex-4'); + expect(decoded.anilist, 6); + expect(decoded.simkl, 7); + expect(decoded.hasAny, isTrue); + }); + + test('orders canonical and membership keys deterministically', () { + const ids = CatalogItemIds( + plex: 'plex-4', + trakt: 8, + slug: 'title', + mal: 5, + anilist: 6, + simkl: 7, + imdb: 'tt123', + tmdb: 2, + tvdb: 3, + ); + + expect(ids.canonicalKey, 'imdb:tt123'); + expect(ids.allKeys, [ + 'imdb:tt123', + 'tmdb:2', + 'tvdb:3', + 'mal:5', + 'anilist:6', + 'simkl:7', + 'plex:plex-4', + 'trakt:8', + 'slug:title', + ]); + expect(const CatalogItemIds(mal: 5, anilist: 6, simkl: 7).canonicalKey, 'mal:5'); + expect(const CatalogItemIds(anilist: 6, simkl: 7).canonicalKey, 'anilist:6'); + expect(const CatalogItemIds(simkl: 7, trakt: 8).canonicalKey, 'simkl:7'); + expect(const CatalogItemIds(plex: 'plex-4', trakt: 8).canonicalKey, 'plex:plex-4'); + }); + }); +} diff --git a/test/providers/catalog_sources_provider_test.dart b/test/providers/catalog_sources_provider_test.dart new file mode 100644 index 00000000..3fbbc162 --- /dev/null +++ b/test/providers/catalog_sources_provider_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/providers/catalog_sources_provider.dart'; +import 'package:plezy/services/plex_discover_client.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(resetSharedPreferencesForTest); + + test('profile hydration exposes Plex when Discover credentials are available', () async { + var calls = 0; + final provider = CatalogSourcesProvider( + plexSessionSupplier: () async { + calls++; + return const PlexDiscoverSession(accessToken: 'profile-token', clientIdentifier: 'client-id'); + }, + ); + addTearDown(provider.dispose); + + await provider.onActiveProfileChanged('profile-1'); + + expect(calls, 1); + expect(provider.connectedSources.map((source) => source.id), [CatalogSourceId.plex]); + expect(provider.activeSource?.displayName, 'Plex'); + expect(provider.watchlistCapableSource?.id, CatalogSourceId.plex); + }); + + test('profile hydration omits Plex when the active profile has no Plex identity', () async { + final provider = CatalogSourcesProvider(plexSessionSupplier: () async => null); + addTearDown(provider.dispose); + + await provider.onActiveProfileChanged('profile-1'); + + expect(provider.connectedSources, isEmpty); + expect(provider.hasAnySource, isFalse); + }); + + test('Plex session lookup failure does not abort profile hydration', () async { + final provider = CatalogSourcesProvider(plexSessionSupplier: () async => throw StateError('vault unavailable')); + addTearDown(provider.dispose); + var notifications = 0; + provider.addListener(() => notifications++); + + await provider.onActiveProfileChanged('profile-1'); + + expect(provider.connectedSources, isEmpty); + expect(notifications, 1); + }); + + test('same-profile binding completion refreshes Plex connection state', () async { + PlexDiscoverSession? session; + var calls = 0; + final provider = CatalogSourcesProvider( + plexSessionSupplier: () async { + calls++; + return session; + }, + ); + addTearDown(provider.dispose); + + await provider.onActiveProfileChanged('profile-1'); + await provider.onProfileBindingStateChanged(false); + expect(calls, 1); + expect(provider.connectedSources, isEmpty); + + await provider.onProfileBindingStateChanged(true); + session = const PlexDiscoverSession(accessToken: 'connected', clientIdentifier: 'client-id'); + await provider.onProfileBindingStateChanged(false); + expect(calls, 2); + expect(provider.connectedSources.map((source) => source.id), [CatalogSourceId.plex]); + + await provider.onProfileBindingStateChanged(true); + session = null; + await provider.onProfileBindingStateChanged(false); + expect(calls, 3); + expect(provider.connectedSources, isEmpty); + }); +} diff --git a/test/providers/explore_provider_test.dart b/test/providers/explore_provider_test.dart index 306f63f1..5d4ca461 100644 --- a/test/providers/explore_provider_test.dart +++ b/test/providers/explore_provider_test.dart @@ -24,6 +24,7 @@ class _FakeSource implements CatalogSource { bool gate = false; final pending = >[]; final fetches = {}; + final failedRows = {}; CatalogPage _page(CatalogRowId row) => CatalogPage( items: [ @@ -39,6 +40,7 @@ class _FakeSource implements CatalogSource { @override Future fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) { fetches[row] = (fetches[row] ?? 0) + 1; + if (failedRows.contains(row)) throw StateError('${row.name} failed'); if (gate) { final completer = Completer(); pending.add(completer); @@ -82,6 +84,45 @@ class _FakeSource implements CatalogSource { void dispose() => watchlist.dispose(); } +class _FakeHubSource extends _FakeSource implements CatalogHubSource { + _FakeHubSource(super.id) : super(rows: const [CatalogRowId.watchlist]); + + final hubPageFetches = []; + int hubFetches = 0; + int hubFailuresRemaining = 0; + bool returnEmptyHubs = false; + + CatalogItem _hubItem(String title) => CatalogItem( + source: id, + kind: MediaKind.movie, + title: title, + ids: const CatalogItemIds(plex: 'plex-hub-item'), + ); + + @override + Future> fetchHubs({int limit = 25}) async { + hubFetches++; + if (hubFailuresRemaining > 0) { + hubFailuresRemaining--; + throw StateError('hub fetch failed'); + } + if (returnEmptyHubs) return const []; + return [ + CatalogHub( + id: 'because-watchlisted', + title: 'Because You Watchlisted Inception', + page: CatalogPage(items: [_hubItem('Initial Recommendation')], hasMore: true), + ), + ]; + } + + @override + Future fetchHub(String id, {int page = 1, int limit = 25}) async { + hubPageFetches.add(page); + return CatalogPage(items: [_hubItem('Recommendation Page $page')], hasMore: page == 1); + } +} + /// Drives [activeSource] directly; the real provider derives it from the /// account providers, which is irrelevant to ExploreProvider's contract. class _FakeSourcesProvider extends CatalogSourcesProvider { @@ -145,6 +186,28 @@ void main() { expect(explore.rowHubs.single.row, CatalogRowId.popularAnime); }); + test('provider-defined hubs retain their titles and page through View All', () async { + final source = _FakeHubSource(CatalogSourceId.plex); + addTearDown(source.dispose); + + sources.setActive(source); + await _pumpMicrotasks(); + + expect(explore.state, ExploreLoadState.loaded); + expect(explore.rowHubs, hasLength(2)); + final providerHub = explore.rowHubs.last; + expect(providerHub.row, isNull); + expect(providerHub.providerHubId, 'because-watchlisted'); + expect(providerHub.hub.title, 'Because You Watchlisted Inception'); + expect(providerHub.hub.items.single.title, 'Initial Recommendation'); + expect(providerHub.hub.more, isTrue); + + final allItems = await explore.loadAllForHub(providerHub); + + expect(source.hubPageFetches, [1, 2]); + expect(allItems.map((item) => item.title), ['Recommendation Page 1', 'Recommendation Page 2']); + }); + test('mutation during the initial load is caught up by ensureFresh', () async { final source = _FakeSource(CatalogSourceId.trakt)..gate = true; addTearDown(source.dispose); @@ -166,5 +229,38 @@ void main() { await _pumpMicrotasks(); expect(source.fetches[CatalogRowId.watchlist], refetchesBefore + 1); }); + + test('empty successful hubs do not mask every fixed row failing', () async { + final source = _FakeHubSource(CatalogSourceId.plex) + ..failedRows.add(CatalogRowId.watchlist) + ..returnEmptyHubs = true; + addTearDown(source.dispose); + + sources.setActive(source); + await _pumpMicrotasks(); + + expect(explore.state, ExploreLoadState.error); + expect(explore.rowHubs, isEmpty); + }); + + test('mutation retries watchlist-derived hubs after a partial refresh failure', () async { + final source = _FakeHubSource(CatalogSourceId.plex); + addTearDown(source.dispose); + sources.setActive(source); + await _pumpMicrotasks(); + expect(source.hubFetches, 1); + + source.hubFailuresRemaining = 1; + source.watchlist.notify(); + explore.ensureFresh(); + await _pumpMicrotasks(); + expect(source.hubFetches, 2); + + // The successful row did not mark the mutation epoch covered while the + // parallel hub refresh failed, so a subsequent freshness pass retries. + explore.ensureFresh(); + await _pumpMicrotasks(); + expect(source.hubFetches, 3); + }); }); } diff --git a/test/screens/explore_screen_test.dart b/test/screens/explore_screen_test.dart index f50e63f2..fdc6b8b6 100644 --- a/test/screens/explore_screen_test.dart +++ b/test/screens/explore_screen_test.dart @@ -11,12 +11,13 @@ import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/catalog_source_logo.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; -class _FakeCatalogSource implements CatalogSource { - _FakeCatalogSource(this.id, this.displayName, this.itemId); +class _FakeCatalogSource implements CatalogSource, CatalogHubSource { + _FakeCatalogSource(this.id, this.displayName, this.itemId, {this.providerHubTitle}); @override final CatalogSourceId id; @@ -25,6 +26,7 @@ class _FakeCatalogSource implements CatalogSource { final String displayName; final int? itemId; + final String? providerHubTitle; final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier(); @override @@ -51,6 +53,31 @@ class _FakeCatalogSource implements CatalogSource { ); } + @override + Future> fetchHubs({int limit = 25}) async { + final title = providerHubTitle; + if (title == null) return const []; + return [ + CatalogHub( + id: 'plex-recommendation', + title: title, + page: CatalogPage( + items: [ + CatalogItem( + source: id, + kind: MediaKind.show, + title: 'Plex Recommendation', + ids: const CatalogItemIds(plex: 'plex-recommendation'), + ), + ], + ), + ), + ]; + } + + @override + Future fetchHub(String id, {int page = 1, int limit = 25}) async => const CatalogPage(items: []); + @override void dispose() => _watchlistChanges.dispose(); @@ -62,9 +89,25 @@ class _FakeCatalogSourcesProvider extends CatalogSourcesProvider { _FakeCatalogSourcesProvider(this.sources); final List sources; + CatalogSourceId? _activeId; @override List get connectedSources => sources; + + @override + CatalogSource? get activeSource { + for (final source in sources) { + if (source.id == _activeId) return source; + } + return sources.isEmpty ? null : sources.first; + } + + @override + Future setActiveSource(CatalogSourceId id) async { + if (_activeId == id) return; + _activeId = id; + notifyListeners(); + } } Future<_FakeCatalogSourcesProvider> _pumpExplore( @@ -79,12 +122,25 @@ Future<_FakeCatalogSourcesProvider> _pumpExplore( final trakt = _FakeCatalogSource(CatalogSourceId.trakt, 'Trakt', traktItemId); final mal = _FakeCatalogSource(CatalogSourceId.mal, 'MyAnimeList', malItemId); - final sources = _FakeCatalogSourcesProvider([trakt, mal]); + final anilist = _FakeCatalogSource(CatalogSourceId.anilist, 'AniList', 3); + final simkl = _FakeCatalogSource(CatalogSourceId.simkl, 'Simkl', 4); + final plex = _FakeCatalogSource( + CatalogSourceId.plex, + 'Plex', + 5, + providerHubTitle: 'Because You Watchlisted Inception', + ); + final seerr = _FakeCatalogSource(CatalogSourceId.seerr, 'Seerr', 6); + final sources = _FakeCatalogSourcesProvider([trakt, mal, anilist, simkl, plex, seerr]); final explore = ExploreProvider(sources); addTearDown(explore.dispose); addTearDown(sources.dispose); addTearDown(trakt.dispose); addTearDown(mal.dispose); + addTearDown(anilist.dispose); + addTearDown(simkl.dispose); + addTearDown(plex.dispose); + addTearDown(seerr.dispose); await tester.pumpWidget( TranslationProvider( @@ -146,6 +202,37 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); }); + testWidgets('source switcher exposes every catalog source with its brand logo', (tester) async { + final sources = await _pumpExplore(tester); + tester.state(find.byType(ExploreScreen)).focusActiveTabIfReady(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + + for (final name in ['Trakt', 'MyAnimeList', 'AniList', 'Simkl', 'Plex', 'Seerr']) { + expect(find.text(name), findsAtLeast(1)); + } + expect(find.byType(CatalogSourceLogo), findsAtLeast(6)); + + await tester.tap(find.text('AniList')); + await tester.pumpAndSettle(); + expect(sources.activeSource?.id, CatalogSourceId.anilist); + expect(find.text('AniList'), findsOneWidget); + expect(find.text('AniList Movie'), findsAtLeast(1)); + }); + + testWidgets('Plex provider-defined recommendation hub renders as an Explore shelf', (tester) async { + final sources = await _pumpExplore(tester); + + await sources.setActiveSource(CatalogSourceId.plex); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Because You Watchlisted Inception'), findsOneWidget); + expect(find.text('Plex Recommendation'), findsAtLeast(1)); + }); + testWidgets('TV source switcher remains focused when the active source has no rows', (tester) async { final sources = await _pumpExplore(tester, traktItemId: null); diff --git a/test/services/catalog/anilist_catalog_source_test.dart b/test/services/catalog/anilist_catalog_source_test.dart new file mode 100644 index 00000000..d363c96f --- /dev/null +++ b/test/services/catalog/anilist_catalog_source_test.dart @@ -0,0 +1,485 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/models/anilist/anilist_media.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/models/trackers/fribb_mapping_row.dart'; +import 'package:plezy/services/catalog/anilist_catalog_source.dart'; +import 'package:plezy/services/catalog/catalog_source.dart'; +import 'package:plezy/services/trackers/anilist/anilist_client.dart'; +import 'package:plezy/services/trackers/fribb_mapping_store.dart'; +import 'package:plezy/services/trackers/tracker_exceptions.dart'; +import 'package:plezy/services/trackers/tracker_constants.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/utils/external_ids.dart'; + +TrackerSession _session() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return TrackerSession( + accessToken: 'access', + refreshToken: null, + expiresAt: now + 86400, + scope: null, + createdAt: now - 3600, + username: 'alice', + ); +} + +class _FakeFribb implements FribbMappingLookup { + final List rows; + + _FakeFribb(this.rows); + + @override + Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => [ + for (final row in rows) + if ((tvdbId != null && row.tvdbId == tvdbId) || + (tmdbId != null && (row.tmdbIds?.contains(tmdbId) ?? false)) || + (imdbId != null && (row.imdbIds?.contains(imdbId) ?? false))) + row, + ]; + + @override + Future lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull; +} + +Map _media({ + required int id, + int? idMal, + String title = 'Attack on Titan', + String format = 'TV', + String status = 'RELEASING', + bool isAdult = false, +}) => { + 'id': id, + 'idMal': ?idMal, + 'title': {'english': title, 'romaji': 'Shingeki no Kyojin', 'userPreferred': 'Preferred'}, + 'format': format, + 'status': status, + 'episodes': 25, + 'duration': 24, + 'description': 'Humanity
fights & survives.', + 'averageScore': 84, + 'season': 'SPRING', + 'seasonYear': 2013, + 'startDate': {'year': 2013}, + 'genres': ['Action', 'Drama'], + 'isAdult': isAdult, + 'coverImage': {'extraLarge': 'https://img.anilist.co/poster/$id.jpg'}, + 'bannerImage': 'https://img.anilist.co/banner/$id.jpg', + 'studios': { + 'nodes': [ + {'name': 'Wit Studio'}, + ], + }, + 'trailer': {'id': 'abc123', 'site': 'youtube'}, +}; + +http.Response _data(Map data, {int status = 200, Map? headers}) => + http.Response(json.encode({'data': data}), status, headers: {'content-type': 'application/json', ...?headers}); + +Map _requestBody(http.Request request) => json.decode(request.body) as Map; + +void main() { + const season1 = FribbMappingRow( + anilistId: 16498, + malId: 16498, + tvdbId: 267440, + tvdbSeason: 1, + imdbIds: ['tt2560140'], + ); + const season3 = FribbMappingRow( + anilistId: 35760, + malId: 35760, + tvdbId: 267440, + tvdbSeason: 3, + imdbIds: ['tt2560140'], + ); + const movie = FribbMappingRow( + anilistId: 21519, + malId: 32281, + tmdbIds: [372058], + imdbIds: ['tt5311514'], + type: 'MOVIE', + ); + + group('AnilistMedia', () { + test('parses requested fields and strips AniList HTML', () { + final media = AnilistMedia.fromJson(_media(id: 1, idMal: 16498)); + + expect(media.displayTitle, 'Attack on Titan'); + expect(media.description, 'Humanity\nfights & survives.'); + expect(media.year, 2013); + expect(media.posterUrl, 'https://img.anilist.co/poster/1.jpg'); + expect(media.backdropUrl, 'https://img.anilist.co/banner/1.jpg'); + expect(media.rating, 8.4); + expect(media.runtimeMinutes, 24); + expect(media.network, 'Wit Studio'); + expect(media.trailerUrl, 'https://www.youtube.com/watch?v=abc123'); + expect(media.isMovie, isFalse); + }); + + test('stripHtml handles line breaks, tags, entities, and empty input', () { + expect( + AnilistMedia.stripHtml('A "title"
B < C > D 'x''), + 'A "title"\nB < C > D \'x\'', + ); + expect(AnilistMedia.stripHtml(''), isNull); + expect(AnilistMedia.stripHtml(null), isNull); + }); + }); + + group('AnilistCatalogSource', () { + late List requests; + late FutureOr Function(http.Request request) responder; + late AnilistClient client; + late AnilistCatalogSource source; + + setUp(() { + requests = []; + responder = (request) { + final body = _requestBody(request); + final query = body['query'] as String; + if (query.contains('Viewer { id }')) { + return _data({ + 'Viewer': {'id': 7}, + }); + } + if (query.contains('MediaListCollection')) { + return _data({ + 'MediaListCollection': { + 'hasNextChunk': false, + 'lists': [ + { + 'isCustomList': false, + 'entries': [ + {'media': _media(id: 16498, idMal: 16498)}, + ], + }, + ], + }, + }); + } + return _data({ + 'Page': { + 'pageInfo': {'hasNextPage': false}, + 'media': [_media(id: 16498, idMal: 16498)], + }, + }); + }; + client = AnilistClient( + _session(), + onSessionInvalidated: () => fail('should not invalidate'), + httpClient: MockClient((request) async { + requests.add(request); + return responder(request); + }), + ); + source = AnilistCatalogSource(client, fribb: _FakeFribb(const [season1, season3, movie])); + }); + + tearDown(() { + source.dispose(); + client.dispose(); + }); + + test('trending query clamps page size and enriches every external id', () async { + responder = (request) { + final body = _requestBody(request); + final variables = body['variables'] as Map; + expect((body['query'] as String), contains('isAdult: false')); + expect(variables['sort'], ['TRENDING_DESC']); + expect(variables['perPage'], 50); + return _data({ + 'Page': { + 'pageInfo': {'hasNextPage': true}, + 'media': [_media(id: 16498, idMal: 16498)], + }, + }); + }; + + final page = await source.fetchRow(CatalogRowId.trendingAnime, limit: 500); + + expect(page.hasMore, isTrue); + expect(page.items, hasLength(1)); + final item = page.items.single; + expect(item.source, CatalogSourceId.anilist); + expect(item.ids.anilist, 16498); + expect(item.ids.mal, 16498); + expect(item.ids.tvdb, 267440); + expect(item.ids.imdb, 'tt2560140'); + expect(item.overview, 'Humanity\nfights & survives.'); + expect(item.airStatus, CatalogAirStatus.airing); + expect(item.episodeCount, 25); + }); + + test('seasonal client sends season and year variables', () async { + responder = (request) { + final variables = _requestBody(request)['variables'] as Map; + expect(variables['season'], 'SPRING'); + expect(variables['seasonYear'], 2026); + expect(variables['sort'], ['POPULARITY_DESC']); + return _data({ + 'Page': { + 'pageInfo': {'hasNextPage': false}, + 'media': >[], + }, + }); + }; + + final page = await client.getSeasonalAnime('SPRING', 2026); + expect(page.items, isEmpty); + }); + + test('currentAnimeSeason handles December rollover and season boundaries', () { + expect(AnilistCatalogSource.currentAnimeSeason(DateTime(2025, 12, 1)), (season: 'WINTER', year: 2026)); + expect(AnilistCatalogSource.currentAnimeSeason(DateTime(2026, 1, 1)), (season: 'WINTER', year: 2026)); + expect(AnilistCatalogSource.currentAnimeSeason(DateTime(2026, 4, 1)), (season: 'SPRING', year: 2026)); + expect(AnilistCatalogSource.currentAnimeSeason(DateTime(2026, 7, 1)), (season: 'SUMMER', year: 2026)); + expect(AnilistCatalogSource.currentAnimeSeason(DateTime(2026, 10, 1)), (season: 'FALL', year: 2026)); + }); + + test('planning row caches viewer id, skips custom lists, and deduplicates media', () async { + var viewerRequests = 0; + responder = (request) { + final query = _requestBody(request)['query'] as String; + if (query.contains('Viewer { id }')) { + viewerRequests++; + return _data({ + 'Viewer': {'id': 7}, + }); + } + expect(query, contains('status: PLANNING')); + return _data({ + 'MediaListCollection': { + 'hasNextChunk': true, + 'lists': [ + { + 'isCustomList': false, + 'entries': [ + {'media': _media(id: 16498, idMal: 16498)}, + {'media': _media(id: 16498, idMal: 16498)}, + ], + }, + { + 'isCustomList': true, + 'entries': [ + {'media': _media(id: 999, idMal: 999)}, + ], + }, + ], + }, + }); + }; + + final first = await source.fetchRow(CatalogRowId.watchlist); + final second = await source.fetchRow(CatalogRowId.watchlist); + + expect(viewerRequests, 1); + expect(first.items.map((item) => item.ids.anilist), [16498]); + expect(first.hasMore, isTrue); + expect(second.items, hasLength(1)); + }); + + test('planning ids query sends a valid GraphQL field selection', () async { + await client.getPlanningIdsPage(7); + + final query = _requestBody(requests.single)['query'] as String; + expect(query, contains('id idMal')); + expect(query, isNot(contains(r'id\nidMal'))); + }); + + test('unsupported rows throw instead of silently returning empty', () { + expect(() => source.fetchRow(CatalogRowId.recommendedMovies), throwsA(isA())); + }); + + test('one-character search requests AniList while whitespace-only does not', () async { + final empty = await source.search(' '); + expect(empty, isEmpty); + expect(requests, isEmpty); + + await source.search(' a '); + expect(requests, hasLength(1)); + final variables = _requestBody(requests.single)['variables'] as Map; + expect(variables['search'], 'a'); + }); + + test('watchlist snapshot matches the MAL identity form alone', () async { + await source.ensureWatchlistLoaded(); + + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(mal: 16498)), isTrue); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(anilist: 999)), isFalse); + }); + + test('resolveItemIds prefers season one for shows and movie rows for movies', () async { + final showIds = await source.resolveItemIds(MediaKind.show, const ExternalIds(tvdb: 267440, imdb: 'tt2560140')); + final movieIds = await source.resolveItemIds(MediaKind.movie, const ExternalIds(tmdb: 372058, imdb: 'tt5311514')); + + expect(showIds?.anilist, 16498); + expect(showIds?.mal, 16498); + expect(movieIds?.anilist, 21519); + expect(movieIds?.mal, 32281); + }); + + test('resolveItemIds returns null when the matching row lacks AniList id', () async { + final noAniListSource = AnilistCatalogSource( + client, + fribb: _FakeFribb(const [FribbMappingRow(malId: 1, tvdbId: 2)]), + ); + addTearDown(noAniListSource.dispose); + + expect(await noAniListSource.resolveItemIds(MediaKind.show, const ExternalIds(tvdb: 2)), isNull); + }); + + test('add writes PLANNING without a progress field', () async { + responder = (request) { + final body = _requestBody(request); + final query = body['query'] as String; + final variables = body['variables'] as Map; + expect(query, contains('SaveMediaListEntry')); + expect(query, isNot(contains('progress'))); + expect(variables, {'mediaId': 16498, 'status': 'PLANNING'}); + return _data({ + 'SaveMediaListEntry': {'id': 1}, + }); + }; + + await source.addToWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498)); + expect(requests, hasLength(1)); + }); + + test('remove is a no-op when the media-list entry is already absent', () async { + responder = (request) { + final query = _requestBody(request)['query'] as String; + expect(query, contains('mediaListEntry')); + return _data({ + 'Media': {'mediaListEntry': null}, + }); + }; + + await source.removeFromWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498)); + expect(requests, hasLength(1)); + }); + + test('failed mutation restores optimistic watchlist membership', () async { + responder = (request) { + final query = _requestBody(request)['query'] as String; + if (query.contains('Viewer { id }')) { + return _data({ + 'Viewer': {'id': 7}, + }); + } + if (query.contains('MediaListCollection')) { + return _data({ + 'MediaListCollection': { + 'hasNextChunk': false, + 'lists': [ + { + 'isCustomList': false, + 'entries': [ + { + 'media': {'id': 16498, 'idMal': 16498}, + }, + ], + }, + ], + }, + }); + } + if (query.contains('mediaListEntry')) { + return _data({ + 'Media': { + 'mediaListEntry': {'id': 99}, + }, + }); + } + return http.Response('failed', 500); + }; + await source.ensureWatchlistLoaded(); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498)), isTrue); + + await expectLater( + source.removeFromWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498)), + throwsA(isA()), + ); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(anilist: 16498)), isTrue); + }); + + test('cast and related map characters and enriched recommendations', () async { + responder = (request) { + final query = _requestBody(request)['query'] as String; + if (query.contains('characters(')) { + return _data({ + 'Media': { + 'characters': { + 'edges': [ + { + 'role': 'MAIN', + 'node': { + 'name': {'full': 'Mikasa Ackerman'}, + 'image': {'large': 'https://img.anilist.co/mikasa.jpg'}, + }, + }, + ], + }, + }, + }); + } + return _data({ + 'Media': { + 'recommendations': { + 'nodes': [ + {'mediaRecommendation': _media(id: 21519, idMal: 32281, title: 'Your Name.', format: 'MOVIE')}, + {'mediaRecommendation': _media(id: 999, title: 'Adult', isAdult: true)}, + ], + }, + }, + }); + }; + const item = CatalogItem( + source: CatalogSourceId.anilist, + kind: MediaKind.show, + title: 'Attack on Titan', + ids: CatalogItemIds(anilist: 16498), + ); + + final cast = await source.fetchCast(item); + final related = await source.fetchRelated(item); + + expect(cast.single.name, 'Mikasa Ackerman'); + expect(cast.single.secondary, 'MAIN'); + expect(related, hasLength(1)); + expect(related.single.kind, MediaKind.movie); + expect(related.single.ids.tmdb, 372058); + }); + }); + + group('AnilistClient 429 handling', () { + test('throws the shared rate-limit exception without blocking or retrying', () async { + var calls = 0; + final client = AnilistClient( + _session(), + onSessionInvalidated: () => fail('should not invalidate'), + httpClient: MockClient((request) async { + calls++; + return http.Response('limited', 429, headers: {'retry-after': '60'}); + }), + ); + addTearDown(client.dispose); + + await expectLater( + client.getViewerId(), + throwsA( + isA() + .having((error) => error.service, 'service', TrackerService.anilist) + .having((error) => error.retryAfterSeconds, 'retryAfterSeconds', 60), + ), + ); + expect(calls, 1); + }); + }); +} diff --git a/test/services/catalog/plex_catalog_source_test.dart b/test/services/catalog/plex_catalog_source_test.dart new file mode 100644 index 00000000..c276e9a1 --- /dev/null +++ b/test/services/catalog/plex_catalog_source_test.dart @@ -0,0 +1,351 @@ +import 'dart:async'; + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/services/catalog/catalog_source.dart'; +import 'package:plezy/services/catalog/plex_catalog_source.dart'; +import 'package:plezy/services/plex_discover_client.dart'; +import 'package:plezy/utils/external_ids.dart'; + +const _session = PlexDiscoverSession(accessToken: 'profile-token', clientIdentifier: 'client-id'); + +http.Response _json(Object body, [int status = 200]) => + http.Response(jsonEncode(body), status, headers: const {'content-type': 'application/json'}); + +Map _metadata({ + String ratingKey = 'plex-movie-1', + String type = 'movie', + String title = 'Inception', +}) => { + 'ratingKey': ratingKey, + 'guid': 'plex://$type/$ratingKey', + 'type': type, + 'title': title, + 'year': 2010, + 'summary': 'A dream within a dream.', + 'duration': 8880000, + 'rating': 8.7, + 'contentRating': 'PG-13', + 'thumb': 'https://metadata-static.plex.tv/poster.jpg', + 'art': 'https://metadata-static.plex.tv/art.jpg', + 'Genre': [ + {'tag': 'Science Fiction'}, + ], + 'Guid': [ + {'id': 'imdb://tt1375666'}, + {'id': 'tmdb://27205'}, + ], +}; + +void main() { + group('PlexCatalogSource', () { + test('watchlist uses offset paging and maps Plex metadata', () async { + late http.Request captured; + final source = PlexCatalogSource( + PlexDiscoverClient( + _session, + httpClient: MockClient((request) async { + captured = request; + return _json({ + 'MediaContainer': { + 'offset': 25, + 'size': 1, + 'totalSize': 27, + 'Metadata': [_metadata()], + }, + }); + }), + ), + ); + addTearDown(source.dispose); + + final page = await source.fetchRow(CatalogRowId.watchlist, page: 2, limit: 25); + + expect(captured.method, 'GET'); + expect(captured.url.path, '/library/sections/watchlist/all'); + expect(captured.url.queryParameters['X-Plex-Container-Start'], '25'); + expect(captured.url.queryParameters['X-Plex-Container-Size'], '25'); + expect(captured.url.queryParameters['includeMeta'], '1'); + expect(captured.headers['X-Plex-Token'], 'profile-token'); + expect(captured.headers['X-Plex-Client-Identifier'], 'client-id'); + expect(page.hasMore, isTrue); + + final item = page.items.single; + expect(item.source, CatalogSourceId.plex); + expect(item.kind, MediaKind.movie); + expect(item.title, 'Inception'); + expect(item.runtimeMinutes, 148); + expect(item.ids.plex, 'plex-movie-1'); + expect(item.ids.imdb, 'tt1375666'); + expect(item.ids.tmdb, 27205); + expect(item.genres, ['Science Fiction']); + }); + + test('recommendation hubs retain Plex titles and support View All paging', () async { + final requests = []; + final source = PlexCatalogSource( + PlexDiscoverClient( + _session, + httpClient: MockClient((request) async { + requests.add(request); + if (request.url.path == '/hubs/sections/watchlist') { + return _json({ + 'MediaContainer': { + 'Hub': [ + { + 'hubIdentifier': 'because-watchlisted', + 'key': '/hubs/sections/watchlist/because-watchlisted?source=watchlist', + 'title': 'Because You Watchlisted Inception', + 'totalSize': 4, + 'more': 1, + 'Metadata': [ + _metadata(), + _metadata(ratingKey: 'plex-show-1', type: 'show', title: 'Severance'), + {'ratingKey': 'person-1', 'type': 'person', 'title': 'A Person'}, + ], + }, + { + 'hubIdentifier': 'people-only', + 'key': '/hubs/sections/watchlist/people-only', + 'title': 'People', + 'Metadata': [ + {'ratingKey': 'person-2', 'type': 'person', 'title': 'Another Person'}, + ], + }, + ], + }, + }); + } + if (request.url.path == '/hubs/sections/watchlist/because-watchlisted') { + return _json({ + 'MediaContainer': { + 'offset': 2, + 'totalSize': 3, + 'Metadata': [_metadata(ratingKey: 'plex-movie-2', title: 'Interstellar')], + }, + }); + } + return _json({'error': 'unexpected'}, 500); + }), + ), + ); + addTearDown(source.dispose); + + final hubs = await source.fetchHubs(limit: 2); + + expect(requests.first.url.queryParameters, containsPair('count', '3')); + expect(requests.first.url.queryParameters, containsPair('includeMeta', '1')); + expect(hubs, hasLength(1)); + expect(hubs.single.id, 'because-watchlisted'); + expect(hubs.single.title, 'Because You Watchlisted Inception'); + expect(hubs.single.page.items.map((item) => item.title), ['Inception', 'Severance']); + expect(hubs.single.page.hasMore, isTrue); + + final page = await source.fetchHub(hubs.single.id, page: 2, limit: 2); + + expect(requests.last.url.queryParameters, containsPair('source', 'watchlist')); + expect(requests.last.url.queryParameters, containsPair('X-Plex-Container-Start', '2')); + expect(requests.last.url.queryParameters, containsPair('X-Plex-Container-Size', '2')); + expect(page.items.single.title, 'Interstellar'); + expect(page.hasMore, isFalse); + }); + + test('a vanished recommendation hub degrades to an empty page', () async { + final requests = []; + final source = PlexCatalogSource( + PlexDiscoverClient( + _session, + httpClient: MockClient((request) async { + requests.add(request); + return _json({'error': 'unexpected'}, 500); + }), + ), + ); + addTearDown(source.dispose); + + final page = await source.fetchHub('no-longer-present'); + + expect(page.items, isEmpty); + expect(page.hasMore, isFalse); + expect(requests, isEmpty); + }); + test('search sends Plex universal-search values and deduplicates media', () async { + late http.Request captured; + final source = PlexCatalogSource( + PlexDiscoverClient( + _session, + httpClient: MockClient((request) async { + captured = request; + return _json({ + 'MediaContainer': { + 'SearchResults': [ + { + 'SearchResult': [ + {'Metadata': _metadata()}, + {'Metadata': _metadata()}, + { + 'Metadata': {'ratingKey': 'person-1', 'type': 'person', 'title': 'A Person'}, + }, + ], + }, + ], + }, + }); + }), + ), + ); + addTearDown(source.dispose); + + final results = await source.search(' Inception ', limit: 12); + + expect(captured.url.path, '/library/search'); + expect(captured.url.queryParameters, containsPair('query', 'Inception')); + expect(captured.url.queryParameters, containsPair('limit', '12')); + expect(captured.url.queryParameters, containsPair('searchTypes', 'movies,tv')); + expect(captured.url.queryParameters, containsPair('searchProviders', 'discover')); + expect(results, hasLength(1)); + expect(results.single.ids.plex, 'plex-movie-1'); + }); + + test('watchlist snapshot and mutation use the advertised action endpoint', () async { + var watchlisted = true; + final requests = []; + final source = PlexCatalogSource( + PlexDiscoverClient( + _session, + httpClient: MockClient((request) async { + requests.add(request); + if (request.url.path == '/library/sections/watchlist/all') { + return _json({ + 'MediaContainer': { + 'totalSize': watchlisted ? 1 : 0, + 'Metadata': watchlisted ? [_metadata()] : [], + }, + }); + } + expect(request.method, 'PUT'); + expect(request.url.path, '/actions/removeFromWatchlist'); + expect(request.url.queryParameters['ratingKey'], 'plex-movie-1'); + watchlisted = false; + return _json(const {}); + }), + ), + ); + addTearDown(source.dispose); + const ids = CatalogItemIds(plex: 'plex-movie-1', imdb: 'tt1375666'); + + await source.ensureWatchlistLoaded(); + expect(source.isOnWatchlist(MediaKind.movie, ids), isTrue); + + await source.removeFromWatchlist(MediaKind.movie, ids); + expect(source.isOnWatchlist(MediaKind.movie, ids), isFalse); + expect(requests, hasLength(2)); + }); + + test('watchlist mutation resolves a missing Plex rating key from external ids', () async { + final requests = []; + final source = PlexCatalogSource( + PlexDiscoverClient( + _session, + httpClient: MockClient((request) async { + requests.add(request); + if (request.url.path == '/library/metadata/matches') { + expect(request.url.queryParameters['guid'], 'imdb://tt1375666'); + return _json({ + 'MediaContainer': { + 'Metadata': [_metadata()], + }, + }); + } + expect(request.method, 'PUT'); + expect(request.url.path, '/actions/addToWatchlist'); + expect(request.url.queryParameters['ratingKey'], 'plex-movie-1'); + return _json(const {}); + }), + ), + ); + addTearDown(source.dispose); + + await source.addToWatchlist(MediaKind.movie, const CatalogItemIds(imdb: 'tt1375666')); + + expect(requests.map((request) => request.url.path), ['/library/metadata/matches', '/actions/addToWatchlist']); + }); + test('external-id matching enables cast and related detail flows', () async { + final source = PlexCatalogSource( + PlexDiscoverClient( + _session, + httpClient: MockClient((request) async { + switch (request.url.path) { + case '/library/metadata/matches': + expect(request.url.queryParameters['guid'], 'imdb://tt1375666'); + return _json({ + 'MediaContainer': { + 'Metadata': [_metadata(type: 'show')], + }, + }); + case '/library/metadata/plex-movie-1': + return _json({ + 'MediaContainer': { + 'Metadata': [ + { + ..._metadata(type: 'show'), + 'Role': [ + {'tag': 'Ken Watanabe', 'role': 'Saito', 'thumb': 'https://images.plex.tv/ken.jpg'}, + ], + }, + ], + }, + }); + case '/library/metadata/plex-movie-1/related': + return _json({ + 'MediaContainer': { + 'Hub': [ + { + 'Metadata': [_metadata(ratingKey: 'related-1', title: 'Interstellar')], + }, + ], + }, + }); + } + return _json({'error': 'unexpected'}, 500); + }), + ), + ); + addTearDown(source.dispose); + + final resolved = await source.resolveItemIds(MediaKind.show, const ExternalIds(imdb: 'tt1375666')); + expect(resolved?.plex, 'plex-movie-1'); + expect(resolved?.imdb, 'tt1375666'); + + const item = CatalogItem( + source: CatalogSourceId.plex, + kind: MediaKind.show, + title: 'Inception', + ids: CatalogItemIds(plex: 'plex-movie-1'), + ); + final cast = await source.fetchCast(item); + final related = await source.fetchRelated(item); + + expect(cast.single.name, 'Ken Watanabe'); + expect(cast.single.secondary, 'Saito'); + expect(related.single.title, 'Interstellar'); + }); + + test('Discover requests have a bounded duration', () async { + final response = Completer(); + final client = PlexDiscoverClient( + _session, + httpClient: MockClient((request) => response.future), + requestTimeout: Duration.zero, + ); + addTearDown(client.dispose); + + await expectLater(client.getWatchlist(), throwsA(isA())); + }); + }); +} diff --git a/test/services/catalog/simkl_catalog_source_test.dart b/test/services/catalog/simkl_catalog_source_test.dart new file mode 100644 index 00000000..9b4894e4 --- /dev/null +++ b/test/services/catalog/simkl_catalog_source_test.dart @@ -0,0 +1,383 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/models/simkl/simkl_trending_item.dart'; +import 'package:plezy/services/catalog/catalog_source.dart'; +import 'package:plezy/services/catalog/simkl_catalog_source.dart'; +import 'package:plezy/services/trackers/simkl/simkl_client.dart'; +import 'package:plezy/services/trackers/simkl/simkl_constants.dart'; +import 'package:plezy/services/trackers/tracker_exceptions.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; + +TrackerSession _session() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return TrackerSession( + accessToken: 'access', + refreshToken: null, + expiresAt: now + 86400, + scope: null, + createdAt: now - 3600, + username: 'alice', + ); +} + +http.Response _json(Object? body, {int status = 200, Map? headers}) => + http.Response(json.encode(body), status, headers: {'content-type': 'application/json', ...?headers}); + +Map _trending({required int simkl, String title = 'Inception', String? animeType}) => { + 'title': title, + 'poster': '12/posterhash', + 'fanart': '34/fanarthash', + 'release_date': '07/16/2010', + 'runtime': '2h 37m', + 'status': 'ended', + 'overview': 'Dreams within dreams.', + 'genres': ['Action', 'Science Fiction'], + 'trailer': 'YoHD9XEInc0', + 'total_episodes': animeType == null ? null : 12, + 'anime_type': ?animeType, + 'ids': {'simkl_id': simkl, 'slug': 'inception', 'imdb': 'tt1375666', 'tmdb': '27205', 'tvdb': '123'}, + 'ratings': { + 'simkl': {'rating': 8.8, 'votes': 1234}, + }, +}; + +Map _best(int simkl) => { + 'title': 'Best $simkl', + 'year': 2020, + 'ids': {'simkl': simkl}, +}; + +Map _allItemsBody() => { + 'movies': [ + { + 'status': 'plantowatch', + 'movie': { + 'title': 'Inception', + 'year': 2010, + 'poster': '12/posterhash', + 'runtime': 148, + 'ids': {'simkl': 1, 'imdb': 'tt1375666', 'tmdb': '27205'}, + }, + }, + ], + 'shows': [ + { + 'status': 'plantowatch', + 'total_episodes_count': 62, + 'show': { + 'title': 'Breaking Bad', + 'year': 2008, + 'poster': '97/showhash', + 'ids': {'simkl': 2, 'tvdb': '81189'}, + }, + }, + ], + 'anime': [ + { + 'status': 'plantowatch', + 'anime_type': 'movie', + 'show': { + 'title': 'Your Name.', + 'year': 2016, + 'poster': '55/animehash', + 'ids': {'simkl': 3, 'mal': '32281', 'anilist': '21519'}, + }, + }, + ], +}; + +void main() { + group('Simkl models', () { + test('parses runtime strings and rejects malformed values', () { + expect(SimklTrendingItem.fromJson(_trending(simkl: 1)).runtimeMinutes, 157); + expect(SimklTrendingItem.fromJson({..._trending(simkl: 1), 'runtime': '45m'}).runtimeMinutes, 45); + expect(SimklTrendingItem.fromJson({..._trending(simkl: 1), 'runtime': 'unknown'}).runtimeMinutes, isNull); + }); + }); + + group('SimklCatalogSource', () { + late List requests; + late FutureOr Function(http.Request request) responder; + late SimklClient client; + late SimklCatalogSource source; + late int invalidations; + + setUp(() { + requests = []; + invalidations = 0; + responder = (request) => _json([]); + client = SimklClient( + _session(), + onSessionInvalidated: () => invalidations++, + httpClient: MockClient((request) async { + requests.add(request); + return responder(request); + }), + ); + source = SimklCatalogSource(client); + }); + + tearDown(() { + source.dispose(); + client.dispose(); + }); + + test('trending uses CDN, coerces ids, builds images, and serves page two from cache', () async { + responder = (request) => _json([_trending(simkl: 1), _trending(simkl: 2, title: 'Second')]); + + final first = await source.fetchRow(CatalogRowId.trendingMovies, limit: 1); + final second = await source.fetchRow(CatalogRowId.trendingMovies, page: 2, limit: 1); + + expect(requests, hasLength(1)); + final request = requests.single; + expect(request.url.host, 'data.simkl.in'); + expect(request.url.path, '/discover/trending/movies/week_100.json'); + expect(request.url.queryParameters['client_id'], SimklConstants.clientId); + expect(request.url.queryParameters['app-name'], SimklConstants.appName); + expect(request.url.queryParameters['app-version'], SimklConstants.appVersion); + expect(request.headers, isNot(contains('authorization'))); + expect(request.headers['user-agent'], '${SimklConstants.appName}/${SimklConstants.appVersion}'); + + expect(first.hasMore, isTrue); + expect(first.items.single.ids.simkl, 1); + expect(first.items.single.ids.tmdb, 27205); + expect(first.items.single.year, 2010); + expect(first.items.single.runtimeMinutes, 157); + expect(first.items.single.posterUrl, 'https://simkl.in/posters/12/posterhash_m.webp'); + expect(first.items.single.backdropUrl, 'https://simkl.in/fanart/34/fanarthash_medium.webp'); + expect(second.items.single.title, 'Second'); + expect(second.hasMore, isFalse); + }); + + test('CDN 401 does not invalidate the authenticated Simkl session', () async { + responder = (request) => http.Response('denied', 401); + + await expectLater(source.fetchRow(CatalogRowId.trendingMovies), throwsA(isA())); + expect(invalidations, 0); + }); + + test('anime_type movie maps a trending anime to MediaKind.movie', () async { + responder = (request) => _json([_trending(simkl: 3, animeType: 'movie')]); + + final page = await source.fetchRow(CatalogRowId.trendingAnime); + expect(page.items.single.kind, MediaKind.movie); + expect(page.items.single.episodeCount, isNull); + }); + + test('best TV uses watched endpoint and tolerates a literal null body', () async { + responder = (request) { + expect(request.url.path, '/tv/best/watched'); + return http.Response('null', 200, headers: {'content-type': 'application/json'}); + }; + + final page = await source.fetchRow(CatalogRowId.popularShows); + expect(page.items, isEmpty); + expect(page.hasMore, isFalse); + }); + + test('best rows expose all cached pages without refetching', () async { + responder = (request) { + expect(request.url.path, '/tv/best/watched'); + return _json([for (var i = 1; i <= 60; i++) _best(i)]); + }; + + final first = await source.fetchRow(CatalogRowId.popularShows, limit: 25); + final second = await source.fetchRow(CatalogRowId.popularShows, page: 2, limit: 25); + final third = await source.fetchRow(CatalogRowId.popularShows, page: 3, limit: 25); + + expect(requests, hasLength(1)); + expect(first.items, hasLength(25)); + expect(first.hasMore, isTrue); + expect(second.items, hasLength(25)); + expect(second.hasMore, isTrue); + expect(third.items, hasLength(10)); + expect(third.hasMore, isFalse); + }); + + test('watchlist row maps full entries and reuses its warm cache', () async { + responder = (request) { + expect(request.url.path, '/sync/all-items/all/plantowatch'); + expect(request.url.queryParameters['extended'], 'full'); + expect(request.headers['authorization'], 'Bearer access'); + return _json(_allItemsBody()); + }; + + final first = await source.fetchRow(CatalogRowId.watchlist, limit: 10); + final second = await source.fetchRow(CatalogRowId.watchlist, limit: 10); + expect(requests, hasLength(1)); + expect(first.items, hasLength(3)); + final movie = first.items.first; + expect(movie.kind, MediaKind.movie); + expect(movie.ids.simkl, 1); + expect(movie.ids.tmdb, 27205); + expect(movie.runtimeMinutes, 148); + expect(first.items.last.kind, MediaKind.movie); + expect(first.items.last.ids.anilist, 21519); + expect(second.items, hasLength(3)); + }); + + test('watchlist row and membership snapshot share one full-library download', () async { + responder = (request) { + expect(request.url.queryParameters['extended'], 'full'); + return _json(_allItemsBody()); + }; + + await source.fetchRow(CatalogRowId.watchlist); + await source.ensureWatchlistLoaded(); + + expect(requests, hasLength(1)); + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 27205)), isTrue); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(tmdb: 27205)), isFalse); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(tvdb: 81189)), isTrue); + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 3)), isTrue); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(simkl: 3)), isTrue); + }); + + test('add uses per-item plantowatch and remove uses history/remove with bare ids', () async { + final bodies = >[]; + responder = (request) { + bodies.add(json.decode(request.body) as Map); + return _json({ + 'added': {}, + 'not_found': {}, + }, status: request.url.path == '/sync/add-to-list' ? 201 : 200); + }; + const ids = CatalogItemIds(simkl: 1, slug: 'response-only', imdb: 'tt1375666', tmdb: 27205); + + await source.addToWatchlist(MediaKind.movie, ids); + await source.removeFromWatchlist(MediaKind.movie, ids); + + expect(requests.map((request) => request.url.path), ['/sync/add-to-list', '/sync/history/remove']); + final added = (bodies.first['movies'] as List).single as Map; + expect(added['to'], 'plantowatch'); + expect(added['ids'], {'simkl': 1, 'imdb': 'tt1375666', 'tmdb': 27205}); + final removed = (bodies.last['movies'] as List).single as Map; + expect(removed.keys, ['ids']); + }); + + test('successful mutations invalidate the shared watchlist payload', () async { + var allItemsRequests = 0; + responder = (request) { + if (request.url.path == '/sync/all-items/all/plantowatch') { + allItemsRequests++; + return _json(_allItemsBody()); + } + return _json(const {}, status: 201); + }; + + await source.fetchRow(CatalogRowId.watchlist); + await source.addToWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 4)); + await source.fetchRow(CatalogRowId.watchlist); + + expect(allItemsRequests, 2); + }); + + test('failed mutation restores optimistic membership', () async { + var loadingSnapshot = true; + responder = (request) { + if (loadingSnapshot) return _json(_allItemsBody()); + return http.Response('failed', 500); + }; + await source.ensureWatchlistLoaded(); + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 1)), isTrue); + + loadingSnapshot = false; + await expectLater( + source.removeFromWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 1)), + throwsA(isA()), + ); + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 1)), isTrue); + }); + + test('snapshot errors are swallowed and membership remains unknown', () async { + responder = (request) => http.Response('failed', 500); + + await source.ensureWatchlistLoaded(); + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(simkl: 1)), isNull); + }); + + test('search fans out across movie, TV, and anime and merges mapped results', () async { + responder = (request) { + expect(request.url.queryParameters['q'], 'cowboy'); + expect(request.url.queryParameters['extended'], 'full'); + final (endpoint, type, animeType) = switch (request.url.path) { + '/search/movie' => ('movies', 'Movie', null), + '/search/tv' => ('tv', 'Show', null), + '/search/anime' => ('anime', 'Anime Movie', 'movie'), + _ => throw StateError('unexpected path ${request.url.path}'), + }; + return _json( + [ + { + 'title': type, + 'year': 2020, + 'endpoint_type': endpoint, + 'type': ?animeType, + 'poster': '1/hash', + 'ids': {'simkl_id': endpoint.hashCode.abs()}, + 'ratings': { + 'simkl': {'rating': 8.0, 'votes': 10}, + }, + }, + ], + headers: {'x-pagination-page': '1', 'x-pagination-page-count': '1', 'x-pagination-item-count': '1'}, + ); + }; + + final results = await source.search(' cowboy ', limit: 30); + + expect(requests.map((request) => request.url.path).toSet(), {'/search/movie', '/search/tv', '/search/anime'}); + expect(results.map((item) => item.title), ['Movie', 'Show', 'Anime Movie']); + expect(results.map((item) => item.kind), [MediaKind.movie, MediaKind.show, MediaKind.movie]); + }); + + test('fetchCast performs no requests', () async { + const item = CatalogItem( + source: CatalogSourceId.simkl, + kind: MediaKind.movie, + title: 'Inception', + ids: CatalogItemIds(simkl: 1), + ); + + expect(await source.fetchCast(item), isEmpty); + expect(requests, isEmpty); + }); + + test('related retries anime when the kind endpoint returns an empty array', () async { + responder = (request) { + expect(request.url.queryParameters, isNot(contains('extended'))); + if (request.url.path == '/tv/3') return _json([]); + expect(request.url.path, '/anime/3'); + return _json({ + 'users_recommendations': [ + { + 'title': 'A Silent Voice', + 'year': 2016, + 'poster': '1/related', + 'type': 'anime', + 'ids': {'simkl': 4, 'mal': 28851}, + }, + ], + }); + }; + const item = CatalogItem( + source: CatalogSourceId.simkl, + kind: MediaKind.show, + title: 'Anime', + ids: CatalogItemIds(simkl: 3), + ); + + final related = await source.fetchRelated(item); + + expect(requests.map((request) => request.url.path), ['/tv/3', '/anime/3']); + expect(related.single.title, 'A Silent Voice'); + expect(related.single.ids.mal, 28851); + }); + }); +}