Files
plezy/lib/models/seerr/seerr_session.dart
edde746 595cd9fe24 feat(seerr): sign in with quick connect and accept schemeless urls
Linking a Seerr instance had two rough edges, both on the Jellyfin path.
The URL step prepended https:// to anything without a scheme, so a
plain-HTTP instance on the LAN — 192.168.1.5:5055, or a bare host on
Seerr's default port — failed with "could not reach" unless the user knew
to type the scheme. And the only way to sign in with a Jellyfin account
was typing a username and password, which on a TV remote is misery.

Schemeless input now expands into candidates that are probed together,
but TLS wins by construction: a plaintext success is held while any https
candidate is still in flight and is accepted only once they have all
failed. The sign-in that follows posts a password to whichever URL wins
here, so a slow-but-working https endpoint has to beat a fast plaintext
one, and the wait is already bounded by the probe timeout. When nothing
answers, the failure that reached a server outranks a transport error, so
"this instance has not completed first-run setup" is not masked by "could
not reach https://..." from a candidate the user never typed.

Quick Connect goes through Seerr's own proxy routes (3.4+): initiate,
then check polled at Seerr's own 2s cadence through the shared
pollWithBackoff — a 404 mid-poll means the secret is gone and is terminal
— then authenticate, which mints the session cookie through the existing
sign-in path. The secret rides in a query string, so it is registered for
log redaction the way the Jellyfin flow registers its own. An instance
that predates the routes 404s the initiate and is told apart from a
generic rejection.

SeerrAuthMethod.quickConnect deliberately stores no secret: silent
re-auth is impossible by construction, so an expired cookie lands in the
existing "no stored credentials" arm, unlinks the session, and the
connect flow asks for a fresh code. The affordance is limited to the
Jellyfin credential form, since Seerr rejects Quick Connect for Emby, and
is not auto-started on TV the way the MediaBrowser form is:
/settings/public exposes no "Quick Connect enabled" flag to gate that on.

The waiting panel and its attempt/cancel bookkeeping moved out of
AddJellyfinScreen into QuickConnectCodePanel and QuickConnectFlowMixin so
both screens share one implementation, and the post-frame focus helper
both forms use moved to AsyncFormStateMixin.
2026-08-25 03:07:14 +02:00

150 lines
5.2 KiB
Dart

import 'dart:convert';
/// How the session was established — determines how a silent re-login is
/// performed when the server-side session expires.
enum SeerrAuthMethod {
/// `POST /auth/plex` with the profile's Plex account token (read live at
/// re-auth time, never copied into the session).
plex,
/// `POST /auth/jellyfin` with stored username/password, serverType 2.
jellyfin,
/// `POST /auth/jellyfin` with stored username/password, serverType 3.
emby,
/// `POST /auth/local` with stored email/password.
local,
/// `POST /auth/jellyfin/quickconnect/authenticate` (Seerr 3.4+). Stores no
/// secret on purpose: re-approval needs the user in front of Jellyfin, so an
/// expired cookie lands in the "no stored credentials" arm of `reauth`,
/// unlinks the session, and the connect flow asks for a fresh code.
quickConnect,
}
/// Which Seerr product the instance runs. The two products disagree on
/// `MediaStatus` wire codes 6/7 (see `SeerrMediaStatus.resolve`), so decoding
/// needs this context.
///
/// Derived from the *presence* of `mediaServerType` in `/settings/public`:
/// Jellyseerr/Seerr always send a numeric value (4 = NOT_CONFIGURED even
/// before setup), while Overseerr's `FullPublicSettings` has no such key.
/// Titles, URLs, and version strings are user-editable and untrustworthy.
enum SeerrProduct {
overseerr,
jellyseerr,
/// Sessions persisted before the discriminator existed. Codes 6/7 decode
/// to `blocklisted` (not available, not requestable — safe under either
/// product) until the next `/settings/public` fetch refreshes the flag.
unknown,
}
/// An authenticated Seerr session for one profile: instance URL, the Express
/// session cookie, the credentials needed to re-login silently, and the
/// Seerr-side user it maps to.
///
/// [secret] is the plaintext password while in memory; the store protects it
/// with CredentialVault before persisting. Empty for [SeerrAuthMethod.plex]
/// and after an unrecoverable decrypt failure (session then lives until the
/// cookie expires and the user must reconnect).
class SeerrSession {
final String baseUrl;
final SeerrAuthMethod method;
/// Username (jellyfin/emby) or email (local); empty for plex.
final String identifier;
final String secret;
/// `connect.sid` cookie value.
final String cookie;
final int userId;
/// Seerr permission bitmask — see `SeerrPermission`.
final int permissions;
final String displayName;
/// Instance `applicationTitle` from `/settings/public`.
final String instanceLabel;
/// Product discriminator captured from `/settings/public`; [SeerrProduct.unknown]
/// for sessions persisted before it existed, until a settings fetch
/// refreshes it.
final SeerrProduct product;
final int createdAt;
const SeerrSession({
required this.baseUrl,
required this.method,
required this.identifier,
required this.secret,
required this.cookie,
required this.userId,
required this.permissions,
required this.displayName,
required this.instanceLabel,
this.product = SeerrProduct.unknown,
required this.createdAt,
});
SeerrSession copyWith({
String? secret,
String? cookie,
int? permissions,
String? displayName,
String? instanceLabel,
SeerrProduct? product,
}) => SeerrSession(
baseUrl: baseUrl,
method: method,
identifier: identifier,
secret: secret ?? this.secret,
cookie: cookie ?? this.cookie,
userId: userId,
permissions: permissions ?? this.permissions,
displayName: displayName ?? this.displayName,
instanceLabel: instanceLabel ?? this.instanceLabel,
product: product ?? this.product,
createdAt: createdAt,
);
Map<String, Object?> toJson() => {
'base_url': baseUrl,
'method': method.name,
'identifier': identifier,
'secret': secret,
'cookie': cookie,
'user_id': userId,
'permissions': permissions,
'display_name': displayName,
'instance_label': instanceLabel,
'product': product.name,
'created_at': createdAt,
};
factory SeerrSession.fromJson(Map<String, Object?> json) => SeerrSession(
baseUrl: json['base_url'] as String,
// An unknown method must not fall back to another (re-auth would post
// garbage credentials); the store's decode try/catch drops the session.
method:
SeerrAuthMethod.values.asNameMap()[json['method']] ??
(throw ArgumentError('Unknown Seerr auth method: ${json['method']}')),
identifier: json['identifier'] as String? ?? '',
secret: json['secret'] as String? ?? '',
cookie: json['cookie'] as String? ?? '',
userId: (json['user_id'] as num).toInt(),
permissions: (json['permissions'] as num?)?.toInt() ?? 0,
displayName: json['display_name'] as String? ?? '',
instanceLabel: json['instance_label'] as String? ?? '',
// Legacy sessions predate the discriminator: decode conservatively as
// unknown; the next /settings/public fetch refreshes and persists it.
product: SeerrProduct.values.asNameMap()[json['product']] ?? SeerrProduct.unknown,
createdAt: (json['created_at'] as num?)?.toInt() ?? 0,
);
String encode() => jsonEncode(toJson());
static SeerrSession decode(String raw) => SeerrSession.fromJson((jsonDecode(raw) as Map).cast<String, Object?>());
}