170 lines
6.1 KiB
Dart
170 lines
6.1 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
|
|
import '../../media/media_kind.dart';
|
|
import '../../models/catalog/catalog_cast_member.dart';
|
|
import '../../models/catalog/catalog_item.dart';
|
|
import '../../models/seerr/seerr_details.dart';
|
|
import '../../models/seerr/seerr_media.dart';
|
|
import '../../models/seerr/seerr_page.dart';
|
|
import '../../utils/external_ids.dart';
|
|
import '../seerr/seerr_client.dart';
|
|
import '../seerr/seerr_constants.dart';
|
|
import 'catalog_source.dart';
|
|
|
|
/// [CatalogSource] backed by a Seerr instance's TMDB-based discover API.
|
|
///
|
|
/// Wraps the catalog [SeerrClient] owned by `SeerrAccountProvider` (not owned
|
|
/// here — never disposed by this class). Seerr has no watchlist; its
|
|
/// contribution besides discovery rows is the request flow, which the
|
|
/// request surfaces reach through [client] directly.
|
|
class SeerrCatalogSource implements CatalogSource {
|
|
final SeerrClient client;
|
|
final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier();
|
|
|
|
SeerrCatalogSource(this.client);
|
|
|
|
@override
|
|
CatalogSourceId get id => CatalogSourceId.seerr;
|
|
|
|
@override
|
|
String get displayName => 'Seerr';
|
|
|
|
@override
|
|
List<CatalogRowId> get supportedRows => const [
|
|
CatalogRowId.trending,
|
|
CatalogRowId.popularMovies,
|
|
CatalogRowId.popularShows,
|
|
CatalogRowId.upcomingMovies,
|
|
CatalogRowId.upcomingShows,
|
|
];
|
|
|
|
@override
|
|
bool get supportsWatchlist => false;
|
|
|
|
/// Whether the signed-in user may request titles of [kind] — gates the
|
|
/// detail-screen Request action.
|
|
bool canRequest(MediaKind kind) => seerrHasPermission(client.session.permissions, [
|
|
SeerrPermission.request,
|
|
kind == MediaKind.movie ? SeerrPermission.requestMovie : SeerrPermission.requestTv,
|
|
]);
|
|
|
|
@override
|
|
Listenable get watchlistChanges => _watchlistChanges;
|
|
|
|
/// Seerr pages are a fixed 20 items; [limit] cannot be honored, so callers
|
|
/// get pages of 20 with [CatalogPage.hasMore] from `totalPages`.
|
|
@override
|
|
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async {
|
|
final res = await switch (row) {
|
|
CatalogRowId.trending => client.getTrending(page: page),
|
|
CatalogRowId.popularMovies => client.getPopularMovies(page: page),
|
|
CatalogRowId.popularShows => client.getPopularTv(page: page),
|
|
CatalogRowId.upcomingMovies => client.getUpcomingMovies(page: page),
|
|
CatalogRowId.upcomingShows => client.getUpcomingTv(page: page),
|
|
CatalogRowId.watchlist ||
|
|
CatalogRowId.recommendedMovies ||
|
|
CatalogRowId.recommendedShows ||
|
|
CatalogRowId.trendingMovies ||
|
|
CatalogRowId.trendingShows ||
|
|
CatalogRowId.trendingAnime ||
|
|
CatalogRowId.suggestedAnime ||
|
|
CatalogRowId.airingAnime ||
|
|
CatalogRowId.popularAnime => throw ArgumentError('Seerr does not serve ${row.name}'),
|
|
};
|
|
return _toPage(res);
|
|
}
|
|
|
|
@override
|
|
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
|
|
final trimmed = query.trim();
|
|
if (trimmed.isEmpty) return const [];
|
|
final page = await client.search(trimmed);
|
|
return _toPage(page).items;
|
|
}
|
|
|
|
@override
|
|
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async {
|
|
final tmdbId = item.ids.tmdb;
|
|
if (tmdbId == null) return const [];
|
|
final page = item.kind == MediaKind.movie
|
|
? await client.getMovieRecommendations(tmdbId)
|
|
: await client.getTvRecommendations(tmdbId);
|
|
return _toPage(page).items.take(limit).toList();
|
|
}
|
|
|
|
/// Seerr requests key on TMDB ids, so any library item carrying one is in
|
|
/// scope; the watchlist action stays hidden regardless
|
|
/// ([supportsWatchlist] is false).
|
|
@override
|
|
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async =>
|
|
external.tmdb == null ? null : CatalogItemIds(tmdb: external.tmdb, imdb: external.imdb, tvdb: external.tvdb);
|
|
|
|
@override
|
|
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async {
|
|
final tmdbId = item.ids.tmdb;
|
|
if (tmdbId == null) return const [];
|
|
final SeerrCredits? credits;
|
|
if (item.kind == MediaKind.movie) {
|
|
credits = (await client.getMovie(tmdbId)).credits;
|
|
} else {
|
|
credits = (await client.getTv(tmdbId)).credits;
|
|
}
|
|
return [
|
|
for (final member in (credits?.cast ?? const <SeerrCastMember>[]).take(limit))
|
|
if (member.name case final String name when name.isNotEmpty)
|
|
CatalogCastMember(
|
|
name: name,
|
|
secondary: member.character,
|
|
imageUrl: tmdbImageUrl(member.profilePath, 'w300'),
|
|
),
|
|
];
|
|
}
|
|
|
|
// Seerr has no watchlist: membership is always unknown and mutations are
|
|
// programming errors (the action is hidden when supportsWatchlist is false).
|
|
|
|
@override
|
|
Future<void> ensureWatchlistLoaded() => Future.value();
|
|
|
|
@override
|
|
bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => null;
|
|
|
|
@override
|
|
Future<void> addToWatchlist(MediaKind kind, CatalogItemIds ids) => throw UnsupportedError('Seerr has no watchlist');
|
|
|
|
@override
|
|
Future<void> removeFromWatchlist(MediaKind kind, CatalogItemIds ids) =>
|
|
throw UnsupportedError('Seerr has no watchlist');
|
|
|
|
CatalogPage _toPage(SeerrPage<SeerrMedia> page) => CatalogPage(
|
|
items: [
|
|
for (final m in page.items)
|
|
if (m.displayTitle.isNotEmpty) _toCatalogItem(m),
|
|
],
|
|
hasMore: page.hasMore,
|
|
);
|
|
|
|
CatalogItem _toCatalogItem(SeerrMedia m) => CatalogItem(
|
|
source: CatalogSourceId.seerr,
|
|
kind: m.isMovie ? MediaKind.movie : MediaKind.show,
|
|
title: m.displayTitle,
|
|
year: m.year,
|
|
overview: m.overview,
|
|
rating: m.voteAverage,
|
|
votes: m.voteCount,
|
|
ids: CatalogItemIds(tmdb: m.id),
|
|
posterUrl: tmdbImageUrl(m.posterPath, 'w600_and_h900_bestv2'),
|
|
backdropUrl: tmdbImageUrl(m.backdropPath, 'w1920_and_h800_multi_faces'),
|
|
);
|
|
|
|
/// Seerr serves TMDB relative paths (`/abc.jpg`); images come straight off
|
|
/// the TMDB CDN at the same sizes the Seerr web UI uses.
|
|
static String? tmdbImageUrl(String? path, String size) =>
|
|
path == null || path.isEmpty ? null : 'https://image.tmdb.org/t/p/$size$path';
|
|
|
|
@override
|
|
void dispose() {
|
|
_watchlistChanges.dispose();
|
|
}
|
|
}
|