mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
306 lines
9.8 KiB
Dart
306 lines
9.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/constants/constants.dart';
|
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|
import 'package:immich_mobile/providers/user.provider.dart';
|
|
import 'package:immich_mobile/services/background_upload.service.dart';
|
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
|
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
|
|
import 'package:logging/logging.dart';
|
|
|
|
part 'drift_backup.provider.freezed.dart';
|
|
|
|
class EnqueueStatus {
|
|
final int enqueueCount;
|
|
final int totalCount;
|
|
|
|
const EnqueueStatus({required this.enqueueCount, required this.totalCount});
|
|
|
|
EnqueueStatus copyWith({int? enqueueCount, int? totalCount}) {
|
|
return EnqueueStatus(enqueueCount: enqueueCount ?? this.enqueueCount, totalCount: totalCount ?? this.totalCount);
|
|
}
|
|
|
|
@override
|
|
String toString() => 'EnqueueStatus(enqueueCount: $enqueueCount, totalCount: $totalCount)';
|
|
}
|
|
|
|
@freezed
|
|
abstract class DriftUploadStatus with _$DriftUploadStatus {
|
|
const factory DriftUploadStatus({
|
|
required String taskId,
|
|
required String filename,
|
|
required double progress,
|
|
required int fileSize,
|
|
required String networkSpeedAsString,
|
|
bool? isFailed,
|
|
String? error,
|
|
}) = _DriftUploadStatus;
|
|
}
|
|
|
|
enum BackupError { none, syncFailed }
|
|
|
|
@freezed
|
|
abstract class DriftBackupState with _$DriftBackupState {
|
|
const DriftBackupState._();
|
|
|
|
const factory DriftBackupState({
|
|
required int totalCount,
|
|
required int backupCount,
|
|
required int remainderCount,
|
|
required int processingCount,
|
|
required bool isSyncing,
|
|
@Default(BackupError.none) BackupError error,
|
|
required Map<String, DriftUploadStatus> uploadItems,
|
|
@Default({}) Map<String, double> iCloudDownloadProgress,
|
|
}) = _DriftBackupState;
|
|
|
|
int get errorCount => uploadItems.values.where((item) => item.isFailed == true).length;
|
|
}
|
|
|
|
final driftBackupProvider = StateNotifierProvider<DriftBackupNotifier, DriftBackupState>((ref) {
|
|
return DriftBackupNotifier(
|
|
ref.watch(foregroundUploadServiceProvider),
|
|
ref.watch(backgroundUploadServiceProvider),
|
|
UploadSpeedManager(),
|
|
);
|
|
});
|
|
|
|
class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
|
DriftBackupNotifier(this._foregroundUploadService, this._backgroundUploadService, this._uploadSpeedManager)
|
|
: super(
|
|
const DriftBackupState(
|
|
totalCount: 0,
|
|
backupCount: 0,
|
|
remainderCount: 0,
|
|
processingCount: 0,
|
|
isSyncing: false,
|
|
uploadItems: {},
|
|
error: BackupError.none,
|
|
),
|
|
);
|
|
|
|
final ForegroundUploadService _foregroundUploadService;
|
|
final BackgroundUploadService _backgroundUploadService;
|
|
final UploadSpeedManager _uploadSpeedManager;
|
|
Completer<void>? _cancelToken;
|
|
|
|
final _logger = Logger("DriftBackupNotifier");
|
|
|
|
/// Remove upload item from state
|
|
void _removeUploadItem(String taskId) {
|
|
if (!mounted) {
|
|
_logger.warning("Skip _removeUploadItem: notifier disposed");
|
|
return;
|
|
}
|
|
if (state.uploadItems.containsKey(taskId)) {
|
|
final updatedItems = Map<String, DriftUploadStatus>.from(state.uploadItems);
|
|
updatedItems.remove(taskId);
|
|
state = state.copyWith(uploadItems: updatedItems);
|
|
}
|
|
}
|
|
|
|
Future<void> getBackupStatus(String userId) async {
|
|
if (!mounted) {
|
|
_logger.warning("Skip getBackupStatus (pre-call): notifier disposed");
|
|
return;
|
|
}
|
|
final counts = await _foregroundUploadService.getBackupCounts(userId);
|
|
if (!mounted) {
|
|
_logger.warning("Skip getBackupStatus (post-call): notifier disposed");
|
|
return;
|
|
}
|
|
|
|
state = state.copyWith(
|
|
totalCount: counts.total,
|
|
backupCount: counts.total - counts.remainder,
|
|
remainderCount: counts.remainder,
|
|
processingCount: counts.processing,
|
|
);
|
|
}
|
|
|
|
void updateError(BackupError error) {
|
|
if (!mounted) {
|
|
_logger.warning("Skip updateError: notifier disposed");
|
|
return;
|
|
}
|
|
state = state.copyWith(error: error);
|
|
}
|
|
|
|
void updateSyncing(bool isSyncing) {
|
|
state = state.copyWith(isSyncing: isSyncing);
|
|
}
|
|
|
|
Future<void> startForegroundBackup(String userId) async {
|
|
// Cancel any existing backup before starting a new one
|
|
if (_cancelToken != null) {
|
|
stopForegroundBackup(reason: "restarting the backup");
|
|
}
|
|
|
|
state = state.copyWith(error: BackupError.none);
|
|
|
|
// A pause during the recount below nulls _cancelToken, so the run keeps its own reference.
|
|
final cancelToken = Completer<void>();
|
|
_cancelToken = cancelToken;
|
|
|
|
// Re-baseline the counters against the same DB read that feeds this run's candidate list,
|
|
// otherwise a resume counts duplicate successes against the old baseline (#26215).
|
|
await getBackupStatus(userId);
|
|
|
|
return _foregroundUploadService.uploadCandidates(
|
|
userId,
|
|
cancelToken,
|
|
callbacks: UploadCallbacks(
|
|
onProgress: _handleForegroundBackupProgress,
|
|
onSuccess: _handleForegroundBackupSuccess,
|
|
onError: _handleForegroundBackupError,
|
|
onICloudProgress: _handleICloudProgress,
|
|
),
|
|
);
|
|
}
|
|
|
|
void stopForegroundBackup({required String reason}) {
|
|
if (_cancelToken != null) {
|
|
_logger.info("Foreground backup cancelled: $reason");
|
|
}
|
|
_cancelToken?.complete();
|
|
_cancelToken = null;
|
|
_uploadSpeedManager.clear();
|
|
state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {});
|
|
}
|
|
|
|
void _handleICloudProgress(String localAssetId, double progress) {
|
|
state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress});
|
|
|
|
if (progress >= 1.0) {
|
|
Future.delayed(const Duration(milliseconds: 250), () {
|
|
final updatedProgress = Map<String, double>.from(state.iCloudDownloadProgress);
|
|
updatedProgress.remove(localAssetId);
|
|
state = state.copyWith(iCloudDownloadProgress: updatedProgress);
|
|
});
|
|
}
|
|
}
|
|
|
|
void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) {
|
|
if (_cancelToken == null) {
|
|
return;
|
|
}
|
|
|
|
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
|
final networkSpeedAsString = _uploadSpeedManager.updateProgress(localAssetId, bytes, totalBytes);
|
|
final currentItem = state.uploadItems[localAssetId];
|
|
if (currentItem != null) {
|
|
state = state.copyWith(
|
|
uploadItems: {
|
|
...state.uploadItems,
|
|
localAssetId: currentItem.copyWith(
|
|
filename: filename,
|
|
progress: progress,
|
|
fileSize: totalBytes,
|
|
networkSpeedAsString: networkSpeedAsString,
|
|
),
|
|
},
|
|
);
|
|
} else {
|
|
state = state.copyWith(
|
|
uploadItems: {
|
|
...state.uploadItems,
|
|
localAssetId: DriftUploadStatus(
|
|
taskId: localAssetId,
|
|
filename: filename,
|
|
progress: progress,
|
|
fileSize: totalBytes,
|
|
networkSpeedAsString: networkSpeedAsString,
|
|
),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) {
|
|
if (!mounted) {
|
|
_logger.warning("Skip _handleForegroundBackupSuccess: notifier disposed");
|
|
return;
|
|
}
|
|
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
|
_uploadSpeedManager.removeTask(localAssetId);
|
|
|
|
Future.delayed(const Duration(milliseconds: 1000), () {
|
|
_removeUploadItem(localAssetId);
|
|
});
|
|
}
|
|
|
|
void _handleForegroundBackupError(String localAssetId, String errorMessage) {
|
|
_logger.severe("Upload failed for $localAssetId: $errorMessage");
|
|
|
|
final currentItem = state.uploadItems[localAssetId];
|
|
if (currentItem != null) {
|
|
state = state.copyWith(
|
|
uploadItems: {
|
|
...state.uploadItems,
|
|
localAssetId: currentItem.copyWith(isFailed: true, error: errorMessage),
|
|
},
|
|
);
|
|
} else {
|
|
state = state.copyWith(
|
|
uploadItems: {
|
|
...state.uploadItems,
|
|
localAssetId: DriftUploadStatus(
|
|
taskId: localAssetId,
|
|
filename: 'Unknown',
|
|
progress: 0,
|
|
fileSize: 0,
|
|
networkSpeedAsString: '',
|
|
isFailed: true,
|
|
error: errorMessage,
|
|
),
|
|
},
|
|
);
|
|
}
|
|
|
|
_uploadSpeedManager.removeTask(localAssetId);
|
|
}
|
|
|
|
Future<void> startBackupWithURLSession(String userId) async {
|
|
if (!mounted) {
|
|
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
|
|
return;
|
|
}
|
|
_logger.info("Start background backup sequence");
|
|
state = state.copyWith(error: BackupError.none);
|
|
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
|
|
if (!mounted) {
|
|
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
|
|
return;
|
|
}
|
|
_logger.info("Found ${tasks.length} pending tasks");
|
|
|
|
if (tasks.isEmpty) {
|
|
_logger.info("No pending tasks, starting new upload");
|
|
return _backgroundUploadService.uploadBackupCandidates(userId);
|
|
}
|
|
|
|
_logger.info("Resuming upload ${tasks.length} assets");
|
|
return _backgroundUploadService.resume();
|
|
}
|
|
}
|
|
|
|
final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>>((ref) {
|
|
final user = ref.watch(currentUserProvider);
|
|
if (user == null) {
|
|
return [];
|
|
}
|
|
|
|
return ref.read(foregroundUploadServiceProvider).getBackupCandidates(user.id, onlyHashed: false);
|
|
});
|
|
|
|
final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family<List<LocalAlbum>, String>((
|
|
ref,
|
|
assetId,
|
|
) {
|
|
return ref.read(localAssetRepository).getSourceAlbums(assetId, backupSelection: BackupSelection.selected);
|
|
});
|