feat(explore): add AniList, Simkl, and Plex catalogs
This commit is contained in:
@@ -108,6 +108,8 @@
|
||||
"libraryDensity": "Плътност на библиотеката",
|
||||
"compact": "Компактна",
|
||||
"comfortable": "Удобна",
|
||||
"tvCornerSpotlightBackdrop": "",
|
||||
"tvCornerSpotlightBackdropDescription": "",
|
||||
"viewMode": "Режим на изглед",
|
||||
"gridView": "Мрежа",
|
||||
"listView": "Списък",
|
||||
@@ -872,6 +874,7 @@
|
||||
"trendingShows": "Актуални сериали",
|
||||
"popularMovies": "Популярни филми",
|
||||
"popularShows": "Популярни сериали",
|
||||
"trendingAnime": "Актуални аниме",
|
||||
"suggestedAnime": "Препоръчани аниме",
|
||||
"airingAnime": "Топ излъчвани аниме",
|
||||
"popularAnime": "Най-популярни аниме",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -108,6 +108,8 @@
|
||||
"libraryDensity": "ライブラリの密度",
|
||||
"compact": "コンパクト",
|
||||
"comfortable": "ゆったり",
|
||||
"tvCornerSpotlightBackdrop": "",
|
||||
"tvCornerSpotlightBackdropDescription": "",
|
||||
"viewMode": "表示モード",
|
||||
"gridView": "グリッド",
|
||||
"listView": "リスト",
|
||||
@@ -871,6 +873,7 @@
|
||||
"trendingShows": "トレンドのテレビ番組",
|
||||
"popularMovies": "人気の映画",
|
||||
"popularShows": "人気のテレビ番組",
|
||||
"trendingAnime": "トレンドのアニメ",
|
||||
"suggestedAnime": "おすすめのアニメ",
|
||||
"airingAnime": "放送中の注目アニメ",
|
||||
"popularAnime": "人気のアニメ",
|
||||
|
||||
@@ -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": "가장 인기 있는 애니메이션",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -108,6 +108,8 @@
|
||||
"libraryDensity": "Плотность библиотеки",
|
||||
"compact": "Компактный",
|
||||
"comfortable": "Комфортный",
|
||||
"tvCornerSpotlightBackdrop": "",
|
||||
"tvCornerSpotlightBackdropDescription": "",
|
||||
"viewMode": "Режим просмотра",
|
||||
"gridView": "Сетка",
|
||||
"listView": "Список",
|
||||
@@ -874,6 +876,7 @@
|
||||
"trendingShows": "Сериалы в тренде",
|
||||
"popularMovies": "Популярные фильмы",
|
||||
"popularShows": "Популярные сериалы",
|
||||
"trendingAnime": "Аниме в тренде",
|
||||
"suggestedAnime": "Рекомендуемые аниме",
|
||||
"airingAnime": "Топ выходящих аниме",
|
||||
"popularAnime": "Самые популярные аниме",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} песни в опашката за изтегляне',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} 曲をダウンロード待機中',
|
||||
|
||||
@@ -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}곡 다운로드 대기 중',
|
||||
|
||||
@@ -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ø',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} треков в очереди на загрузку',
|
||||
|
||||
@@ -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ö',
|
||||
|
||||
@@ -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} 首曲目已加入下载队列',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -108,6 +108,8 @@
|
||||
"libraryDensity": "媒体库密度",
|
||||
"compact": "紧凑",
|
||||
"comfortable": "舒适",
|
||||
"tvCornerSpotlightBackdrop": "",
|
||||
"tvCornerSpotlightBackdropDescription": "",
|
||||
"viewMode": "视图模式",
|
||||
"gridView": "网格视图",
|
||||
"listView": "列表视图",
|
||||
@@ -871,6 +873,7 @@
|
||||
"trendingShows": "趋势剧集",
|
||||
"popularMovies": "热门电影",
|
||||
"popularShows": "热门剧集",
|
||||
"trendingAnime": "热门动画",
|
||||
"suggestedAnime": "推荐动画",
|
||||
"airingAnime": "热门连载动画",
|
||||
"popularAnime": "最受欢迎动画",
|
||||
|
||||
@@ -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(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'")
|
||||
.trim();
|
||||
return plain.isEmpty ? null : plain;
|
||||
}
|
||||
|
||||
static String? _nonEmpty(String? value) => value == null || value.isEmpty ? null : value;
|
||||
|
||||
static List<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});
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>),
|
||||
);
|
||||
@@ -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>),
|
||||
);
|
||||
@@ -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>),
|
||||
);
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user