import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../../i18n/app_locale_utils.dart'; import '../../i18n/strings.g.dart'; import '../../models/seerr/seerr_details.dart'; import '../../models/seerr/seerr_media.dart'; import '../../models/seerr/seerr_page.dart'; import '../../models/seerr/seerr_public_settings.dart'; import '../../models/seerr/seerr_request.dart'; import '../../models/seerr/seerr_service.dart'; import '../../models/seerr/seerr_session.dart'; import '../../models/seerr/seerr_user.dart'; import '../../utils/app_logger.dart'; import '../trackers/future_coalescer.dart'; import 'seerr_auth_service.dart'; import 'seerr_constants.dart'; import 'seerr_exceptions.dart'; import 'seerr_http_client.dart'; /// Supplies the profile's current Plex account token at silent-re-auth time, /// so plex-method sessions never store a token copy that could go stale. typedef SeerrPlexTokenSupplier = Future Function(); /// Authenticated Seerr API client, scoped to one [SeerrSession]. /// /// On 401 it re-logins silently via [SeerrAuthService.reauth] (password /// methods use the stored secret; plex uses [plexTokenSupplier]), swaps the /// cookie, and retries once. Concurrent re-auths coalesce per instance+user /// so a burst of in-flight 401s triggers a single login POST — the same /// shape as `TraktClient._refreshesByToken`. class SeerrClient { static final KeyedFutureCoalescer _reauthsByIdentity = KeyedFutureCoalescer(); SeerrSession _session; final SeerrHttpClient _http; final SeerrAuthService _auth; final SeerrPlexTokenSupplier? plexTokenSupplier; /// Fired when re-auth fails permanently (rejected credentials, no stored /// secret). The owning provider clears local state. final void Function() onSessionInvalidated; /// Fired when re-auth succeeds with a fresh cookie so the owner persists it. final void Function(SeerrSession session)? onSessionUpdated; SeerrClient( SeerrSession session, { required this.onSessionInvalidated, this.onSessionUpdated, this.plexTokenSupplier, SeerrAuthService? authService, http.Client? httpClient, }) : _session = session, _http = SeerrHttpClient(baseUrl: session.baseUrl, httpClient: httpClient, cookie: session.cookie), _auth = authService ?? SeerrAuthService(); SeerrSession get session => _session; void updateSession(SeerrSession session) { _session = session; _http.cookie = session.cookie; } void dispose() => _http.dispose(); // ---------- Auth ---------- @visibleForTesting Future getMe() async { final data = await _request('GET', '/auth/me'); return SeerrUser.fromJson(data as Map); } SeerrPublicSettings? _publicSettingsCache; /// Instance flags the request sheet gates on (4K enablement, partial /// requests). Cached for the client's lifetime — admins change these /// rarely and a new client is built per session rebind anyway. Future getPublicSettings() async { if (_publicSettingsCache case final SeerrPublicSettings cached) return cached; final data = await _request('GET', '/settings/public'); return _publicSettingsCache = SeerrPublicSettings.fromJson(data as Map); } // ---------- Discover / search ---------- /// `/discover/movies` — popular movies. Future> getPopularMovies({int page = 1}) => _mediaPage('/discover/movies', page, 'movie'); /// `/discover/tv` — popular series. Future> getPopularTv({int page = 1}) => _mediaPage('/discover/tv', page, 'tv'); Future> getUpcomingMovies({int page = 1}) => _mediaPage('/discover/movies/upcoming', page, 'movie'); Future> getUpcomingTv({int page = 1}) => _mediaPage('/discover/tv/upcoming', page, 'tv'); /// `/discover/trending` — mixed movies/TV/people; person entries are /// dropped. Future> getTrending({int page = 1}) => _mediaPage('/discover/trending', page, null); /// `/search` — Seerr's TMDB-backed catalog search (mixed results, person /// entries dropped). Future> search(String query, {int page = 1}) async { final data = await _request('GET', '/search', query: {'query': query, 'page': page, 'language': _language}); return _parseMediaPage(data, null); } /// TMDB "more like this" for a title; items lack `mediaType` like the /// single-type discover endpoints. Future> getMovieRecommendations(int tmdbId, {int page = 1}) => _mediaPage('/movie/$tmdbId/recommendations', page, 'movie'); Future> getTvRecommendations(int tmdbId, {int page = 1}) => _mediaPage('/tv/$tmdbId/recommendations', page, 'tv'); Future> _mediaPage(String path, int page, String? coerceMediaType) async { final data = await _request('GET', path, query: {'page': page, 'language': _language}); return _parseMediaPage(data, coerceMediaType); } SeerrPage _parseMediaPage(dynamic data, String? coerceMediaType) { return SeerrPage.fromJson(data as Map, (item) { final mediaType = item['mediaType'] as String? ?? coerceMediaType; if (mediaType != 'movie' && mediaType != 'tv') return null; return SeerrMedia.fromJson({...item, 'mediaType': mediaType}); }); } // ---------- Details ---------- Future getMovie(int tmdbId) => _details('/movie/$tmdbId'); Future getTv(int tmdbId) => _details('/tv/$tmdbId'); Future _details(String path) async { final data = await _request('GET', path, query: {'language': _language}); return SeerrDetails.fromJson(data as Map); } // ---------- Requests ---------- Future createRequest(SeerrRequestPayload payload) async { final data = await _request('POST', '/request', body: payload.toJson()); return SeerrRequest.fromJson(data as Map); } // ---------- Sonarr / Radarr options (request sheet advanced pickers) ---------- Future> getRadarrServices() => _serviceList('/service/radarr'); Future> getSonarrServices() => _serviceList('/service/sonarr'); Future getRadarrService(int id) => _serviceDetail('/service/radarr/$id'); Future getSonarrService(int id) => _serviceDetail('/service/sonarr/$id'); Future> _serviceList(String path) async { final data = await _request('GET', path); return [ if (data is List) for (final item in data) if (item is Map) SeerrServiceInstance.fromJson(item), ]; } Future _serviceDetail(String path) async { final data = await _request('GET', path); return SeerrServiceDetail.fromJson(data as Map); } // ---------- Internals ---------- String get _language => LocaleSettings.currentLocale.plexLanguageCode; Future _request( String method, String path, { Map? query, Map? body, }) async { var res = await _http.send(method, path, query: query, body: body); if (res.statusCode == 401) { try { await _reauthCoalesced(); } on SeerrAuthException { onSessionInvalidated(); rethrow; } res = await _http.send(method, path, query: query, body: body); if (res.statusCode == 401) { onSessionInvalidated(); throw const SeerrAuthException('Session rejected after successful re-auth', statusCode: 401); } } SeerrHttpClient.throwForStatus(res); return res.data; } Future _reauthCoalesced() async { final identity = '${_session.baseUrl}#${_session.userId}'; final next = await _reauthsByIdentity.run(identity, _doReauth); // No-op for the initiating client (_doReauth adopted already); joiners // sharing the identity pick up the fresh cookie here. if (next.cookie != _session.cookie) _adopt(next); } Future _doReauth() async { appLogger.d('Seerr: session expired, re-authenticating silently'); // The supplier reaches into profile/registry state with no timeout of // its own; unbounded, a hang here would park the coalesced future in // _reauthsByIdentity forever and wedge every future re-auth for this // identity. A null token maps to a retryable SeerrReauthUnavailable. final plexToken = _session.method == SeerrAuthMethod.plex ? await _resolvePlexToken().timeout(SeerrConstants.authTimeout, onTimeout: () => null) : null; final next = await _auth.reauth(_session, plexToken: plexToken); _adopt(next); return next; } /// Owns the `Future` type: calling `.timeout(onTimeout: () => /// null)` directly on the supplier's future trips the covariant-generics /// runtime check when a caller hands us a `Future Function()`. Future _resolvePlexToken() async => plexTokenSupplier == null ? null : await plexTokenSupplier!(); void _adopt(SeerrSession next) { updateSession(next); onSessionUpdated?.call(next); } }