mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-03 16:50:40 +02:00
perf(download): reduce progress wakeups and harden retries
This commit is contained in:
@@ -3,6 +3,8 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
|
||||
part 'download_item.g.dart';
|
||||
|
||||
const Object _downloadItemUnset = Object();
|
||||
|
||||
enum DownloadStatus {
|
||||
queued,
|
||||
downloading,
|
||||
@@ -72,9 +74,9 @@ class DownloadItem {
|
||||
double? speedMBps,
|
||||
int? bytesReceived,
|
||||
int? bytesTotal,
|
||||
String? filePath,
|
||||
String? error,
|
||||
DownloadErrorType? errorType,
|
||||
Object? filePath = _downloadItemUnset,
|
||||
Object? error = _downloadItemUnset,
|
||||
Object? errorType = _downloadItemUnset,
|
||||
String? preparationStage,
|
||||
DateTime? createdAt,
|
||||
String? qualityOverride,
|
||||
@@ -92,9 +94,15 @@ class DownloadItem {
|
||||
speedMBps: speedMBps ?? this.speedMBps,
|
||||
bytesReceived: bytesReceived ?? this.bytesReceived,
|
||||
bytesTotal: bytesTotal ?? this.bytesTotal,
|
||||
filePath: filePath ?? this.filePath,
|
||||
error: error ?? this.error,
|
||||
errorType: errorType ?? this.errorType,
|
||||
filePath: identical(filePath, _downloadItemUnset)
|
||||
? this.filePath
|
||||
: filePath as String?,
|
||||
error: identical(error, _downloadItemUnset)
|
||||
? this.error
|
||||
: error as String?,
|
||||
errorType: identical(errorType, _downloadItemUnset)
|
||||
? this.errorType
|
||||
: errorType as DownloadErrorType?,
|
||||
preparationStage: preparationStage ?? this.preparationStage,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||
|
||||
@@ -1311,7 +1311,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
void retryItem(String id) {
|
||||
Future<void> retryItem(String id) async {
|
||||
final item = state.items.where((i) => i.id == id).firstOrNull;
|
||||
if (item == null) {
|
||||
_log.w('retryItem: Item not found: $id');
|
||||
@@ -1328,11 +1328,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// A cancel issued while the item never started leaves a pre-registered
|
||||
// flag in the Go backend that the next attempt would consume and abort
|
||||
// instantly; the user asked for a retry, so drop it first.
|
||||
unawaited(
|
||||
PlatformBridge.resetDownloadCancel(id).catchError((Object e) {
|
||||
_log.w('Failed to reset cancel flag for $id: $e');
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await PlatformBridge.resetDownloadCancel(id);
|
||||
} catch (e) {
|
||||
_log.w('Failed to reset cancel flag for $id: $e');
|
||||
return;
|
||||
}
|
||||
final current = state.items.where((i) => i.id == id).firstOrNull;
|
||||
if (current == null ||
|
||||
(current.status != DownloadStatus.failed &&
|
||||
current.status != DownloadStatus.skipped)) {
|
||||
return;
|
||||
}
|
||||
_locallyCancelledItemIds.remove(id);
|
||||
_verificationRetryGuard.clearItem(id);
|
||||
_rateLimitRetriedItemIds.remove(id);
|
||||
@@ -1347,6 +1354,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
status: DownloadStatus.queued,
|
||||
progress: 0,
|
||||
error: null,
|
||||
errorType: null,
|
||||
filePath: null,
|
||||
);
|
||||
}
|
||||
return i;
|
||||
@@ -1362,7 +1371,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
void retryAllFailed({bool networkOnly = false}) {
|
||||
Future<void> retryAllFailed({bool networkOnly = false}) async {
|
||||
final failedIds = state.items
|
||||
.where(
|
||||
(item) =>
|
||||
@@ -1378,24 +1387,34 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
_log.i('Retrying ${failedIds.length} failed download(s)');
|
||||
for (final id in failedIds) {
|
||||
unawaited(
|
||||
PlatformBridge.resetDownloadCancel(id).catchError((Object e) {
|
||||
final resetResults = await Future.wait(
|
||||
failedIds.map((id) async {
|
||||
try {
|
||||
await PlatformBridge.resetDownloadCancel(id);
|
||||
return id;
|
||||
} catch (e) {
|
||||
_log.w('Failed to reset cancel flag for $id: $e');
|
||||
}),
|
||||
);
|
||||
}
|
||||
_locallyCancelledItemIds.removeAll(failedIds);
|
||||
_pausePendingItemIds.removeAll(failedIds);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
final retryableIds = resetResults.whereType<String>().toSet();
|
||||
if (retryableIds.isEmpty) return;
|
||||
_locallyCancelledItemIds.removeAll(retryableIds);
|
||||
_pausePendingItemIds.removeAll(retryableIds);
|
||||
|
||||
for (final item in state.items) {
|
||||
if (!failedIds.contains(item.id)) continue;
|
||||
if (!retryableIds.contains(item.id)) continue;
|
||||
_purgeAlbumRgEntry(item.track);
|
||||
}
|
||||
|
||||
final items = state.items
|
||||
.map((item) {
|
||||
if (!failedIds.contains(item.id)) return item;
|
||||
if (!retryableIds.contains(item.id)) return item;
|
||||
if (item.status != DownloadStatus.failed &&
|
||||
item.status != DownloadStatus.skipped) {
|
||||
return item;
|
||||
}
|
||||
return item.copyWith(
|
||||
status: DownloadStatus.queued,
|
||||
progress: 0,
|
||||
@@ -1403,6 +1422,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
bytesReceived: 0,
|
||||
bytesTotal: 0,
|
||||
error: null,
|
||||
errorType: null,
|
||||
filePath: null,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
|
||||
@@ -56,8 +56,9 @@ class ProgressStreamPoller<T> {
|
||||
StreamSubscription<T>? _sub;
|
||||
bool _hasReceivedStreamEvent = false;
|
||||
bool _usingStream = false;
|
||||
bool _inFlight = false;
|
||||
int? _inFlightGeneration;
|
||||
int _errorCount = 0;
|
||||
int _generation = 0;
|
||||
|
||||
bool get usingStream => _usingStream;
|
||||
|
||||
@@ -65,6 +66,8 @@ class ProgressStreamPoller<T> {
|
||||
/// stream (falling back to polling on timeout/error); otherwise starts
|
||||
/// polling immediately.
|
||||
void start({required bool useStream}) {
|
||||
_generation++;
|
||||
final generation = _generation;
|
||||
_timer?.cancel();
|
||||
_bootstrapTimer?.cancel();
|
||||
_bootstrapTimer = null;
|
||||
@@ -74,22 +77,28 @@ class ProgressStreamPoller<T> {
|
||||
_usingStream = false;
|
||||
|
||||
if (useStream) {
|
||||
_attachStream();
|
||||
_attachStream(generation);
|
||||
return;
|
||||
}
|
||||
startPollingTimer();
|
||||
startPollingTimer(generation: generation);
|
||||
}
|
||||
|
||||
void _attachStream() {
|
||||
void _attachStream(int generation) {
|
||||
_sub = streamProvider().listen(
|
||||
(progress) async {
|
||||
if (generation != _generation) return;
|
||||
_hasReceivedStreamEvent = true;
|
||||
_usingStream = true;
|
||||
_bootstrapTimer?.cancel();
|
||||
_bootstrapTimer = null;
|
||||
await _runGuarded(() async => progress, onStreamProcessingError);
|
||||
await _runGuarded(
|
||||
() async => progress,
|
||||
onStreamProcessingError,
|
||||
generation: generation,
|
||||
);
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (generation != _generation) return;
|
||||
if (_usingStream) {
|
||||
onStreamFailed(error);
|
||||
}
|
||||
@@ -98,59 +107,74 @@ class ProgressStreamPoller<T> {
|
||||
_usingStream = false;
|
||||
_bootstrapTimer?.cancel();
|
||||
_bootstrapTimer = null;
|
||||
startPollingTimer();
|
||||
startPollingTimer(generation: generation);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
_bootstrapTimer = Timer(bootstrapTimeout, () {
|
||||
if (generation != _generation) return;
|
||||
if (_hasReceivedStreamEvent) return;
|
||||
onStreamTimeout();
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
_usingStream = false;
|
||||
startPollingTimer();
|
||||
startPollingTimer(generation: generation);
|
||||
});
|
||||
}
|
||||
|
||||
/// (Re)starts the fallback polling timer.
|
||||
void startPollingTimer() {
|
||||
void startPollingTimer({int? generation}) {
|
||||
final activeGeneration = generation ?? _generation;
|
||||
if (activeGeneration != _generation) return;
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(pollingInterval, (_) async {
|
||||
await _runGuarded(pollProvider, onPollError, gate: shouldPollTick);
|
||||
await _runGuarded(
|
||||
pollProvider,
|
||||
onPollError,
|
||||
gate: shouldPollTick,
|
||||
generation: activeGeneration,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot immediate fetch reusing the same in-flight guard and error
|
||||
/// counter as the periodic poll, with its own error callback.
|
||||
Future<void> pollOnce(void Function(Object error) onError) =>
|
||||
_runGuarded(pollProvider, onError);
|
||||
_runGuarded(pollProvider, onError, generation: _generation);
|
||||
|
||||
Future<void> _runGuarded(
|
||||
Future<T> Function() fetch,
|
||||
void Function(Object error) onError, {
|
||||
bool Function()? gate,
|
||||
required int generation,
|
||||
}) async {
|
||||
if (_inFlight) return;
|
||||
_inFlight = true;
|
||||
if (generation != _generation || _inFlightGeneration == generation) return;
|
||||
_inFlightGeneration = generation;
|
||||
try {
|
||||
if (gate != null && !gate()) return;
|
||||
final progress = await fetch();
|
||||
if (generation != _generation) return;
|
||||
await onProgress(progress);
|
||||
if (generation != _generation) return;
|
||||
_errorCount = 0;
|
||||
} catch (e) {
|
||||
if (generation != _generation) return;
|
||||
_errorCount++;
|
||||
if (_errorCount <= _errorLogThreshold) {
|
||||
onError(e);
|
||||
}
|
||||
} finally {
|
||||
_inFlight = false;
|
||||
if (_inFlightGeneration == generation) {
|
||||
_inFlightGeneration = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels timers/subscription and resets guard/counter state, without
|
||||
/// releasing the poller for reuse (matches each caller's `_stop*` reset).
|
||||
void stop() {
|
||||
_generation++;
|
||||
_timer?.cancel();
|
||||
_bootstrapTimer?.cancel();
|
||||
_sub?.cancel();
|
||||
@@ -158,7 +182,7 @@ class ProgressStreamPoller<T> {
|
||||
_bootstrapTimer = null;
|
||||
_sub = null;
|
||||
_errorCount = 0;
|
||||
_inFlight = false;
|
||||
_inFlightGeneration = null;
|
||||
_hasReceivedStreamEvent = false;
|
||||
_usingStream = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user