1
0
mirror of https://github.com/immich-app/immich.git synced 2025-08-08 23:07:06 +02:00

feat(mobile): preserve mobile album info on upload (#11965)

* curating assets with albums to upload

* sorting for background backup

* background upload works

* transform fields string array to javascript array

* send json array

* generate sql

* refactor upload callback

* remove albums info from upload payload

* mechanism to create album on album selection

* album creation

* Sync to upload album

* Remove unused service

* unify name changes

* Add mechanism to sync uploaded assets to albums

* Put add to album operation after updating the UI state

* clean up

* background album sync

* add to album in background context

* remove add to album in callback

* refactor

* refactor

* refactor

* fix: make sure all selected albums are selected for building upload candidate

* clean up

* add manual sync button

* lint

* revert server changes

* pr feedback

* revert time filtering

* const

* sync album on manual upload

* linting

* pr feedback and proper time filtering

* wording
This commit is contained in:
Alex
2024-08-26 13:21:19 -05:00
committed by GitHub
parent f4371578f5
commit 6b6d2a6621
19 changed files with 657 additions and 233 deletions

View File

@ -23,6 +23,7 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
});
_streamSub = query.watch().listen((data) => state = data);
}
final AlbumService _albumService;
late final StreamSubscription<List<Album>> _streamSub;
@ -41,6 +42,23 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
) =>
_albumService.createAlbum(albumTitle, assets, []);
Future<Album?> getAlbumByName(String albumName, {bool remoteOnly = false}) =>
_albumService.getAlbumByName(albumName, remoteOnly);
/// Create an album on the server with the same name as the selected album for backup
/// First this will check if the album already exists on the server with name
/// If it does not exist, it will create the album on the server
Future<void> createSyncAlbum(
String albumName,
) async {
final album = await getAlbumByName(albumName, remoteOnly: true);
if (album != null) {
return;
}
await createAlbum(albumName, {});
}
@override
void dispose() {
_streamSub.cancel();

View File

@ -2,13 +2,16 @@ import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
@ -290,8 +293,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
///
Future<void> _updateBackupAssetCount() async {
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
final Set<AssetEntity> assetsFromSelectedAlbums = {};
final Set<AssetEntity> assetsFromExcludedAlbums = {};
final Set<BackupCandidate> assetsFromSelectedAlbums = {};
final Set<BackupCandidate> assetsFromExcludedAlbums = {};
for (final album in state.selectedBackupAlbums) {
final assetCount = await album.albumEntity.assetCountAsync;
@ -304,7 +307,27 @@ class BackupNotifier extends StateNotifier<BackUpState> {
start: 0,
end: assetCount,
);
assetsFromSelectedAlbums.addAll(assets);
// Add album's name to the asset info
for (final asset in assets) {
List<String> albumNames = [album.name];
final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull(
(a) => a.asset.id == asset.id,
);
if (existingAsset != null) {
albumNames.addAll(existingAsset.albumNames);
assetsFromSelectedAlbums.remove(existingAsset);
}
assetsFromSelectedAlbums.add(
BackupCandidate(
asset: asset,
albumNames: albumNames,
),
);
}
}
for (final album in state.excludedBackupAlbums) {
@ -318,11 +341,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
start: 0,
end: assetCount,
);
assetsFromExcludedAlbums.addAll(assets);
for (final asset in assets) {
assetsFromExcludedAlbums.add(
BackupCandidate(asset: asset, albumNames: [album.name]),
);
}
}
final Set<AssetEntity> allUniqueAssets =
final Set<BackupCandidate> allUniqueAssets =
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
@ -331,14 +360,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Find asset that were backup from selected albums
final Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id));
Set.from(allUniqueAssets.map((e) => e.asset.id));
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
(asset) => duplicatedAssetIds.contains(asset.id),
(candidate) => duplicatedAssetIds.contains(candidate.asset.id),
);
if (allUniqueAssets.isEmpty) {
@ -433,10 +462,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return;
}
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up
for (final assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId);
}
if (assetsWillBeBackup.isEmpty) {
@ -456,11 +485,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await _backupService.backupAsset(
assetsWillBeBackup,
state.cancelToken,
pmProgressHandler,
_onAssetUploaded,
_onUploadProgress,
_onSetCurrentBackupAsset,
_onBackupError,
pmProgressHandler: pmProgressHandler,
onSuccess: _onAssetUploaded,
onProgress: _onUploadProgress,
onCurrentAsset: _onSetCurrentBackupAsset,
onError: _onBackupError,
);
await notifyBackgroundServiceCanRun();
} else {
@ -497,34 +526,36 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
}
void _onAssetUploaded(
String deviceAssetId,
String deviceId,
bool isDuplicated,
) {
if (isDuplicated) {
void _onAssetUploaded(SuccessUploadAsset result) async {
if (result.isDuplicate) {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
.where((asset) => asset.id != deviceAssetId)
.where(
(candidate) => candidate.asset.id != result.candidate.asset.id,
)
.toSet(),
);
} else {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId,
result.candidate.asset.id,
},
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
allAssetsInDatabase: [
...state.allAssetsInDatabase,
result.candidate.asset.id,
],
);
}
if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length ==
0) {
final latestAssetBackup =
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
(v, e) => e.isAfter(v) ? e : v,
);
final latestAssetBackup = state.allUniqueAssets
.map((candidate) => candidate.asset.modifiedDateTime)
.reduce(
(v, e) => e.isAfter(v) ? e : v,
);
state = state.copyWith(
selectedBackupAlbums: state.selectedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup))

