feat(explore): add AniList, Simkl, and Plex catalogs

This commit is contained in:
edde746
2026-07-20 10:28:31 +02:00
parent f8cb550be7
commit e32fcc2190
78 changed files with 4327 additions and 199 deletions
+3
View File
@@ -108,6 +108,8 @@
"libraryDensity": "Плътност на библиотеката",
"compact": "Компактна",
"comfortable": "Удобна",
"tvCornerSpotlightBackdrop": "",
"tvCornerSpotlightBackdropDescription": "",
"viewMode": "Режим на изглед",
"gridView": "Мрежа",
"listView": "Списък",
@@ -872,6 +874,7 @@
"trendingShows": "Актуални сериали",
"popularMovies": "Популярни филми",
"popularShows": "Популярни сериали",
"trendingAnime": "Актуални аниме",
"suggestedAnime": "Препоръчани аниме",
"airingAnime": "Топ излъчвани аниме",
"popularAnime": "Най-популярни аниме",
+3
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -108,6 +108,8 @@
"libraryDensity": "ライブラリの密度",
"compact": "コンパクト",
"comfortable": "ゆったり",
"tvCornerSpotlightBackdrop": "",
"tvCornerSpotlightBackdropDescription": "",
"viewMode": "表示モード",
"gridView": "グリッド",
"listView": "リスト",
@@ -871,6 +873,7 @@
"trendingShows": "トレンドのテレビ番組",
"popularMovies": "人気の映画",
"popularShows": "人気のテレビ番組",
"trendingAnime": "トレンドのアニメ",
"suggestedAnime": "おすすめのアニメ",
"airingAnime": "放送中の注目アニメ",
"popularAnime": "人気のアニメ",
+3
View File
@@ -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": "가장 인기 있는 애니메이션",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -108,6 +108,8 @@
"libraryDensity": "Плотность библиотеки",
"compact": "Компактный",
"comfortable": "Комфортный",
"tvCornerSpotlightBackdrop": "",
"tvCornerSpotlightBackdropDescription": "",
"viewMode": "Режим просмотра",
"gridView": "Сетка",
"listView": "Список",
@@ -874,6 +876,7 @@
"trendingShows": "Сериалы в тренде",
"popularMovies": "Популярные фильмы",
"popularShows": "Популярные сериалы",
"trendingAnime": "Аниме в тренде",
"suggestedAnime": "Рекомендуемые аниме",
"airingAnime": "Топ выходящих аниме",
"popularAnime": "Самые популярные аниме",
+1 -1
View File
@@ -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
+10 -4
View File
@@ -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} песни в опашката за изтегляне',
+10 -4
View File
@@ -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',
+3 -1
View File
@@ -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',
+5 -1
View File
@@ -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',
+10 -4
View File
@@ -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',
+10 -4
View File
@@ -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',
+10 -4
View File
@@ -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',
+10 -4
View File
@@ -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} 曲をダウンロード待機中',
+10 -4
View File
@@ -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}곡 다운로드 대기 중',
+10 -4
View File
@@ -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ø',
+10 -4
View File
@@ -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',
+10 -4
View File
@@ -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',
+10 -4
View File
@@ -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',
+10 -4
View File
@@ -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} треков в очереди на загрузку',
+10 -4
View File
@@ -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ö',
+10 -4
View File
@@ -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} 首曲目已加入下载队列',
+3
View File
@@ -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",
+3
View File
@@ -108,6 +108,8 @@
"libraryDensity": "媒体库密度",
"compact": "紧凑",
"comfortable": "舒适",
"tvCornerSpotlightBackdrop": "",
"tvCornerSpotlightBackdropDescription": "",
"viewMode": "视图模式",
"gridView": "网格视图",
"listView": "列表视图",
@@ -871,6 +873,7 @@
"trendingShows": "趋势剧集",
"popularMovies": "热门电影",
"popularShows": "热门剧集",
"trendingAnime": "热门动画",
"suggestedAnime": "推荐动画",
"airingAnime": "热门连载动画",
"popularAnime": "最受欢迎动画",
+161
View File
@@ -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<String>? genres;
final bool isAdult;
final String? coverImageExtraLarge;
final String? coverImageLarge;
final String? bannerImage;
final List<String>? 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<String, dynamic> 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 <String>[]) {
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'<br\s*/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<[^>]+>'), '')
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'")
.trim();
return plain.isEmpty ? null : plain;
}
static String? _nonEmpty(String? value) => value == null || value.isEmpty ? null : value;
static List<String>? _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<String>? _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<AnilistMedia> items, bool hasMore});
+38 -4
View File
@@ -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<String, Object?> 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<String, Object?> 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?,
@@ -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<String>? 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<String, dynamic> 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<String, dynamic> json) => _$SimklAllItemsEntryFromJson(json);
}
class SimklAllItems {
final List<SimklAllItemsEntry> shows;
final List<SimklAllItemsEntry> movies;
final List<SimklAllItemsEntry> anime;
const SimklAllItems({this.shows = const [], this.movies = const [], this.anime = const []});
factory SimklAllItems.fromJson(Map<String, dynamic> json) =>
SimklAllItems(shows: _entries(json['shows']), movies: _entries(json['movies']), anime: _entries(json['anime']));
static List<SimklAllItemsEntry> _entries(Object? value) {
if (value is! List) return const [];
return [
for (final entry in value)
if (entry is Map<String, dynamic>) SimklAllItemsEntry.fromJson(entry),
];
}
}
@@ -0,0 +1,36 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'simkl_all_items_entry.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SimklAllItemsMedia _$SimklAllItemsMediaFromJson(Map<String, dynamic> 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<dynamic>?)
?.map((e) => e as String)
.toList(),
network: json['network'] as String?,
status: json['status'] as String?,
ids: SimklIds.fromJson(json['ids'] as Map<String, dynamic>),
);
SimklAllItemsEntry _$SimklAllItemsEntryFromJson(Map<String, dynamic> json) =>
SimklAllItemsEntry(
status: json['status'] as String?,
show: json['show'] == null
? null
: SimklAllItemsMedia.fromJson(json['show'] as Map<String, dynamic>),
movie: json['movie'] == null
? null
: SimklAllItemsMedia.fromJson(json['movie'] as Map<String, dynamic>),
animeType: json['anime_type'] as String?,
totalEpisodes: flexibleInt(json['total_episodes_count']),
);
+21
View File
@@ -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<String, dynamic> json) => _$SimklBestItemFromJson(json);
}
+18
View File
@@ -0,0 +1,18 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'simkl_best_item.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SimklBestItem _$SimklBestItemFromJson(Map<String, dynamic> json) =>
SimklBestItem(
title: json['title'] as String?,
year: flexibleInt(json['year']),
poster: json['poster'] as String?,
ids: SimklIds.fromJson(json['ids'] as Map<String, dynamic>),
ratings: json['ratings'] == null
? null
: SimklRatings.fromJson(json['ratings'] as Map<String, dynamic>),
);
+51
View File
@@ -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<String, dynamic> toJson() => _$SimklIdsToJson(this);
factory SimklIds.fromJson(Map<String, dynamic> json) => _$SimklIdsFromJson(json);
}
+26
View File
@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'simkl_ids.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SimklIds _$SimklIdsFromJson(Map<String, dynamic> 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<String, dynamic> _$SimklIdsToJson(SimklIds instance) => <String, dynamic>{
'simkl': ?instance.simkl,
'imdb': ?instance.imdb,
'tmdb': ?instance.tmdb,
'tvdb': ?instance.tvdb,
'mal': ?instance.mal,
'anilist': ?instance.anilist,
};
+9
View File
@@ -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';
}
+30
View File
@@ -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<String, dynamic> 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<String, dynamic> json) => _$SimklRatingsFromJson(json);
}
+24
View File
@@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'simkl_rating.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SimklRating _$SimklRatingFromJson(Map<String, dynamic> json) => SimklRating(
rating: flexibleDouble(json['rating']),
votes: flexibleInt(json['votes']),
);
SimklRatings _$SimklRatingsFromJson(Map<String, dynamic> json) => SimklRatings(
simkl: json['simkl'] == null
? null
: SimklRating.fromJson(json['simkl'] as Map<String, dynamic>),
imdb: json['imdb'] == null
? null
: SimklRating.fromJson(json['imdb'] as Map<String, dynamic>),
mal: json['mal'] == null
? null
: SimklRating.fromJson(json['mal'] as Map<String, dynamic>),
);
@@ -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<String, dynamic> json) => _$SimklRecommendationFromJson(json);
}
@@ -0,0 +1,16 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'simkl_recommendation.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SimklRecommendation _$SimklRecommendationFromJson(Map<String, dynamic> 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<String, dynamic>),
);
+39
View File
@@ -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<String, dynamic> json) => _$SimklSearchResultFromJson(json);
}
@@ -0,0 +1,22 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'simkl_search_result.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SimklSearchResult _$SimklSearchResultFromJson(Map<String, dynamic> 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<String, dynamic>),
episodeCount: flexibleInt(json['ep_count']),
status: json['status'] as String?,
ratings: json['ratings'] == null
? null
: SimklRatings.fromJson(json['ratings'] as Map<String, dynamic>),
);
+68
View File
@@ -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<String>? 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<String, dynamic> json) => _$SimklTrendingItemFromJson(json);
}
@@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'simkl_trending_item.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SimklTrendingItem _$SimklTrendingItemFromJson(Map<String, dynamic> 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<dynamic>?)
?.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<String, dynamic>),
totalEpisodes: flexibleInt(json['total_episodes']),
network: json['network'] as String?,
animeType: json['anime_type'] as String?,
ids: SimklIds.fromJson(json['ids'] as Map<String, dynamic>),
);
+16 -4
View File
@@ -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<ActiveProfileProvider>(),
connections: context.read<ConnectionRegistry>(),
profileConnections: context.read<ProfileConnectionRegistry>(),
),
);
}
/// 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<ProfileSessionScreen> {
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<ProfileSessionScreen> {
);
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;
},
),
+147 -47
View File
@@ -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<PlexDiscoverSession?> 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<PlexDiscoverSession?> 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<Client extends Object, Source extends CatalogSource> {
_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<PlexDiscoverSession, PlexCatalogSource> _plex = _CatalogSourceBinding(
(session) => PlexCatalogSource(PlexDiscoverClient(session)),
equals: (previous, next) => previous == next,
);
final _CatalogSourceBinding<TraktClient, TraktCatalogSource> _trakt = _CatalogSourceBinding(TraktCatalogSource.new);
final _CatalogSourceBinding<MalClient, MalCatalogSource> _mal = _CatalogSourceBinding(MalCatalogSource.new);
final _CatalogSourceBinding<AnilistClient, AnilistCatalogSource> _anilist = _CatalogSourceBinding(
AnilistCatalogSource.new,
);
final _CatalogSourceBinding<SimklClient, SimklCatalogSource> _simkl = _CatalogSourceBinding(SimklCatalogSource.new);
final _CatalogSourceBinding<SeerrClient, SeerrCatalogSource> _seerr = _CatalogSourceBinding(SeerrCatalogSource.new);
int _profileBindingGeneration = 0;
int _plexSessionGeneration = 0;
bool? _lastProfileBindingState;
static const String _activeSourceBaseKey = 'catalog_active_source';
CatalogSourceId? _preferredSourceId;
String _activeUserUuid = '';
List<CatalogSource> get connectedSources => [?_trakt, ?_mal, ?_seerr];
List<CatalogSource> 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<void> 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<void> onProfileBindingStateChanged(bool isBinding) async {
final previous = _lastProfileBindingState;
_lastProfileBindingState = isBinding;
if (isBinding && previous != true) _plexSessionGeneration++;
if (previous == true && !isBinding) await _refreshPlexSession();
}
Future<void> _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<void> 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();
}
}
+131 -44
View File
@@ -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<CatalogRowId, CatalogPage> _rows = {};
List<CatalogHub> _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<void> _watchlistRefreshCoalescer = FutureCoalescer();
List<ExploreRowHub>? _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 = <CatalogRowId, CatalogPage>{};
List<CatalogHub>? fetchedProviderHubs;
Object? firstError;
final results = await Future.wait([
for (final row in rows)
source.fetchRow(row, limit: rowLimit).then<CatalogPage?>((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<void>([
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 = <CatalogRowId, CatalogPage>{
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<List<MediaItem>> 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<List<MediaItem>> 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<List<MediaItem>> _loadAllPages(String label, Future<CatalogPage> Function(int page) fetchPage) async {
final items = <MediaItem>[];
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<CatalogHub>? fetchedProviderHubs;
final CatalogHubSource? hubSource = source is CatalogHubSource ? source as CatalogHubSource : null;
var providerHubRefreshFailed = false;
await Future.wait<void>([
() 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();
}
}
+6
View File
@@ -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;
+3 -1
View File
@@ -408,7 +408,9 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
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),
+9 -7
View File
@@ -127,13 +127,15 @@ class ExploreScreenState extends State<ExploreScreen>
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<ExploreScreen>
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<ExploreScreen>
);
}
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<ExploreScreen>
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,
+10 -2
View File
@@ -718,8 +718,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// than one, the toggle opens a source chooser.
final candidates = <WatchlistCandidate>[];
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);
@@ -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<int> _viewerIdLoad = FutureCoalescer<int>();
int? _viewerId;
AnilistCatalogSource(this._client, {FribbMappingLookup? fribb}) : _fribb = fribb ?? FribbMappingStore.instance;
@override
CatalogSourceId get id => CatalogSourceId.anilist;
@override
String get displayName => 'AniList';
@override
List<CatalogRowId> 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<CatalogPage> 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<List<CatalogItem>> 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<List<CatalogItem>> _toCatalogItems(List<AnilistMedia> 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<FribbMappingRow?>.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<List<CatalogCastMember>> 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<List<CatalogItem>> 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<CatalogItemIds?> 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<FribbMappingRow> 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<String> _identityKeys(CatalogItemIds ids) => [
if (ids.anilist case final int id) 'anilist:$id',
if (ids.mal case final int id) 'mal:$id',
];
@override
List<String> membershipKeysFor(MediaKind kind, CatalogItemIds ids) => _identityKeys(ids);
@override
Future<WatchlistKeyPage> 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<int> _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<CatalogItemIds> 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<void> 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();
}
}
+22
View File
@@ -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<List<CatalogHub>> fetchHubs({int limit = 25});
Future<CatalogPage> 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).
///
@@ -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<String> 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<String> 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<WatchlistKeyPage> fetchWatchlistKeyPage(int page, int limit);
@@ -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}'),
@@ -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<String, String> _hubKeys = {};
PlexCatalogSource(this._client);
@override
CatalogSourceId get id => CatalogSourceId.plex;
@override
String get displayName => 'Plex';
@override
List<CatalogRowId> 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<CatalogPage> 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<List<CatalogHub>> fetchHubs({int limit = 25}) async {
final fetched = await _client.getRecommendedHubs(limit: limit);
final keys = <String, String>{};
final result = <CatalogHub>[];
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<CatalogPage> 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<List<CatalogItem>> 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<CatalogItemIds?> 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<CatalogItemIds> 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<List<CatalogCastMember>> 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<List<CatalogItem>> 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<WatchlistKeyPage> 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<void> 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<CatalogItem> _fromMetadata(List<Map<String, dynamic>> metadata) {
final items = <CatalogItem>[];
final seen = <String>{};
for (final value in metadata) {
final item = _toCatalogItem(value);
if (item != null && seen.add(item.identityKey)) items.add(item);
}
return items;
}
CatalogItem? _toCatalogItem(Map<String, dynamic> 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<String, dynamic> 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();
}
}
@@ -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}'),
@@ -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<CatalogRowId, List<CatalogItem>> _rowCache = {};
final Map<CatalogRowId, DateTime> _rowCacheLoadedAt = {};
final KeyedFutureCoalescer<CatalogRowId, List<CatalogItem>> _rowLoads = KeyedFutureCoalescer();
final FutureCoalescer<SimklAllItems> _watchlistLoad = FutureCoalescer();
SimklAllItems? _watchlistCache;
int _watchlistCacheGeneration = 0;
SimklCatalogSource(this._client);
@override
CatalogSourceId get id => CatalogSourceId.simkl;
@override
String get displayName => 'Simkl';
@override
List<CatalogRowId> 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<CatalogPage> 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<List<CatalogItem>> _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<List<CatalogItem>> _fetchTrending(SimklCatalogType type) async => [
for (final item in await _client.getTrending(type))
if (item.ids.hasAny) _catalogItemFromTrending(item, type),
];
Future<List<CatalogItem>> _fetchBest(SimklCatalogType type) async => [
for (final item in await _client.getBest(type))
if (item.ids.hasAny) _catalogItemFromBest(item, type),
];
Future<List<CatalogItem>> _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<SimklAllItems> _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<List<CatalogItem>> 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<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [];
@override
Future<List<CatalogItem>> 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<SimklRecommendation> 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<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
external.hasAny ? CatalogItemIds.fromExternal(external) : null;
@override
Future<WatchlistKeyPage> 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<void> 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();
}
}
@@ -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:
+265
View File
@@ -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<Map<String, dynamic>> 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<PlexDiscoverPage> 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<List<PlexDiscoverHub>> 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 = <PlexDiscoverHub>[];
final seen = <String>{};
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<PlexDiscoverPage> 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<List<Map<String, dynamic>>> 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<String, dynamic> metadata) metadata,
];
}
Future<Map<String, dynamic>?> 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<Map<String, dynamic>?> 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<List<Map<String, dynamic>>> 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<void> setWatchlisted(String ratingKey, {required bool add}) async {
await _request(
'PUT',
add ? '/actions/addToWatchlist' : '/actions/removeFromWatchlist',
query: {'ratingKey': ratingKey},
);
}
Future<Map<String, dynamic>?> _request(
String method,
String path, {
Map<String, Object?>? 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 <MapEntry<String, Object?>>[])
if (entry.value != null) entry.key: entry.value.toString(),
},
);
final headers = <String, String>{
'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 <String, dynamic>{};
final decoded = jsonDecode(response.body);
return decoded is Map<String, dynamic> ? decoded : const <String, dynamic>{};
}
static Map<String, dynamic> _mediaContainer(Map<String, dynamic> data) =>
firstFlexibleMap(data['MediaContainer']) ?? const <String, dynamic>{};
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<String, dynamic> ? firstFlexibleMap(decoded['Error']) : null;
return error?['message']?.toString() ?? error?['error']?.toString() ?? 'Request failed';
} catch (_) {
return 'Request failed';
}
}
void dispose() => _http.close();
}
@@ -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<AnilistPage> getTrendingAnime({int page = 1, int limit = 25}) =>
_getAnimePage(sort: 'TRENDING_DESC', page: page, limit: limit);
Future<AnilistPage> getPopularAnime({int page = 1, int limit = 25}) =>
_getAnimePage(sort: 'POPULARITY_DESC', page: page, limit: limit);
Future<AnilistPage> getSeasonalAnime(String season, int seasonYear, {int page = 1, int limit = 25}) =>
_getAnimePage(sort: 'POPULARITY_DESC', season: season, seasonYear: seasonYear, page: page, limit: limit);
Future<AnilistPage> searchAnime(String search, {int page = 1, int limit = 30}) =>
_getAnimePage(sort: 'SEARCH_MATCH', search: search, page: page, limit: limit);
Future<AnilistPage> _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 <AnilistMedia>[], 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<String, dynamic>) AnilistMedia.fromJson(node),
],
hasMore: pageInfo is Map && pageInfo['hasNextPage'] == true,
);
}
Future<AnilistPage> getPlanningPage(int userId, {int chunk = 1, int perChunk = 500}) =>
_getPlanningPage(userId, chunk: chunk, perChunk: perChunk, idsOnly: false);
Future<AnilistPage> getPlanningIdsPage(int userId, {int chunk = 1, int perChunk = 500}) =>
_getPlanningPage(userId, chunk: chunk, perChunk: perChunk, idsOnly: true);
Future<AnilistPage> _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 <AnilistMedia>[], hasMore: false);
final seen = <int>{};
final items = <AnilistMedia>[];
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<String, dynamic>) 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<int> 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<void> 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<List<AnilistCharacter>> 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<List<AnilistMedia>> 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 = <AnilistMedia>[];
for (final node in nodes) {
if (node is! Map) continue;
final recommendation = node['mediaRecommendation'];
if (recommendation is! Map<String, dynamic>) continue;
final item = AnilistMedia.fromJson(recommendation);
if (!item.isAdult) items.add(item);
}
return items;
}
Future<Map<String, dynamic>> query(String query, {Map<String, dynamic>? variables}) async {
final uri = Uri.parse(AnilistConstants.apiBase);
final headers = AnilistConstants.headers(accessToken: _session.accessToken);
final res = await _http.sendJson(
Future<http.Response> 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(
@@ -13,18 +13,18 @@ import 'simkl_constants.dart';
/// Simkl OAuth PIN (device-code) flow.
///
/// `GET /oauth/pin?client_id=...&redirect=<success page>` 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/<user_code>?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/<user_code>` with the same identity.
class SimklAuthService extends DeviceCodeAuthServiceBase {
SimklAuthService({super.httpClient});
@override
Future<DeviceCode> 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<DevicePollEvent> 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(
+107 -8
View File
@@ -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<dynamic> _request(String method, String path, {Map<String, dynamic>? 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<List<SimklTrendingItem>> 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<String, dynamic>) SimklTrendingItem.fromJson(item),
];
}
Future<TraktPage<SimklSearchResult>> 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<String, dynamic>) SimklSearchResult.fromJson(item),
];
return TraktPage.fromResponse(response, items);
}
Future<List<SimklBestItem>> 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<String, dynamic>) SimklBestItem.fromJson(item),
];
}
Future<SimklAllItems> 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<String, dynamic> ? SimklAllItems.fromJson(decoded) : const SimklAllItems();
}
Future<void> addToList(Map<String, dynamic> body) async {
await _request('POST', '/sync/add-to-list', body: body);
}
Future<List<SimklRecommendation>> 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<String, dynamic>) SimklRecommendation.fromJson(item),
];
}
Future<dynamic> _request(
String method,
String path, {
Map<String, dynamic>? body,
Map<String, String>? query,
String? baseOverride,
}) async {
final response = await _requestResponse(method, path, body: body, query: query, baseOverride: baseOverride);
return TrackerHttpClient.decodeJson(response.body);
}
Future<http.Response> _requestResponse(
String method,
String path, {
Map<String, dynamic>? body,
Map<String, String>? 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;
}
}
@@ -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<String, String> queryParameters([Map<String, String>? 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<String, String> headers({String? accessToken}) => {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': '$appName/$appVersion',
'simkl-api-key': clientId,
if (accessToken != null) 'Authorization': 'Bearer $accessToken',
};
+9 -6
View File
@@ -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;
+70
View File
@@ -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');
});
});
}
@@ -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);
});
}
+96
View File
@@ -24,6 +24,7 @@ class _FakeSource implements CatalogSource {
bool gate = false;
final pending = <Completer<CatalogPage>>[];
final fetches = <CatalogRowId, int>{};
final failedRows = <CatalogRowId>{};
CatalogPage _page(CatalogRowId row) => CatalogPage(
items: [
@@ -39,6 +40,7 @@ class _FakeSource implements CatalogSource {
@override
Future<CatalogPage> 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<CatalogPage>();
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>[];
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<List<CatalogHub>> 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<CatalogPage> 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);
});
});
}
+90 -3
View File
@@ -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<List<CatalogHub>> 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<CatalogPage> 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<CatalogSource> sources;
CatalogSourceId? _activeId;
@override
List<CatalogSource> 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<void> 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<ExploreScreenState>(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);
@@ -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<FribbMappingRow> rows;
_FakeFribb(this.rows);
@override
Future<List<FribbMappingRow>> 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<FribbMappingRow?> lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull;
}
Map<String, dynamic> _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': '<b>Humanity</b><br>fights &amp; 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<String, dynamic> data, {int status = 200, Map<String, String>? headers}) =>
http.Response(json.encode({'data': data}), status, headers: {'content-type': 'application/json', ...?headers});
Map<String, dynamic> _requestBody(http.Request request) => json.decode(request.body) as Map<String, dynamic>;
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('<i>A &quot;title&quot;</i><br />B &lt; C &gt; D&nbsp;&#39;x&#39;'),
'A "title"\nB < C > D \'x\'',
);
expect(AnilistMedia.stripHtml('<b></b>'), isNull);
expect(AnilistMedia.stripHtml(null), isNull);
});
});
group('AnilistCatalogSource', () {
late List<http.Request> requests;
late FutureOr<http.Response> 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<String, dynamic>;
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<String, dynamic>;
expect(variables['season'], 'SPRING');
expect(variables['seasonYear'], 2026);
expect(variables['sort'], ['POPULARITY_DESC']);
return _data({
'Page': {
'pageInfo': {'hasNextPage': false},
'media': <Map<String, dynamic>>[],
},
});
};
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<ArgumentError>()));
});
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<String, dynamic>;
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<String, dynamic>;
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<TrackerApiException>()),
);
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<TrackerRateLimitException>()
.having((error) => error.service, 'service', TrackerService.anilist)
.having((error) => error.retryAfterSeconds, 'retryAfterSeconds', 60),
),
);
expect(calls, 1);
});
});
}
@@ -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<String, Object?> _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 = <http.Request>[];
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 = <http.Request>[];
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 = <http.Request>[];
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()] : <Object>[],
},
});
}
expect(request.method, 'PUT');
expect(request.url.path, '/actions/removeFromWatchlist');
expect(request.url.queryParameters['ratingKey'], 'plex-movie-1');
watchlisted = false;
return _json(const <String, Object?>{});
}),
),
);
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 = <http.Request>[];
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 <String, Object?>{});
}),
),
);
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<http.Response>();
final client = PlexDiscoverClient(
_session,
httpClient: MockClient((request) => response.future),
requestTimeout: Duration.zero,
);
addTearDown(client.dispose);
await expectLater(client.getWatchlist(), throwsA(isA<TimeoutException>()));
});
});
}
@@ -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<String, String>? headers}) =>
http.Response(json.encode(body), status, headers: {'content-type': 'application/json', ...?headers});
Map<String, dynamic> _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<String, dynamic> _best(int simkl) => {
'title': 'Best $simkl',
'year': 2020,
'ids': {'simkl': simkl},
};
Map<String, dynamic> _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<http.Request> requests;
late FutureOr<http.Response> 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<TrackerApiException>()));
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 = <Map<String, dynamic>>[];
responder = (request) {
bodies.add(json.decode(request.body) as Map<String, dynamic>);
return _json({
'added': <String, dynamic>{},
'not_found': <String, dynamic>{},
}, 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<String, dynamic>;
expect(added['to'], 'plantowatch');
expect(added['ids'], {'simkl': 1, 'imdb': 'tt1375666', 'tmdb': 27205});
final removed = (bodies.last['movies'] as List).single as Map<String, dynamic>;
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 <String, Object?>{}, 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<TrackerApiException>()),
);
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);
});
});
}