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.
This commit is contained in:
@@ -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<String, dynamic> json) => _$SeerrMovieDetailsFromJson(json);
|
||||
}
|
||||
|
||||
/// Full TV detail from `GET /tv/{tmdbId}`.
|
||||
@JsonSerializable(createToJson: false)
|
||||
class SeerrTvDetails {
|
||||
class SeerrDetails {
|
||||
final List<SeerrSeason>? 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<String, dynamic> json) => _$SeerrTvDetailsFromJson(json);
|
||||
factory SeerrDetails.fromJson(Map<String, dynamic> json) => _$SeerrDetailsFromJson(json);
|
||||
}
|
||||
|
||||
/// One TMDB season entry (`TvDetails.seasons[]`). Season 0 is specials.
|
||||
|
||||
@@ -6,28 +6,17 @@ part of 'seerr_details.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map<String, dynamic> json) =>
|
||||
SeerrMovieDetails(
|
||||
credits: json['credits'] == null
|
||||
? null
|
||||
: SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>),
|
||||
mediaInfo: json['mediaInfo'] == null
|
||||
? null
|
||||
: SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
SeerrTvDetails _$SeerrTvDetailsFromJson(Map<String, dynamic> json) =>
|
||||
SeerrTvDetails(
|
||||
seasons: (json['seasons'] as List<dynamic>?)
|
||||
?.map((e) => SeerrSeason.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
credits: json['credits'] == null
|
||||
? null
|
||||
: SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>),
|
||||
mediaInfo: json['mediaInfo'] == null
|
||||
? null
|
||||
: SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>),
|
||||
);
|
||||
SeerrDetails _$SeerrDetailsFromJson(Map<String, dynamic> json) => SeerrDetails(
|
||||
seasons: (json['seasons'] as List<dynamic>?)
|
||||
?.map((e) => SeerrSeason.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
credits: json['credits'] == null
|
||||
? null
|
||||
: SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>),
|
||||
mediaInfo: json['mediaInfo'] == null
|
||||
? null
|
||||
: SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
SeerrSeason _$SeerrSeasonFromJson(Map<String, dynamic> json) => SeerrSeason(
|
||||
seasonNumber: (json['seasonNumber'] as num).toInt(),
|
||||
|
||||
@@ -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<void>? _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<TrackerSession?>([
|
||||
_malStore.load(userUuid),
|
||||
_anilistStore.load(userUuid),
|
||||
_simklStore.load(userUuid),
|
||||
]);
|
||||
final rebinds = [for (final slot in _slots) slot.rebindGeneration];
|
||||
final results = await Future.wait<TrackerSession?>([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<bool> 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<void> disconnectMal() => _clearAndRebind(TrackerService.mal, _malStore, () {
|
||||
_mal = null;
|
||||
_rebindMal();
|
||||
});
|
||||
Future<void> disconnectMal() => _clearAndRebind(_mal);
|
||||
|
||||
Future<bool> 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<void> disconnectAnilist() => _clearAndRebind(TrackerService.anilist, _anilistStore, () {
|
||||
_anilist = null;
|
||||
_rebindAnilist();
|
||||
});
|
||||
Future<void> disconnectAnilist() => _clearAndRebind(_anilist);
|
||||
|
||||
Future<bool> 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<void> disconnectSimkl() => _clearAndRebind(TrackerService.simkl, _simklStore, () {
|
||||
_simkl = null;
|
||||
_rebindSimkl();
|
||||
});
|
||||
Future<void> disconnectSimkl() => _clearAndRebind(_simkl);
|
||||
|
||||
Future<bool> _runConnect({
|
||||
required TrackerService service,
|
||||
required bool alreadyConnected,
|
||||
bool _isConnectCancelled() => _cancelCompleter?.isCompleted ?? false;
|
||||
|
||||
Future<bool> _runConnect(
|
||||
_TrackerSlot slot, {
|
||||
required Future<TrackerSession?> Function() authorize,
|
||||
required Future<TrackerSession> 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<void> _clearAndRebind(
|
||||
TrackerService service,
|
||||
TrackerAccountStore store,
|
||||
void Function() clearAndRebind,
|
||||
) async {
|
||||
_invalidateConnect(service);
|
||||
Future<void> _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;
|
||||
}
|
||||
|
||||
@@ -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<MediaItem> items;
|
||||
final List<MediaItem> 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<PlaybackStateProvider>();
|
||||
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<PlaybackStateProvider>();
|
||||
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 <MediaItem>[];
|
||||
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<PlayQueueResult> _launchLocalQueue({
|
||||
required String serverId,
|
||||
required String queueId,
|
||||
required String contextKey,
|
||||
required bool shuffle,
|
||||
required bool showLoadingIndicator,
|
||||
required Future<List<MediaItem>> Function(MediaServerClient client, AbortController abort) fetchItems,
|
||||
int Function(List<MediaItem> 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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -128,14 +128,13 @@ class SeerrClient {
|
||||
|
||||
// ---------- Details ----------
|
||||
|
||||
Future<SeerrMovieDetails> getMovie(int tmdbId) async {
|
||||
final data = await _request('GET', '/movie/$tmdbId');
|
||||
return SeerrMovieDetails.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
Future<SeerrDetails> getMovie(int tmdbId) => _details('/movie/$tmdbId');
|
||||
|
||||
Future<SeerrTvDetails> getTv(int tmdbId) async {
|
||||
final data = await _request('GET', '/tv/$tmdbId');
|
||||
return SeerrTvDetails.fromJson(data as Map<String, dynamic>);
|
||||
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 ----------
|
||||
|
||||
Reference in New Issue
Block a user