From 58ec7553ea7b15ef3df7b4b19d781a1ad5a9fe7b Mon Sep 17 00:00:00 2001 From: Alex <alex.tran1502@gmail.com> Date: Wed, 6 Jul 2022 16:12:55 -0500 Subject: [PATCH] Add information for uploading asset and error indication with error message for each failed upload. (#315) * Added info box * Fixed upload endpoint doesn't report error status code * Added chip to show update error * Added chip to show failed upload * Add duplication check for upload * Better duplication-checking placement * Remove check for duplicated asset * Added failed backup status route * added page * Display error card with thumbnail * Improved styling * Set thumbnail with better quality * Remove force upload error --- mobile/ios/fastlane/Fastfile | 2 +- .../backup/models/backup_state.model.dart | 58 ++++-- .../check_duplicate_asset_response.model.dart | 48 +++++ .../models/current_upload_asset.model.dart | 78 ++++++++ .../models/error_upload_asset.model.dart | 53 +++++ .../backup/providers/backup.provider.dart | 44 +++- .../providers/error_backup_list.provider.dart | 23 +++ .../backup/services/backup.service.dart | 72 ++++++- .../backup/views/backup_controller_page.dart | 189 +++++++++++++++--- .../views/failed_backup_status_page.dart | 139 +++++++++++++ mobile/lib/routing/router.dart | 6 + mobile/lib/routing/router.gr.dart | 21 +- .../lib/shared/services/network.service.dart | 2 +- mobile/pubspec.yaml | 2 +- .../src/api-v1/asset/asset.controller.ts | 23 ++- .../immich/src/api-v1/asset/asset.service.ts | 18 +- .../asset/dto/check-duplicate-asset.dto.ts | 9 + .../src/constants/server_version.constant.ts | 4 +- web/src/lib/utils/file-uploader.ts | 2 +- 19 files changed, 706 insertions(+), 87 deletions(-) create mode 100644 mobile/lib/modules/backup/models/check_duplicate_asset_response.model.dart create mode 100644 mobile/lib/modules/backup/models/current_upload_asset.model.dart create mode 100644 mobile/lib/modules/backup/models/error_upload_asset.model.dart create mode 100644 mobile/lib/modules/backup/providers/error_backup_list.provider.dart create mode 100644 mobile/lib/modules/backup/views/failed_backup_status_page.dart create mode 100644 server/apps/immich/src/api-v1/asset/dto/check-duplicate-asset.dto.ts diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 7f3bec3264..5fd5aaed92 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.16.1" + version_number: "1.17.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/lib/modules/backup/models/backup_state.model.dart b/mobile/lib/modules/backup/models/backup_state.model.dart index 4848bf1e39..c3c66f08c8 100644 --- a/mobile/lib/modules/backup/models/backup_state.model.dart +++ b/mobile/lib/modules/backup/models/backup_state.model.dart @@ -1,13 +1,14 @@ import 'package:cancellation_token_http/http.dart'; -import 'package:equatable/equatable.dart'; +import 'package:collection/collection.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; +import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart'; import 'package:immich_mobile/shared/models/server_info.model.dart'; enum BackUpProgressEnum { idle, inProgress, done } -class BackUpState extends Equatable { +class BackUpState { // enum final BackUpProgressEnum backupProgress; final List<String> allAssetsInDatabase; @@ -26,6 +27,9 @@ class BackUpState extends Equatable { /// All assets from the selected albums that have been backup final Set<String> selectedAlbumsBackupAssetsIds; + // Current Backup Asset + final CurrentUploadAsset currentUploadAsset; + const BackUpState({ required this.backupProgress, required this.allAssetsInDatabase, @@ -37,6 +41,7 @@ class BackUpState extends Equatable { required this.excludedBackupAlbums, required this.allUniqueAssets, required this.selectedAlbumsBackupAssetsIds, + required this.currentUploadAsset, }); BackUpState copyWith({ @@ -50,6 +55,7 @@ class BackUpState extends Equatable { Set<AssetPathEntity>? excludedBackupAlbums, Set<AssetEntity>? allUniqueAssets, Set<String>? selectedAlbumsBackupAssetsIds, + CurrentUploadAsset? currentUploadAsset, }) { return BackUpState( backupProgress: backupProgress ?? this.backupProgress, @@ -63,27 +69,47 @@ class BackUpState extends Equatable { allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets, selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds, + currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset, ); } @override String toString() { - return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)'; + return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; } @override - List<Object> get props { - return [ - backupProgress, - allAssetsInDatabase, - progressInPercentage, - cancelToken, - serverInfo, - availableAlbums, - selectedBackupAlbums, - excludedBackupAlbums, - allUniqueAssets, - selectedAlbumsBackupAssetsIds, - ]; + bool operator ==(Object other) { + if (identical(this, other)) return true; + final collectionEquals = const DeepCollectionEquality().equals; + + return other is BackUpState && + other.backupProgress == backupProgress && + collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) && + other.progressInPercentage == progressInPercentage && + other.cancelToken == cancelToken && + other.serverInfo == serverInfo && + collectionEquals(other.availableAlbums, availableAlbums) && + collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) && + collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) && + collectionEquals(other.allUniqueAssets, allUniqueAssets) && + collectionEquals(other.selectedAlbumsBackupAssetsIds, + selectedAlbumsBackupAssetsIds) && + other.currentUploadAsset == currentUploadAsset; + } + + @override + int get hashCode { + return backupProgress.hashCode ^ + allAssetsInDatabase.hashCode ^ + progressInPercentage.hashCode ^ + cancelToken.hashCode ^ + serverInfo.hashCode ^ + availableAlbums.hashCode ^ + selectedBackupAlbums.hashCode ^ + excludedBackupAlbums.hashCode ^ + allUniqueAssets.hashCode ^ + selectedAlbumsBackupAssetsIds.hashCode ^ + currentUploadAsset.hashCode; } } diff --git a/mobile/lib/modules/backup/models/check_duplicate_asset_response.model.dart b/mobile/lib/modules/backup/models/check_duplicate_asset_response.model.dart new file mode 100644 index 0000000000..2fc30ea61f --- /dev/null +++ b/mobile/lib/modules/backup/models/check_duplicate_asset_response.model.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +class CheckDuplicateAssetResponse { + final bool isExist; + CheckDuplicateAssetResponse({ + required this.isExist, + }); + + CheckDuplicateAssetResponse copyWith({ + bool? isExist, + }) { + return CheckDuplicateAssetResponse( + isExist: isExist ?? this.isExist, + ); + } + + Map<String, dynamic> toMap() { + final result = <String, dynamic>{}; + + result.addAll({'isExist': isExist}); + + return result; + } + + factory CheckDuplicateAssetResponse.fromMap(Map<String, dynamic> map) { + return CheckDuplicateAssetResponse( + isExist: map['isExist'] ?? false, + ); + } + + String toJson() => json.encode(toMap()); + + factory CheckDuplicateAssetResponse.fromJson(String source) => + CheckDuplicateAssetResponse.fromMap(json.decode(source)); + + @override + String toString() => 'CheckDuplicateAssetResponse(isExist: $isExist)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is CheckDuplicateAssetResponse && other.isExist == isExist; + } + + @override + int get hashCode => isExist.hashCode; +} diff --git a/mobile/lib/modules/backup/models/current_upload_asset.model.dart b/mobile/lib/modules/backup/models/current_upload_asset.model.dart new file mode 100644 index 0000000000..493fc55c94 --- /dev/null +++ b/mobile/lib/modules/backup/models/current_upload_asset.model.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +class CurrentUploadAsset { + final String id; + final DateTime createdAt; + final String fileName; + final String fileType; + + CurrentUploadAsset({ + required this.id, + required this.createdAt, + required this.fileName, + required this.fileType, + }); + + CurrentUploadAsset copyWith({ + String? id, + DateTime? createdAt, + String? fileName, + String? fileType, + }) { + return CurrentUploadAsset( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + fileName: fileName ?? this.fileName, + fileType: fileType ?? this.fileType, + ); + } + + Map<String, dynamic> toMap() { + final result = <String, dynamic>{}; + + result.addAll({'id': id}); + result.addAll({'createdAt': createdAt.millisecondsSinceEpoch}); + result.addAll({'fileName': fileName}); + result.addAll({'fileType': fileType}); + + return result; + } + + factory CurrentUploadAsset.fromMap(Map<String, dynamic> map) { + return CurrentUploadAsset( + id: map['id'] ?? '', + createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt']), + fileName: map['fileName'] ?? '', + fileType: map['fileType'] ?? '', + ); + } + + String toJson() => json.encode(toMap()); + + factory CurrentUploadAsset.fromJson(String source) => + CurrentUploadAsset.fromMap(json.decode(source)); + + @override + String toString() { + return 'CurrentUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is CurrentUploadAsset && + other.id == id && + other.createdAt == createdAt && + other.fileName == fileName && + other.fileType == fileType; + } + + @override + int get hashCode { + return id.hashCode ^ + createdAt.hashCode ^ + fileName.hashCode ^ + fileType.hashCode; + } +} diff --git a/mobile/lib/modules/backup/models/error_upload_asset.model.dart b/mobile/lib/modules/backup/models/error_upload_asset.model.dart new file mode 100644 index 0000000000..365a5a1696 --- /dev/null +++ b/mobile/lib/modules/backup/models/error_upload_asset.model.dart @@ -0,0 +1,53 @@ +import 'package:equatable/equatable.dart'; +import 'package:photo_manager/photo_manager.dart'; + +class ErrorUploadAsset extends Equatable { + final String id; + final DateTime createdAt; + final String fileName; + final String fileType; + final AssetEntity asset; + final String errorMessage; + + const ErrorUploadAsset({ + required this.id, + required this.createdAt, + required this.fileName, + required this.fileType, + required this.asset, + required this.errorMessage, + }); + + ErrorUploadAsset copyWith({ + String? id, + DateTime? createdAt, + String? fileName, + String? fileType, + AssetEntity? asset, + String? errorMessage, + }) { + return ErrorUploadAsset( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + fileName: fileName ?? this.fileName, + fileType: fileType ?? this.fileType, + asset: asset ?? this.asset, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + String toString() { + return 'ErrorUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType, asset: $asset, errorMessage: $errorMessage)'; + } + + @override + List<Object> get props { + return [ + id, + fileName, + fileType, + errorMessage, + ]; + } +} diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index 0bcdeaab53..2f6970f55e 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -5,7 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; 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/providers/error_backup_list.provider.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; @@ -14,8 +17,12 @@ import 'package:immich_mobile/shared/services/server_info.service.dart'; import 'package:photo_manager/photo_manager.dart'; class BackupNotifier extends StateNotifier<BackUpState> { - BackupNotifier(this._backupService, this._serverInfoService, this._authState) - : super( + BackupNotifier( + this._backupService, + this._serverInfoService, + this._authState, + this.ref, + ) : super( BackUpState( backupProgress: BackUpProgressEnum.idle, allAssetsInDatabase: const [], @@ -35,6 +42,12 @@ class BackupNotifier extends StateNotifier<BackUpState> { excludedBackupAlbums: const {}, allUniqueAssets: const {}, selectedAlbumsBackupAssetsIds: const {}, + currentUploadAsset: CurrentUploadAsset( + id: '...', + createdAt: DateTime.parse('2020-10-04'), + fileName: '...', + fileType: '...', + ), ), ) { getBackupInfo(); @@ -43,6 +56,7 @@ class BackupNotifier extends StateNotifier<BackUpState> { final BackupService _backupService; final ServerInfoService _serverInfoService; final AuthenticationState _authState; + final Ref ref; /// /// UI INTERACTION @@ -235,8 +249,11 @@ class BackupNotifier extends StateNotifier<BackUpState> { /// and then update the UI according to those information /// Future<void> getBackupInfo() async { - await _getBackupAlbumsInfo(); - await _updateServerInfo(); + await Future.wait([ + _getBackupAlbumsInfo(), + _updateServerInfo(), + ]); + await _updateBackupAssetCount(); } @@ -287,13 +304,27 @@ class BackupNotifier extends StateNotifier<BackUpState> { // Perform Backup state = state.copyWith(cancelToken: CancellationToken()); - _backupService.backupAsset(assetsWillBeBackup, state.cancelToken, - _onAssetUploaded, _onUploadProgress); + _backupService.backupAsset( + assetsWillBeBackup, + state.cancelToken, + _onAssetUploaded, + _onUploadProgress, + _onSetCurrentBackupAsset, + _onBackupError, + ); } else { PhotoManager.openSetting(); } } + void _onBackupError(ErrorUploadAsset errorAssetInfo) { + ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo); + } + + void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) { + state = state.copyWith(currentUploadAsset: currentUploadAsset); + } + void cancelBackup() { state.cancelToken.cancel(); state = state.copyWith( @@ -375,5 +406,6 @@ final backupProvider = ref.watch(backupServiceProvider), ref.watch(serverInfoServiceProvider), ref.watch(authenticationProvider), + ref, ); }); diff --git a/mobile/lib/modules/backup/providers/error_backup_list.provider.dart b/mobile/lib/modules/backup/providers/error_backup_list.provider.dart new file mode 100644 index 0000000000..213027dba0 --- /dev/null +++ b/mobile/lib/modules/backup/providers/error_backup_list.provider.dart @@ -0,0 +1,23 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart'; + +class ErrorBackupListNotifier extends StateNotifier<Set<ErrorUploadAsset>> { + ErrorBackupListNotifier() : super({}); + + add(ErrorUploadAsset errorAsset) { + state = state.union({errorAsset}); + } + + remove(ErrorUploadAsset errorAsset) { + state = state.difference({errorAsset}); + } + + empty() { + state = {}; + } +} + +final errorBackupListProvider = + StateNotifierProvider<ErrorBackupListNotifier, Set<ErrorUploadAsset>>( + (ref) => ErrorBackupListNotifier(), +); diff --git a/mobile/lib/modules/backup/services/backup.service.dart b/mobile/lib/modules/backup/services/backup.service.dart index 61390a3948..c1b1576ab8 100644 --- a/mobile/lib/modules/backup/services/backup.service.dart +++ b/mobile/lib/modules/backup/services/backup.service.dart @@ -7,6 +7,9 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/modules/backup/models/check_duplicate_asset_response.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/shared/services/network.service.dart'; import 'package:immich_mobile/shared/models/device_info.model.dart'; import 'package:immich_mobile/utils/files_helper.dart'; @@ -20,6 +23,7 @@ final backupServiceProvider = class BackupService { final NetworkService _networkService; + BackupService(this._networkService); Future<List<String>> getDeviceBackupAsset() async { @@ -32,17 +36,40 @@ class BackupService { return result.cast<String>(); } + Future<bool> checkDuplicateAsset(String deviceAssetId) async { + String deviceId = Hive.box(userInfoBox).get(deviceIdKey); + + try { + Response response = + await _networkService.postRequest(url: "asset/check", data: { + "deviceId": deviceId, + "deviceAssetId": deviceAssetId, + }); + + if (response.statusCode == 200) { + var result = CheckDuplicateAssetResponse.fromJson(response.toString()); + + return result.isExist; + } else { + return false; + } + } catch (e) { + return false; + } + } + backupAsset( - Set<AssetEntity> assetList, - http.CancellationToken cancelToken, - Function(String, String) singleAssetDoneCb, - Function(int, int) uploadProgress) async { + Set<AssetEntity> assetList, + http.CancellationToken cancelToken, + Function(String, String) singleAssetDoneCb, + Function(int, int) uploadProgressCb, + Function(CurrentUploadAsset) setCurrentUploadAssetCb, + Function(ErrorUploadAsset) errorCb, + ) async { String deviceId = Hive.box(userInfoBox).get(deviceIdKey); String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); File? file; - http.MultipartFile? thumbnailUploadData; - for (var entity in assetList) { try { if (entity.type == AssetType.video) { @@ -74,7 +101,7 @@ class BackupService { var req = MultipartRequest( 'POST', Uri.parse('$savedEndpoint/asset/upload'), onProgress: ((bytes, totalBytes) => - uploadProgress(bytes, totalBytes))); + uploadProgressCb(bytes, totalBytes))); req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}"; req.fields['deviceAssetId'] = entity.id; @@ -88,10 +115,35 @@ class BackupService { req.files.add(assetRawUploadData); - var res = await req.send(cancellationToken: cancelToken); + setCurrentUploadAssetCb( + CurrentUploadAsset( + id: entity.id, + createdAt: entity.createDateTime, + fileName: originalFileName, + fileType: _getAssetType(entity.type), + ), + ); - if (res.statusCode == 201) { + var response = await req.send(cancellationToken: cancelToken); + + if (response.statusCode == 201) { singleAssetDoneCb(entity.id, deviceId); + } else { + var data = await response.stream.bytesToString(); + var error = jsonDecode(data); + + debugPrint( + "Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}"); + + errorCb(ErrorUploadAsset( + asset: entity, + id: entity.id, + createdAt: entity.createDateTime, + fileName: originalFileName, + fileType: _getAssetType(entity.type), + errorMessage: error['error'], + )); + continue; } } } on http.CancelledException { @@ -108,6 +160,8 @@ class BackupService { } } + void sendBackupRequest(AssetEntity entity) {} + String _getAssetType(AssetType assetType) { switch (assetType) { case AssetType.audio: diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index 41645ca930..dd2afd7b11 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_state.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; @@ -9,6 +10,7 @@ import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart'; +import 'package:intl/intl.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; class BackupControllerPage extends HookConsumerWidget { @@ -42,7 +44,7 @@ class BackupControllerPage extends HookConsumerWidget { color: Theme.of(context).primaryColor, ), title: const Text( - "Server Storage", + "Server storage", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ), subtitle: Padding( @@ -56,7 +58,7 @@ class BackupControllerPage extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), barRadius: const Radius.circular(2), - lineHeight: 6.0, + lineHeight: 10.0, percent: backupState.serverInfo.diskUsagePercentage / 100.0, backgroundColor: Colors.grey, progressColor: Theme.of(context).primaryColor, @@ -246,6 +248,141 @@ class BackupControllerPage extends HookConsumerWidget { ); } + _buildCurrentBackupAssetInfoCard() { + return ListTile( + leading: Icon( + Icons.info_outline_rounded, + color: Theme.of(context).primaryColor, + ), + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Uploading file info", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + if (ref.watch(errorBackupListProvider).isNotEmpty) + ActionChip( + avatar: Icon( + Icons.info, + size: 24, + color: Colors.red[400], + ), + elevation: 1, + visualDensity: VisualDensity.compact, + label: Text( + "Failed (${ref.watch(errorBackupListProvider).length})", + style: TextStyle( + color: Colors.red[400], + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + backgroundColor: Colors.white, + onPressed: () { + AutoRouter.of(context).push(const FailedBackupStatusRoute()); + }, + ), + ], + ), + subtitle: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: LinearPercentIndicator( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), + barRadius: const Radius.circular(2), + lineHeight: 10.0, + trailing: Text( + " ${backupState.progressInPercentage.toStringAsFixed(0)}%", + style: const TextStyle(fontSize: 12), + ), + percent: backupState.progressInPercentage / 100.0, + backgroundColor: Colors.grey, + progressColor: Theme.of(context).primaryColor, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Table( + border: TableBorder.all( + color: Colors.black12, + width: 1, + ), + children: [ + TableRow( + decoration: BoxDecoration( + color: Colors.grey[100], + ), + children: [ + TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Text( + 'File name: ${backupState.currentUploadAsset.fileName} [${backupState.currentUploadAsset.fileType.toLowerCase()}]', + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 10.0), + ), + ), + ), + ], + ), + TableRow( + decoration: BoxDecoration( + color: Colors.grey[200], + ), + children: [ + TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Text( + "Created on: ${DateFormat.yMMMMd('en_US').format( + DateTime.parse( + backupState.currentUploadAsset.createdAt + .toString(), + ), + )}", + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 10.0), + ), + ), + ), + ], + ), + TableRow( + decoration: BoxDecoration( + color: Colors.grey[100], + ), + children: [ + TableCell( + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Text( + "ID: ${backupState.currentUploadAsset.id}", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 10.0, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + void startBackup() { + ref.watch(errorBackupListProvider.notifier).empty(); + ref.watch(backupProvider.notifier).startBackupProcess(); + } + return Scaffold( appBar: AppBar( elevation: 0, @@ -264,7 +401,7 @@ class BackupControllerPage extends HookConsumerWidget { )), ), body: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32), child: ListView( // crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -297,23 +434,11 @@ class BackupControllerPage extends HookConsumerWidget { const Divider(), _buildStorageInformation(), const Divider(), + _buildCurrentBackupAssetInfoCard(), Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - "Asset that were being backup: ${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length} [${backupState.progressInPercentage.toStringAsFixed(0)}%]"), - ), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Row(children: [ - const Text("Backup Progress:"), - const Padding(padding: EdgeInsets.symmetric(horizontal: 2)), - backupState.backupProgress == BackUpProgressEnum.inProgress - ? const CircularProgressIndicator.adaptive() - : const Text("Done"), - ]), - ), - Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.only( + top: 24, + ), child: Container( child: backupState.backupProgress == BackUpProgressEnum.inProgress @@ -321,25 +446,33 @@ class BackupControllerPage extends HookConsumerWidget { style: ElevatedButton.styleFrom( primary: Colors.red[300], onPrimary: Colors.grey[50], + padding: const EdgeInsets.all(14), ), onPressed: () { ref.read(backupProvider.notifier).cancelBackup(); }, - child: const Text("Cancel"), + child: const Text( + "CANCEL", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), ) : ElevatedButton( style: ElevatedButton.styleFrom( primary: Theme.of(context).primaryColor, onPrimary: Colors.grey[50], + padding: const EdgeInsets.all(14), + ), + onPressed: shouldBackup ? startBackup : null, + child: const Text( + "START BACKUP", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), ), - onPressed: shouldBackup - ? () { - ref - .read(backupProvider.notifier) - .startBackupProcess(); - } - : null, - child: const Text("Start Backup"), ), ), ) diff --git a/mobile/lib/modules/backup/views/failed_backup_status_page.dart b/mobile/lib/modules/backup/views/failed_backup_status_page.dart new file mode 100644 index 0000000000..203ea0fad5 --- /dev/null +++ b/mobile/lib/modules/backup/views/failed_backup_status_page.dart @@ -0,0 +1,139 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart'; +import 'package:intl/intl.dart'; +import 'package:photo_manager/photo_manager.dart'; + +class FailedBackupStatusPage extends HookConsumerWidget { + const FailedBackupStatusPage({Key? key}) : super(key: key); + @override + Widget build(BuildContext context, WidgetRef ref) { + final errorBackupList = ref.watch(errorBackupListProvider); + + return Scaffold( + appBar: AppBar( + elevation: 0, + title: Text( + "Failed Backup (${errorBackupList.length})", + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + leading: IconButton( + onPressed: () { + AutoRouter.of(context).pop(true); + }, + splashRadius: 24, + icon: const Icon( + Icons.arrow_back_ios_rounded, + )), + ), + body: ListView.builder( + shrinkWrap: true, + itemCount: errorBackupList.length, + itemBuilder: ((context, index) { + var errorAsset = errorBackupList.elementAt(index); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 4, + ), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), // if you need this + side: const BorderSide( + color: Colors.black12, + width: 1, + ), + ), + elevation: 0, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 100, + minHeight: 150, + maxWidth: 100, + maxHeight: 200, + ), + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(15), + topLeft: Radius.circular(15), + ), + clipBehavior: Clip.hardEdge, + child: Image( + fit: BoxFit.cover, + image: AssetEntityImageProvider( + errorAsset.asset, + isOriginal: false, + thumbnailSize: const ThumbnailSize.square(512), + thumbnailFormat: ThumbnailFormat.jpeg, + ), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + DateFormat.yMMMMd('en_US').format( + DateTime.parse( + errorAsset.createdAt.toString(), + ), + ), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey[700]), + ), + Icon( + Icons.error, + color: Colors.red.withAlpha(200), + size: 18, + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + errorAsset.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: Theme.of(context).primaryColor, + ), + ), + ), + Text( + errorAsset.errorMessage, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey[800], + ), + ), + ], + ), + ), + ) + ], + ), + ), + ); + }), + ), + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 8100248f03..6bce788af9 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/modules/backup/views/album_preview_page.dart'; import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart'; +import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart'; import 'package:immich_mobile/modules/login/views/change_password_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/home/views/home_page.dart'; @@ -65,6 +66,11 @@ part 'router.gr.dart'; ), AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]), AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]), + CustomRoute( + page: FailedBackupStatusPage, + guards: [AuthGuard], + transitionsBuilder: TransitionsBuilders.slideBottom, + ), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 598e81b77a..16042b45a3 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -115,6 +115,14 @@ class _$AppRouter extends RootStackRouter { routeData: routeData, child: AlbumPreviewPage(key: args.key, album: args.album)); }, + FailedBackupStatusRoute.name: (routeData) { + return CustomPage<dynamic>( + routeData: routeData, + child: const FailedBackupStatusPage(), + transitionsBuilder: TransitionsBuilders.slideBottom, + opaque: true, + barrierDismissible: false); + }, HomeRoute.name: (routeData) { return MaterialPageX<dynamic>( routeData: routeData, child: const HomePage()); @@ -177,7 +185,9 @@ class _$AppRouter extends RootStackRouter { RouteConfig(BackupAlbumSelectionRoute.name, path: '/backup-album-selection-page', guards: [authGuard]), RouteConfig(AlbumPreviewRoute.name, - path: '/album-preview-page', guards: [authGuard]) + path: '/album-preview-page', guards: [authGuard]), + RouteConfig(FailedBackupStatusRoute.name, + path: '/failed-backup-status-page', guards: [authGuard]) ]; } @@ -437,6 +447,15 @@ class AlbumPreviewRouteArgs { } } +/// generated route for +/// [FailedBackupStatusPage] +class FailedBackupStatusRoute extends PageRouteInfo<void> { + const FailedBackupStatusRoute() + : super(FailedBackupStatusRoute.name, path: '/failed-backup-status-page'); + + static const String name = 'FailedBackupStatusRoute'; +} + /// generated route for /// [HomePage] class HomeRoute extends PageRouteInfo<void> { diff --git a/mobile/lib/shared/services/network.service.dart b/mobile/lib/shared/services/network.service.dart index 300d69dfcf..24ad7649de 100644 --- a/mobile/lib/shared/services/network.service.dart +++ b/mobile/lib/shared/services/network.service.dart @@ -79,7 +79,7 @@ class NetworkService { return res; } on DioError catch (e) { - debugPrint("DioError: ${e.response}"); + debugPrint("[postRequest] DioError: ${e.response}"); return null; } catch (e) { debugPrint("ERROR PostRequest: $e"); diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 270ec50732..2c1828e5e9 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.16.1+24 +version: 1.17.0+25 environment: sdk: ">=2.17.0 <3.0.0" diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index d7e73aa3b9..8814fc1a37 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts @@ -15,6 +15,7 @@ import { Delete, Logger, HttpCode, + BadRequestException, } from '@nestjs/common'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { AssetService } from './asset.service'; @@ -34,6 +35,7 @@ import { Queue } from 'bull'; import { IAssetUploadedJob } from '@app/job/index'; import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant'; import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant'; +import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; @UseGuards(JwtAuthGuard) @Controller('asset') @@ -66,17 +68,16 @@ export class AssetController { try { const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype); - if (!savedAsset) { - return; + if (savedAsset) { + await this.assetUploadedQueue.add( + assetUploadedProcessorName, + { asset: savedAsset, fileName: file.originalname, fileSize: file.size }, + { jobId: savedAsset.id }, + ); } - - await this.assetUploadedQueue.add( - assetUploadedProcessorName, - { asset: savedAsset, fileName: file.originalname, fileSize: file.size }, - { jobId: savedAsset.id }, - ); } catch (e) { - Logger.error(`Error receiving upload file ${e}`); + Logger.error(`Error uploading file ${e}`); + throw new BadRequestException(`Error uploading file`, `${e}`); } } @@ -172,9 +173,9 @@ export class AssetController { @HttpCode(200) async checkDuplicateAsset( @GetAuthUser() authUser: AuthUserDto, - @Body(ValidationPipe) { deviceAssetId }: { deviceAssetId: string }, + @Body(ValidationPipe) checkDuplicateAssetDto: CheckDuplicateAssetDto, ) { - const res = await this.assetService.checkDuplicatedAsset(authUser, deviceAssetId); + const res = await this.assetService.checkDuplicatedAsset(authUser, checkDuplicateAssetDto); return { isExist: res, diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index a1fb560efa..12cf2080ea 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -18,6 +18,7 @@ import { promisify } from 'util'; import { DeleteAssetDto } from './dto/delete-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto'; import fs from 'fs/promises'; +import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; const fileInfo = promisify(stat); @@ -58,15 +59,11 @@ export class AssetService { asset.mimeType = mimeType; asset.duration = assetInfo.duration || null; - try { - const createdAsset = await this.assetRepository.save(asset); - if (!createdAsset) { - throw new Error('Asset not created'); - } - return createdAsset; - } catch (e) { - Logger.error(`Error Create New Asset ${e}`, 'createUserAsset'); + const createdAsset = await this.assetRepository.save(asset); + if (!createdAsset) { + throw new Error('Asset not created'); } + return createdAsset; } public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) { @@ -439,10 +436,11 @@ export class AssetService { ); } - async checkDuplicatedAsset(authUser: AuthUserDto, deviceAssetId: string) { + async checkDuplicatedAsset(authUser: AuthUserDto, checkDuplicateAssetDto: CheckDuplicateAssetDto) { const res = await this.assetRepository.findOne({ where: { - deviceAssetId, + deviceAssetId: checkDuplicateAssetDto.deviceAssetId, + deviceId: checkDuplicateAssetDto.deviceId, userId: authUser.id, }, }); diff --git a/server/apps/immich/src/api-v1/asset/dto/check-duplicate-asset.dto.ts b/server/apps/immich/src/api-v1/asset/dto/check-duplicate-asset.dto.ts new file mode 100644 index 0000000000..5f2f6161dd --- /dev/null +++ b/server/apps/immich/src/api-v1/asset/dto/check-duplicate-asset.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty } from 'class-validator'; + +export class CheckDuplicateAssetDto { + @IsNotEmpty() + deviceAssetId!: string; + + @IsNotEmpty() + deviceId!: string; +} diff --git a/server/apps/immich/src/constants/server_version.constant.ts b/server/apps/immich/src/constants/server_version.constant.ts index 8c48a078d5..ed266d341d 100644 --- a/server/apps/immich/src/constants/server_version.constant.ts +++ b/server/apps/immich/src/constants/server_version.constant.ts @@ -3,7 +3,7 @@ export const serverVersion = { major: 1, - minor: 16, + minor: 17, patch: 0, - build: 23, + build: 25, }; diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 2681ebb06b..b9da12e291 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -53,7 +53,7 @@ export async function fileUploader(asset: File, accessToken: string) { // Check if asset upload on server before performing upload const res = await fetch(serverEndpoint + '/asset/check', { method: 'POST', - body: JSON.stringify({ deviceAssetId }), + body: JSON.stringify({ deviceAssetId, deviceId: 'WEB' }), headers: { Authorization: 'Bearer ' + accessToken, 'Content-Type': 'application/json',