From 7416327d4ba81fcf3d6f84913e95f96279893fc2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:23:08 +0200 Subject: [PATCH] refactor: unify tracker slots, Seerr detail models, and queue launches - Merge SeerrMovieDetails/SeerrTvDetails into one SeerrDetails model and route both detail endpoints through a single request helper. - Replace the three parallel tracker session/store/rebind-generation triples in TrackersProvider with a _TrackerSlot record plus one _rebind path. - Fold the three JellyfinSequentialLauncher entry points onto a shared _launchLocalQueue helper that owns loading, abort, shuffle and publish; each caller now supplies only its fetch. --- lib/models/seerr/seerr_details.dart | 22 +- lib/models/seerr/seerr_details.g.dart | 33 +-- lib/providers/trackers_provider.dart | 270 +++++++----------- .../jellyfin_sequential_launcher.dart | 177 +++++------- lib/services/seerr/seerr_client.dart | 13 +- 5 files changed, 197 insertions(+), 318 deletions(-) diff --git a/lib/models/seerr/seerr_details.dart b/lib/models/seerr/seerr_details.dart index f6ff6f26..aa25ef1c 100644 --- a/lib/models/seerr/seerr_details.dart +++ b/lib/models/seerr/seerr_details.dart @@ -4,28 +4,18 @@ import 'seerr_media.dart'; part 'seerr_details.g.dart'; -/// Full movie detail from `GET /movie/{tmdbId}` — the subset the catalog -/// surfaces need (credits, availability). +/// Full detail from `GET /movie/{tmdbId}` and `GET /tv/{tmdbId}` — the subset +/// the catalog surfaces need (credits, availability, seasons). `seasons` is +/// absent on movies. @JsonSerializable(createToJson: false) -class SeerrMovieDetails { - final SeerrCredits? credits; - final SeerrMediaInfo? mediaInfo; - - const SeerrMovieDetails({this.credits, this.mediaInfo}); - - factory SeerrMovieDetails.fromJson(Map json) => _$SeerrMovieDetailsFromJson(json); -} - -/// Full TV detail from `GET /tv/{tmdbId}`. -@JsonSerializable(createToJson: false) -class SeerrTvDetails { +class SeerrDetails { final List? seasons; final SeerrCredits? credits; final SeerrMediaInfo? mediaInfo; - const SeerrTvDetails({this.seasons, this.credits, this.mediaInfo}); + const SeerrDetails({this.seasons, this.credits, this.mediaInfo}); - factory SeerrTvDetails.fromJson(Map json) => _$SeerrTvDetailsFromJson(json); + factory SeerrDetails.fromJson(Map json) => _$SeerrDetailsFromJson(json); } /// One TMDB season entry (`TvDetails.seasons[]`). Season 0 is specials. diff --git a/lib/models/seerr/seerr_details.g.dart b/lib/models/seerr/seerr_details.g.dart index d0f9afe4..487de020 100644 --- a/lib/models/seerr/seerr_details.g.dart +++ b/lib/models/seerr/seerr_details.g.dart @@ -6,28 +6,17 @@ part of 'seerr_details.dart'; // JsonSerializableGenerator // ************************************************************************** -SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map json) => - SeerrMovieDetails( - credits: json['credits'] == null - ? null - : SeerrCredits.fromJson(json['credits'] as Map), - mediaInfo: json['mediaInfo'] == null - ? null - : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), - ); - -SeerrTvDetails _$SeerrTvDetailsFromJson(Map json) => - SeerrTvDetails( - seasons: (json['seasons'] as List?) - ?.map((e) => SeerrSeason.fromJson(e as Map)) - .toList(), - credits: json['credits'] == null - ? null - : SeerrCredits.fromJson(json['credits'] as Map), - mediaInfo: json['mediaInfo'] == null - ? null - : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), - ); +SeerrDetails _$SeerrDetailsFromJson(Map json) => SeerrDetails( + seasons: (json['seasons'] as List?) + ?.map((e) => SeerrSeason.fromJson(e as Map)) + .toList(), + credits: json['credits'] == null + ? null + : SeerrCredits.fromJson(json['credits'] as Map), + mediaInfo: json['mediaInfo'] == null + ? null + : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), +); SeerrSeason _$SeerrSeasonFromJson(Map json) => SeerrSeason( seasonNumber: (json['seasonNumber'] as num).toInt(), diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index 8384aae8..466511e5 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -45,13 +45,23 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin final MalAuthService _malAuth = MalAuthService(); final AnilistAuthService _anilistAuth = AnilistAuthService(); final SimklAuthService _simklAuth = SimklAuthService(); - final TrackerAccountStore _malStore = trackerAccountStore(TrackerService.mal); - final TrackerAccountStore _anilistStore = trackerAccountStore(TrackerService.anilist); - final TrackerAccountStore _simklStore = trackerAccountStore(TrackerService.simkl); - TrackerSession? _mal; - TrackerSession? _anilist; - TrackerSession? _simkl; + final _TrackerSlot _mal = _TrackerSlot( + TrackerService.mal, + (session, {required onInvalidated, onUpdated}) => + MalTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated, onSessionUpdated: onUpdated), + ); + final _TrackerSlot _anilist = _TrackerSlot( + TrackerService.anilist, + (session, {required onInvalidated, onUpdated}) => + AnilistTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated), + ); + final _TrackerSlot _simkl = _TrackerSlot( + TrackerService.simkl, + (session, {required onInvalidated, onUpdated}) => + SimklTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated), + ); + late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl]; String _activeUserUuid = ''; int _profileBindingGeneration = 0; @@ -59,22 +69,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin Completer? _cancelCompleter; int _connectGeneration = 0; - // Bumped on every rebind so a late callback from a disposed client (e.g. an - // in-flight MAL token refresh that resolves after a profile switch) can't - // persist or clear a session under the wrong profile, and so a disconnect - // racing an in-flight profile load only suppresses its own service. Mirrors - // TraktAccountProvider's binding-generation guard, but per service. - final _RebindGeneration _malRebind = _RebindGeneration(); - final _RebindGeneration _anilistRebind = _RebindGeneration(); - final _RebindGeneration _simklRebind = _RebindGeneration(); + TrackerSession? get mal => _mal.session; + TrackerSession? get anilist => _anilist.session; + TrackerSession? get simkl => _simkl.session; - TrackerSession? get mal => _mal; - TrackerSession? get anilist => _anilist; - TrackerSession? get simkl => _simkl; - - bool get isMalConnected => _mal != null; - bool get isAnilistConnected => _anilist != null; - bool get isSimklConnected => _simkl != null; + bool get isMalConnected => _mal.session != null; + bool get isAnilistConnected => _anilist.session != null; + bool get isSimklConnected => _simkl.session != null; /// The live MAL client for the Explore catalog, shared with the scrobble /// tracker so both ride one session (MAL rotates refresh tokens — a second @@ -82,17 +83,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// provider's own session so a freshly-mounted profile subtree never sees /// the previous profile's client while its sessions are still loading; /// every rebind is followed by a notify, so proxy consumers track identity. - MalClient? get malCatalogClient => _mal == null ? null : MalTracker.instance.client; + MalClient? get malCatalogClient => _mal.session == null ? null : MalTracker.instance.client; /// Live AniList and Simkl clients for Explore. Like [malCatalogClient], /// these are gated on this provider's profile-bound sessions so a fresh /// profile subtree cannot observe clients still bound to the prior profile. - AnilistClient? get anilistCatalogClient => _anilist == null ? null : AnilistTracker.instance.client; - SimklClient? get simklCatalogClient => _simkl == null ? null : SimklTracker.instance.client; + AnilistClient? get anilistCatalogClient => _anilist.session == null ? null : AnilistTracker.instance.client; + SimklClient? get simklCatalogClient => _simkl.session == null ? null : SimklTracker.instance.client; - String? get malUsername => _mal?.username; - String? get anilistUsername => _anilist?.username; - String? get simklUsername => _simkl?.username; + String? get malUsername => _mal.session?.username; + String? get anilistUsername => _anilist.session?.username; + String? get simklUsername => _simkl.session?.username; bool isConnecting(TrackerService service) => _connecting == service; @@ -114,26 +115,14 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Snapshot each service's rebind generation before the await so a disconnect // that races this load only suppresses its own service (whose generation // moves) rather than dropping the freshly-loaded sessions for the others. - final malRebind = _malRebind.value; - final anilistRebind = _anilistRebind.value; - final simklRebind = _simklRebind.value; - final results = await Future.wait([ - _malStore.load(userUuid), - _anilistStore.load(userUuid), - _simklStore.load(userUuid), - ]); + final rebinds = [for (final slot in _slots) slot.rebindGeneration]; + final results = await Future.wait([for (final slot in _slots) slot.store.load(userUuid)]); if (!_isCurrentProfileBinding(userUuid, generation)) return; - if (_malRebind.value == malRebind) { - _mal = results.first; - _rebindMal(); - } - if (_anilistRebind.value == anilistRebind) { - _anilist = results[1]; - _rebindAnilist(); - } - if (_simklRebind.value == simklRebind) { - _simkl = results[2]; - _rebindSimkl(); + for (var i = 0; i < _slots.length; i++) { + final slot = _slots[i]; + if (slot.rebindGeneration != rebinds[i]) continue; + slot.session = results[i]; + _rebind(slot); } // Connect/disconnect may flip `needsFribb` — drop cached resolver IDs so // the next lookup re-evaluates whether to consult Fribb. @@ -142,78 +131,51 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } Future connectMal({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( - service: TrackerService.mal, - alreadyConnected: isMalConnected, + _mal, authorize: () => _malAuth.authorize( onCodeReady: onCodeReady, - shouldCancel: () => _cancelCompleter?.isCompleted ?? false, + shouldCancel: _isConnectCancelled, onCancel: _cancelCompleter!.future, ), enrich: _enrichMal, - store: _malStore, - assign: (s) { - _mal = s; - _rebindMal(); - }, ); - Future disconnectMal() => _clearAndRebind(TrackerService.mal, _malStore, () { - _mal = null; - _rebindMal(); - }); + Future disconnectMal() => _clearAndRebind(_mal); Future connectAnilist({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( - service: TrackerService.anilist, - alreadyConnected: isAnilistConnected, + _anilist, authorize: () => _anilistAuth.authorize( onCodeReady: onCodeReady, - shouldCancel: () => _cancelCompleter?.isCompleted ?? false, + shouldCancel: _isConnectCancelled, onCancel: _cancelCompleter!.future, ), enrich: _enrichAnilist, - store: _anilistStore, - assign: (s) { - _anilist = s; - _rebindAnilist(); - }, ); - Future disconnectAnilist() => _clearAndRebind(TrackerService.anilist, _anilistStore, () { - _anilist = null; - _rebindAnilist(); - }); + Future disconnectAnilist() => _clearAndRebind(_anilist); Future connectSimkl({required void Function(DeviceCode code) onCodeReady}) => _runConnect( - service: TrackerService.simkl, - alreadyConnected: isSimklConnected, + _simkl, authorize: () => _simklAuth.authorize( onCodeReady: onCodeReady, - shouldCancel: () => _cancelCompleter?.isCompleted ?? false, + shouldCancel: _isConnectCancelled, onCancel: _cancelCompleter!.future, ), enrich: _enrichSimkl, - store: _simklStore, - assign: (s) { - _simkl = s; - _rebindSimkl(); - }, ); - Future disconnectSimkl() => _clearAndRebind(TrackerService.simkl, _simklStore, () { - _simkl = null; - _rebindSimkl(); - }); + Future disconnectSimkl() => _clearAndRebind(_simkl); - Future _runConnect({ - required TrackerService service, - required bool alreadyConnected, + bool _isConnectCancelled() => _cancelCompleter?.isCompleted ?? false; + + Future _runConnect( + _TrackerSlot slot, { required Future Function() authorize, required Future Function(TrackerSession raw) enrich, - required TrackerAccountStore store, - required void Function(TrackerSession session) assign, }) async { - if (isDisposed || _connecting != null || alreadyConnected) return false; + if (isDisposed || _connecting != null || slot.session != null) return false; + final service = slot.service; final userUuid = _activeUserUuid; final generation = ++_connectGeneration; _connecting = service; @@ -231,11 +193,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin enrich: enrich, save: (session) async { if (!_isCurrentConnect(service, userUuid, generation)) return; - await store.save(userUuid, session); + await slot.store.save(userUuid, session); }, assign: (session) { if (!_isCurrentConnect(service, userUuid, generation)) return; - assign(session); + slot.session = session; + _rebind(slot); TrackerCoordinator.instance.invalidateResolverCache(); assigned = true; }, @@ -250,20 +213,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } - Future _clearAndRebind( - TrackerService service, - TrackerAccountStore store, - void Function() clearAndRebind, - ) async { - _invalidateConnect(service); + Future _clearAndRebind(_TrackerSlot slot) async { + _invalidateConnect(slot.service); final userUuid = _activeUserUuid; - // `clearAndRebind` bumps the affected service's rebind generation, which is - // what stops an in-flight profile load from resurrecting the cleared - // session — so we no longer touch the shared profile-binding generation - // (which would also abort that load for the other two services). - clearAndRebind(); + // The rebind bumps the affected service's generation, which is what stops + // an in-flight profile load from resurrecting the cleared session — so we + // no longer touch the shared profile-binding generation (which would also + // abort that load for the other two services). + slot.session = null; + _rebind(slot); safeNotifyListeners(); - await store.clear(userUuid); + await slot.store.clear(userUuid); } void _invalidateConnect([TrackerService? service]) { @@ -305,68 +265,33 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin }, ); - /// Snapshot the active profile + bump this service's rebind generation, - /// returning the bound uuid and an `isCurrent` predicate. Bumping here is what - /// lets a stale client callback — or a racing profile load — detect that it - /// has been superseded for this service. - (String, bool Function()) _beginRebind(_RebindGeneration gen) { - final boundUuid = _activeUserUuid; - final generation = gen.bump(); - bool isCurrent() => !isDisposed && boundUuid == _activeUserUuid && generation == gen.value; - return (boundUuid, isCurrent); - } - - void _rebindMal() { + /// Push a slot's session to its tracker, snapshotting the active profile and + /// bumping the slot's rebind generation first. Bumping here is what lets a + /// stale client callback — or a racing profile load — detect that it has been + /// superseded for this service. + void _rebind(_TrackerSlot slot) { if (isDisposed) return; - final (boundUuid, isCurrent) = _beginRebind(_malRebind); - MalTracker.instance.rebindSession( - _mal, - onSessionInvalidated: () { - if (isCurrent()) _handleInvalidated(_malStore, boundUuid, () => _mal = null, _rebindMal); - }, - onSessionUpdated: (next) { + final boundUuid = _activeUserUuid; + final generation = ++slot.rebindGeneration; + bool isCurrent() => !isDisposed && boundUuid == _activeUserUuid && generation == slot.rebindGeneration; + slot.bind( + slot.session, + onInvalidated: () { if (!isCurrent()) return; - _mal = next; - _malStore.save(boundUuid, next); + slot.store.clear(boundUuid); + slot.session = null; + _rebind(slot); + safeNotifyListeners(); + }, + onUpdated: (next) { + if (!isCurrent()) return; + slot.session = next; + slot.store.save(boundUuid, next); safeNotifyListeners(); }, ); } - void _rebindAnilist() { - if (isDisposed) return; - final (boundUuid, isCurrent) = _beginRebind(_anilistRebind); - AnilistTracker.instance.rebindSession( - _anilist, - onSessionInvalidated: () { - if (isCurrent()) _handleInvalidated(_anilistStore, boundUuid, () => _anilist = null, _rebindAnilist); - }, - ); - } - - void _rebindSimkl() { - if (isDisposed) return; - final (boundUuid, isCurrent) = _beginRebind(_simklRebind); - SimklTracker.instance.rebindSession( - _simkl, - onSessionInvalidated: () { - if (isCurrent()) _handleInvalidated(_simklStore, boundUuid, () => _simkl = null, _rebindSimkl); - }, - ); - } - - void _handleInvalidated( - TrackerAccountStore store, - String userUuid, - void Function() clearSession, - void Function() rebind, - ) { - store.clear(userUuid); - clearSession(); - rebind(); - safeNotifyListeners(); - } - @override void dispose() { _invalidateConnect(); @@ -377,12 +302,29 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } -/// A monotonic per-service rebind counter. Each rebind bumps it so a stale -/// client callback — or a profile load that started earlier — can tell it has -/// been superseded for that service. -class _RebindGeneration { - int _value = 0; +/// Pushes a session to one service's tracker singleton. `onUpdated` is only +/// wired for MAL, the one service that rotates its refresh token. +typedef _TrackerBind = + void Function( + TrackerSession? session, { + required void Function() onInvalidated, + void Function(TrackerSession session)? onUpdated, + }); - int bump() => ++_value; - int get value => _value; +/// Owns one service's session, the generation guarding its rebinds, and the +/// adapter that pushes that session to the service's tracker singleton. +class _TrackerSlot { + _TrackerSlot(this.service, this.bind) : store = trackerAccountStore(service); + + final TrackerService service; + final TrackerAccountStore store; + final _TrackerBind bind; + TrackerSession? session; + + /// Bumped on every rebind so a late callback from a disposed client (e.g. an + /// in-flight MAL token refresh that resolves after a profile switch) can't + /// persist or clear a session under the wrong profile, and so a disconnect + /// racing an in-flight profile load only suppresses its own service. Mirrors + /// TraktAccountProvider's binding-generation guard, but per service. + int rebindGeneration = 0; } diff --git a/lib/services/jellyfin_sequential_launcher.dart b/lib/services/jellyfin_sequential_launcher.dart index b6a4c4d7..a15e62cf 100644 --- a/lib/services/jellyfin_sequential_launcher.dart +++ b/lib/services/jellyfin_sequential_launcher.dart @@ -64,24 +64,18 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { return PlayQueueError(Exception('Item is missing serverId')); } - final abort = AbortController(); - - return executeWithLoading( - context: context, - showLoading: showLoadingIndicator, - actionLabel: shuffle ? t.common.shuffle : t.common.play, - abort: abort, - execute: (dismissLoading) async { - final client = clientForTesting ?? _resolveClient(ServerId(serverId)); - if (client == null) { - return _missingClientError(serverId, dismissLoading); - } - + return _launchLocalQueue( + serverId: serverId, + queueId: 'jellyfin:${facts.id}', + contextKey: facts.id, + shuffle: shuffle, + showLoadingIndicator: showLoadingIndicator, + fetchItems: (client, abort) async { // Playlists go through the dedicated `/Playlists/{id}/Items` endpoint // so playlist-defined order is preserved; collections fall back to // recursive descendant expansion (which skips unplayable Series // containers and surfaces Movies + Episodes flat). - List items; + final List items; if (facts.isPlaylist) { items = await fetchAllPlaylistItems(client, facts.id, abort: abort); } else if (client is JellyfinClient) { @@ -92,44 +86,15 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { items = await client.fetchPlayableDescendants(facts.id); } abort.throwIfAborted(); - - if (items.isEmpty) return const PlayQueueEmpty(); - - abort.throwIfAborted(); - if (shuffle) { - items = List.of(items)..shuffle(Random()); - } - - abort.throwIfAborted(); - // When a startItem is given (and we're not shuffling), keep the full - // original order and move the local queue cursor to that item. - var startIndex = 0; - if (!shuffle && startItem != null) { - startIndex = items.indexWhere((it) => it.id == startItem.id); - if (startIndex < 0) startIndex = 0; - } - - await dismissLoading(); - abort.throwIfAborted(); - if (!context.mounted && navigateForTesting == null) { - return const PlayQueueError('Context not mounted'); - } - - abort.throwIfAborted(); - final playbackState = playbackStateForTesting ?? context.read(); - return launchLocalQueuePlayback( - context: context, - playbackState: playbackState, - queue: LocalPlayQueue( - id: 'jellyfin:${facts.id}', - items: items, - currentIndex: startIndex, - shuffled: shuffle, - backendId: client.backend.id, - ), - contextKey: facts.id, - navigateForTesting: navigateForTesting, - ); + return items; + }, + // When a startItem is given (and we're not shuffling), keep the full + // original order and move the local queue cursor to that item. + resolveStartIndex: (items) { + final start = startItem; + if (shuffle || start == null) return 0; + final index = items.indexWhere((it) => it.id == start.id); + return index < 0 ? 0 : index; }, ); } @@ -148,24 +113,18 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { return PlayQueueError(Exception('Item is missing serverId')); } - final abort = AbortController(); - - return executeWithLoading( - context: context, - showLoading: showLoadingIndicator, - actionLabel: shuffle ? t.common.shuffle : t.common.play, - abort: abort, - execute: (dismissLoading) async { - final client = clientForTesting ?? _resolveClient(ServerId(serverId)); - if (client == null) { - return _missingClientError(serverId, dismissLoading); - } - + return _launchLocalQueue( + serverId: serverId, + queueId: 'jellyfin:folder:${folder.id}', + contextKey: folder.id, + shuffle: shuffle, + showLoadingIndicator: showLoadingIndicator, + fetchItems: (client, abort) async { final fetched = client is JellyfinClient ? await client.fetchPlayableFolderDescendants(folder.id, abort: abort) : await client.fetchPlayableDescendants(folder.id); abort.throwIfAborted(); - var items = fetched.where((item) => item.kind.isVideo).map((item) { + return fetched.where((item) => item.kind.isVideo).map((item) { return item.copyWith( serverId: item.serverId ?? serverId, serverName: item.serverName ?? folder.serverName, @@ -173,35 +132,6 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { libraryTitle: item.libraryTitle ?? folder.libraryTitle, ); }).toList(); - - if (items.isEmpty) return const PlayQueueEmpty(); - - abort.throwIfAborted(); - if (shuffle) { - items = List.of(items)..shuffle(Random()); - } - - await dismissLoading(); - abort.throwIfAborted(); - if (!context.mounted && navigateForTesting == null) { - return const PlayQueueError('Context not mounted'); - } - - abort.throwIfAborted(); - final playbackState = playbackStateForTesting ?? context.read(); - return launchLocalQueuePlayback( - context: context, - playbackState: playbackState, - queue: LocalPlayQueue( - id: 'jellyfin:folder:${folder.id}', - items: items, - currentIndex: 0, - shuffled: shuffle, - backendId: client.backend.id, - ), - contextKey: folder.id, - navigateForTesting: navigateForTesting, - ); }, ); } @@ -227,12 +157,43 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { seriesId = parent; } + return _launchLocalQueue( + serverId: serverId, + queueId: 'jellyfin:$seriesId', + contextKey: seriesId, + shuffle: true, + showLoadingIndicator: showLoadingIndicator, + fetchItems: (client, abort) async { + final raw = client is JellyfinClient + ? await client.fetchClientSideEpisodeQueue(seriesId, abort: abort) + : await client.fetchClientSideEpisodeQueue(seriesId); + abort.throwIfAborted(); + if (raw == null) return const []; + return raw + .map((e) => e.copyWith(serverId: serverId, serverName: metadata.serverName ?? e.serverName)) + .toList(); + }, + ); + } + + /// Fetch, shuffle, and publish a local queue behind the cancellable loading + /// dialog. [fetchItems] carries the only per-entry-point difference: which + /// client call produces the items and how they're normalized. + Future _launchLocalQueue({ + required String serverId, + required String queueId, + required String contextKey, + required bool shuffle, + required bool showLoadingIndicator, + required Future> Function(MediaServerClient client, AbortController abort) fetchItems, + int Function(List items)? resolveStartIndex, + }) async { final abort = AbortController(); return executeWithLoading( context: context, showLoading: showLoadingIndicator, - actionLabel: t.common.shuffle, + actionLabel: shuffle ? t.common.shuffle : t.common.play, abort: abort, execute: (dismissLoading) async { final client = clientForTesting ?? _resolveClient(ServerId(serverId)); @@ -240,18 +201,16 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { return _missingClientError(serverId, dismissLoading); } - final raw = client is JellyfinClient - ? await client.fetchClientSideEpisodeQueue(seriesId, abort: abort) - : await client.fetchClientSideEpisodeQueue(seriesId); - abort.throwIfAborted(); - if (raw == null || raw.isEmpty) return const PlayQueueEmpty(); + var items = await fetchItems(client, abort); + if (items.isEmpty) return const PlayQueueEmpty(); abort.throwIfAborted(); - final shuffled = List.of(raw)..shuffle(Random()); + if (shuffle) { + items = List.of(items)..shuffle(Random()); + } + abort.throwIfAborted(); - final items = shuffled - .map((e) => e.copyWith(serverId: serverId, serverName: metadata.serverName ?? e.serverName)) - .toList(); + final startIndex = resolveStartIndex?.call(items) ?? 0; await dismissLoading(); abort.throwIfAborted(); @@ -265,13 +224,13 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { context: context, playbackState: playbackState, queue: LocalPlayQueue( - id: 'jellyfin:$seriesId', + id: queueId, items: items, - currentIndex: 0, - shuffled: true, + currentIndex: startIndex, + shuffled: shuffle, backendId: client.backend.id, ), - contextKey: seriesId, + contextKey: contextKey, navigateForTesting: navigateForTesting, ); }, diff --git a/lib/services/seerr/seerr_client.dart b/lib/services/seerr/seerr_client.dart index 144475c5..39080475 100644 --- a/lib/services/seerr/seerr_client.dart +++ b/lib/services/seerr/seerr_client.dart @@ -128,14 +128,13 @@ class SeerrClient { // ---------- Details ---------- - Future getMovie(int tmdbId) async { - final data = await _request('GET', '/movie/$tmdbId'); - return SeerrMovieDetails.fromJson(data as Map); - } + Future getMovie(int tmdbId) => _details('/movie/$tmdbId'); - Future getTv(int tmdbId) async { - final data = await _request('GET', '/tv/$tmdbId'); - return SeerrTvDetails.fromJson(data as Map); + Future getTv(int tmdbId) => _details('/tv/$tmdbId'); + + Future _details(String path) async { + final data = await _request('GET', path); + return SeerrDetails.fromJson(data as Map); } // ---------- Requests ----------