Files
plezy/lib/screens/auth_screen.dart
edde746 05fd622968 feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.

Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.

Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
  `/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
  forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
  the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
  ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.

Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
  from list rows unless named in `Fields`, which would otherwise strip the year
  and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
  `EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
  detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
  requested. Without it every recency-ordered surface silently degrades to
  library-add time, and `JellyfinApiCache.applyWatchState` stamps
  `DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
  the cached play time of everything it walked.

Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
  returns nothing under every parameter combination tried. The shelf is
  therefore reconstructed from a played-episode recency scan plus one
  `/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
  that covers the scan as well — per-request timeouts cannot bound the pass
  because `MediaServerHttpClient` times the connect and receive phases
  independently. Rows are stamped with their series' newest play from the same
  response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
  filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
  ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
  unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
  episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
  makes Continue Watching removal a real capability.

Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
  `PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
  reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
  plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
  for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
  and intro/credit markers fall back to chapter names.

Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
2026-08-05 06:09:26 +02:00

580 lines
21 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connection/connection.dart';
import '../connection/connection_registry.dart';
import '../connection/plex_account_setup.dart';
import '../database/app_database.dart';
import '../mixins/controller_disposer_mixin.dart';
import '../profiles/active_profile_binder.dart';
import '../profiles/active_profile_provider.dart';
import '../profiles/plex_home_service.dart';
import '../profiles/profile.dart';
import '../profiles/profile_connection_registry.dart';
import '../profiles/profile_selection_policy.dart';
import '../services/plex_auth_service.dart';
import '../services/settings_service.dart';
import '../services/storage_service.dart';
import '../providers/user_profile_provider.dart';
import '../i18n/strings.g.dart';
import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import '../focus/key_event_utils.dart';
import '../media/media_backend.dart';
import '../media/media_browser_dialect.dart';
import '../navigation/profile_session_screen.dart';
import '../utils/navigation_transitions.dart';
import '../widgets/backend_badge.dart';
import '../widgets/dialog_action_button.dart';
import 'auth/plex_pin_auth_flow.dart';
import 'profile/profile_switch_screen.dart';
import 'settings/add_jellyfin_screen.dart';
class AuthScreen extends StatefulWidget {
const AuthScreen({
super.key,
this.initialErrorMessage,
this.initializeServices = true,
this.databaseRecoveryRequired = false,
});
final String? initialErrorMessage;
final bool initializeServices;
final bool databaseRecoveryRequired;
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
bool _isAuthenticating = false;
String? _errorMessage;
// Reuse a one-shot service for the debug-token verify path; the Plex
// PIN/QR flow inside [PlexPinAuthFlow] owns its own service instance.
PlexAuthService? _verifyOnlyService;
Future<void>? _recoveryAcknowledgement;
bool _recoveryAcknowledged = false;
@override
void initState() {
super.initState();
_errorMessage = widget.initialErrorMessage;
// Debug-token verification only — release builds must not hold an idle
// auth service (and its HTTP client) for a dialog that can't open.
if (kDebugMode && widget.initializeServices) unawaited(_initVerifyService());
}
Future<void> _initVerifyService() async {
final svc = await PlexAuthService.create();
if (!mounted) {
svc.dispose();
return;
}
setState(() => _verifyOnlyService = svc);
}
@override
void dispose() {
_verifyOnlyService?.dispose();
super.dispose();
}
/// Auto-select the active profile after sign-in *only* when there's a
/// single Plex Home user — there's no choice for the user to make. With
/// multiple Home users (the "real" Home case) we leave the active id
/// unset so [MainScreen] forces the picker before the binder runs,
/// avoiding a surprise PIN prompt on whichever user we'd otherwise
/// pre-select.
Future<void> _selectInitialProfile(
PlexHomeService plexHome,
ActiveProfileProvider activeProfiles,
PlexAccountConnection accountConn,
) async {
await activeProfiles.initialize();
final profile = initialPlexHomeProfileFromCache(plexHome, accountConn);
if (profile == null) {
await activeProfiles.clearActiveProfile();
return;
}
await activeProfiles.activate(profile);
}
Future<bool> _prepareDatabaseRecoveryForSignIn() async {
if (!widget.databaseRecoveryRequired || _recoveryAcknowledged) return true;
final acknowledgement = _recoveryAcknowledgement ??= context
.read<AppDatabase>()
.acknowledgeTvosDatabaseRecoveryRequired();
try {
await acknowledgement;
_recoveryAcknowledged = true;
if (mounted) setState(() => _errorMessage = null);
return mounted;
} catch (_) {
_recoveryAcknowledgement = null;
if (mounted) setState(() => _errorMessage = t.auth.localDataRecoveryRequired);
return false;
}
}
Future<void> _startPlexAfterRecovery(VoidCallback start) async {
if (await _prepareDatabaseRecoveryForSignIn()) start();
}
/// Persist the new Plex account into the connection pipeline, resolve the
/// initial active profile when possible, and navigate to the main screen.
/// The top-level [ActiveProfileBinder] picks up the active profile id and
/// connects servers via [MultiServerManager.refreshTokensForProfile].
Future<void> _connectToAllServersAndNavigate(String plexToken) async {
if (!await _prepareDatabaseRecoveryForSignIn()) return;
if (!mounted) return;
setState(() {
_isAuthenticating = true;
_errorMessage = null;
});
final connectionRegistry = context.read<ConnectionRegistry>();
final profileConnections = context.read<ProfileConnectionRegistry>();
final plexHome = context.read<PlexHomeService>();
try {
final storage = await StorageService.getInstance();
final registration = await registerPlexAccountFromToken(
token: plexToken,
connections: connectionRegistry,
profileConnections: profileConnections,
storage: storage,
plexHome: plexHome,
);
final accountConnection = registration.connection;
if (accountConnection.servers.isEmpty) {
// Nothing to roll back — the registered account row is exactly what
// a retry will upsert over; wiping global credentials here would
// also nuke the device identity and every server-endpoint cache.
if (!mounted) return;
setState(() {
_isAuthenticating = false;
_errorMessage = t.serverSelection.noServersFoundForAccount(
username: registration.username,
email: registration.email,
);
});
return;
}
if (!registration.homeUsersFetched && plexHome.current[accountConnection.id] == null) {
// A failed home-user fetch is NOT "multiple users, let them pick":
// with no home users and no locals there is no profile to select,
// and navigating lands in a dead session. Surface a retry instead.
if (!mounted) return;
setState(() {
_isAuthenticating = false;
_errorMessage = t.profiles.failedToLoadHomeUsers;
});
return;
}
if (!mounted) return;
final activeProfiles = context.read<ActiveProfileProvider>();
await _selectInitialProfile(plexHome, activeProfiles, accountConnection);
if (!mounted) return;
// Start the binder before the picker/MainScreen, mirroring the
// cold-start SetupScreen ordering. On a fresh install SetupScreen
// routes here without ever starting it, so without this the profile
// activated above is bound only by MainScreen's post-frame start() —
// Discover renders a "No servers available" flash in the gap, and the
// picker's awaitBindingSettle resolves before anything is bound.
context.read<ActiveProfileBinder>().start();
final settings = await SettingsService.getInstance();
if (!mounted) return;
final promptHandled = shouldPromptForInitialProfileSelection(
activeProfile: activeProfiles.active,
hasProfiles: activeProfiles.profiles.isNotEmpty,
accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true,
requireProfileSelectionOnOpen: activeProfiles.requiresSelectionOnOpen(settings),
);
if (promptHandled) {
final selected = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true)));
if (!mounted) return;
if (selected != true || activeProfiles.active == null) {
setState(() => _isAuthenticating = false);
return;
}
}
await context.read<UserProfileProvider>().initialize();
if (!mounted) return;
unawaited(
Navigator.pushReplacement(context, fadeRoute(ProfileSessionScreen(initialPromptHandled: promptHandled))),
);
} catch (e) {
appLogger.e('Failed to connect to servers', error: e);
if (!mounted) return;
setState(() {
_isAuthenticating = false;
_errorMessage = t.serverSelection.failedToLoadServers(error: e);
});
}
}
void _handleDebugTap() {
if (!kDebugMode) return;
_showDebugTokenDialog();
}
Future<void> _connectToMediaBrowser(MediaBrowserDialect dialect) async {
if (!await _prepareDatabaseRecoveryForSignIn()) return;
if (!mounted) return;
final added = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => AddJellyfinScreen(dialect: dialect)),
);
if (!mounted || added != true) return;
// The connection persisted and the manager registered the client; move
// straight to the main screen. [MainScreen] reads the active client
// from the server provider, so no client argument is needed here.
unawaited(Navigator.pushReplacement(context, fadeRoute(const ProfileSessionScreen())));
}
void _showDebugTokenDialog() {
showDialog<void>(
context: context,
builder: (BuildContext context) {
return _DebugTokenDialog(verifyService: _verifyOnlyService, onTokenAccepted: _connectToAllServersAndNavigate);
},
);
}
@override
Widget build(BuildContext context) {
// Use two-column layout on desktop, single column on mobile
final isDesktop = MediaQuery.sizeOf(context).width > 700;
return Focus(
canRequestFocus: false,
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
child: Scaffold(
body: Center(
child: Container(
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: isDesktop
? Row(
crossAxisAlignment: .center,
children: [
Expanded(
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .center,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(width: 48),
Expanded(
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [_buildAuthBody()],
),
),
),
),
],
)
: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
_buildAuthBody(),
],
),
),
),
),
),
);
}
Widget _buildAuthBody() {
if (_isAuthenticating) {
return Column(
mainAxisSize: .min,
children: [
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 16),
Text(
t.auth.waitingForAuth,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)),
),
],
);
}
return PlexPinAuthFlow(
onTokenReceived: _connectToAllServersAndNavigate,
autoStartQrOnTV: false,
initializeService: widget.initializeServices,
initialButtonsBuilder: _buildInitialButtons,
);
}
Widget _buildInitialButtons(BuildContext context, VoidCallback startBrowser, VoidCallback startQr, bool busy) {
final isTV = PlatformDetector.isTV();
final isAppleTV = PlatformDetector.isAppleTV();
void startBrowserAfterRecovery() => unawaited(_startPlexAfterRecovery(startBrowser));
void startQrAfterRecovery() => unawaited(_startPlexAfterRecovery(startQr));
const jellyfinDialect = MediaBrowserDialect.jellyfin;
const embyDialect = MediaBrowserDialect.emby;
void connectToJellyfin() => unawaited(_connectToMediaBrowser(jellyfinDialect));
void connectToEmby() => unawaited(_connectToMediaBrowser(embyDialect));
return Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
if (isTV) ...[
FocusableButton(
autofocus: true,
onPressed: busy ? null : startQrAfterRecovery,
useBackgroundFocus: true,
child: ElevatedButton(
onPressed: busy ? null : startQrAfterRecovery,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Row(
mainAxisAlignment: .center,
mainAxisSize: .min,
children: [
const BackendBadge(backend: MediaBackend.plex, size: 18),
const SizedBox(width: 8),
Text(t.auth.signInWithPlex),
],
),
),
),
if (!isAppleTV) ...[
const SizedBox(height: 12),
FocusableButton(
onPressed: busy ? null : startBrowserAfterRecovery,
child: OutlinedButton(
onPressed: busy ? null : startBrowserAfterRecovery,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(t.auth.useBrowser),
),
),
],
] else ...[
FocusableButton(
onPressed: busy ? null : startBrowserAfterRecovery,
useBackgroundFocus: true,
child: ElevatedButton.icon(
onPressed: busy ? null : startBrowserAfterRecovery,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
icon: const BackendBadge(backend: MediaBackend.plex, size: 18),
label: Text(t.auth.signInWithPlex),
),
),
const SizedBox(height: 12),
FocusableButton(
onPressed: busy ? null : startQrAfterRecovery,
child: OutlinedButton(
onPressed: busy ? null : startQrAfterRecovery,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(t.auth.showQRCode),
),
),
],
const SizedBox(height: 24),
Row(
children: [
Expanded(child: Divider(color: Theme.of(context).colorScheme.outlineVariant)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
t.auth.or,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
),
),
Expanded(child: Divider(color: Theme.of(context).colorScheme.outlineVariant)),
],
),
const SizedBox(height: 12),
FocusableButton(
onPressed: connectToJellyfin,
child: OutlinedButton.icon(
onPressed: connectToJellyfin,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
icon: const BackendBadge(backend: MediaBackend.jellyfin, size: 18),
label: Text(t.auth.connectToMediaBrowser(product: jellyfinDialect.productName)),
),
),
const SizedBox(height: 12),
FocusableButton(
onPressed: connectToEmby,
child: OutlinedButton.icon(
onPressed: connectToEmby,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
icon: const BackendBadge(backend: MediaBackend.emby, size: 18),
label: Text(t.auth.connectToMediaBrowser(product: embyDialect.productName)),
),
),
if (kDebugMode) ...[
const SizedBox(height: 12),
FocusableButton(
onPressed: _handleDebugTap,
child: OutlinedButton(
onPressed: _handleDebugTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)),
),
child: const Text('Debug: Enter Plex Token', style: TextStyle(fontSize: 12)),
),
),
],
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Text(
_errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
],
],
);
}
}
@visibleForTesting
Profile? initialPlexHomeProfileFromCache(PlexHomeService plexHome, PlexAccountConnection accountConn) {
final users = plexHome.current[accountConn.id];
if (users == null || users.length != 1) return null;
return Profile.virtualPlexHome(connectionId: accountConn.id, homeUser: users.single);
}
@visibleForTesting
bool shouldPromptForInitialProfileSelection({
required Profile? activeProfile,
required bool hasProfiles,
required bool accountHasHomeUsers,
required bool requireProfileSelectionOnOpen,
}) {
return requireProfileSelectionOnOpen || (activeProfile == null && (hasProfiles || accountHasHomeUsers));
}
/// Stateful so the [TextEditingController] is disposed when the dialog
/// closes — the previous inline `showDialog` builder created the
/// controller in a closure and leaked it on every dismissal.
class _DebugTokenDialog extends StatefulWidget {
final PlexAuthService? verifyService;
final Future<void> Function(String token) onTokenAccepted;
const _DebugTokenDialog({required this.verifyService, required this.onTokenAccepted});
@override
State<_DebugTokenDialog> createState() => _DebugTokenDialogState();
}
class _DebugTokenDialogState extends State<_DebugTokenDialog> with ControllerDisposerMixin {
late final TextEditingController _tokenController = createTextEditingController();
String? _errorMessage;
bool _busy = false;
Future<void> _submit() async {
final token = _tokenController.text.trim();
if (token.isEmpty) {
setState(() => _errorMessage = t.errors.pleaseEnterToken);
return;
}
final svc = widget.verifyService;
if (svc == null) {
setState(() => _errorMessage = 'Auth service not ready');
return;
}
final navigator = Navigator.of(context);
setState(() {
_errorMessage = null;
_busy = true;
});
try {
final isValid = await svc.verifyToken(token);
if (!mounted) return;
if (!isValid) {
setState(() {
_errorMessage = t.errors.invalidToken;
_busy = false;
});
return;
}
navigator.pop();
await widget.onTokenAccepted(token);
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = t.errors.failedToVerifyToken(error: e);
_busy = false;
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Debug: Enter Plex Token'),
content: Column(
mainAxisSize: .min,
children: [
FocusableTextFormField(
controller: _tokenController,
decoration: InputDecoration(
labelText: 'Plex Auth Token',
hintText: 'Enter your Plex.tv token',
errorText: _errorMessage,
border: const OutlineInputBorder(),
),
obscureText: true,
maxLines: 1,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _busy ? null : _submit(),
),
],
),
actions: [
DialogActionButton(onPressed: _busy ? null : () => Navigator.of(context).pop(), label: t.common.cancel),
DialogActionButton(onPressed: _busy ? null : _submit, label: t.auth.authenticate, isPrimary: true),
],
);
}
}