You've already forked immich
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:
@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/services/backup.service.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
@ -28,7 +27,6 @@ final albumServiceProvider = Provider(
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(backupServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@ -37,7 +35,6 @@ class AlbumService {
|
||||
final UserService _userService;
|
||||
final SyncService _syncService;
|
||||
final Isar _db;
|
||||
final BackupService _backupService;
|
||||
final Logger _log = Logger('AlbumService');
|
||||
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
||||
@ -47,9 +44,15 @@ class AlbumService {
|
||||
this._userService,
|
||||
this._syncService,
|
||||
this._db,
|
||||
this._backupService,
|
||||
);
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
selectedAlbumsQuery() =>
|
||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
excludedAlbumsQuery() =>
|
||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
|
||||
|
||||
/// Checks all selected device albums for changes of albums and their assets
|
||||
/// Updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshDeviceAlbums() async {
|
||||
@ -63,9 +66,9 @@ class AlbumService {
|
||||
bool changes = false;
|
||||
try {
|
||||
final List<String> excludedIds =
|
||||
await _backupService.excludedAlbumsQuery().idProperty().findAll();
|
||||
await excludedAlbumsQuery().idProperty().findAll();
|
||||
final List<String> selectedIds =
|
||||
await _backupService.selectedAlbumsQuery().idProperty().findAll();
|
||||
await selectedAlbumsQuery().idProperty().findAll();
|
||||
if (selectedIds.isEmpty) {
|
||||
final numLocal = await _db.albums.where().localIdIsNotNull().count();
|
||||
if (numLocal > 0) {
|
||||
@ -441,4 +444,33 @@ class AlbumService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Album?> getAlbumByName(String name, bool remoteOnly) async {
|
||||
return _db.albums
|
||||
.filter()
|
||||
.optional(remoteOnly, (q) => q.localIdIsNull())
|
||||
.nameEqualTo(name)
|
||||
.sharedEqualTo(false)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
///
|
||||
/// Add the uploaded asset to the selected albums
|
||||
///
|
||||
Future<void> syncUploadAlbums(
|
||||
List<String> albumNames,
|
||||
List<String> assetIds,
|
||||
) async {
|
||||
for (final albumName in albumNames) {
|
||||
Album? album = await getAlbumByName(albumName, true);
|
||||
album ??= await createAlbum(albumName, []);
|
||||
|
||||
if (album != null && album.remoteId != null) {
|
||||
await _apiService.albumsApi.addAssetsToAlbum(
|
||||
album.remoteId!,
|
||||
BulkIdsDto(ids: assetIds),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -76,6 +76,7 @@ enum AppSettingsEnum<T> {
|
||||
false,
|
||||
),
|
||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
|
||||
;
|
||||
|
||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||
|
@ -2,15 +2,20 @@
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/etag.entity.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/backup.service.dart';
|
||||
import 'package:immich_mobile/services/sync.service.dart';
|
||||
import 'package:immich_mobile/services/user.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
@ -23,6 +28,8 @@ final assetServiceProvider = Provider(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(backupServiceProvider),
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
),
|
||||
);
|
||||
@ -31,6 +38,8 @@ class AssetService {
|
||||
final ApiService _apiService;
|
||||
final SyncService _syncService;
|
||||
final UserService _userService;
|
||||
final BackupService _backupService;
|
||||
final AlbumService _albumService;
|
||||
final log = Logger('AssetService');
|
||||
final Isar _db;
|
||||
|
||||
@ -38,6 +47,8 @@ class AssetService {
|
||||
this._apiService,
|
||||
this._syncService,
|
||||
this._userService,
|
||||
this._backupService,
|
||||
this._albumService,
|
||||
this._db,
|
||||
);
|
||||
|
||||
@ -284,4 +295,64 @@ class AssetService {
|
||||
return Future.value(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncUploadedAssetToAlbums() async {
|
||||
try {
|
||||
final [selectedAlbums, excludedAlbums] = await Future.wait([
|
||||
_backupService.selectedAlbumsQuery().findAll(),
|
||||
_backupService.excludedAlbumsQuery().findAll(),
|
||||
]);
|
||||
|
||||
final candidates = await _backupService.buildUploadCandidates(
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
useTimeFilter: false,
|
||||
);
|
||||
|
||||
final duplicates = await _apiService.assetsApi.checkExistingAssets(
|
||||
CheckExistingAssetsDto(
|
||||
deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
|
||||
deviceId: Store.get(StoreKey.deviceId),
|
||||
),
|
||||
);
|
||||
|
||||
if (duplicates != null) {
|
||||
candidates
|
||||
.removeWhere((c) => !duplicates.existingIds.contains(c.asset.id));
|
||||
}
|
||||
|
||||
await refreshRemoteAssets();
|
||||
final remoteAssets = await _db.assets
|
||||
.where()
|
||||
.localIdIsNotNull()
|
||||
.filter()
|
||||
.remoteIdIsNotNull()
|
||||
.findAll();
|
||||
|
||||
/// Map<AlbumName, [AssetId]>
|
||||
Map<String, List<String>> assetToAlbums = {};
|
||||
|
||||
for (BackupCandidate candidate in candidates) {
|
||||
final asset = remoteAssets.firstWhereOrNull(
|
||||
(a) => a.localId == candidate.asset.id,
|
||||
);
|
||||
|
||||
if (asset != null) {
|
||||
for (final albumName in candidate.albumNames) {
|
||||
assetToAlbums.putIfAbsent(albumName, () => []).add(asset.remoteId!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload assets to albums
|
||||
for (final entry in assetToAlbums.entries) {
|
||||
final albumName = entry.key;
|
||||
final assetIds = entry.value;
|
||||
|
||||
await _albumService.syncUploadAlbums([albumName], assetIds);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
log.severe("Error while syncing uploaded asset to albums", error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,10 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/main.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/album.service.dart';
|
||||
import 'package:immich_mobile/services/hash.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||
@ -18,6 +22,9 @@ import 'package:immich_mobile/services/backup.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/partner.service.dart';
|
||||
import 'package:immich_mobile/services/sync.service.dart';
|
||||
import 'package:immich_mobile/services/user.service.dart';
|
||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
||||
@ -345,8 +352,16 @@ class BackgroundService {
|
||||
ApiService apiService = ApiService();
|
||||
apiService.setAccessToken(Store.get(StoreKey.accessToken));
|
||||
AppSettingsService settingService = AppSettingsService();
|
||||
BackupService backupService = BackupService(apiService, db, settingService);
|
||||
AppSettingsService settingsService = AppSettingsService();
|
||||
PartnerService partnerService = PartnerService(apiService, db);
|
||||
HashService hashService = HashService(db, this);
|
||||
SyncService syncSerive = SyncService(db, hashService);
|
||||
UserService userService =
|
||||
UserService(apiService, db, syncSerive, partnerService);
|
||||
AlbumService albumService =
|
||||
AlbumService(apiService, userService, syncSerive, db);
|
||||
BackupService backupService =
|
||||
BackupService(apiService, db, settingService, albumService);
|
||||
|
||||
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
|
||||
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
|
||||
@ -416,7 +431,7 @@ class BackgroundService {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<AssetEntity> toUpload = await backupService.buildUploadCandidates(
|
||||
Set<BackupCandidate> toUpload = await backupService.buildUploadCandidates(
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
);
|
||||
@ -460,29 +475,47 @@ class BackgroundService {
|
||||
final bool ok = await backupService.backupAsset(
|
||||
toUpload,
|
||||
_cancellationToken!,
|
||||
pmProgressHandler,
|
||||
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
|
||||
notifySingleProgress ? _onProgress : (sent, total) {},
|
||||
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
|
||||
_onBackupError,
|
||||
sortAssets: true,
|
||||
pmProgressHandler: pmProgressHandler,
|
||||
onSuccess: (result) => _onAssetUploaded(
|
||||
result: result,
|
||||
shouldNotify: notifyTotalProgress,
|
||||
),
|
||||
onProgress: (bytes, totalBytes) =>
|
||||
_onProgress(bytes, totalBytes, shouldNotify: notifySingleProgress),
|
||||
onCurrentAsset: (asset) =>
|
||||
_onSetCurrentBackupAsset(asset, shouldNotify: notifySingleProgress),
|
||||
onError: _onBackupError,
|
||||
isBackground: true,
|
||||
);
|
||||
|
||||
if (!ok && !_cancellationToken!.isCancelled) {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
content: "backup_background_service_backup_failed_message".tr(),
|
||||
);
|
||||
}
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
|
||||
void _onAssetUploaded({
|
||||
required SuccessUploadAsset result,
|
||||
bool shouldNotify = false,
|
||||
}) async {
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
|
||||
_uploadedAssetsCount++;
|
||||
_throttledNotifiy();
|
||||
}
|
||||
|
||||
void _onProgress(int sent, int total) {
|
||||
_throttledDetailNotify(progress: sent, total: total);
|
||||
void _onProgress(int bytes, int totalBytes, {bool shouldNotify = false}) {
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
|
||||
_throttledDetailNotify(progress: bytes, total: totalBytes);
|
||||
}
|
||||
|
||||
void _updateDetailProgress(String? title, int progress, int total) {
|
||||
@ -522,7 +555,14 @@ class BackgroundService {
|
||||
);
|
||||
}
|
||||
|
||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||
void _onSetCurrentBackupAsset(
|
||||
CurrentUploadAsset currentUploadAsset, {
|
||||
bool shouldNotify = false,
|
||||
}) {
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
|
||||
_throttledDetailNotify.title =
|
||||
"backup_background_service_current_upload_notification"
|
||||
.tr(args: [currentUploadAsset.fileName]);
|
||||
|
@ -9,11 +9,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_candidate.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/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
@ -28,6 +31,7 @@ final backupServiceProvider = Provider(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(albumServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@ -37,8 +41,14 @@ class BackupService {
|
||||
final Isar _db;
|
||||
final Logger _log = Logger("BackupService");
|
||||
final AppSettingsService _appSetting;
|
||||
final AlbumService _albumService;
|
||||
|
||||
BackupService(this._apiService, this._db, this._appSetting);
|
||||
BackupService(
|
||||
this._apiService,
|
||||
this._db,
|
||||
this._appSetting,
|
||||
this._albumService,
|
||||
);
|
||||
|
||||
Future<List<String>?> getDeviceBackupAsset() async {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
@ -70,10 +80,12 @@ class BackupService {
|
||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
|
||||
|
||||
/// Returns all assets newer than the last successful backup per album
|
||||
Future<List<AssetEntity>> buildUploadCandidates(
|
||||
/// if `useTimeFilter` is set to true, all assets will be returned
|
||||
Future<Set<BackupCandidate>> buildUploadCandidates(
|
||||
List<BackupAlbum> selectedBackupAlbums,
|
||||
List<BackupAlbum> excludedBackupAlbums,
|
||||
) async {
|
||||
List<BackupAlbum> excludedBackupAlbums, {
|
||||
bool useTimeFilter = true,
|
||||
}) async {
|
||||
final filter = FilterOptionGroup(
|
||||
containsPathModified: true,
|
||||
orders: [const OrderOption(type: OrderOptionType.updateDate)],
|
||||
@ -82,105 +94,156 @@ class BackupService {
|
||||
videoOption: const FilterOption(needTitle: true),
|
||||
);
|
||||
final now = DateTime.now();
|
||||
|
||||
final List<AssetPathEntity?> selectedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now);
|
||||
await _loadAlbumsWithTimeFilter(
|
||||
selectedBackupAlbums,
|
||||
filter,
|
||||
now,
|
||||
useTimeFilter: useTimeFilter,
|
||||
);
|
||||
|
||||
if (selectedAlbums.every((e) => e == null)) {
|
||||
return [];
|
||||
}
|
||||
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
|
||||
if (allIdx != -1) {
|
||||
final List<AssetPathEntity?> excludedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
|
||||
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums.slice(allIdx, allIdx + 1),
|
||||
selectedBackupAlbums.slice(allIdx, allIdx + 1),
|
||||
now,
|
||||
);
|
||||
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
||||
excludedAlbums,
|
||||
excludedBackupAlbums,
|
||||
now,
|
||||
);
|
||||
return toAdd.toSet().difference(toRemove.toSet()).toList();
|
||||
} else {
|
||||
return await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums,
|
||||
selectedBackupAlbums,
|
||||
now,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
final List<AssetPathEntity?> excludedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(
|
||||
excludedBackupAlbums,
|
||||
filter,
|
||||
now,
|
||||
useTimeFilter: useTimeFilter,
|
||||
);
|
||||
|
||||
final Set<BackupCandidate> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums,
|
||||
selectedBackupAlbums,
|
||||
now,
|
||||
useTimeFilter: useTimeFilter,
|
||||
);
|
||||
|
||||
final Set<BackupCandidate> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
||||
excludedAlbums,
|
||||
excludedBackupAlbums,
|
||||
now,
|
||||
useTimeFilter: useTimeFilter,
|
||||
);
|
||||
|
||||
return toAdd.difference(toRemove);
|
||||
}
|
||||
|
||||
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
|
||||
List<BackupAlbum> albums,
|
||||
FilterOptionGroup filter,
|
||||
DateTime now,
|
||||
) async {
|
||||
DateTime now, {
|
||||
bool useTimeFilter = true,
|
||||
}) async {
|
||||
List<AssetPathEntity?> result = [];
|
||||
for (BackupAlbum a in albums) {
|
||||
for (BackupAlbum backupAlbum in albums) {
|
||||
try {
|
||||
final optionGroup = useTimeFilter
|
||||
? filter.copyWith(
|
||||
updateTimeCond: DateTimeCond(
|
||||
// subtract 2 seconds to prevent missing assets due to rounding issues
|
||||
min: backupAlbum.lastBackup
|
||||
.subtract(const Duration(seconds: 2)),
|
||||
max: now,
|
||||
),
|
||||
)
|
||||
: filter;
|
||||
|
||||
final AssetPathEntity album =
|
||||
await AssetPathEntity.obtainPathFromProperties(
|
||||
id: a.id,
|
||||
optionGroup: filter.copyWith(
|
||||
updateTimeCond: DateTimeCond(
|
||||
// subtract 2 seconds to prevent missing assets due to rounding issues
|
||||
min: a.lastBackup.subtract(const Duration(seconds: 2)),
|
||||
max: now,
|
||||
),
|
||||
),
|
||||
id: backupAlbum.id,
|
||||
optionGroup: optionGroup,
|
||||
maxDateTimeToNow: false,
|
||||
);
|
||||
|
||||
result.add(album);
|
||||
} on StateError {
|
||||
// either there are no assets matching the filter criteria OR the album no longer exists
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
|
||||
List<AssetPathEntity?> albums,
|
||||
Future<Set<BackupCandidate>> _fetchAssetsAndUpdateLastBackup(
|
||||
List<AssetPathEntity?> localAlbums,
|
||||
List<BackupAlbum> backupAlbums,
|
||||
DateTime now,
|
||||
) async {
|
||||
List<AssetEntity> result = [];
|
||||
for (int i = 0; i < albums.length; i++) {
|
||||
final AssetPathEntity? a = albums[i];
|
||||
if (a != null &&
|
||||
a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) {
|
||||
result.addAll(
|
||||
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
|
||||
);
|
||||
backupAlbums[i].lastBackup = now;
|
||||
DateTime now, {
|
||||
bool useTimeFilter = true,
|
||||
}) async {
|
||||
Set<BackupCandidate> candidate = {};
|
||||
|
||||
for (int i = 0; i < localAlbums.length; i++) {
|
||||
final localAlbum = localAlbums[i];
|
||||
if (localAlbum == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (useTimeFilter &&
|
||||
localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) ==
|
||||
true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final assets = await localAlbum.getAssetListRange(
|
||||
start: 0,
|
||||
end: await localAlbum.assetCountAsync,
|
||||
);
|
||||
|
||||
// Add album's name to the asset info
|
||||
for (final asset in assets) {
|
||||
List<String> albumNames = [localAlbum.name];
|
||||
|
||||
final existingAsset = candidate.firstWhereOrNull(
|
||||
(a) => a.asset.id == asset.id,
|
||||
);
|
||||
|
||||
if (existingAsset != null) {
|
||||
albumNames.addAll(existingAsset.albumNames);
|
||||
candidate.remove(existingAsset);
|
||||
}
|
||||
|
||||
candidate.add(
|
||||
BackupCandidate(
|
||||
asset: asset,
|
||||
albumNames: albumNames,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
backupAlbums[i].lastBackup = now;
|
||||
}
|
||||
return result;
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/// Returns a new list of assets not yet uploaded
|
||||
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
|
||||
List<AssetEntity> candidates,
|
||||
Future<Set<BackupCandidate>> removeAlreadyUploadedAssets(
|
||||
Set<BackupCandidate> candidates,
|
||||
) async {
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
|
||||
candidates = duplicatedAssetIds.isEmpty
|
||||
? candidates
|
||||
: candidates
|
||||
.whereNot((asset) => duplicatedAssetIds.contains(asset.id))
|
||||
.toList();
|
||||
candidates.removeWhere(
|
||||
(candidate) => duplicatedAssetIds.contains(candidate.asset.id),
|
||||
);
|
||||
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
final Set<String> existing = {};
|
||||
try {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final CheckExistingAssetsResponseDto? duplicates =
|
||||
await _apiService.assetsApi.checkExistingAssets(
|
||||
CheckExistingAssetsDto(
|
||||
deviceAssetIds: candidates.map((e) => e.id).toList(),
|
||||
deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
|
||||
deviceId: deviceId,
|
||||
),
|
||||
);
|
||||
@ -194,55 +257,75 @@ class BackupService {
|
||||
existing.addAll(allAssetsInDatabase);
|
||||
}
|
||||
}
|
||||
return existing.isEmpty
|
||||
? candidates
|
||||
: candidates.whereNot((e) => existing.contains(e.id)).toList();
|
||||
|
||||
if (existing.isNotEmpty) {
|
||||
candidates.removeWhere((c) => existing.contains(c.asset.id));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
Future<bool> backupAsset(
|
||||
Iterable<AssetEntity> assetList,
|
||||
http.CancellationToken cancelToken,
|
||||
PMProgressHandler? pmProgressHandler,
|
||||
Function(String, String, bool) uploadSuccessCb,
|
||||
Function(int, int) uploadProgressCb,
|
||||
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
|
||||
Function(ErrorUploadAsset) errorCb, {
|
||||
bool sortAssets = false,
|
||||
}) async {
|
||||
final bool isIgnoreIcloudAssets =
|
||||
_appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
|
||||
|
||||
Future<bool> _checkPermissions() async {
|
||||
if (Platform.isAndroid &&
|
||||
!(await pm.Permission.accessMediaLocation.status).isGranted) {
|
||||
// double check that permission is granted here, to guard against
|
||||
// uploading corrupt assets without EXIF information
|
||||
_log.warning("Media location permission is not granted. "
|
||||
"Cannot access original assets for backup.");
|
||||
|
||||
return false;
|
||||
}
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
bool anyErrors = false;
|
||||
final List<String> duplicatedAssetIds = [];
|
||||
|
||||
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
|
||||
if (Platform.isIOS) {
|
||||
await PhotoManager.requestPermissionExtend();
|
||||
}
|
||||
|
||||
List<AssetEntity> assetsToUpload = sortAssets
|
||||
// Upload images before video assets
|
||||
// these are further sorted by using their creation date
|
||||
? assetList.sorted(
|
||||
(a, b) {
|
||||
final cmp = a.typeInt - b.typeInt;
|
||||
if (cmp != 0) return cmp;
|
||||
return a.createDateTime.compareTo(b.createDateTime);
|
||||
},
|
||||
)
|
||||
: assetList.toList();
|
||||
return true;
|
||||
}
|
||||
|
||||
for (var entity in assetsToUpload) {
|
||||
/// Upload images before video assets for background tasks
|
||||
/// these are further sorted by using their creation date
|
||||
List<BackupCandidate> _sortPhotosFirst(List<BackupCandidate> candidates) {
|
||||
return candidates.sorted(
|
||||
(a, b) {
|
||||
final cmp = a.asset.typeInt - b.asset.typeInt;
|
||||
if (cmp != 0) return cmp;
|
||||
return a.asset.createDateTime.compareTo(b.asset.createDateTime);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> backupAsset(
|
||||
Iterable<BackupCandidate> assets,
|
||||
http.CancellationToken cancelToken, {
|
||||
bool isBackground = false,
|
||||
PMProgressHandler? pmProgressHandler,
|
||||
required void Function(SuccessUploadAsset result) onSuccess,
|
||||
required void Function(int bytes, int totalBytes) onProgress,
|
||||
required void Function(CurrentUploadAsset asset) onCurrentAsset,
|
||||
required void Function(ErrorUploadAsset error) onError,
|
||||
}) async {
|
||||
final bool isIgnoreIcloudAssets =
|
||||
_appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
|
||||
final shouldSyncAlbums = _appSetting.getSetting(AppSettingsEnum.syncAlbums);
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final List<String> duplicatedAssetIds = [];
|
||||
bool anyErrors = false;
|
||||
|
||||
final hasPermission = await _checkPermissions();
|
||||
if (!hasPermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<BackupCandidate> candidates = assets.toList();
|
||||
if (isBackground) {
|
||||
candidates = _sortPhotosFirst(candidates);
|
||||
}
|
||||
|
||||
for (final candidate in candidates) {
|
||||
final AssetEntity entity = candidate.asset;
|
||||
File? file;
|
||||
File? livePhotoFile;
|
||||
|
||||
@ -257,7 +340,7 @@ class BackupService {
|
||||
continue;
|
||||
}
|
||||
|
||||
setCurrentUploadAssetCb(
|
||||
onCurrentAsset(
|
||||
CurrentUploadAsset(
|
||||
id: entity.id,
|
||||
fileCreatedAt: entity.createDateTime.year == 1970
|
||||
@ -299,23 +382,22 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
var fileStream = file.openRead();
|
||||
var assetRawUploadData = http.MultipartFile(
|
||||
final fileStream = file.openRead();
|
||||
final assetRawUploadData = http.MultipartFile(
|
||||
"assetData",
|
||||
fileStream,
|
||||
file.lengthSync(),
|
||||
filename: originalFileName,
|
||||
);
|
||||
|
||||
var baseRequest = MultipartRequest(
|
||||
final baseRequest = MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$savedEndpoint/assets'),
|
||||
onProgress: ((bytes, totalBytes) =>
|
||||
uploadProgressCb(bytes, totalBytes)),
|
||||
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
|
||||
);
|
||||
|
||||
baseRequest.headers.addAll(ApiService.getRequestHeaders());
|
||||
baseRequest.headers["Transfer-Encoding"] = "chunked";
|
||||
|
||||
baseRequest.fields['deviceAssetId'] = entity.id;
|
||||
baseRequest.fields['deviceId'] = deviceId;
|
||||
baseRequest.fields['fileCreatedAt'] =
|
||||
@ -324,12 +406,9 @@ class BackupService {
|
||||
entity.modifiedDateTime.toUtc().toIso8601String();
|
||||
baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
|
||||
baseRequest.fields['duration'] = entity.videoDuration.toString();
|
||||
|
||||
baseRequest.files.add(assetRawUploadData);
|
||||
|
||||
var fileSize = file.lengthSync();
|
||||
|
||||
setCurrentUploadAssetCb(
|
||||
onCurrentAsset(
|
||||
CurrentUploadAsset(
|
||||
id: entity.id,
|
||||
fileCreatedAt: entity.createDateTime.year == 1970
|
||||
@ -337,7 +416,7 @@ class BackupService {
|
||||
: entity.createDateTime,
|
||||
fileName: originalFileName,
|
||||
fileType: _getAssetType(entity.type),
|
||||
fileSize: fileSize,
|
||||
fileSize: file.lengthSync(),
|
||||
iCloudAsset: false,
|
||||
),
|
||||
);
|
||||
@ -356,22 +435,23 @@ class BackupService {
|
||||
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||
}
|
||||
|
||||
var response = await httpClient.send(
|
||||
final response = await httpClient.send(
|
||||
baseRequest,
|
||||
cancellationToken: cancelToken,
|
||||
);
|
||||
|
||||
var responseBody = jsonDecode(await response.stream.bytesToString());
|
||||
final responseBody =
|
||||
jsonDecode(await response.stream.bytesToString());
|
||||
|
||||
if (![200, 201].contains(response.statusCode)) {
|
||||
var error = responseBody;
|
||||
var errorMessage = error['message'] ?? error['error'];
|
||||
final error = responseBody;
|
||||
final errorMessage = error['message'] ?? error['error'];
|
||||
|
||||
debugPrint(
|
||||
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
|
||||
);
|
||||
|
||||
errorCb(
|
||||
onError(
|
||||
ErrorUploadAsset(
|
||||
asset: entity,
|
||||
id: entity.id,
|
||||
@ -386,23 +466,37 @@ class BackupService {
|
||||
anyErrors = true;
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var isDuplicate = false;
|
||||
bool isDuplicate = false;
|
||||
if (response.statusCode == 200) {
|
||||
isDuplicate = true;
|
||||
duplicatedAssetIds.add(entity.id);
|
||||
}
|
||||
|
||||
uploadSuccessCb(entity.id, deviceId, isDuplicate);
|
||||
onSuccess(
|
||||
SuccessUploadAsset(
|
||||
candidate: candidate,
|
||||
remoteAssetId: responseBody['id'] as String,
|
||||
isDuplicate: isDuplicate,
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldSyncAlbums && !isDuplicate) {
|
||||
await _albumService.syncUploadAlbums(
|
||||
candidate.albumNames,
|
||||
[responseBody['id'] as String],
|
||||
);
|
||||
}
|
||||
}
|
||||
} on http.CancelledException {
|
||||
debugPrint("Backup was cancelled by the user");
|
||||
anyErrors = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR backupAsset: ${e.toString()}");
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint("Error backup asset: ${error.toString()}: $stackTrace");
|
||||
anyErrors = true;
|
||||
continue;
|
||||
} finally {
|
||||
@ -416,9 +510,11 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicatedAssetIds.isNotEmpty) {
|
||||
await _saveDuplicatedAssetIds(duplicatedAssetIds);
|
||||
}
|
||||
|
||||
return !anyErrors;
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user