Parcourir la source

Implemented delete asset on device and on database (#22)

* refactor serving file function asset service
* Remove PhotoViewer for now since it creates a problem in 2.10
* Added error message for wrong decode file and logo for failed to load file
* Fixed error when read stream cannot be created and crash server
* Added method to get all assets as a raw array
* Implemented cleaner way of grouping image
* Implemented operation to delete assets in the database
* Implemented delete on database operation
* Implemented delete on device operation
* Fixed issue display wrong information when the auto backup is enabled after deleting all assets
Alex il y a 3 ans
Parent
commit
897d49f734

+ 3 - 4
README.md

@@ -53,19 +53,18 @@ You can use docker compose for development, there are several services that comp
 
 Navigate to `server` directory and run
 
-```
+````
 cp .env.example .env
-```
 
 Then populate the value in there.
 
-Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned the user that run the `docker-compose` command below.
+Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned by the user that run the `docker-compose` command below.
 
 To start, run
 
 ```bash
 docker-compose -f ./server/docker-compose.yml up
-```
+````
 
 To force rebuild node modules after installing new packages
 

+ 31 - 7
mobile/lib/modules/asset_viewer/views/image_viewer_page.dart

@@ -1,4 +1,3 @@
-import 'package:auto_route/auto_route.dart';
 import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
@@ -10,7 +9,6 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
-import 'package:photo_view/photo_view.dart';
 
 // ignore: must_be_immutable
 class ImageViewerPage extends HookConsumerWidget {
@@ -35,6 +33,7 @@ class ImageViewerPage extends HookConsumerWidget {
 
     useEffect(() {
       getAssetExif();
+      return null;
     }, []);
 
     return Scaffold(
@@ -60,12 +59,34 @@ class ImageViewerPage extends HookConsumerWidget {
             imageUrl: imageUrl,
             httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
             fadeInDuration: const Duration(milliseconds: 250),
-            errorWidget: (context, url, error) => const Icon(Icons.error),
-            imageBuilder: (context, imageProvider) {
-              return PhotoView(imageProvider: imageProvider);
-            },
+            errorWidget: (context, url, error) => ConstrainedBox(
+              constraints: const BoxConstraints(maxWidth: 300),
+              child: Wrap(
+                spacing: 32,
+                runSpacing: 32,
+                alignment: WrapAlignment.center,
+                children: [
+                  const Text(
+                    "Failed To Render Image - Possibly Corrupted Data",
+                    textAlign: TextAlign.center,
+                    style: TextStyle(fontSize: 16, color: Colors.white),
+                  ),
+                  SingleChildScrollView(
+                    child: Text(
+                      error.toString(),
+                      textAlign: TextAlign.center,
+                      style: TextStyle(fontSize: 12, color: Colors.grey[400]),
+                    ),
+                  ),
+                ],
+              ),
+            ),
+            // imageBuilder: (context, imageProvider) {
+            //   return PhotoView(imageProvider: imageProvider);
+            // },
             placeholder: (context, url) {
               return CachedNetworkImage(
+                cacheKey: thumbnailUrl,
                 fit: BoxFit.cover,
                 imageUrl: thumbnailUrl,
                 httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
@@ -74,7 +95,10 @@ class ImageViewerPage extends HookConsumerWidget {
                   scale: 0.2,
                   child: CircularProgressIndicator(value: downloadProgress.progress),
                 ),
-                errorWidget: (context, url, error) => const Icon(Icons.error),
+                errorWidget: (context, url, error) => Icon(
+                  Icons.error,
+                  color: Colors.grey[300],
+                ),
               );
             },
           ),

+ 52 - 0
mobile/lib/modules/home/models/delete_asset_response.model.dart

@@ -0,0 +1,52 @@
+import 'dart:convert';
+
+class DeleteAssetResponse {
+  final String id;
+  final String status;
+
+  DeleteAssetResponse({
+    required this.id,
+    required this.status,
+  });
+
+  DeleteAssetResponse copyWith({
+    String? id,
+    String? status,
+  }) {
+    return DeleteAssetResponse(
+      id: id ?? this.id,
+      status: status ?? this.status,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'id': id,
+      'status': status,
+    };
+  }
+
+  factory DeleteAssetResponse.fromMap(Map<String, dynamic> map) {
+    return DeleteAssetResponse(
+      id: map['id'] ?? '',
+      status: map['status'] ?? '',
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory DeleteAssetResponse.fromJson(String source) => DeleteAssetResponse.fromMap(json.decode(source));
+
+  @override
+  String toString() => 'DeleteAssetResponse(id: $id, status: $status)';
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is DeleteAssetResponse && other.id == id && other.status == status;
+  }
+
+  @override
+  int get hashCode => id.hashCode ^ status.hashCode;
+}

+ 0 - 0
mobile/lib/modules/home/models/get_all_asset_respose.model.dart → mobile/lib/modules/home/models/get_all_asset_response.model.dart


+ 43 - 70
mobile/lib/modules/home/providers/asset.provider.dart

@@ -1,99 +1,72 @@
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
+import 'package:immich_mobile/modules/home/models/delete_asset_response.model.dart';
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
-import 'package:intl/intl.dart';
+import 'package:immich_mobile/shared/services/device_info.service.dart';
 import 'package:collection/collection.dart';
+import 'package:intl/intl.dart';
+import 'package:photo_manager/photo_manager.dart';
 
-class AssetNotifier extends StateNotifier<List<ImmichAssetGroupByDate>> {
+class AssetNotifier extends StateNotifier<List<ImmichAsset>> {
   final AssetService _assetService = AssetService();
+  final DeviceInfoService _deviceInfoService = DeviceInfoService();
 
   AssetNotifier() : super([]);
 
-  late String? nextPageKey = "";
-  bool isFetching = false;
+  getAllAsset() async {
+    List<ImmichAsset>? allAssets = await _assetService.getAllAsset();
 
-  // Get All assets
-  getAllAssets() async {
-    GetAllAssetResponse? res = await _assetService.getAllAsset();
-    nextPageKey = res?.nextPageKey;
-
-    if (res != null) {
-      for (var assets in res.data) {
-        state = [...state, assets];
-      }
+    if (allAssets != null) {
+      allAssets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
+      state = allAssets;
     }
   }
 
-  // Get Asset From The Past
-  getOlderAsset() async {
-    if (nextPageKey != null && !isFetching) {
-      isFetching = true;
-      GetAllAssetResponse? res = await _assetService.getOlderAsset(nextPageKey);
-
-      if (res != null) {
-        nextPageKey = res.nextPageKey;
-
-        List<ImmichAssetGroupByDate> previousState = state;
-        List<ImmichAssetGroupByDate> currentState = [];
-
-        for (var assets in res.data) {
-          currentState = [...currentState, assets];
-        }
+  clearAllAsset() {
+    state = [];
+  }
 
-        if (previousState.last.date == currentState.first.date) {
-          previousState.last.assets = [...previousState.last.assets, ...currentState.first.assets];
-          state = [...previousState, ...currentState.sublist(1)];
-        } else {
-          state = [...previousState, ...currentState];
+  deleteAssets(Set<ImmichAsset> deleteAssets) async {
+    var deviceInfo = await _deviceInfoService.getDeviceInfo();
+    var deviceId = deviceInfo["deviceId"];
+    List<String> deleteIdList = [];
+    // Delete asset from device
+    for (var asset in deleteAssets) {
+      // Delete asset on device if present
+      if (asset.deviceId == deviceId) {
+        AssetEntity? localAsset = await AssetEntity.fromId(asset.deviceAssetId);
+
+        if (localAsset != null) {
+          deleteIdList.add(localAsset.id);
         }
       }
-
-      isFetching = false;
     }
-  }
 
-  // Get newer asset from the current time
-  getNewAsset() async {
-    if (state.isNotEmpty) {
-      var latestGroup = state.first;
+    final List<String> result = await PhotoManager.editor.deleteWithIds(deleteIdList);
+    print(result);
 
-      // Sort the last asset group and put the lastest asset in front.
-      latestGroup.assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
-      var latestAsset = latestGroup.assets.first;
-      var formatDateTemplate = 'y-MM-dd';
-      var latestAssetDateText = DateFormat(formatDateTemplate).format(DateTime.parse(latestAsset.createdAt));
-
-      List<ImmichAsset> newAssets = await _assetService.getNewAsset(latestAsset.createdAt);
+    // Delete asset on server
+    List<DeleteAssetResponse>? deleteAssetResult = await _assetService.deleteAssets(deleteAssets);
+    if (deleteAssetResult == null) {
+      return;
+    }
 
-      if (newAssets.isEmpty) {
-        return;
+    for (var asset in deleteAssetResult) {
+      if (asset.status == 'success') {
+        state = state.where((immichAsset) => immichAsset.id != asset.id).toList();
       }
-
-      // Grouping by data
-      var groupByDateList = groupBy<ImmichAsset, String>(
-          newAssets, (asset) => DateFormat(formatDateTemplate).format(DateTime.parse(asset.createdAt)));
-
-      groupByDateList.forEach((groupDateInFormattedText, assets) {
-        if (groupDateInFormattedText != latestAssetDateText) {
-          ImmichAssetGroupByDate newGroup = ImmichAssetGroupByDate(assets: assets, date: groupDateInFormattedText);
-          state = [newGroup, ...state];
-        } else {
-          latestGroup.assets.insertAll(0, assets);
-
-          state = [latestGroup, ...state.sublist(1)];
-        }
-      });
     }
   }
-
-  clearAllAsset() {
-    state = [];
-  }
 }
 
 final currentLocalPageProvider = StateProvider<int>((ref) => 0);
 
-final assetProvider = StateNotifierProvider<AssetNotifier, List<ImmichAssetGroupByDate>>((ref) {
+final assetProvider = StateNotifierProvider<AssetNotifier, List<ImmichAsset>>((ref) {
   return AssetNotifier();
 });
+
+final assetGroupByDateTimeProvider = StateProvider((ref) {
+  var assetGroup = ref.watch(assetProvider);
+
+  return assetGroup.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
+});

+ 37 - 3
mobile/lib/modules/home/services/asset.service.dart

@@ -1,7 +1,8 @@
 import 'dart:convert';
 
 import 'package:flutter/material.dart';
-import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
+import 'package:immich_mobile/modules/home/models/delete_asset_response.model.dart';
+import 'package:immich_mobile/modules/home/models/get_all_asset_response.model.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
 import 'package:immich_mobile/shared/services/network.service.dart';
@@ -9,7 +10,20 @@ import 'package:immich_mobile/shared/services/network.service.dart';
 class AssetService {
   final NetworkService _networkService = NetworkService();
 
-  Future<GetAllAssetResponse?> getAllAsset() async {
+  Future<List<ImmichAsset>?> getAllAsset() async {
+    var res = await _networkService.getRequest(url: "asset/");
+    try {
+      List<dynamic> decodedData = jsonDecode(res.toString());
+
+      List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
+      return result;
+    } catch (e) {
+      debugPrint("Error getAllAsset  ${e.toString()}");
+    }
+    return null;
+  }
+
+  Future<GetAllAssetResponse?> getAllAssetWithPagination() async {
     var res = await _networkService.getRequest(url: "asset/all");
     try {
       Map<String, dynamic> decodedData = jsonDecode(res.toString());
@@ -69,7 +83,27 @@ class AssetService {
       Map<String, dynamic> decodedData = jsonDecode(res.toString());
 
       ImmichAssetWithExif result = ImmichAssetWithExif.fromMap(decodedData);
-      print("result $result");
+      return result;
+    } catch (e) {
+      debugPrint("Error getAllAsset  ${e.toString()}");
+      return null;
+    }
+  }
+
+  Future<List<DeleteAssetResponse>?> deleteAssets(Set<ImmichAsset> deleteAssets) async {
+    try {
+      var payload = [];
+
+      for (var asset in deleteAssets) {
+        payload.add(asset.id);
+      }
+
+      var res = await _networkService.deleteRequest(url: "asset/", data: {"ids": payload});
+
+      List<dynamic> decodedData = jsonDecode(res.toString());
+
+      List<DeleteAssetResponse> result = List.from(decodedData.map((a) => DeleteAssetResponse.fromMap(a)));
+
       return result;
     } catch (e) {
       debugPrint("Error getAllAsset  ${e.toString()}");

+ 13 - 3
mobile/lib/modules/home/ui/delete_diaglog.dart

@@ -1,10 +1,15 @@
 import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
+import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
 
-class DeleteDialog extends StatelessWidget {
+class DeleteDialog extends ConsumerWidget {
   const DeleteDialog({Key? key}) : super(key: key);
 
   @override
-  Widget build(BuildContext context) {
+  Widget build(BuildContext context, WidgetRef ref) {
+    final homePageState = ref.watch(homePageStateProvider);
+
     return AlertDialog(
       backgroundColor: Colors.grey[200],
       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
@@ -21,7 +26,12 @@ class DeleteDialog extends StatelessWidget {
           ),
         ),
         TextButton(
-          onPressed: () {},
+          onPressed: () {
+            ref.watch(assetProvider.notifier).deleteAssets(homePageState.selectedItems);
+            ref.watch(homePageStateProvider.notifier).disableMultiSelect();
+
+            Navigator.of(context).pop();
+          },
           child: Text(
             "Delete",
             style: TextStyle(color: Colors.red[400]),

+ 0 - 1
mobile/lib/modules/home/ui/immich_sliver_appbar.dart

@@ -3,7 +3,6 @@ import 'package:badges/badges.dart';
 import 'package:flutter/material.dart';
 import 'package:google_fonts/google_fonts.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 
 import 'package:immich_mobile/routing/router.dart';

+ 2 - 0
mobile/lib/modules/home/ui/profile_drawer.dart

@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
 import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
+import 'package:immich_mobile/shared/providers/backup.provider.dart';
 
 class ProfileDrawer extends ConsumerWidget {
   const ProfileDrawer({Key? key}) : super(key: key);
@@ -57,6 +58,7 @@ class ProfileDrawer extends ConsumerWidget {
               bool res = await ref.read(authenticationProvider.notifier).logout();
 
               if (res) {
+                ref.watch(backupProvider.notifier).cancelBackup();
                 ref.watch(assetProvider.notifier).clearAllAsset();
                 AutoRouter.of(context).popUntilRoot();
               }

+ 16 - 3
mobile/lib/modules/home/ui/thumbnail_image.dart

@@ -7,6 +7,7 @@ import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
+import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 import 'package:immich_mobile/routing/router.dart';
 
@@ -25,6 +26,7 @@ class ThumbnailImage extends HookConsumerWidget {
 
     var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
     var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
+    var deviceId = ref.watch(authenticationProvider).deviceId;
 
     Widget _buildSelectionIcon(ImmichAsset asset) {
       if (selectedAsset.contains(asset)) {
@@ -42,6 +44,7 @@ class ThumbnailImage extends HookConsumerWidget {
 
     return GestureDetector(
       onTap: () {
+        debugPrint("View ${asset.id}");
         if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length == 1) {
           ref.watch(homePageStateProvider.notifier).disableMultiSelect();
         } else if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length > 1) {
@@ -99,9 +102,10 @@ class ThumbnailImage extends HookConsumerWidget {
                   child: CircularProgressIndicator(value: downloadProgress.progress),
                 ),
                 errorWidget: (context, url, error) {
-                  debugPrint("Error Loading Thumbnail Widget $error");
-                  cacheKey.value += 1;
-                  return const Icon(Icons.error);
+                  return Icon(
+                    Icons.image_not_supported_outlined,
+                    color: Theme.of(context).primaryColor,
+                  );
                 },
               ),
             ),
@@ -116,6 +120,15 @@ class ThumbnailImage extends HookConsumerWidget {
                     )
                   : Container(),
             ),
+            Positioned(
+              right: 10,
+              bottom: 5,
+              child: Icon(
+                (deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded,
+                color: Colors.white,
+                size: 18,
+              ),
+            )
           ],
         ),
       ),

+ 20 - 46
mobile/lib/modules/home/views/home_page.dart

@@ -10,7 +10,6 @@ import 'package:immich_mobile/modules/home/ui/image_grid.dart';
 import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
 import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
 import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
-import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
 import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
 import 'package:sliver_tools/sliver_tools.dart';
 
@@ -20,76 +19,51 @@ class HomePage extends HookConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     ScrollController _scrollController = useScrollController();
-    List<ImmichAssetGroupByDate> _assetGroup = ref.watch(assetProvider);
+    var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
     List<Widget> _imageGridGroup = [];
     var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
     var homePageState = ref.watch(homePageStateProvider);
 
-    _scrollControllerCallback() {
-      var endOfPage = _scrollController.position.maxScrollExtent;
-
-      if (_scrollController.offset >= endOfPage - (endOfPage * 0.1) && !_scrollController.position.outOfRange) {
-        ref.read(assetProvider.notifier).getOlderAsset();
-      }
-    }
-
     useEffect(() {
-      ref.read(assetProvider.notifier).getAllAssets();
-
-      _scrollController.addListener(_scrollControllerCallback);
-      return () {
-        _scrollController.removeListener(_scrollControllerCallback);
-      };
+      ref.read(assetProvider.notifier).getAllAsset();
+      return null;
     }, []);
 
     onPopBackFromBackupPage() {
-      ref.read(assetProvider.notifier).getNewAsset();
-      // Remove and force getting new widget again if there is not many widget on screen.
-      // Otherwise do nothing.
-
-      if (_imageGridGroup.isNotEmpty && _imageGridGroup.length < 20) {
-        ref.read(assetProvider.notifier).getOlderAsset();
-      } else if (_imageGridGroup.isEmpty) {
-        ref.read(assetProvider.notifier).getAllAssets();
-      }
+      ref.read(assetProvider.notifier).getAllAsset();
     }
 
     Widget _buildBody() {
-      if (_assetGroup.isNotEmpty) {
-        String lastGroupDate = _assetGroup[0].date;
-
-        for (var group in _assetGroup) {
-          var dateTitle = group.date;
-          var assetGroup = group.assets;
+      if (assetGroupByDateTime.isNotEmpty) {
+        int? lastMonth;
 
-          int? currentMonth = DateTime.tryParse(dateTitle)?.month;
-          int? previousMonth = DateTime.tryParse(lastGroupDate)?.month;
+        assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
+          DateTime parseDateGroup = DateTime.parse(dateGroup);
+          int currentMonth = parseDateGroup.month;
 
-          // Add Monthly Title Group if started at the beginning of the month
-
-          if (currentMonth != null && previousMonth != null) {
-            if ((currentMonth - previousMonth) != 0) {
+          if (lastMonth != null) {
+            if (currentMonth - lastMonth! != 0) {
               _imageGridGroup.add(
-                MonthlyTitleText(isoDate: dateTitle),
+                MonthlyTitleText(
+                  isoDate: dateGroup,
+                ),
               );
             }
           }
 
-          // Add Daily Title Group
           _imageGridGroup.add(
             DailyTitleText(
-              isoDate: dateTitle,
-              assetGroup: assetGroup,
+              isoDate: dateGroup,
+              assetGroup: immichAssetList,
             ),
           );
 
-          // Add Image Group
           _imageGridGroup.add(
-            ImageGrid(assetGroup: assetGroup),
+            ImageGrid(assetGroup: immichAssetList),
           );
-          //
-          lastGroupDate = dateTitle;
-        }
+
+          lastMonth = currentMonth;
+        });
       }
 
       return SafeArea(

+ 28 - 26
mobile/lib/modules/login/ui/login_form.dart

@@ -15,36 +15,38 @@ class LoginForm extends HookConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
     final usernameController = useTextEditingController(text: 'testuser@email.com');
     final passwordController = useTextEditingController(text: 'password');
-    final serverEndpointController = useTextEditingController(text: 'http://192.168.1.204:2283');
+    final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283');
 
     return Center(
       child: ConstrainedBox(
         constraints: const BoxConstraints(maxWidth: 300),
-        child: Wrap(
-          spacing: 32,
-          runSpacing: 32,
-          alignment: WrapAlignment.center,
-          children: [
-            const Image(
-              image: AssetImage('assets/immich-logo-no-outline.png'),
-              width: 128,
-              filterQuality: FilterQuality.high,
-            ),
-            Text(
-              'IMMICH',
-              style: GoogleFonts.snowburstOne(
-                  textStyle:
-                      TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)),
-            ),
-            EmailInput(controller: usernameController),
-            PasswordInput(controller: passwordController),
-            ServerEndpointInput(controller: serverEndpointController),
-            LoginButton(
-              emailController: usernameController,
-              passwordController: passwordController,
-              serverEndpointController: serverEndpointController,
-            ),
-          ],
+        child: SingleChildScrollView(
+          child: Wrap(
+            spacing: 32,
+            runSpacing: 32,
+            alignment: WrapAlignment.center,
+            children: [
+              const Image(
+                image: AssetImage('assets/immich-logo-no-outline.png'),
+                width: 128,
+                filterQuality: FilterQuality.high,
+              ),
+              Text(
+                'IMMICH',
+                style: GoogleFonts.snowburstOne(
+                    textStyle:
+                        TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)),
+              ),
+              EmailInput(controller: usernameController),
+              PasswordInput(controller: passwordController),
+              ServerEndpointInput(controller: serverEndpointController),
+              LoginButton(
+                emailController: usernameController,
+                passwordController: passwordController,
+                serverEndpointController: serverEndpointController,
+              ),
+            ],
+          ),
         ),
       ),
     );

+ 35 - 28
mobile/lib/shared/providers/backup.provider.dart

@@ -1,3 +1,5 @@
+import 'dart:async';
+
 import 'package:dio/dio.dart';
 import 'package:flutter/foundation.dart';
 import 'package:hive_flutter/hive_flutter.dart';
@@ -11,7 +13,7 @@ import 'package:immich_mobile/shared/services/backup.service.dart';
 import 'package:photo_manager/photo_manager.dart';
 
 class BackupNotifier extends StateNotifier<BackUpState> {
-  BackupNotifier(this.ref)
+  BackupNotifier({this.ref})
       : super(
           BackUpState(
             backupProgress: BackUpProgressEnum.idle,
@@ -32,22 +34,25 @@ class BackupNotifier extends StateNotifier<BackUpState> {
           ),
         );
 
-  final Ref ref;
+  Ref? ref;
   final BackupService _backupService = BackupService();
   final ServerInfoService _serverInfoService = ServerInfoService();
+  final StreamController _onAssetBackupStreamCtrl = StreamController.broadcast();
 
   void getBackupInfo() async {
     _updateServerInfo();
 
     List<AssetPathEntity> list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.common);
+    List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
 
     if (list.isEmpty) {
       debugPrint("No Asset On Device");
+      state = state.copyWith(
+          backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: didBackupAsset.length);
       return;
     }
 
     int totalAsset = list[0].assetCount;
-    List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
 
     state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
   }
@@ -65,19 +70,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
       List<AssetPathEntity> list =
           await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
 
+      // Get device assets info from database
+      // Compare and find different assets that has not been backing up
+      // Backup those assets
+      List<String> backupAsset = await _backupService.getDeviceBackupAsset();
+
       if (list.isEmpty) {
         debugPrint("No Asset On Device - Abort Backup Process");
+        state = state.copyWith(
+            backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: backupAsset.length);
         return;
       }
 
       int totalAsset = list[0].assetCount;
       List<AssetEntity> currentAssets = await list[0].getAssetListRange(start: 0, end: totalAsset);
 
-      // Get device assets info from database
-      // Compare and find different assets that has not been backing up
-      // Backup those assets
-      List<String> backupAsset = await _backupService.getDeviceBackupAsset();
-
       state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length);
       // Remove item that has already been backed up
       for (var backupAssetId in backupAsset) {
@@ -103,7 +110,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
   }
 
-  void _onAssetUploaded() {
+  void _onAssetUploaded(String deviceAssetId, String deviceId) {
     state =
         state.copyWith(backingUpAssetCount: state.backingUpAssetCount - 1, assetOnDatabase: state.assetOnDatabase + 1);
 
@@ -136,36 +143,36 @@ class BackupNotifier extends StateNotifier<BackUpState> {
   }
 
   void resumeBackup() {
-    debugPrint("[resumeBackup]");
-    var authState = ref.read(authenticationProvider);
+    var authState = ref?.read(authenticationProvider);
 
     // Check if user is login
     var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
 
     // User has been logged out return
-    if (accessKey == null || !authState.isAuthenticated) {
-      debugPrint("[resumeBackup] not authenticated - abort");
-      return;
-    }
-
-    // Check if this device is enable backup by the user
-    if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
-      // check if backup is alreayd in process - then return
-      if (state.backupProgress == BackUpProgressEnum.inProgress) {
-        debugPrint("[resumeBackup] Backup is already in progress - abort");
+    if (authState != null) {
+      if (accessKey == null || !authState.isAuthenticated) {
+        debugPrint("[resumeBackup] not authenticated - abort");
         return;
       }
 
-      // Run backup
-      debugPrint("[resumeBackup] Start back up");
-      startBackupProcess();
-    }
+      // Check if this device is enable backup by the user
+      if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
+        // check if backup is alreayd in process - then return
+        if (state.backupProgress == BackUpProgressEnum.inProgress) {
+          debugPrint("[resumeBackup] Backup is already in progress - abort");
+          return;
+        }
+
+        // Run backup
+        debugPrint("[resumeBackup] Start back up");
+        startBackupProcess();
+      }
 
-    debugPrint("[resumeBackup] User disables auto backup");
-    return;
+      return;
+    }
   }
 }
 
 final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
-  return BackupNotifier(ref);
+  return BackupNotifier(ref: ref);
 });

+ 2 - 2
mobile/lib/shared/services/backup.service.dart

@@ -26,7 +26,7 @@ class BackupService {
     return result.cast<String>();
   }
 
-  backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function singleAssetDoneCb,
+  backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
       Function(int, int) uploadProgress) async {
     var dio = Dio();
     dio.interceptors.add(AuthenticatedRequestInterceptor());
@@ -77,7 +77,7 @@ class BackupService {
           );
 
           if (res.statusCode == 201) {
-            singleAssetDoneCb();
+            singleAssetDoneCb(entity.id, deviceId);
           }
         }
       } on DioError catch (e) {

+ 18 - 0
mobile/lib/shared/services/network.service.dart

@@ -7,6 +7,24 @@ import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/utils/dio_http_interceptor.dart';
 
 class NetworkService {
+  Future<dynamic> deleteRequest({required String url, dynamic data}) async {
+    try {
+      var dio = Dio();
+      dio.interceptors.add(AuthenticatedRequestInterceptor());
+
+      var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
+      Response res = await dio.delete('$savedEndpoint/$url', data: data);
+
+      if (res.statusCode == 200) {
+        return res;
+      }
+    } on DioError catch (e) {
+      debugPrint("DioError: ${e.response}");
+    } catch (e) {
+      debugPrint("ERROR getRequest: ${e.toString()}");
+    }
+  }
+
   Future<dynamic> getRequest({required String url}) async {
     try {
       var dio = Dio();

+ 2 - 0
mobile/lib/shared/views/backup_controller_page.dart

@@ -22,6 +22,8 @@ class BackupControllerPage extends HookConsumerWidget {
       if (_backupState.backupProgress != BackUpProgressEnum.inProgress) {
         ref.read(backupProvider.notifier).getBackupInfo();
       }
+
+      return null;
     }, []);
 
     Widget _buildStorageInformation() {

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 10308
server/package-lock.json


+ 30 - 78
server/src/api-v1/asset/asset.controller.ts

@@ -12,27 +12,22 @@ import {
   Query,
   Response,
   Headers,
-  BadRequestException,
+  Delete,
 } from '@nestjs/common';
 import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
 import { AssetService } from './asset.service';
-import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
+import { FilesInterceptor } from '@nestjs/platform-express';
 import { multerOption } from '../../config/multer-option.config';
 import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
 import { CreateAssetDto } from './dto/create-asset.dto';
-import { createReadStream } from 'fs';
 import { ServeFileDto } from './dto/serve-file.dto';
 import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
-import { AssetType } from './entities/asset.entity';
+import { AssetEntity, AssetType } from './entities/asset.entity';
 import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
 import { Response as Res } from 'express';
-import { promisify } from 'util';
-import { stat } from 'fs';
-import { pipeline } from 'stream';
 import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
 import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
-
-const fileInfo = promisify(stat);
+import { DeleteAssetDto } from './dto/delete-asset.dto';
 
 @UseGuards(JwtAuthGuard)
 @Controller('asset')
@@ -73,75 +68,7 @@ export class AssetController {
     @Response({ passthrough: true }) res: Res,
     @Query(ValidationPipe) query: ServeFileDto,
   ): Promise<StreamableFile> {
-    let file = null;
-    const asset = await this.assetService.findOne(authUser, query.did, query.aid);
-
-    // Handle Sending Images
-    if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
-      res.set({
-        'Content-Type': asset.mimeType,
-      });
-
-      if (query.isThumb === 'false' || !query.isThumb) {
-        file = createReadStream(asset.originalPath);
-      } else {
-        file = createReadStream(asset.resizePath);
-      }
-
-      return new StreamableFile(file);
-    } else if (asset.type == AssetType.VIDEO) {
-      // Handle Handling Video
-      const { size } = await fileInfo(asset.originalPath);
-      const range = headers.range;
-
-      if (range) {
-        /** Extracting Start and End value from Range Header */
-        let [start, end] = range.replace(/bytes=/, '').split('-');
-        start = parseInt(start, 10);
-        end = end ? parseInt(end, 10) : size - 1;
-
-        if (!isNaN(start) && isNaN(end)) {
-          start = start;
-          end = size - 1;
-        }
-        if (isNaN(start) && !isNaN(end)) {
-          start = size - end;
-          end = size - 1;
-        }
-
-        // Handle unavailable range request
-        if (start >= size || end >= size) {
-          console.error('Bad Request');
-          // Return the 416 Range Not Satisfiable.
-          res.status(416).set({
-            'Content-Range': `bytes */${size}`,
-          });
-
-          throw new BadRequestException('Bad Request Range');
-        }
-
-        /** Sending Partial Content With HTTP Code 206 */
-
-        res.status(206).set({
-          'Content-Range': `bytes ${start}-${end}/${size}`,
-          'Accept-Ranges': 'bytes',
-          'Content-Length': end - start + 1,
-          'Content-Type': asset.mimeType,
-        });
-
-        const videoStream = createReadStream(asset.originalPath, { start: start, end: end });
-
-        return new StreamableFile(videoStream);
-      } else {
-        res.set({
-          'Content-Type': asset.mimeType,
-        });
-
-        return new StreamableFile(createReadStream(asset.originalPath));
-      }
-    }
-
-    console.log('SHOULD NOT BE HERE');
+    return this.assetService.serveFile(authUser, query, res, headers);
   }
 
   @Get('/new')
@@ -154,6 +81,11 @@ export class AssetController {
     return await this.assetService.getAllAssets(authUser, query);
   }
 
+  @Get('/')
+  async getAllAssetsNoPagination(@GetAuthUser() authUser: AuthUserDto) {
+    return await this.assetService.getAllAssetsNoPagination(authUser);
+  }
+
   @Get('/:deviceId')
   async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
     return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
@@ -163,4 +95,24 @@ export class AssetController {
   async getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param('assetId') assetId) {
     return this.assetService.getAssetById(authUser, assetId);
   }
+
+  @Delete('/')
+  async deleteAssetById(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) assetIds: DeleteAssetDto) {
+    const deleteAssetList: AssetEntity[] = [];
+
+    assetIds.ids.forEach(async (id) => {
+      const assets = await this.assetService.getAssetById(authUser, id);
+      deleteAssetList.push(assets);
+    });
+
+    const result = await this.assetService.deleteAssetById(authUser, assetIds);
+
+    result.forEach((res) => {
+      deleteAssetList.filter((a) => a.id == res.id && res.status == 'success');
+    });
+
+    await this.backgroundTaskService.deleteFileOnDisk(deleteAssetList);
+
+    return result;
+  }
 }

+ 123 - 2
server/src/api-v1/asset/asset.service.ts

@@ -1,13 +1,20 @@
-import { BadRequestException, Injectable, Logger } from '@nestjs/common';
+import { BadRequestException, Injectable, Logger, StreamableFile } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { MoreThan, Repository } from 'typeorm';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { UpdateAssetDto } from './dto/update-asset.dto';
 import { AssetEntity, AssetType } from './entities/asset.entity';
-import _ from 'lodash';
+import _, { result } from 'lodash';
 import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
 import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto';
+import { createReadStream, stat } from 'fs';
+import { ServeFileDto } from './dto/serve-file.dto';
+import { Response as Res } from 'express';
+import { promisify } from 'util';
+import { DeleteAssetDto } from './dto/delete-asset.dto';
+
+const fileInfo = promisify(stat);
 
 @Injectable()
 export class AssetService {
@@ -52,6 +59,20 @@ export class AssetService {
     return res;
   }
 
+  public async getAllAssetsNoPagination(authUser: AuthUserDto) {
+    try {
+      const assets = await this.assetRepository
+        .createQueryBuilder('a')
+        .where('a."userId" = :userId', { userId: authUser.id })
+        .orderBy('a."createdAt"::date', 'DESC')
+        .getMany();
+
+        return assets;
+    } catch (e) {
+      Logger.error(e, 'getAllAssets');
+    }
+  }
+
   public async getAllAssets(authUser: AuthUserDto, query: GetAllAssetQueryDto): Promise<GetAllAssetReponseDto> {
     try {
       const assets = await this.assetRepository
@@ -122,4 +143,104 @@ export class AssetService {
       relations: ['exifInfo'],
     });
   }
+
+  public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
+    let file = null;
+    const asset = await this.findOne(authUser, query.did, query.aid);
+
+    // Handle Sending Images
+    if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
+      res.set({
+        'Content-Type': asset.mimeType,
+      });
+
+      if (query.isThumb === 'false' || !query.isThumb) {
+        file = createReadStream(asset.originalPath);
+      } else {
+        file = createReadStream(asset.resizePath);
+      }
+
+      file.on('error', (error) => {
+        Logger.log(`Cannot create read stream ${error}`);
+        return new BadRequestException('Cannot Create Read Stream');
+      });
+      return new StreamableFile(file);
+    } else if (asset.type == AssetType.VIDEO) {
+      // Handle Handling Video
+      const { size } = await fileInfo(asset.originalPath);
+      const range = headers.range;
+
+      if (range) {
+        /** Extracting Start and End value from Range Header */
+        let [start, end] = range.replace(/bytes=/, '').split('-');
+        start = parseInt(start, 10);
+        end = end ? parseInt(end, 10) : size - 1;
+
+        if (!isNaN(start) && isNaN(end)) {
+          start = start;
+          end = size - 1;
+        }
+        if (isNaN(start) && !isNaN(end)) {
+          start = size - end;
+          end = size - 1;
+        }
+
+        // Handle unavailable range request
+        if (start >= size || end >= size) {
+          console.error('Bad Request');
+          // Return the 416 Range Not Satisfiable.
+          res.status(416).set({
+            'Content-Range': `bytes */${size}`,
+          });
+
+          throw new BadRequestException('Bad Request Range');
+        }
+
+        /** Sending Partial Content With HTTP Code 206 */
+
+        res.status(206).set({
+          'Content-Range': `bytes ${start}-${end}/${size}`,
+          'Accept-Ranges': 'bytes',
+          'Content-Length': end - start + 1,
+          'Content-Type': asset.mimeType,
+        });
+
+        const videoStream = createReadStream(asset.originalPath, { start: start, end: end });
+
+        return new StreamableFile(videoStream);
+      } else {
+        res.set({
+          'Content-Type': asset.mimeType,
+        });
+
+        return new StreamableFile(createReadStream(asset.originalPath));
+      }
+    }
+  }
+
+  public async deleteAssetById(authUser: AuthUserDto, assetIds: DeleteAssetDto) {
+    let result = [];
+
+    const target = assetIds.ids;
+    for (let assetId of target) {
+      const res = await this.assetRepository.delete({
+        id: assetId,
+        userId: authUser.id,
+      });
+
+      if (res.affected) {
+        result.push({
+          id: assetId,
+          status: 'success',
+        });
+      } else {
+        result.push({
+          id: assetId,
+          status: 'failed',
+        });
+      }
+    }
+
+    return result;
+  }
 }

+ 6 - 0
server/src/api-v1/asset/dto/delete-asset.dto.ts

@@ -0,0 +1,6 @@
+import { IsNotEmpty } from 'class-validator';
+
+export class DeleteAssetDto {
+  @IsNotEmpty()
+  ids: string[];
+}

+ 20 - 0
server/src/modules/background-task/background-task.processor.ts

@@ -6,6 +6,7 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
 import { ConfigService } from '@nestjs/config';
 import exifr from 'exifr';
 import { readFile } from 'fs/promises';
+import fs from 'fs';
 import { Logger } from '@nestjs/common';
 import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
 
@@ -56,4 +57,23 @@ export class BackgroundTaskProcessor {
       Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
     }
   }
+
+  @Process('delete-file-on-disk')
+  async deleteFileOnDisk(job) {
+    const { assets }: { assets: AssetEntity[] } = job.data;
+
+    assets.forEach(async (asset) => {
+      fs.unlink(asset.originalPath, (err) => {
+        if (err) {
+          console.log('error deleting ', asset.originalPath);
+        }
+      });
+
+      fs.unlink(asset.resizePath, (err) => {
+        if (err) {
+          console.log('error deleting ', asset.originalPath);
+        }
+      });
+    });
+  }
 }

+ 11 - 1
server/src/modules/background-task/background-task.service.ts

@@ -12,7 +12,7 @@ export class BackgroundTaskService {
   ) {}
 
   async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) {
-    const job = await this.backgroundTaskQueue.add(
+    await this.backgroundTaskQueue.add(
       'extract-exif',
       {
         savedAsset,
@@ -22,4 +22,14 @@ export class BackgroundTaskService {
       { jobId: randomUUID() },
     );
   }
+
+  async deleteFileOnDisk(assets: AssetEntity[]) {
+    await this.backgroundTaskQueue.add(
+      'delete-file-on-disk',
+      {
+        assets,
+      },
+      { jobId: randomUUID() },
+    );
+  }
 }

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff