1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(mobile) duplicated asset upload handling mechanism (#853)

This commit is contained in:
Alex 2022-10-25 09:51:03 -05:00 committed by GitHub
parent f1af17bf4d
commit 6159c83fd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 355 additions and 38 deletions

View File

@ -24,6 +24,7 @@
"backup_controller_page_backup_selected": "Ausgewählt: ", "backup_controller_page_backup_selected": "Ausgewählt: ",
"backup_controller_page_backup_sub": "Gesicherte Fotos und Videos", "backup_controller_page_backup_sub": "Gesicherte Fotos und Videos",
"backup_controller_page_cancel": "Abbrechen", "backup_controller_page_cancel": "Abbrechen",
"backup_background_service_default_notification": "Suche nach neuen assets…",
"backup_controller_page_created": "Erstellt: {}", "backup_controller_page_created": "Erstellt: {}",
"backup_controller_page_desc_backup": "Aktiviere die Sicherung um Elemente automatisch auf den Server zu laden.", "backup_controller_page_desc_backup": "Aktiviere die Sicherung um Elemente automatisch auf den Server zu laden.",
"backup_controller_page_excluded": "Ausgeschlossen: ", "backup_controller_page_excluded": "Ausgeschlossen: ",

View File

@ -25,3 +25,7 @@ const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
const String backupFailedSince = "immichBackupFailedSince"; // Key 1 const String backupFailedSince = "immichBackupFailedSince"; // Key 1
const String backupRequireWifi = "immichBackupRequireWifi"; // Key 2 const String backupRequireWifi = "immichBackupRequireWifi"; // Key 2
const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3 const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
// Duplicate asset
const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1

View File

@ -10,6 +10,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.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/backup.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@ -30,12 +31,14 @@ void main() async {
Hive.registerAdapter(HiveSavedLoginInfoAdapter()); Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter()); Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
await Hive.openBox(userInfoBox); await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox); await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox); await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
await Hive.openBox(hiveGithubReleaseInfoBox); await Hive.openBox(hiveGithubReleaseInfoBox);
await Hive.openBox(userSettingInfoBox); await Hive.openBox(userSettingInfoBox);
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle( const SystemUiOverlayStyle(

View File

@ -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/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_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_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/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.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'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@ -316,10 +317,13 @@ class BackgroundService {
Hive.registerAdapter(HiveSavedLoginInfoAdapter()); Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter()); Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
await Hive.openBox(userInfoBox); await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox); await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox(userSettingInfoBox); await Hive.openBox(userSettingInfoBox);
await Hive.openBox(backgroundBackupInfoBox); await Hive.openBox(backgroundBackupInfoBox);
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
ApiService apiService = ApiService(); ApiService apiService = ApiService();
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey)); apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
@ -410,7 +414,7 @@ class BackgroundService {
final bool ok = await backupService.backupAsset( final bool ok = await backupService.backupAsset(
toUpload, toUpload,
_cancellationToken!, _cancellationToken!,
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId) {}, notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
notifySingleProgress ? _onProgress : (sent, total) {}, notifySingleProgress ? _onProgress : (sent, total) {},
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {}, notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
_onBackupError, _onBackupError,
@ -429,7 +433,7 @@ class BackgroundService {
return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)"; return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
} }
void _onAssetUploaded(String deviceAssetId, String deviceId) { void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
debugPrint("Uploaded $deviceAssetId from $deviceId"); debugPrint("Uploaded $deviceAssetId from $deviceId");
_uploadedAssetsCount++; _uploadedAssetsCount++;
_updateNotification( _updateNotification(

View File

@ -45,8 +45,10 @@ class ErrorUploadAsset extends Equatable {
List<Object> get props { List<Object> get props {
return [ return [
id, id,
createdAt,
fileName, fileName,
fileType, fileType,
asset,
errorMessage, errorMessage,
]; ];
} }

View File

@ -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;
}

View File

@ -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/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_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_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/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/services/backup.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 /// Those assets are unique and are used as the total assets
/// ///
Future<void> _updateBackupAssetCount() async { Future<void> _updateBackupAssetCount() async {
Set<String> duplicatedAssetIds = _backupService.getDuplicatedAssetIds();
Set<AssetEntity> assetsFromSelectedAlbums = {}; Set<AssetEntity> assetsFromSelectedAlbums = {};
Set<AssetEntity> assetsFromExcludedAlbums = {}; Set<AssetEntity> assetsFromExcludedAlbums = {};
@ -326,9 +328,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Find asset that were backup from selected albums // Find asset that were backup from selected albums
Set<String> selectedAlbumsBackupAssets = Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id)); Set.from(allUniqueAssets.map((e) => e.id));
selectedAlbumsBackupAssets selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId)); .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
(asset) => duplicatedAssetIds.contains(asset.id),
);
if (allUniqueAssets.isEmpty) { if (allUniqueAssets.isEmpty) {
debugPrint("No Asset On Device"); debugPrint("No Asset On Device");
state = state.copyWith( state = state.copyWith(
@ -455,14 +463,26 @@ class BackupNotifier extends StateNotifier<BackUpState> {
); );
} }
void _onAssetUploaded(String deviceAssetId, String deviceId) { void _onAssetUploaded(
state = state.copyWith( String deviceAssetId,
selectedAlbumsBackupAssetsIds: { String deviceId,
...state.selectedAlbumsBackupAssetsIds, bool isDuplicated,
deviceAssetId ) {
}, if (isDuplicated) {
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId], 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 - if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length == state.selectedAlbumsBackupAssetsIds.length ==
@ -564,6 +584,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
albums.lastExcludedBackupTime, albums.lastExcludedBackupTime,
); );
} }
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox); final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox);
state = state.copyWith( state = state.copyWith(
backupProgress: previous, backupProgress: previous,
@ -608,6 +629,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} catch (error) { } catch (error) {
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box"); 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 { try {
if (Hive.isBoxOpen(backgroundBackupInfoBox)) { if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
await Hive.box(backgroundBackupInfoBox).close(); await Hive.box(backgroundBackupInfoBox).close();

View File

@ -19,6 +19,8 @@ import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:cancellation_token_http/http.dart' as http; import 'package:cancellation_token_http/http.dart' as http;
import '../models/hive_duplicated_assets.model.dart';
final backupServiceProvider = Provider( final backupServiceProvider = Provider(
(ref) => BackupService( (ref) => BackupService(
ref.watch(apiServiceProvider), 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 /// Returns all assets newer than the last successful backup per album
Future<List<AssetEntity>> buildUploadCandidates( Future<List<AssetEntity>> buildUploadCandidates(
HiveBackupAlbums backupAlbums, HiveBackupAlbums backupAlbums,
@ -140,34 +165,47 @@ class BackupService {
Future<List<AssetEntity>> removeAlreadyUploadedAssets( Future<List<AssetEntity>> removeAlreadyUploadedAssets(
List<AssetEntity> candidates, List<AssetEntity> candidates,
) async { ) async {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey); if (candidates.isEmpty) {
if (candidates.length < 10) { return candidates;
final List<CheckDuplicateAssetResponseDto?> duplicateResponse = }
await Future.wait( final Set<String> duplicatedAssetIds = getDuplicatedAssetIds();
candidates.map( candidates = duplicatedAssetIds.isEmpty
(e) => _apiService.assetApi.checkDuplicateAsset( ? candidates
CheckDuplicateAssetDto(deviceAssetId: e.id, deviceId: deviceId), : 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 if (duplicates != null) {
.whereIndexed((i, e) => duplicateResponse[i]?.isExist == false) existing.addAll(duplicates.existingIds);
.toList(); }
} else { } on ApiException {
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset(); // workaround for older server versions or when checking for too many assets at once
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
if (allAssetsInDatabase == null) { if (allAssetsInDatabase != null) {
return candidates; 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( Future<bool> backupAsset(
Iterable<AssetEntity> assetList, Iterable<AssetEntity> assetList,
http.CancellationToken cancelToken, http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb, Function(String, String, bool) uploadSuccessCb,
Function(int, int) uploadProgressCb, Function(int, int) uploadProgressCb,
Function(CurrentUploadAsset) setCurrentUploadAssetCb, Function(CurrentUploadAsset) setCurrentUploadAssetCb,
Function(ErrorUploadAsset) errorCb, Function(ErrorUploadAsset) errorCb,
@ -176,6 +214,7 @@ class BackupService {
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file; File? file;
bool anyErrors = false; bool anyErrors = false;
final List<String> duplicatedAssetIds = [];
for (var entity in assetList) { for (var entity in assetList) {
try { try {
@ -235,8 +274,13 @@ class BackupService {
var response = await req.send(cancellationToken: cancelToken); var response = await req.send(cancellationToken: cancelToken);
if (response.statusCode == 201) { if (response.statusCode == 200) {
singleAssetDoneCb(entity.id, deviceId); // 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 { } else {
var data = await response.stream.bytesToString(); var data = await response.stream.bytesToString();
var error = jsonDecode(data); var error = jsonDecode(data);
@ -260,7 +304,8 @@ class BackupService {
} }
} on http.CancelledException { } on http.CancelledException {
debugPrint("Backup was cancelled by the user"); debugPrint("Backup was cancelled by the user");
return false; anyErrors = true;
break;
} catch (e) { } catch (e) {
debugPrint("ERROR backupAsset: ${e.toString()}"); debugPrint("ERROR backupAsset: ${e.toString()}");
anyErrors = true; anyErrors = true;
@ -271,6 +316,9 @@ class BackupService {
} }
} }
} }
if (duplicatedAssetIds.isNotEmpty) {
_saveDuplicatedAssetIdToLocalStorage(duplicatedAssetIds);
}
return !anyErrors; return !anyErrors;
} }

View File

@ -419,7 +419,6 @@ class BackupControllerPage extends HookConsumerWidget {
ActionChip( ActionChip(
avatar: Icon( avatar: Icon(
Icons.info, Icons.info,
size: 24,
color: Colors.red[400], color: Colors.red[400],
), ),
elevation: 1, elevation: 1,

View File

@ -19,6 +19,8 @@ doc/AssetTypeEnum.md
doc/AuthenticationApi.md doc/AuthenticationApi.md
doc/CheckDuplicateAssetDto.md doc/CheckDuplicateAssetDto.md
doc/CheckDuplicateAssetResponseDto.md doc/CheckDuplicateAssetResponseDto.md
doc/CheckExistingAssetsDto.md
doc/CheckExistingAssetsResponseDto.md
doc/CreateAlbumDto.md doc/CreateAlbumDto.md
doc/CreateDeviceInfoDto.md doc/CreateDeviceInfoDto.md
doc/CreateProfileImageResponseDto.md doc/CreateProfileImageResponseDto.md
@ -93,6 +95,8 @@ lib/model/asset_response_dto.dart
lib/model/asset_type_enum.dart lib/model/asset_type_enum.dart
lib/model/check_duplicate_asset_dto.dart lib/model/check_duplicate_asset_dto.dart
lib/model/check_duplicate_asset_response_dto.dart lib/model/check_duplicate_asset_response_dto.dart
lib/model/check_existing_assets_dto.dart
lib/model/check_existing_assets_response_dto.dart
lib/model/create_album_dto.dart lib/model/create_album_dto.dart
lib/model/create_device_info_dto.dart lib/model/create_device_info_dto.dart
lib/model/create_profile_image_response_dto.dart lib/model/create_profile_image_response_dto.dart
@ -133,3 +137,5 @@ lib/model/user_count_response_dto.dart
lib/model/user_response_dto.dart lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart lib/model/validate_access_token_response_dto.dart
pubspec.yaml pubspec.yaml
test/check_existing_assets_dto_test.dart
test/check_existing_assets_response_dto_test.dart

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -137,6 +137,7 @@ describe('Album service', () => {
getAssetWithNoEXIF: jest.fn(), getAssetWithNoEXIF: jest.fn(),
getAssetWithNoThumbnail: jest.fn(), getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(), getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(),
}; };
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock); sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);

View File

@ -10,6 +10,9 @@ import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto'; import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { In } from 'typeorm/find-options/operator/In';
export interface IAssetRepository { export interface IAssetRepository {
create( create(
@ -32,6 +35,7 @@ export interface IAssetRepository {
getAssetWithNoThumbnail(): Promise<AssetEntity[]>; getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
getAssetWithNoEXIF(): Promise<AssetEntity[]>; getAssetWithNoEXIF(): Promise<AssetEntity[]>;
getAssetWithNoSmartInfo(): Promise<AssetEntity[]>; getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<CheckExistingAssetsResponseDto>;
} }
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY'; export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
@ -279,4 +283,17 @@ export class AssetRepository implements IAssetRepository {
relations: ['exifInfo'], relations: ['exifInfo'],
}); });
} }
async getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<CheckExistingAssetsResponseDto> {
const existingAssets = await this.assetRepository.find({
select: {deviceAssetId: true},
where: {
deviceAssetId: In(checkDuplicateAssetDto.deviceAssetIds),
deviceId: checkDuplicateAssetDto.deviceId,
userId,
},
});
return new CheckExistingAssetsResponseDto(existingAssets.map(a => a.deviceAssetId));
}
} }

View File

@ -48,6 +48,8 @@ import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-buck
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { QueryFailedError } from 'typeorm'; import { QueryFailedError } from 'typeorm';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ -74,6 +76,7 @@ export class AssetController {
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Body(ValidationPipe) assetInfo: CreateAssetDto, @Body(ValidationPipe) assetInfo: CreateAssetDto,
@Response({ passthrough: true }) res: Res,
): Promise<AssetFileUploadResponseDto> { ): Promise<AssetFileUploadResponseDto> {
const checksum = await this.assetService.calculateChecksum(file.path); const checksum = await this.assetService.calculateChecksum(file.path);
@ -111,6 +114,7 @@ export class AssetController {
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') { if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum); const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum);
res.status(200); // normal POST is 201. we use 200 to indicate the asset already exists
return new AssetFileUploadResponseDto(existedAsset.id); return new AssetFileUploadResponseDto(existedAsset.id);
} }
@ -254,4 +258,16 @@ export class AssetController {
): Promise<CheckDuplicateAssetResponseDto> { ): Promise<CheckDuplicateAssetResponseDto> {
return await this.assetService.checkDuplicatedAsset(authUser, checkDuplicateAssetDto); return await this.assetService.checkDuplicatedAsset(authUser, checkDuplicateAssetDto);
} }
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Post('/exist')
@HttpCode(200)
async checkExistingAssets(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) checkExistingAssetsDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return await this.assetService.checkExistingAssets(authUser, checkExistingAssetsDto);
}
} }

View File

@ -110,6 +110,7 @@ describe('AssetService', () => {
getAssetWithNoEXIF: jest.fn(), getAssetWithNoEXIF: jest.fn(),
getAssetWithNoThumbnail: jest.fn(), getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(), getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(),
}; };
sui = new AssetService(assetRepositoryMock, a); sui = new AssetService(assetRepositoryMock, a);

View File

@ -37,6 +37,8 @@ import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-buck
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { timeUtils } from '@app/common/utils'; import { timeUtils } from '@app/common/utils';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@ -466,6 +468,13 @@ export class AssetService {
return new CheckDuplicateAssetResponseDto(isDuplicated, res?.id); return new CheckDuplicateAssetResponseDto(isDuplicated, res?.id);
} }
async checkExistingAssets(
authUser: AuthUserDto,
checkExistingAssetsDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return this._assetRepository.getExistingAssets(authUser.id, checkExistingAssetsDto);
}
async getAssetCountByTimeBucket( async getAssetCountByTimeBucket(
authUser: AuthUserDto, authUser: AuthUserDto,
getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto, getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto,

View File

@ -0,0 +1,9 @@
import { IsNotEmpty } from 'class-validator';
export class CheckExistingAssetsDto {
@IsNotEmpty()
deviceAssetIds!: string[];
@IsNotEmpty()
deviceId!: string;
}

View File

@ -0,0 +1,7 @@
export class CheckExistingAssetsResponseDto {
constructor(existingIds: string[]) {
this.existingIds = existingIds;
}
existingIds: string[];
}

File diff suppressed because one or more lines are too long

View File

@ -452,6 +452,38 @@ export interface CheckDuplicateAssetResponseDto {
*/ */
'id'?: string; 'id'?: string;
} }
/**
*
* @export
* @interface CheckExistingAssetsDto
*/
export interface CheckExistingAssetsDto {
/**
*
* @type {Array<string>}
* @memberof CheckExistingAssetsDto
*/
'deviceAssetIds': Array<string>;
/**
*
* @type {string}
* @memberof CheckExistingAssetsDto
*/
'deviceId': string;
}
/**
*
* @export
* @interface CheckExistingAssetsResponseDto
*/
export interface CheckExistingAssetsResponseDto {
/**
*
* @type {Array<string>}
* @memberof CheckExistingAssetsResponseDto
*/
'existingIds': Array<string>;
}
/** /**
* *
* @export * @export
@ -2334,6 +2366,46 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
* @summary
* @param {CheckExistingAssetsDto} checkExistingAssetsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
checkExistingAssets: async (checkExistingAssetsDto: CheckExistingAssetsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'checkExistingAssetsDto' is not null or undefined
assertParamExists('checkExistingAssets', 'checkExistingAssetsDto', checkExistingAssetsDto)
const localVarPath = `/asset/exist`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(checkExistingAssetsDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/** /**
* *
* @param {DeleteAssetDto} deleteAssetDto * @param {DeleteAssetDto} deleteAssetDto
@ -2953,6 +3025,17 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.checkDuplicateAsset(checkDuplicateAssetDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.checkDuplicateAsset(checkDuplicateAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
* @summary
* @param {CheckExistingAssetsDto} checkExistingAssetsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async checkExistingAssets(checkExistingAssetsDto: CheckExistingAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<CheckExistingAssetsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.checkExistingAssets(checkExistingAssetsDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {DeleteAssetDto} deleteAssetDto * @param {DeleteAssetDto} deleteAssetDto
@ -3128,6 +3211,16 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
checkDuplicateAsset(checkDuplicateAssetDto: CheckDuplicateAssetDto, options?: any): AxiosPromise<CheckDuplicateAssetResponseDto> { checkDuplicateAsset(checkDuplicateAssetDto: CheckDuplicateAssetDto, options?: any): AxiosPromise<CheckDuplicateAssetResponseDto> {
return localVarFp.checkDuplicateAsset(checkDuplicateAssetDto, options).then((request) => request(axios, basePath)); return localVarFp.checkDuplicateAsset(checkDuplicateAssetDto, options).then((request) => request(axios, basePath));
}, },
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
* @summary
* @param {CheckExistingAssetsDto} checkExistingAssetsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
checkExistingAssets(checkExistingAssetsDto: CheckExistingAssetsDto, options?: any): AxiosPromise<CheckExistingAssetsResponseDto> {
return localVarFp.checkExistingAssets(checkExistingAssetsDto, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {DeleteAssetDto} deleteAssetDto * @param {DeleteAssetDto} deleteAssetDto
@ -3290,6 +3383,18 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).checkDuplicateAsset(checkDuplicateAssetDto, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).checkDuplicateAsset(checkDuplicateAssetDto, options).then((request) => request(this.axios, this.basePath));
} }
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
* @summary
* @param {CheckExistingAssetsDto} checkExistingAssetsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public checkExistingAssets(checkExistingAssetsDto: CheckExistingAssetsDto, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).checkExistingAssets(checkExistingAssetsDto, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* *
* @param {DeleteAssetDto} deleteAssetDto * @param {DeleteAssetDto} deleteAssetDto

View File

@ -200,12 +200,12 @@ async function fileUploader(asset: File, uploadType: UploadType) {
} }
// TODO: This should have a proper type // TODO: This should have a proper type
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleUploadError(asset: File, respBody?: any) { function handleUploadError(asset: File, respBody: any, extraMessage?: string) {
const extraMsg = respBody ? ' ' + respBody.message : ''; const extraMsg = respBody ? ' ' + respBody?.message : '';
notificationController.show({ notificationController.show({
type: NotificationType.Error, type: NotificationType.Error,
message: `Cannot upload file ${asset.name}!${extraMsg}`, message: `Cannot upload file ${asset.name} ${extraMsg}${extraMessage}`,
timeout: 5000 timeout: 5000
}); });
} }