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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user