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',