View File

@ -6,6 +6,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
@ -22,6 +24,7 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';
@ -31,6 +34,7 @@ final manualUploadProvider =
return ManualUploadNotifier(
ref.watch(localNotificationService),
ref.watch(backupProvider.notifier),
ref.watch(backupServiceProvider),
ref,
);
});
@ -39,11 +43,13 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final Logger _log = Logger("ManualUploadNotifier");
final LocalNotificationService _localNotificationService;
final BackupNotifier _backupProvider;
final BackupService _backupService;
final Ref ref;
ManualUploadNotifier(
this._localNotificationService,
this._backupProvider,
this._backupService,
this.ref,
) : super(
ManualUploadState(
@ -115,11 +121,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
}
}
void _onAssetUploaded(
String deviceAssetId,
String deviceId,
bool isDuplicated,
) {
void _onAssetUploaded(SuccessUploadAsset result) {
state = state.copyWith(successfulUploads: state.successfulUploads + 1);
_backupProvider.updateDiskInfo();
}
@ -209,9 +211,23 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
);
}
Set<AssetEntity> allUploadAssets = allAssetsFromDevice.nonNulls.toSet();
final selectedBackupAlbums =
_backupService.selectedAlbumsQuery().findAllSync();
final excludedBackupAlbums =
_backupService.excludedAlbumsQuery().findAllSync();
if (allUploadAssets.isEmpty) {
// Get candidates from selected albums and excluded albums
Set<BackupCandidate> candidates =
await _backupService.buildUploadCandidates(
selectedBackupAlbums,
excludedBackupAlbums,
);
// Extrack candidate from allAssetsFromDevice.nonNulls
final uploadAssets = candidates
.where((e) => allAssetsFromDevice.nonNulls.contains(e.asset));
if (uploadAssets.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process");
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
return false;
@ -221,7 +237,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
totalAssetsToUpload: allUploadAssets.length,
totalAssetsToUpload: uploadAssets.length,
successfulUploads: 0,
currentAssetIndex: 0,
currentUploadAsset: CurrentUploadAsset(
@ -250,13 +266,13 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final bool ok = await ref.read(backupServiceProvider).backupAsset(
allUploadAssets,
uploadAssets,
state.cancelToken,
pmProgressHandler,
_onAssetUploaded,
_onProgress,
_onSetCurrentBackupAsset,
_onAssetUploadError,
pmProgressHandler: pmProgressHandler,
onSuccess: _onAssetUploaded,
onProgress: _onProgress,
onCurrentAsset: _onSetCurrentBackupAsset,
onError: _onAssetUploadError,
);
// Close detailed notification