Explore shelf cards drew a poster, a title and a year. An audit of all six catalog sources found the rest was lost at two boundaries — the wire-to-DTO mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the grid card fell through every branch of buildMetadataSubtitle to the year-only case, while the list card used by search already composed certification, runtime and rating from fields the synthesized MediaItem already held. Extend CatalogItem with the neutral facts every provider had been dropping: attributed rating sources, leaderboard ranks that keep their season window, audience counters that keep their timeframe, broadcast slots, next-episode air times, server availability and request state, exact release dates, alternate titles, format, source material, studios, countries, languages, credits, tags, links, artwork variants, play state, gallery art and background prose. Replace fetchCast and fetchRelated with one fetchDetail returning the enriched item, its cast, its recommendations and labelled franchise relations without adding a request: sources needing two calls keep two and run them concurrently with isolated failures. Map those fields in all six sources, widening only field selections that cost no extra round trip — MAL's fields list, AniList's selection set and a bounded row cast that lets detail skip its character call, Trakt's guest stars, Seerr's language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub artwork widens only on TV, where the spotlight is its only consumer, because it doubles the payload. Render them: a rating-first caption and bounded badges on the shelf card, labelled sections on the detail screen, provider hub styles and result counts on shelves, and logo, banner and accent art in the TV spotlight. Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
168 lines
6.1 KiB
Dart
168 lines
6.1 KiB
Dart
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_detail.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';
|
|
import '../tracker_http_client.dart';
|
|
import '../tracker_session.dart';
|
|
import 'simkl_constants.dart';
|
|
|
|
/// HTTP wrapper for the Simkl REST API.
|
|
///
|
|
/// Simkl tokens don't expire; a 401 is terminal (user revoked access at
|
|
/// simkl.com/settings/apps). [onSessionInvalidated] clears the local session
|
|
/// in that case.
|
|
class SimklClient implements DisposableTrackerClient {
|
|
final TrackerSession session;
|
|
final TrackerHttpClient _http;
|
|
final void Function() onSessionInvalidated;
|
|
|
|
SimklClient(this.session, {required this.onSessionInvalidated, http.Client? httpClient})
|
|
: _http = TrackerHttpClient(service: TrackerService.simkl, logLabel: 'Simkl', httpClient: httpClient);
|
|
|
|
@override
|
|
void dispose() => _http.dispose();
|
|
|
|
/// Fetch current user info. Used to populate the display name.
|
|
Future<Map<String, dynamic>?> getUserSettings() async {
|
|
final res = await _request('GET', '/users/settings');
|
|
return res is Map ? res.cast<String, dynamic>() : null;
|
|
}
|
|
|
|
/// Mark one or more items as watched. Body shape:
|
|
/// ```
|
|
/// {"movies": [{"ids": {"simkl": 123}}], "shows": [...]}
|
|
/// ```
|
|
Future<void> addToHistory(Map<String, dynamic> body) => _request('POST', '/sync/history', body: body);
|
|
|
|
Future<void> removeFromHistory(Map<String, dynamic> body) => _request('POST', '/sync/history/remove', body: body);
|
|
|
|
Future<void> addRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings', body: body);
|
|
|
|
Future<void> removeRatings(Map<String, dynamic> body) => _request('POST', '/sync/ratings/remove', body: body);
|
|
|
|
Future<List<dynamic>> getRatings(String type) async {
|
|
final res = await _request('GET', '/sync/ratings/$type');
|
|
if (res is List) return res;
|
|
if (res is Map && res[type] is List) return res[type] as List<dynamic>;
|
|
return const [];
|
|
}
|
|
|
|
// --- Catalog endpoints (Explore tab) ---
|
|
|
|
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<SimklDetail?> getDetail(SimklCatalogType urlType, int simklId) async {
|
|
final decoded = await _request('GET', '/${urlType.detailPath}/$simklId');
|
|
return decoded is Map<String, dynamic> ? SimklDetail.fromJson(decoded) : null;
|
|
}
|
|
|
|
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'},
|
|
);
|
|
|
|
// Only the authenticated host may invalidate: the data host is called
|
|
// without a token, so its 401s say nothing about the session.
|
|
if (mainApiHost && response.statusCode == 401) {
|
|
onSessionInvalidated();
|
|
throw const TrackerAuthException(
|
|
service: TrackerService.simkl,
|
|
message: 'Session invalidated (401)',
|
|
statusCode: 401,
|
|
isPermanent: true,
|
|
);
|
|
}
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
throw TrackerApiException(service: TrackerService.simkl, statusCode: response.statusCode);
|
|
}
|
|
return response;
|
|
}
|
|
}
|