mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	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
This commit is contained in:
		@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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],
 | 
			
		||||
                ),
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
}
 | 
			
		||||
@@ -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];
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 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];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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];
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      isFetching = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Get newer asset from the current time
 | 
			
		||||
  getNewAsset() async {
 | 
			
		||||
    if (state.isNotEmpty) {
 | 
			
		||||
      var latestGroup = state.first;
 | 
			
		||||
 | 
			
		||||
      // 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);
 | 
			
		||||
 | 
			
		||||
      if (newAssets.isEmpty) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // 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)];
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    if (allAssets != null) {
 | 
			
		||||
      allAssets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
 | 
			
		||||
      state = allAssets;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearAllAsset() {
 | 
			
		||||
    state = [];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final List<String> result = await PhotoManager.editor.deleteWithIds(deleteIdList);
 | 
			
		||||
    print(result);
 | 
			
		||||
 | 
			
		||||
    // Delete asset on server
 | 
			
		||||
    List<DeleteAssetResponse>? deleteAssetResult = await _assetService.deleteAssets(deleteAssets);
 | 
			
		||||
    if (deleteAssetResult == null) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (var asset in deleteAssetResult) {
 | 
			
		||||
      if (asset.status == 'success') {
 | 
			
		||||
        state = state.where((immichAsset) => immichAsset.id != asset.id).toList();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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)));
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -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()}");
 | 
			
		||||
 
 | 
			
		||||
@@ -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]),
 | 
			
		||||
 
 | 
			
		||||
@@ -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';
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
              }
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
              ),
 | 
			
		||||
            )
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
      if (assetGroupByDateTime.isNotEmpty) {
 | 
			
		||||
        int? lastMonth;
 | 
			
		||||
 | 
			
		||||
        for (var group in _assetGroup) {
 | 
			
		||||
          var dateTitle = group.date;
 | 
			
		||||
          var assetGroup = group.assets;
 | 
			
		||||
        assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
 | 
			
		||||
          DateTime parseDateGroup = DateTime.parse(dateGroup);
 | 
			
		||||
          int currentMonth = parseDateGroup.month;
 | 
			
		||||
 | 
			
		||||
          int? currentMonth = DateTime.tryParse(dateTitle)?.month;
 | 
			
		||||
          int? previousMonth = DateTime.tryParse(lastGroupDate)?.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(
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    debugPrint("[resumeBackup] User disables auto backup");
 | 
			
		||||
    return;
 | 
			
		||||
        // Run backup
 | 
			
		||||
        debugPrint("[resumeBackup] Start back up");
 | 
			
		||||
        startBackupProcess();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
 | 
			
		||||
  return BackupNotifier(ref);
 | 
			
		||||
  return BackupNotifier(ref: ref);
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,8 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
      if (_backupState.backupProgress != BackUpProgressEnum.inProgress) {
 | 
			
		||||
        ref.read(backupProvider.notifier).getBackupInfo();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return null;
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    Widget _buildStorageInformation() {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10355
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10355
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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
									
								
								server/src/api-v1/asset/dto/delete-asset.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								server/src/api-v1/asset/dto/delete-asset.dto.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
import { IsNotEmpty } from 'class-validator';
 | 
			
		||||
 | 
			
		||||
export class DeleteAssetDto {
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  ids: string[];
 | 
			
		||||
}
 | 
			
		||||
@@ -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);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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() },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user