refactor: share future coalescing, Plex client access, and event helpers

Deduplicates the hand-rolled coalescing/caching maps, the Plex client cast,
the missing-serverId event guard and the progress-failure backoff, and drops
the MusicPlaybackService availability gate, which could never fail in
production.
This commit is contained in:
edde746
2026-07-26 06:09:50 +02:00
parent 9429a76acc
commit eb3ed45af1
21 changed files with 118 additions and 154 deletions
@@ -38,10 +38,6 @@ class MusicPlayContext {
/// discrete changes (track, status, queue shape, modes) — progress bars
/// subscribe to [positionStream] instead.
abstract class MusicPlaybackService extends ChangeNotifier {
/// False on the stub — playback affordances should render disabled or
/// fall back to a "not supported yet" notice.
bool get isAvailable;
MediaItem? get currentTrack;
MusicPlaybackStatus get status;
bool get isPlaying => status == MusicPlaybackStatus.playing;
@@ -158,8 +154,6 @@ class StubMusicPlaybackService extends MusicPlaybackService {
final ValueNotifier<double> _volumeNotifier = ValueNotifier<double>(100);
int _playIntentGeneration = 0;
int _queueSessionRevision = 0;
@override
bool get isAvailable => false;
@override
MediaItem? get currentTrack => null;
@@ -169,9 +169,6 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
// Getters
// ---------------------------------------------------------------------
@override
bool get isAvailable => true;
@override
MediaItem? get currentTrack => _currentTrack;
+13 -15
View File
@@ -255,27 +255,14 @@ class PlaybackProgressTracker {
}
})
.catchError((Object e) {
_consecutiveFailures++;
// Exponential backoff: skip 1, 2, 4, 8... ticks (capped at 6 ≈ 60s)
_ticksToSkip = (1 << (_consecutiveFailures - 1)).clamp(1, 6);
appLogger.d(
'Progress update failed ($_consecutiveFailures consecutive), '
'skipping next $_ticksToSkip tick(s)',
error: e,
);
_recordProgressFailure(e);
unawaited(_queueOnlineFailureProgress(position, duration));
}),
);
}
} catch (e) {
if (!isOffline) {
_consecutiveFailures++;
_ticksToSkip = (1 << (_consecutiveFailures - 1)).clamp(1, 6);
appLogger.d(
'Progress update failed ($_consecutiveFailures consecutive), '
'skipping next $_ticksToSkip tick(s)',
error: e,
);
_recordProgressFailure(e);
await _queueOnlineFailureProgress(
attemptedPosition ?? player.state.position,
attemptedDuration ?? player.state.duration,
@@ -303,6 +290,17 @@ class PlaybackProgressTracker {
}
}
void _recordProgressFailure(Object e) {
_consecutiveFailures++;
// Exponential backoff: skip 1, 2, 4, 8... ticks (capped at 6 ≈ 60s)
_ticksToSkip = (1 << (_consecutiveFailures - 1)).clamp(1, 6);
appLogger.d(
'Progress update failed ($_consecutiveFailures consecutive), '
'skipping next $_ticksToSkip tick(s)',
error: e,
);
}
void _resetBackoff() {
if (_consecutiveFailures > 0) {
_consecutiveFailures = 0;
@@ -3,6 +3,7 @@ import '../../media/media_kind.dart';
import '../../media/media_server_client.dart';
import '../../models/trackers/anime_lists_mapping.dart';
import '../../utils/app_logger.dart';
import 'future_coalescer.dart';
enum AnimeProgressScope { show, season, mapped }
@@ -30,7 +31,7 @@ abstract interface class AnimeEpisodeProgressLookup {
class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup {
final MediaServerClient _client;
final Map<String, Future<Map<int, _SeasonProgress>?>> _seasonProgressLoads = {};
final KeyedFutureCoalescer<String, Map<int, _SeasonProgress>?> _seasonProgressLoads = KeyedFutureCoalescer();
AnimeEpisodeProgressResolver(this._client);
@@ -61,7 +62,7 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup {
return includeCurrentEpisode ? ResolvedAnimeProgress(progress: animeMatch.anidbEpisode) : null;
}
final progressBySeason = await _seasonProgressFor(showId);
final progressBySeason = await _seasonProgressLoads.run(showId, () => _loadSeasonProgress(showId));
if (progressBySeason == null) return null;
final currentAlreadyWatched = (episode.viewCount ?? 0) > 0 || !includeCurrentEpisode;
@@ -97,20 +98,6 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup {
}
}
Future<Map<int, _SeasonProgress>?> _seasonProgressFor(String showId) async {
final existing = _seasonProgressLoads[showId];
if (existing != null) return existing;
late final Future<Map<int, _SeasonProgress>?> loading;
loading = _loadSeasonProgress(showId).whenComplete(() {
if (identical(_seasonProgressLoads[showId], loading)) {
final _ = _seasonProgressLoads.remove(showId);
}
});
_seasonProgressLoads[showId] = loading;
return loading;
}
ResolvedAnimeProgress? _showProgress(Map<int, _SeasonProgress> seasons, bool currentAlreadyWatched) {
if (seasons.isEmpty) return null;
var watched = 0;
@@ -1,12 +1,13 @@
import '../../models/trackers/anime_ids.dart';
import '../../models/trackers/tracker_context.dart';
import '../../utils/app_logger.dart';
import 'future_coalescer.dart';
import 'tracker.dart';
import 'tracker_id_resolver.dart';
mixin AnimeListTrackerBase<TClient extends DisposableTrackerClient> on TrackerBase, ClientBackedTracker<TClient>
implements TrackerRatingSource {
final Map<int, Future<int?>> _episodeCountLoads = {};
final KeyedFutureCache<int, int?> _episodeCountLoads = KeyedFutureCache();
@override
bool get needsFribb => true;
@@ -83,19 +84,11 @@ mixin AnimeListTrackerBase<TClient extends DisposableTrackerClient> on TrackerBa
return (activeClient, id);
}
Future<int?> _episodeCount(TClient activeClient, int id) {
final existing = _episodeCountLoads[id];
if (existing != null) return existing;
late final Future<int?> loading;
loading = loadAnimeEpisodeCount(activeClient, id).catchError((Object e) {
if (identical(_episodeCountLoads[id], loading)) {
final _ = _episodeCountLoads.remove(id);
}
appLogger.d('$logLabel: failed to fetch anime episode count ($name=$id)', error: e);
return null;
});
_episodeCountLoads[id] = loading;
return loading;
}
Future<int?> _episodeCount(TClient activeClient, int id) => _episodeCountLoads
.run(
id,
() => loadAnimeEpisodeCount(activeClient, id),
onError: (e) => appLogger.d('$logLabel: failed to fetch anime episode count ($name=$id)', error: e),
)
.catchError((Object _) => null);
}
@@ -38,4 +38,35 @@ class KeyedFutureCoalescer<K, T> {
_inFlight[key] = future;
return future;
}
/// Detach every in-flight future — the keyed form of [FutureCoalescer.reset].
void clear() {
_inFlight.clear();
}
}
/// Keyed cache of loads: like [KeyedFutureCoalescer], but a successful future
/// stays memoized instead of being dropped on completion, and only a failure
/// evicts the key so the next call retries. [onError] fires once per failed
/// load, before the error is rethrown to every caller.
class KeyedFutureCache<K, T> {
final Map<K, Future<T>> _entries = {};
Future<T> run(K key, Future<T> Function() create, {void Function(Object error)? onError}) {
final existing = _entries[key];
if (existing != null) return existing;
late final Future<T> future;
future = create().catchError((Object e) {
if (identical(_entries[key], future)) _entries.remove(key);
onError?.call(e);
throw e;
});
_entries[key] = future;
return future;
}
void clear() {
_entries.clear();
}
}
+4 -14
View File
@@ -8,6 +8,7 @@ import '../../utils/external_ids.dart';
import 'anime_episode_progress_resolver.dart';
import 'anime_lists_mapping_store.dart';
import 'fribb_mapping_store.dart';
import 'future_coalescer.dart';
/// Paired ID output: always-present Plex external IDs (tvdb/imdb/tmdb) plus
/// optional Fribb-sourced anime IDs (mal/anilist/simkl). Simkl uses [external]
@@ -77,7 +78,7 @@ class TrackerIdResolver {
/// Null entries mean "the server had no IDs" — cached so scrubbing on an
/// un-matched item doesn't re-hit the server every position update.
final Map<String, TrackerIds?> _cache = {};
final Map<String, Future<ExternalIds>> _externalIdLoads = {};
final KeyedFutureCache<String, ExternalIds> _externalIdLoads = KeyedFutureCache();
TrackerIdResolver(
MediaServerClient client, {
@@ -97,19 +98,8 @@ class TrackerIdResolver {
/// [MediaServerClient.fetchExternalIds] surface — Plex hits
/// `/library/metadata/{id}?includeGuids=1`, Jellyfin reads the inline
/// `ProviderIds` map.
Future<ExternalIds> _fetchExternalIds(String itemId) {
final existing = _externalIdLoads[itemId];
if (existing != null) return existing;
late final Future<ExternalIds> loading;
loading = _client.fetchExternalIds(itemId).catchError((Object e) {
if (identical(_externalIdLoads[itemId], loading)) {
final _ = _externalIdLoads.remove(itemId);
}
throw e;
});
_externalIdLoads[itemId] = loading;
return loading;
}
Future<ExternalIds> _fetchExternalIds(String itemId) =>
_externalIdLoads.run(itemId, () => _client.fetchExternalIds(itemId));
/// Resolve IDs for a movie.
Future<TrackerIds?> resolveForMovie(String itemId) async {