mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
background upload plugin
add schemas sync variants formatting initial implementation use existing db, wip move to separate folder fix table definitions wip wiring it up
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
@@ -11,7 +10,6 @@ import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
@@ -148,21 +146,13 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
|
||||
|
||||
try {
|
||||
bool syncSuccess = false;
|
||||
await Future.wait([
|
||||
_safeRun(backgroundManager.syncLocal(), "syncLocal"),
|
||||
_safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"),
|
||||
_safeRun(backgroundManager.syncRemote(), "syncRemote"),
|
||||
]);
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
|
||||
_resumeBackup();
|
||||
}),
|
||||
_resumeBackup(),
|
||||
]);
|
||||
} else {
|
||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||
}
|
||||
|
||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||
await _safeRun(uploadApi.refresh(), "refresh");
|
||||
|
||||
if (isAlbumLinkedSyncEnable) {
|
||||
await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum");
|
||||
@@ -172,20 +162,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resumeBackup() async {
|
||||
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
|
||||
if (isEnableBackup) {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
if (currentUser != null) {
|
||||
await _safeRun(
|
||||
_ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id),
|
||||
"handleBackupResume",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to check if operations should continue
|
||||
bool _shouldContinueOperation() {
|
||||
return [AppLifeCycleEnum.resumed, AppLifeCycleEnum.active].contains(state) &&
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||
import 'package:immich_mobile/platform/upload_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||
((ref) => ShareIntentUploadStateNotifier(
|
||||
@@ -25,10 +22,12 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||
final AppRouter router;
|
||||
final UploadService _uploadService;
|
||||
final ShareIntentService _shareIntentService;
|
||||
late final StreamSubscription<UploadApiTaskStatus> _taskStatusStream;
|
||||
late final StreamSubscription<UploadApiTaskProgress> _taskProgressStream;
|
||||
|
||||
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
|
||||
_uploadService.taskStatusStream.listen(_updateUploadStatus);
|
||||
_uploadService.taskProgressStream.listen(_taskProgressCallback);
|
||||
_taskStatusStream = _uploadService.taskStatusStream.listen(_updateUploadStatus);
|
||||
_taskProgressStream = _uploadService.taskProgressStream.listen(_taskProgressCallback);
|
||||
}
|
||||
|
||||
void init() {
|
||||
@@ -36,6 +35,13 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||
_shareIntentService.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_taskStatusStream.cancel());
|
||||
unawaited(_taskProgressStream.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void onSharedMedia(List<ShareIntentAttachment> attachments) {
|
||||
router.removeWhere((route) => route.name == "ShareIntentRoute");
|
||||
clearAttachments();
|
||||
@@ -65,82 +71,35 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||
state = [];
|
||||
}
|
||||
|
||||
void _updateUploadStatus(TaskStatusUpdate task) async {
|
||||
if (task.status == TaskStatus.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final taskId = task.task.taskId;
|
||||
void _updateUploadStatus(UploadApiTaskStatus task) {
|
||||
final uploadStatus = switch (task.status) {
|
||||
TaskStatus.complete => UploadStatus.complete,
|
||||
TaskStatus.failed => UploadStatus.failed,
|
||||
TaskStatus.canceled => UploadStatus.canceled,
|
||||
TaskStatus.enqueued => UploadStatus.enqueued,
|
||||
TaskStatus.running => UploadStatus.running,
|
||||
TaskStatus.paused => UploadStatus.paused,
|
||||
TaskStatus.notFound => UploadStatus.notFound,
|
||||
TaskStatus.waitingToRetry => UploadStatus.waitingToRetry,
|
||||
UploadApiStatus.uploadComplete => UploadStatus.complete,
|
||||
UploadApiStatus.uploadFailed || UploadApiStatus.downloadFailed => UploadStatus.failed,
|
||||
UploadApiStatus.uploadQueued => UploadStatus.enqueued,
|
||||
_ => UploadStatus.preparing,
|
||||
};
|
||||
|
||||
final taskId = task.id.toInt();
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
|
||||
if (attachment.id == taskId) attachment.copyWith(status: uploadStatus) else attachment,
|
||||
];
|
||||
}
|
||||
|
||||
void _taskProgressCallback(TaskProgressUpdate update) {
|
||||
void _taskProgressCallback(UploadApiTaskProgress update) {
|
||||
// Ignore if the task is canceled or completed
|
||||
if (update.progress == downloadFailed || update.progress == downloadCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final taskId = update.task.taskId;
|
||||
final taskId = update.id.toInt();
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == taskId.toInt()) attachment.copyWith(uploadProgress: update.progress) else attachment,
|
||||
if (attachment.id == taskId) attachment.copyWith(uploadProgress: update.progress) else attachment,
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> upload(File file) async {
|
||||
final task = await _buildUploadTask(hash(file.path).toString(), file);
|
||||
|
||||
await _uploadService.enqueueTasks([task]);
|
||||
}
|
||||
|
||||
Future<UploadTask> _buildUploadTask(String id, File file, {Map<String, String>? fields}) async {
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final url = Uri.parse('$serverEndpoint/assets').toString();
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
|
||||
final stats = await file.stat();
|
||||
final fileCreatedAt = stats.changed;
|
||||
final fileModifiedAt = stats.modified;
|
||||
|
||||
final fieldsMap = {
|
||||
'filename': filename,
|
||||
'deviceAssetId': id,
|
||||
'deviceId': deviceId,
|
||||
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': 'false',
|
||||
'duration': '0',
|
||||
if (fields != null) ...fields,
|
||||
};
|
||||
|
||||
return UploadTask(
|
||||
taskId: id,
|
||||
httpRequestMethod: 'POST',
|
||||
url: url,
|
||||
headers: headers,
|
||||
filename: filename,
|
||||
fields: fieldsMap,
|
||||
baseDirectory: baseDirectory,
|
||||
directory: directory,
|
||||
fileField: 'assetData',
|
||||
group: kManualUploadGroup,
|
||||
updates: Updates.statusAndProgress,
|
||||
);
|
||||
Future<void> upload(List<ShareIntentAttachment> files) {
|
||||
return uploadApi.enqueueFiles(files.map((e) => e.path).toList(growable: false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:collection/collection.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/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/platform/upload_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
@@ -196,6 +194,9 @@ final driftBackupProvider = StateNotifierProvider<DriftBackupNotifier, DriftBack
|
||||
});
|
||||
|
||||
class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
late final StreamSubscription<UploadApiTaskStatus> _taskStatusStream;
|
||||
late final StreamSubscription<UploadApiTaskProgress> _taskProgressStream;
|
||||
|
||||
DriftBackupNotifier(this._uploadService)
|
||||
: super(
|
||||
const DriftBackupState(
|
||||
@@ -211,17 +212,20 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
error: BackupError.none,
|
||||
),
|
||||
) {
|
||||
{
|
||||
_uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
|
||||
_uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
|
||||
}
|
||||
_taskStatusStream = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
|
||||
_taskProgressStream = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
|
||||
}
|
||||
|
||||
final UploadService _uploadService;
|
||||
StreamSubscription<TaskStatusUpdate>? _statusSubscription;
|
||||
StreamSubscription<TaskProgressUpdate>? _progressSubscription;
|
||||
final _logger = Logger("DriftBackupNotifier");
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_taskStatusStream.cancel());
|
||||
unawaited(_taskProgressStream.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Remove upload item from state
|
||||
void _removeUploadItem(String taskId) {
|
||||
if (state.uploadItems.containsKey(taskId)) {
|
||||
@@ -231,16 +235,12 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
|
||||
final taskId = update.task.taskId;
|
||||
void _handleTaskStatusUpdate(UploadApiTaskStatus update) {
|
||||
final taskId = update.id;
|
||||
|
||||
switch (update.status) {
|
||||
case TaskStatus.complete:
|
||||
if (update.task.group == kBackupGroup) {
|
||||
if (update.responseStatusCode == 201) {
|
||||
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
||||
}
|
||||
}
|
||||
case UploadApiStatus.uploadComplete:
|
||||
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
||||
|
||||
// Remove the completed task from the upload items
|
||||
if (state.uploadItems.containsKey(taskId)) {
|
||||
@@ -249,40 +249,45 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
});
|
||||
}
|
||||
|
||||
case TaskStatus.failed:
|
||||
// Ignore retry errors to avoid confusing users
|
||||
if (update.exception?.description == 'Delayed or retried enqueue failed') {
|
||||
_removeUploadItem(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
case UploadApiStatus.uploadFailed:
|
||||
final currentItem = state.uploadItems[taskId];
|
||||
if (currentItem == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String? error;
|
||||
final exception = update.exception;
|
||||
if (exception != null && exception is TaskHttpException) {
|
||||
final message = tryJsonDecode(exception.description)?['message'] as String?;
|
||||
if (message != null) {
|
||||
final responseCode = exception.httpResponseCode;
|
||||
error = "${exception.exceptionType}, response code $responseCode: $message";
|
||||
}
|
||||
}
|
||||
error ??= update.exception?.toString();
|
||||
// String? error;
|
||||
// final exception = update.exception;
|
||||
// if (exception != null && exception is TaskHttpException) {
|
||||
// final message = tryJsonDecode(exception.description)?['message'] as String?;
|
||||
// if (message != null) {
|
||||
// final responseCode = exception.httpResponseCode;
|
||||
// error = "${exception.exceptionType}, response code $responseCode: $message";
|
||||
// }
|
||||
// }
|
||||
// error ??= update.exception?.toString();
|
||||
|
||||
// state = state.copyWith(
|
||||
// uploadItems: {
|
||||
// ...state.uploadItems,
|
||||
// taskId: currentItem.copyWith(isFailed: true, error: error),
|
||||
// },
|
||||
// );
|
||||
// _logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}");
|
||||
break;
|
||||
|
||||
case UploadApiStatus.uploadPending:
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
taskId: currentItem.copyWith(isFailed: true, error: error),
|
||||
taskId: DriftUploadStatus(
|
||||
taskId: update.id.toString(),
|
||||
filename: update.filename,
|
||||
fileSize: 0,
|
||||
networkSpeedAsString: "",
|
||||
progress: 0.0,
|
||||
),
|
||||
},
|
||||
);
|
||||
_logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}");
|
||||
break;
|
||||
|
||||
case TaskStatus.canceled:
|
||||
_removeUploadItem(update.task.taskId);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -290,42 +295,21 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTaskProgressUpdate(TaskProgressUpdate update) {
|
||||
final taskId = update.task.taskId;
|
||||
final filename = update.task.displayName;
|
||||
void _handleTaskProgressUpdate(UploadApiTaskProgress update) {
|
||||
final taskId = update.id;
|
||||
final progress = update.progress;
|
||||
final currentItem = state.uploadItems[taskId];
|
||||
if (currentItem != null) {
|
||||
if (progress == kUploadStatusCanceled) {
|
||||
_removeUploadItem(update.task.taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
taskId: update.hasExpectedFileSize
|
||||
? currentItem.copyWith(
|
||||
progress: progress,
|
||||
fileSize: update.expectedFileSize,
|
||||
networkSpeedAsString: update.networkSpeedAsString,
|
||||
)
|
||||
: currentItem.copyWith(progress: progress),
|
||||
},
|
||||
);
|
||||
|
||||
if (currentItem == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
taskId: DriftUploadStatus(
|
||||
taskId: taskId,
|
||||
filename: filename,
|
||||
taskId: currentItem.copyWith(
|
||||
progress: progress,
|
||||
fileSize: update.expectedFileSize,
|
||||
networkSpeedAsString: update.networkSpeedAsString,
|
||||
fileSize: update.totalBytes,
|
||||
networkSpeedAsString: update.speed?.toStringAsFixed(2) ?? "",
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -350,52 +334,18 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
state = state.copyWith(isSyncing: isSyncing);
|
||||
}
|
||||
|
||||
Future<void> startBackup(String userId) {
|
||||
Future<void> startBackup() {
|
||||
state = state.copyWith(error: BackupError.none);
|
||||
return _uploadService.startBackup(userId, _updateEnqueueCount);
|
||||
}
|
||||
|
||||
void _updateEnqueueCount(EnqueueStatus status) {
|
||||
state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount);
|
||||
return _uploadService.startBackup();
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
dPrint(() => "Canceling backup tasks...");
|
||||
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none);
|
||||
|
||||
final activeTaskCount = await _uploadService.cancelBackup();
|
||||
|
||||
if (activeTaskCount > 0) {
|
||||
dPrint(() => "$activeTaskCount tasks left, continuing to cancel...");
|
||||
await cancel();
|
||||
} else {
|
||||
dPrint(() => "All tasks canceled successfully.");
|
||||
// Clear all upload items when cancellation is complete
|
||||
state = state.copyWith(isCanceling: false, uploadItems: {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleBackupResume(String userId) async {
|
||||
_logger.info("Resuming backup tasks...");
|
||||
state = state.copyWith(error: BackupError.none);
|
||||
final tasks = await _uploadService.getActiveTasks(kBackupGroup);
|
||||
_logger.info("Found ${tasks.length} tasks");
|
||||
|
||||
if (tasks.isEmpty) {
|
||||
// Start a new backup queue
|
||||
_logger.info("Start a new backup queue");
|
||||
return startBackup(userId);
|
||||
}
|
||||
|
||||
_logger.info("Tasks to resume: ${tasks.length}");
|
||||
return _uploadService.resumeBackup();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_statusSubscription?.cancel();
|
||||
_progressSubscription?.cancel();
|
||||
super.dispose();
|
||||
await _uploadService.cancelBackup();
|
||||
dPrint(() => "All tasks canceled successfully.");
|
||||
state = state.copyWith(isCanceling: false, uploadItems: {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/thumbnail_api.g.dart';
|
||||
import 'package:immich_mobile/platform/upload_api.g.dart';
|
||||
|
||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||
|
||||
@@ -17,3 +18,5 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||
|
||||
final thumbnailApi = ThumbnailApi();
|
||||
|
||||
final uploadApi = UploadApi();
|
||||
|
||||
Reference in New Issue
Block a user