mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat(mobile) duplicated asset upload handling mechanism (#853)
This commit is contained in:
@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/backup/background_service/localization.dar
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
@@ -316,10 +317,13 @@ class BackgroundService {
|
||||
|
||||
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
|
||||
|
||||
await Hive.openBox(userInfoBox);
|
||||
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||
await Hive.openBox(userSettingInfoBox);
|
||||
await Hive.openBox(backgroundBackupInfoBox);
|
||||
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
|
||||
|
||||
ApiService apiService = ApiService();
|
||||
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
||||
@@ -410,7 +414,7 @@ class BackgroundService {
|
||||
final bool ok = await backupService.backupAsset(
|
||||
toUpload,
|
||||
_cancellationToken!,
|
||||
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId) {},
|
||||
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
|
||||
notifySingleProgress ? _onProgress : (sent, total) {},
|
||||
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
|
||||
_onBackupError,
|
||||
@@ -429,7 +433,7 @@ class BackgroundService {
|
||||
return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
|
||||
}
|
||||
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
|
||||
debugPrint("Uploaded $deviceAssetId from $deviceId");
|
||||
_uploadedAssetsCount++;
|
||||
_updateNotification(
|
||||
|
||||
@@ -45,8 +45,10 @@ class ErrorUploadAsset extends Equatable {
|
||||
List<Object> get props {
|
||||
return [
|
||||
id,
|
||||
createdAt,
|
||||
fileName,
|
||||
fileType,
|
||||
asset,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'hive_duplicated_assets.model.g.dart';
|
||||
|
||||
@HiveType(typeId: 2)
|
||||
class HiveDuplicatedAssets {
|
||||
@HiveField(0, defaultValue: [])
|
||||
List<String> duplicatedAssetIds;
|
||||
|
||||
HiveDuplicatedAssets({
|
||||
required this.duplicatedAssetIds,
|
||||
});
|
||||
|
||||
HiveDuplicatedAssets copyWith({
|
||||
List<String>? duplicatedAssetIds,
|
||||
}) {
|
||||
return HiveDuplicatedAssets(
|
||||
duplicatedAssetIds: duplicatedAssetIds ?? this.duplicatedAssetIds,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'duplicatedAssetIds': duplicatedAssetIds,
|
||||
};
|
||||
}
|
||||
|
||||
factory HiveDuplicatedAssets.fromMap(Map<String, dynamic> map) {
|
||||
return HiveDuplicatedAssets(
|
||||
duplicatedAssetIds: List<String>.from(map['duplicatedAssetIds']),
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory HiveDuplicatedAssets.fromJson(String source) =>
|
||||
HiveDuplicatedAssets.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'HiveDuplicatedAssets(duplicatedAssetIds: $duplicatedAssetIds)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return other is HiveDuplicatedAssets &&
|
||||
listEquals(other.duplicatedAssetIds, duplicatedAssetIds);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => duplicatedAssetIds.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'hive_duplicated_assets.model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class HiveDuplicatedAssetsAdapter extends TypeAdapter<HiveDuplicatedAssets> {
|
||||
@override
|
||||
final int typeId = 2;
|
||||
|
||||
@override
|
||||
HiveDuplicatedAssets read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return HiveDuplicatedAssets(
|
||||
duplicatedAssetIds:
|
||||
fields[0] == null ? [] : (fields[0] as List).cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, HiveDuplicatedAssets obj) {
|
||||
writer
|
||||
..writeByte(1)
|
||||
..writeByte(0)
|
||||
..write(obj.duplicatedAssetIds);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is HiveDuplicatedAssetsAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
@@ -296,6 +297,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// Those assets are unique and are used as the total assets
|
||||
///
|
||||
Future<void> _updateBackupAssetCount() async {
|
||||
Set<String> duplicatedAssetIds = _backupService.getDuplicatedAssetIds();
|
||||
Set<AssetEntity> assetsFromSelectedAlbums = {};
|
||||
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||
|
||||
@@ -326,9 +328,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
// Find asset that were backup from selected albums
|
||||
Set<String> selectedAlbumsBackupAssets =
|
||||
Set.from(allUniqueAssets.map((e) => e.id));
|
||||
|
||||
selectedAlbumsBackupAssets
|
||||
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
|
||||
|
||||
// Remove duplicated asset from all unique assets
|
||||
allUniqueAssets.removeWhere(
|
||||
(asset) => duplicatedAssetIds.contains(asset.id),
|
||||
);
|
||||
|
||||
if (allUniqueAssets.isEmpty) {
|
||||
debugPrint("No Asset On Device");
|
||||
state = state.copyWith(
|
||||
@@ -455,14 +463,26 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||
state = state.copyWith(
|
||||
selectedAlbumsBackupAssetsIds: {
|
||||
...state.selectedAlbumsBackupAssetsIds,
|
||||
deviceAssetId
|
||||
},
|
||||
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
|
||||
);
|
||||
void _onAssetUploaded(
|
||||
String deviceAssetId,
|
||||
String deviceId,
|
||||
bool isDuplicated,
|
||||
) {
|
||||
if (isDuplicated) {
|
||||
state = state.copyWith(
|
||||
allUniqueAssets: state.allUniqueAssets
|
||||
.where((asset) => asset.id != deviceAssetId)
|
||||
.toSet(),
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
selectedAlbumsBackupAssetsIds: {
|
||||
...state.selectedAlbumsBackupAssetsIds,
|
||||
deviceAssetId
|
||||
},
|
||||
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
|
||||
);
|
||||
}
|
||||
|
||||
if (state.allUniqueAssets.length -
|
||||
state.selectedAlbumsBackupAssetsIds.length ==
|
||||
@@ -564,6 +584,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
albums.lastExcludedBackupTime,
|
||||
);
|
||||
}
|
||||
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
|
||||
final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox);
|
||||
state = state.copyWith(
|
||||
backupProgress: previous,
|
||||
@@ -608,6 +629,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
} catch (error) {
|
||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||
}
|
||||
try {
|
||||
if (Hive.isBoxOpen(duplicatedAssetsBox)) {
|
||||
await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||
}
|
||||
try {
|
||||
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
|
||||
await Hive.box(backgroundBackupInfoBox).close();
|
||||
|
||||
@@ -19,6 +19,8 @@ import 'package:http_parser/http_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:cancellation_token_http/http.dart' as http;
|
||||
|
||||
import '../models/hive_duplicated_assets.model.dart';
|
||||
|
||||
final backupServiceProvider = Provider(
|
||||
(ref) => BackupService(
|
||||
ref.watch(apiServiceProvider),
|
||||
@@ -41,6 +43,29 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
void _saveDuplicatedAssetIdToLocalStorage(List<String> deviceAssetIds) {
|
||||
HiveDuplicatedAssets duplicatedAssets =
|
||||
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
|
||||
.get(duplicatedAssetsKey) ??
|
||||
HiveDuplicatedAssets(duplicatedAssetIds: []);
|
||||
|
||||
duplicatedAssets.duplicatedAssetIds =
|
||||
{...duplicatedAssets.duplicatedAssetIds, ...deviceAssetIds}.toList();
|
||||
|
||||
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
|
||||
.put(duplicatedAssetsKey, duplicatedAssets);
|
||||
}
|
||||
|
||||
/// Get duplicated asset id from Hive storage
|
||||
Set<String> getDuplicatedAssetIds() {
|
||||
HiveDuplicatedAssets duplicatedAssets =
|
||||
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
|
||||
.get(duplicatedAssetsKey) ??
|
||||
HiveDuplicatedAssets(duplicatedAssetIds: []);
|
||||
|
||||
return duplicatedAssets.duplicatedAssetIds.toSet();
|
||||
}
|
||||
|
||||
/// Returns all assets newer than the last successful backup per album
|
||||
Future<List<AssetEntity>> buildUploadCandidates(
|
||||
HiveBackupAlbums backupAlbums,
|
||||
@@ -140,34 +165,47 @@ class BackupService {
|
||||
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
|
||||
List<AssetEntity> candidates,
|
||||
) async {
|
||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
if (candidates.length < 10) {
|
||||
final List<CheckDuplicateAssetResponseDto?> duplicateResponse =
|
||||
await Future.wait(
|
||||
candidates.map(
|
||||
(e) => _apiService.assetApi.checkDuplicateAsset(
|
||||
CheckDuplicateAssetDto(deviceAssetId: e.id, deviceId: deviceId),
|
||||
),
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
final Set<String> duplicatedAssetIds = getDuplicatedAssetIds();
|
||||
candidates = duplicatedAssetIds.isEmpty
|
||||
? candidates
|
||||
: candidates
|
||||
.whereNot((asset) => duplicatedAssetIds.contains(asset.id))
|
||||
.toList();
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
final Set<String> existing = {};
|
||||
try {
|
||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
final CheckExistingAssetsResponseDto? duplicates =
|
||||
await _apiService.assetApi.checkExistingAssets(
|
||||
CheckExistingAssetsDto(
|
||||
deviceAssetIds: candidates.map((e) => e.id).toList(),
|
||||
deviceId: deviceId,
|
||||
),
|
||||
);
|
||||
return candidates
|
||||
.whereIndexed((i, e) => duplicateResponse[i]?.isExist == false)
|
||||
.toList();
|
||||
} else {
|
||||
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
|
||||
|
||||
if (allAssetsInDatabase == null) {
|
||||
return candidates;
|
||||
if (duplicates != null) {
|
||||
existing.addAll(duplicates.existingIds);
|
||||
}
|
||||
} on ApiException {
|
||||
// workaround for older server versions or when checking for too many assets at once
|
||||
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
|
||||
if (allAssetsInDatabase != null) {
|
||||
existing.addAll(allAssetsInDatabase);
|
||||
}
|
||||
final Set<String> inDb = allAssetsInDatabase.toSet();
|
||||
return candidates.whereNot((e) => inDb.contains(e.id)).toList();
|
||||
}
|
||||
return existing.isEmpty
|
||||
? candidates
|
||||
: candidates.whereNot((e) => existing.contains(e.id)).toList();
|
||||
}
|
||||
|
||||
Future<bool> backupAsset(
|
||||
Iterable<AssetEntity> assetList,
|
||||
http.CancellationToken cancelToken,
|
||||
Function(String, String) singleAssetDoneCb,
|
||||
Function(String, String, bool) uploadSuccessCb,
|
||||
Function(int, int) uploadProgressCb,
|
||||
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
|
||||
Function(ErrorUploadAsset) errorCb,
|
||||
@@ -176,6 +214,7 @@ class BackupService {
|
||||
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
File? file;
|
||||
bool anyErrors = false;
|
||||
final List<String> duplicatedAssetIds = [];
|
||||
|
||||
for (var entity in assetList) {
|
||||
try {
|
||||
@@ -235,8 +274,13 @@ class BackupService {
|
||||
|
||||
var response = await req.send(cancellationToken: cancelToken);
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
singleAssetDoneCb(entity.id, deviceId);
|
||||
if (response.statusCode == 200) {
|
||||
// asset is a duplicate (already exists on the server)
|
||||
duplicatedAssetIds.add(entity.id);
|
||||
uploadSuccessCb(entity.id, deviceId, true);
|
||||
} else if (response.statusCode == 201) {
|
||||
// stored a new asset on the server
|
||||
uploadSuccessCb(entity.id, deviceId, false);
|
||||
} else {
|
||||
var data = await response.stream.bytesToString();
|
||||
var error = jsonDecode(data);
|
||||
@@ -260,7 +304,8 @@ class BackupService {
|
||||
}
|
||||
} on http.CancelledException {
|
||||
debugPrint("Backup was cancelled by the user");
|
||||
return false;
|
||||
anyErrors = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR backupAsset: ${e.toString()}");
|
||||
anyErrors = true;
|
||||
@@ -271,6 +316,9 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (duplicatedAssetIds.isNotEmpty) {
|
||||
_saveDuplicatedAssetIdToLocalStorage(duplicatedAssetIds);
|
||||
}
|
||||
return !anyErrors;
|
||||
}
|
||||
|
||||
|
||||
@@ -419,7 +419,6 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
ActionChip(
|
||||
avatar: Icon(
|
||||
Icons.info,
|
||||
size: 24,
|
||||
color: Colors.red[400],
|
||||
),
|
||||
elevation: 1,
|
||||
|
||||
Reference in New Issue
Block a user