Permission changes could be confused with expired authentication, and delayed session persistence could cross a profile boundary. Refresh raw authority after route denials and foreground resumes without login or request replay. Fence refreshes to the current binding, serialize persisted session ownership, and reconcile open request surfaces after grants and revocations.
392 lines
16 KiB
Dart
392 lines
16 KiB
Dart
import 'dart:async';
|
|
|
|
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<String?> Function();
|
|
|
|
/// Authenticated Seerr API client, scoped to one [SeerrSession].
|
|
///
|
|
/// When the instance no longer knows the session (see [_probeSession])
|
|
/// 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 within this client binding so a
|
|
/// burst of in-flight rejections triggers a single login POST.
|
|
class SeerrClient {
|
|
final FutureCoalescer<void> _reauth = FutureCoalescer();
|
|
bool _disposed = false;
|
|
int _cookieGeneration = 0;
|
|
int _authorityRead = 0;
|
|
|
|
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 dispose() {
|
|
_disposed = true;
|
|
_http.dispose();
|
|
}
|
|
|
|
// ---------- Auth ----------
|
|
|
|
Future<SeerrUser> getMe() async {
|
|
final data = await _request('GET', '/auth/me');
|
|
return _parseUser(data);
|
|
}
|
|
|
|
/// Re-read the signed-in user and adopt a changed permission mask or
|
|
/// display name.
|
|
///
|
|
/// The session stores both as a sign-in-time snapshot. An admin granting
|
|
/// request rights later, or a session persisted by a build that read the
|
|
/// partial `/auth/local` body as permission-less (#2213), only reaches the
|
|
/// Request action once the snapshot is refreshed — and a silent re-auth,
|
|
/// the only other refresh, never runs while the cookie is still accepted.
|
|
Future<void> refreshUser() async {
|
|
await getMe();
|
|
}
|
|
|
|
/// Adopt a fresh `/auth/me` body: a changed permission mask or display
|
|
/// name reaches the owner through [onSessionUpdated] in place — the client
|
|
/// is never replaced for a capability change.
|
|
void _adoptUser(SeerrUser user, int cookieGeneration, int authorityRead) {
|
|
if (!_isCurrentCookie(cookieGeneration) || authorityRead != _authorityRead) return;
|
|
final displayName = user.displayName ?? _session.displayName;
|
|
if (user.permissions == _session.permissions && displayName == _session.displayName) return;
|
|
_adopt(_session.copyWith(permissions: user.permissions, displayName: displayName));
|
|
}
|
|
|
|
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');
|
|
final settings = SeerrPublicSettings.fromJson(data as Map<String, dynamic>);
|
|
_publicSettingsCache = settings;
|
|
// Every settings fetch re-derives the product discriminator (MediaStatus
|
|
// codes 6/7 decode per product) so legacy sessions persisted before the
|
|
// flag existed, and sessions whose instance changed product, converge.
|
|
if (settings.product != _session.product) _adopt(_session.copyWith(product: settings.product));
|
|
return settings;
|
|
}
|
|
|
|
// ---------- Discover / search ----------
|
|
|
|
/// `/discover/movies` — popular movies.
|
|
///
|
|
/// Deliberately unlocalized. Overseerr and Jellyseerr bind this route's
|
|
/// `language` query parameter to `originalLanguage`, i.e. TMDB's
|
|
/// `with_original_language`, so sending the app locale narrows the shelf to
|
|
/// titles *originally made* in that language (#1763). The display language
|
|
/// here comes from the instance/user locale, which already wins over the
|
|
/// query value, so omitting it costs nothing.
|
|
Future<SeerrPage<SeerrMedia>> getPopularMovies({int page = 1}) =>
|
|
_mediaPage('/discover/movies', page, 'movie', localized: false);
|
|
|
|
/// `/discover/tv` — popular series. Unlocalized for the same reason as
|
|
/// [getPopularMovies].
|
|
Future<SeerrPage<SeerrMedia>> getPopularTv({int page = 1}) =>
|
|
_mediaPage('/discover/tv', page, 'tv', localized: false);
|
|
|
|
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, 'language': _language});
|
|
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');
|
|
|
|
/// [localized] adds the app locale as `language`. Only the two paged
|
|
/// discover routes opt out; everywhere else Seerr treats it as the display
|
|
/// language, which is what we want.
|
|
Future<SeerrPage<SeerrMedia>> _mediaPage(
|
|
String path,
|
|
int page,
|
|
String? coerceMediaType, {
|
|
bool localized = true,
|
|
}) async {
|
|
final data = await _request('GET', path, query: {'page': page, if (localized) 'language': _language});
|
|
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, query: {'language': _language});
|
|
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 ----------
|
|
String get _language => LocaleSettings.currentLocale.plexLanguageCode;
|
|
|
|
Future<dynamic> _request(
|
|
String method,
|
|
String path, {
|
|
Map<String, Object?>? query,
|
|
Map<String, Object?>? body,
|
|
}) async {
|
|
for (var attempt = 0; ; attempt++) {
|
|
_ensureActive();
|
|
final cookieGeneration = _cookieGeneration;
|
|
final authorityRead = path == '/auth/me' ? ++_authorityRead : null;
|
|
final res = await _http.send(method, path, query: query, body: body);
|
|
_ensureActive();
|
|
final rejection = SeerrHttpClient.classify(res, path: path);
|
|
if (rejection == SeerrRejection.routeDenied) {
|
|
// The handler may mean permissions, quota or blocklist. Its error is
|
|
// authoritative; this optional raw read must never log in or replay.
|
|
if (_isCurrentCookie(cookieGeneration) && path != '/auth/me') {
|
|
try {
|
|
await _probeSession(path, cookieGeneration);
|
|
} catch (e) {
|
|
appLogger.d('Seerr: denied-action authority refresh failed', error: e);
|
|
}
|
|
}
|
|
} else if (rejection == SeerrRejection.session) {
|
|
final verdict = await _probeSession(path, cookieGeneration);
|
|
_ensureActive();
|
|
if (verdict == _SessionVerdict.live) throw _permissionDenied(res, path);
|
|
if (verdict == _SessionVerdict.gone || !_isCurrentCookie(cookieGeneration)) {
|
|
if (attempt == 0) {
|
|
if (_isCurrentCookie(cookieGeneration)) {
|
|
try {
|
|
await _reauth.run(() => _doReauth(cookieGeneration));
|
|
} on SeerrAuthException {
|
|
if (_isCurrentCookie(cookieGeneration)) onSessionInvalidated();
|
|
rethrow;
|
|
}
|
|
}
|
|
_ensureActive();
|
|
// Only middleware proves the handler did not execute. At most
|
|
// one replay, including failures arriving for an older cookie.
|
|
continue;
|
|
}
|
|
if (_isCurrentCookie(cookieGeneration) && verdict == _SessionVerdict.gone) {
|
|
onSessionInvalidated();
|
|
throw SeerrAuthException(
|
|
'Session rejected after successful re-auth',
|
|
statusCode: res.statusCode,
|
|
display: t.seerr.sessionRejectedAfterReauth,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
SeerrHttpClient.throwForStatus(res, path: path);
|
|
_ensureActive();
|
|
if (authorityRead != null) _adoptUser(_parseUser(res.data), cookieGeneration, authorityRead);
|
|
return res.data;
|
|
}
|
|
}
|
|
|
|
SeerrPermissionException _permissionDenied(SeerrResponse res, String path) => SeerrPermissionException(
|
|
'Permission denied for $path',
|
|
display: t.seerr.permissionDenied,
|
|
statusCode: res.statusCode,
|
|
);
|
|
|
|
/// Whether a middleware rejection on [path] means the instance no longer
|
|
/// knows the session.
|
|
///
|
|
/// Only Seerr's own middleware rejection ([SeerrRejection.session]) can
|
|
/// mean that, and it carries the same status and body as a permission
|
|
/// denial, so it counts as expiry only once `GET /auth/me` (authenticated,
|
|
/// no permission bits) refuses the cookie the same way. Re-authing on
|
|
/// every 403 would instead unlink a Quick Connect session, which has no
|
|
/// re-auth credentials, over a plain permission denial. Anything an
|
|
/// intermediary answered — on the request or on the probe — is no
|
|
/// rejection at all: the cookie may be fine behind the wall, and a re-auth
|
|
/// would fail the same way and unlink the session.
|
|
///
|
|
/// A probe that answers with the user proves the session live *and*
|
|
/// carries the current permission mask — the very authority the denied
|
|
/// request was gated on — so it is adopted before the caller reports the
|
|
/// denial, and the owner's surfaces re-derive their gates from it.
|
|
Future<_SessionVerdict> _probeSession(String path, int cookieGeneration) async {
|
|
if (!_isCurrentCookie(cookieGeneration)) return _SessionVerdict.inconclusive;
|
|
if (path == '/auth/me') return _SessionVerdict.gone;
|
|
final authorityRead = ++_authorityRead;
|
|
final me = await _http.send('GET', '/auth/me');
|
|
if (!_isCurrentCookie(cookieGeneration)) return _SessionVerdict.inconclusive;
|
|
SeerrHttpClient.throwIfIntermediary(me, path: '/auth/me');
|
|
if (SeerrHttpClient.classify(me, path: '/auth/me') == SeerrRejection.session) return _SessionVerdict.gone;
|
|
if (me.statusCode < 200 || me.statusCode >= 300) return _SessionVerdict.inconclusive;
|
|
final SeerrUser user;
|
|
try {
|
|
user = _parseUser(me.data);
|
|
} on SeerrApiException {
|
|
return _SessionVerdict.inconclusive;
|
|
}
|
|
_adoptUser(user, cookieGeneration, authorityRead);
|
|
return _SessionVerdict.live;
|
|
}
|
|
|
|
SeerrUser _parseUser(dynamic data) {
|
|
if (data is Map<String, dynamic>) {
|
|
try {
|
|
final user = SeerrUser.fromJson(data);
|
|
if (user.id == _session.userId) return user;
|
|
} on TypeError {
|
|
// Malformed authority is not a zero-permission user or session expiry.
|
|
}
|
|
}
|
|
throw SeerrApiException(t.seerr.noUserInformation, statusCode: 200);
|
|
}
|
|
|
|
bool _isCurrentCookie(int generation) => !_disposed && generation == _cookieGeneration;
|
|
|
|
void _ensureActive() {
|
|
if (_disposed) throw StateError('Seerr client is disposed');
|
|
}
|
|
|
|
Future<void> _doReauth(int cookieGeneration) async {
|
|
if (!_isCurrentCookie(cookieGeneration)) return;
|
|
final previous = _session;
|
|
appLogger.d('Seerr: session expired, re-authenticating silently');
|
|
final plexToken = previous.method == SeerrAuthMethod.plex
|
|
? await _resolvePlexToken().timeout(SeerrConstants.authTimeout, onTimeout: () => null)
|
|
: null;
|
|
if (!_isCurrentCookie(cookieGeneration)) return;
|
|
final next = await _auth.reauth(previous, plexToken: plexToken);
|
|
if (!_isCurrentCookie(cookieGeneration)) return;
|
|
// Invalidate every read issued for the old cookie, even if the server
|
|
// happened to issue the same cookie value on the replacement login.
|
|
_cookieGeneration++;
|
|
_adopt(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!();
|
|
|
|
/// Adopt a refreshed session, merging the product discriminator instead of
|
|
/// wholesale-replacing it: a re-auth completes from the snapshot taken when
|
|
/// it started, so a concurrent [getPublicSettings] adoption would otherwise
|
|
/// be overwritten — and the settings cache means it would never be
|
|
/// reapplied. Cached settings are authoritative; failing that, a known
|
|
/// product never downgrades to unknown.
|
|
void _adopt(SeerrSession next) {
|
|
if (_disposed) return;
|
|
final product = _publicSettingsCache?.product ?? _session.product;
|
|
final merged = product == SeerrProduct.unknown || product == next.product ? next : next.copyWith(product: product);
|
|
_session = merged;
|
|
_http.cookie = merged.cookie;
|
|
onSessionUpdated?.call(merged);
|
|
}
|
|
}
|
|
|
|
/// What the `/auth/me` probe said about a middleware-rejected cookie.
|
|
enum _SessionVerdict {
|
|
/// The probe refused the cookie the same way: re-auth.
|
|
gone,
|
|
|
|
/// The probe answered with the user: the rejection was a permission miss.
|
|
live,
|
|
|
|
/// The probe answered with neither (a 5xx, a non-JSON body): report the
|
|
/// original response as the plain API failure it looks like.
|
|
inconclusive,
|
|
}
|