Focus chrome was implemented twice, once in the focusable wrapper and once in the focus builders; both now go through FocusChrome. TvColorPicker's channel row was a copy of TvNumberSpinner and is now that widget in compact density. Also trims unused helpers and fields and simplifies the Jellyfin browse paths.
231 lines
9.0 KiB
Dart
231 lines
9.0 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
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<String?> 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<String, SeerrSession> _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<SeerrUser> getMe() async {
|
|
final data = await _request('GET', '/auth/me');
|
|
return SeerrUser.fromJson(data as Map<String, dynamic>);
|
|
}
|
|
|
|
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<SeerrPublicSettings> getPublicSettings() async {
|
|
if (_publicSettingsCache case final SeerrPublicSettings cached) return cached;
|
|
final data = await _request('GET', '/settings/public');
|
|
return _publicSettingsCache = SeerrPublicSettings.fromJson(data as Map<String, dynamic>);
|
|
}
|
|
|
|
// ---------- Discover / search ----------
|
|
|
|
/// `/discover/movies` — popular movies.
|
|
Future<SeerrPage<SeerrMedia>> getPopularMovies({int page = 1}) => _mediaPage('/discover/movies', page, 'movie');
|
|
|
|
/// `/discover/tv` — popular series.
|
|
Future<SeerrPage<SeerrMedia>> getPopularTv({int page = 1}) => _mediaPage('/discover/tv', page, 'tv');
|
|
|
|
Future<SeerrPage<SeerrMedia>> getUpcomingMovies({int page = 1}) =>
|
|
_mediaPage('/discover/movies/upcoming', page, 'movie');
|
|
|
|
Future<SeerrPage<SeerrMedia>> getUpcomingTv({int page = 1}) => _mediaPage('/discover/tv/upcoming', page, 'tv');
|
|
|
|
/// `/discover/trending` — mixed movies/TV/people; person entries are
|
|
/// dropped.
|
|
Future<SeerrPage<SeerrMedia>> getTrending({int page = 1}) => _mediaPage('/discover/trending', page, null);
|
|
|
|
/// `/search` — Seerr's TMDB-backed catalog search (mixed results, person
|
|
/// entries dropped).
|
|
Future<SeerrPage<SeerrMedia>> search(String query, {int page = 1}) async {
|
|
final data = await _request('GET', '/search', query: {'query': query, 'page': page});
|
|
return _parseMediaPage(data, null);
|
|
}
|
|
|
|
/// TMDB "more like this" for a title; items lack `mediaType` like the
|
|
/// single-type discover endpoints.
|
|
Future<SeerrPage<SeerrMedia>> getMovieRecommendations(int tmdbId, {int page = 1}) =>
|
|
_mediaPage('/movie/$tmdbId/recommendations', page, 'movie');
|
|
|
|
Future<SeerrPage<SeerrMedia>> getTvRecommendations(int tmdbId, {int page = 1}) =>
|
|
_mediaPage('/tv/$tmdbId/recommendations', page, 'tv');
|
|
|
|
Future<SeerrPage<SeerrMedia>> _mediaPage(String path, int page, String? coerceMediaType) async {
|
|
final data = await _request('GET', path, query: {'page': page});
|
|
return _parseMediaPage(data, coerceMediaType);
|
|
}
|
|
|
|
SeerrPage<SeerrMedia> _parseMediaPage(dynamic data, String? coerceMediaType) {
|
|
return SeerrPage<SeerrMedia>.fromJson(data as Map<String, dynamic>, (item) {
|
|
final mediaType = item['mediaType'] as String? ?? coerceMediaType;
|
|
if (mediaType != 'movie' && mediaType != 'tv') return null;
|
|
return SeerrMedia.fromJson({...item, 'mediaType': mediaType});
|
|
});
|
|
}
|
|
|
|
// ---------- Details ----------
|
|
|
|
Future<SeerrDetails> getMovie(int tmdbId) => _details('/movie/$tmdbId');
|
|
|
|
Future<SeerrDetails> getTv(int tmdbId) => _details('/tv/$tmdbId');
|
|
|
|
Future<SeerrDetails> _details(String path) async {
|
|
final data = await _request('GET', path);
|
|
return SeerrDetails.fromJson(data as Map<String, dynamic>);
|
|
}
|
|
|
|
// ---------- Requests ----------
|
|
|
|
Future<SeerrRequest> createRequest(SeerrRequestPayload payload) async {
|
|
final data = await _request('POST', '/request', body: payload.toJson());
|
|
return SeerrRequest.fromJson(data as Map<String, dynamic>);
|
|
}
|
|
|
|
// ---------- Sonarr / Radarr options (request sheet advanced pickers) ----------
|
|
|
|
Future<List<SeerrServiceInstance>> getRadarrServices() => _serviceList('/service/radarr');
|
|
|
|
Future<List<SeerrServiceInstance>> getSonarrServices() => _serviceList('/service/sonarr');
|
|
|
|
Future<SeerrServiceDetail> getRadarrService(int id) => _serviceDetail('/service/radarr/$id');
|
|
|
|
Future<SeerrServiceDetail> getSonarrService(int id) => _serviceDetail('/service/sonarr/$id');
|
|
|
|
Future<List<SeerrServiceInstance>> _serviceList(String path) async {
|
|
final data = await _request('GET', path);
|
|
return [
|
|
if (data is List)
|
|
for (final item in data)
|
|
if (item is Map<String, dynamic>) SeerrServiceInstance.fromJson(item),
|
|
];
|
|
}
|
|
|
|
Future<SeerrServiceDetail> _serviceDetail(String path) async {
|
|
final data = await _request('GET', path);
|
|
return SeerrServiceDetail.fromJson(data as Map<String, dynamic>);
|
|
}
|
|
|
|
// ---------- Internals ----------
|
|
|
|
Future<dynamic> _request(
|
|
String method,
|
|
String path, {
|
|
Map<String, Object?>? query,
|
|
Map<String, Object?>? 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<void> _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<SeerrSession> _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<String?>` type: calling `.timeout(onTimeout: () =>
|
|
/// null)` directly on the supplier's future trips the covariant-generics
|
|
/// runtime check when a caller hands us a `Future<String> Function()`.
|
|
Future<String?> _resolvePlexToken() async => plexTokenSupplier == null ? null : await plexTokenSupplier!();
|
|
|
|
void _adopt(SeerrSession next) {
|
|
updateSession(next);
|
|
onSessionUpdated?.call(next);
|
|
}
|
|
}
|