فهرست منبع

feat: support iOS LivePhoto backup (#950)

Alex 2 سال پیش
والد
کامیت
8bc64be77b
30فایلهای تغییر یافته به همراه677 افزوده شده و 242 حذف شده
  1. 46 15
      mobile/lib/modules/asset_viewer/services/image_viewer.service.dart
  2. 1 1
      mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
  3. 16 12
      mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
  4. 34 14
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  5. 37 8
      mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
  6. 35 0
      mobile/lib/modules/backup/services/backup.service.dart
  7. 25 5
      mobile/lib/routing/router.gr.dart
  8. 1 0
      mobile/openapi/doc/AssetResponseDto.md
  9. 53 41
      mobile/openapi/lib/model/album_response_dto.dart
  10. 79 56
      mobile/openapi/lib/model/asset_response_dto.dart
  11. 1 1
      mobile/pubspec.lock
  12. 1 2
      mobile/pubspec.yaml
  13. 1 0
      mobile/test/asset_grid_data_structure_test.dart
  14. 19 1
      server/apps/immich/src/api-v1/asset/asset-repository.ts
  15. 25 64
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  16. 8 0
      server/apps/immich/src/api-v1/asset/asset.module.ts
  17. 15 2
      server/apps/immich/src/api-v1/asset/asset.service.spec.ts
  18. 116 2
      server/apps/immich/src/api-v1/asset/asset.service.ts
  19. 2 0
      server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts
  20. 7 2
      server/apps/immich/src/config/asset-upload.config.ts
  21. 1 1
      server/apps/microservices/src/processors/video-transcode.processor.ts
  22. 0 0
      server/immich-openapi-specs.json
  23. 6 0
      server/libs/database/src/entities/asset.entity.ts
  24. 16 0
      server/libs/database/src/migrations/1668383120461-AddLivePhotosRelatedColumnToAssetTable.ts
  25. 6 0
      web/src/api/open-api/api.ts
  26. 1 0
      web/src/app.d.ts
  27. 36 4
      web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
  28. 29 7
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  29. 3 1
      web/src/lib/components/asset-viewer/video-viewer.svelte
  30. 57 3
      web/src/lib/components/shared-components/immich-thumbnail.svelte

+ 46 - 15
mobile/lib/modules/asset_viewer/services/image_viewer.service.dart

@@ -22,27 +22,58 @@ class ImageViewerService {
     try {
       String fileName = p.basename(asset.originalPath);
 
-      var res = await _apiService.assetApi.downloadFileWithHttpInfo(
-        asset.id,
-        isThumb: false,
-        isWeb: false,
-      );
+      // Download LivePhotos image and motion part
+      if (asset.type == AssetTypeEnum.IMAGE && asset.livePhotoVideoId != null) {
+        var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo(
+          asset.id,
+          isThumb: false,
+          isWeb: false,
+        );
+
+        var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo(
+          asset.livePhotoVideoId!,
+          isThumb: false,
+          isWeb: false,
+        );
 
-      final AssetEntity? entity;
+        final AssetEntity? entity;
 
-      if (asset.type == AssetTypeEnum.IMAGE) {
-        entity = await PhotoManager.editor.saveImage(
-          res.bodyBytes,
+        final tempDir = await getTemporaryDirectory();
+        File videoFile = await File('${tempDir.path}/livephoto.mov').create();
+        File imageFile = await File('${tempDir.path}/livephoto.heic').create();
+        videoFile.writeAsBytesSync(motionReponse.bodyBytes);
+        imageFile.writeAsBytesSync(imageResponse.bodyBytes);
+
+        entity = await PhotoManager.editor.darwin.saveLivePhoto(
+          imageFile: imageFile,
+          videoFile: videoFile,
           title: p.basename(asset.originalPath),
         );
+
+        return entity != null;
       } else {
-        final tempDir = await getTemporaryDirectory();
-        File tempFile = await File('${tempDir.path}/$fileName').create();
-        tempFile.writeAsBytesSync(res.bodyBytes);
-        entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName);
-      }
+        var res = await _apiService.assetApi.downloadFileWithHttpInfo(
+          asset.id,
+          isThumb: false,
+          isWeb: false,
+        );
 
-      return entity != null;
+        final AssetEntity? entity;
+
+        if (asset.type == AssetTypeEnum.IMAGE) {
+          entity = await PhotoManager.editor.saveImage(
+            res.bodyBytes,
+            title: p.basename(asset.originalPath),
+          );
+        } else {
+          final tempDir = await getTemporaryDirectory();
+          File tempFile = await File('${tempDir.path}/$fileName').create();
+          tempFile.writeAsBytesSync(res.bodyBytes);
+          entity =
+              await PhotoManager.editor.saveVideo(tempFile, title: fileName);
+        }
+        return entity != null;
+      }
     } catch (e) {
       debugPrint("Error saving file $e");
       return false;

+ 1 - 1
mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart

@@ -37,7 +37,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
   }
 
   void handleSwipUpDown(PointerMoveEvent details) {
-    int sensitivity = 10;
+    int sensitivity = 15;
 
     if (_zoomedIn) {
       return;

+ 16 - 12
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart

@@ -3,21 +3,23 @@ import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 
-class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
+class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
   const TopControlAppBar({
     Key? key,
     required this.asset,
     required this.onMoreInfoPressed,
     required this.onDownloadPressed,
     required this.onSharePressed,
-    this.loading = false,
+    required this.onToggleMotionVideo,
+    required this.isPlayingMotionVideo,
   }) : super(key: key);
 
   final Asset asset;
   final Function onMoreInfoPressed;
   final VoidCallback? onDownloadPressed;
+  final VoidCallback onToggleMotionVideo;
   final Function onSharePressed;
-  final bool loading;
+  final bool isPlayingMotionVideo;
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
@@ -38,14 +40,16 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
         ),
       ),
       actions: [
-        if (loading)
-          Center(
-            child: Container(
-              margin: const EdgeInsets.symmetric(horizontal: 15.0),
-              width: iconSize,
-              height: iconSize,
-              child: const CircularProgressIndicator(strokeWidth: 2.0),
-            ),
+        if (asset.remote?.livePhotoVideoId != null)
+          IconButton(
+            iconSize: iconSize,
+            splashRadius: iconSize,
+            onPressed: () {
+              onToggleMotionVideo();
+            },
+            icon: isPlayingMotionVideo
+                ? const Icon(Icons.motion_photos_pause_outlined)
+                : const Icon(Icons.play_circle_outline_rounded),
           ),
         if (!asset.isLocal)
           IconButton(
@@ -79,7 +83,7 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
               Icons.more_horiz_rounded,
               color: Colors.grey[200],
             ),
-          )
+          ),
       ],
     );
   }

+ 34 - 14
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -33,10 +33,10 @@ class GalleryViewerPage extends HookConsumerWidget {
     final Box<dynamic> box = Hive.box(userInfoBox);
     final appSettingService = ref.watch(appSettingsServiceProvider);
     final threeStageLoading = useState(false);
-    final loading = useState(false);
     final isZoomed = useState<bool>(false);
-    ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
     final indexOfAsset = useState(assetList.indexOf(asset));
+    final isPlayingMotionVideo = useState(false);
+    ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
 
     PageController controller =
         PageController(initialPage: assetList.indexOf(asset));
@@ -45,6 +45,7 @@ class GalleryViewerPage extends HookConsumerWidget {
       () {
         threeStageLoading.value = appSettingService
             .getSetting<bool>(AppSettingsEnum.threeStageLoading);
+        isPlayingMotionVideo.value = false;
         return null;
       },
       [],
@@ -85,7 +86,7 @@ class GalleryViewerPage extends HookConsumerWidget {
     return Scaffold(
       backgroundColor: Colors.black,
       appBar: TopControlAppBar(
-        loading: loading.value,
+        isPlayingMotionVideo: isPlayingMotionVideo.value,
         asset: assetList[indexOfAsset.value],
         onMoreInfoPressed: () {
           showInfo();
@@ -94,13 +95,18 @@ class GalleryViewerPage extends HookConsumerWidget {
             ? null
             : () {
                 ref.watch(imageViewerStateProvider.notifier).downloadAsset(
-                    assetList[indexOfAsset.value].remote!, context);
+                      assetList[indexOfAsset.value].remote!,
+                      context,
+                    );
               },
         onSharePressed: () {
           ref
               .watch(imageViewerStateProvider.notifier)
               .shareAsset(assetList[indexOfAsset.value], context);
         },
+        onToggleMotionVideo: (() {
+          isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
+        }),
       ),
       body: SafeArea(
         child: PageView.builder(
@@ -119,18 +125,28 @@ class GalleryViewerPage extends HookConsumerWidget {
             getAssetExif();
 
             if (assetList[index].isImage) {
-              return ImageViewerPage(
-                authToken: 'Bearer ${box.get(accessTokenKey)}',
-                isZoomedFunction: isZoomedMethod,
-                isZoomedListener: isZoomedListener,
-                asset: assetList[index],
-                heroTag: assetList[index].id,
-                threeStageLoading: threeStageLoading.value,
-              );
+              if (isPlayingMotionVideo.value) {
+                return VideoViewerPage(
+                  asset: assetList[index],
+                  isMotionVideo: true,
+                  onVideoEnded: () {
+                    isPlayingMotionVideo.value = false;
+                  },
+                );
+              } else {
+                return ImageViewerPage(
+                  authToken: 'Bearer ${box.get(accessTokenKey)}',
+                  isZoomedFunction: isZoomedMethod,
+                  isZoomedListener: isZoomedListener,
+                  asset: assetList[index],
+                  heroTag: assetList[index].id,
+                  threeStageLoading: threeStageLoading.value,
+                );
+              }
             } else {
               return GestureDetector(
                 onVerticalDragUpdate: (details) {
-                  const int sensitivity = 10;
+                  const int sensitivity = 15;
                   if (details.delta.dy > sensitivity) {
                     // swipe down
                     AutoRouter.of(context).pop();
@@ -141,7 +157,11 @@ class GalleryViewerPage extends HookConsumerWidget {
                 },
                 child: Hero(
                   tag: assetList[index].id,
-                  child: VideoViewerPage(asset: assetList[index]),
+                  child: VideoViewerPage(
+                    asset: assetList[index],
+                    isMotionVideo: false,
+                    onVideoEnded: () {},
+                  ),
                 ),
               );
             }

+ 37 - 8
mobile/lib/modules/asset_viewer/views/video_viewer_page.dart

@@ -15,15 +15,26 @@ import 'package:video_player/video_player.dart';
 // ignore: must_be_immutable
 class VideoViewerPage extends HookConsumerWidget {
   final Asset asset;
+  final bool isMotionVideo;
+  final VoidCallback onVideoEnded;
 
-  const VideoViewerPage({Key? key, required this.asset}) : super(key: key);
+  const VideoViewerPage({
+    Key? key,
+    required this.asset,
+    required this.isMotionVideo,
+    required this.onVideoEnded,
+  }) : super(key: key);
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     if (asset.isLocal) {
       final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!));
       return videoFile.when(
-        data: (data) => VideoThumbnailPlayer(file: data),
+        data: (data) => VideoThumbnailPlayer(
+          file: data,
+          isMotionVideo: false,
+          onVideoEnded: () {},
+        ),
         error: (error, stackTrace) => Icon(
           Icons.image_not_supported_outlined,
           color: Theme.of(context).primaryColor,
@@ -41,14 +52,17 @@ class VideoViewerPage extends HookConsumerWidget {
         ref.watch(imageViewerStateProvider).downloadAssetStatus;
     final box = Hive.box(userInfoBox);
     final String jwtToken = box.get(accessTokenKey);
-    final String videoUrl =
-        '${box.get(serverEndpointKey)}/asset/file/${asset.id}';
+    final String videoUrl = isMotionVideo
+        ? '${box.get(serverEndpointKey)}/asset/file/${asset.remote?.livePhotoVideoId!}'
+        : '${box.get(serverEndpointKey)}/asset/file/${asset.id}';
 
     return Stack(
       children: [
         VideoThumbnailPlayer(
           url: videoUrl,
           jwtToken: jwtToken,
+          isMotionVideo: isMotionVideo,
+          onVideoEnded: onVideoEnded,
         ),
         if (downloadAssetStatus == DownloadAssetStatus.loading)
           const Center(
@@ -72,9 +86,17 @@ class VideoThumbnailPlayer extends StatefulWidget {
   final String? url;
   final String? jwtToken;
   final File? file;
-
-  const VideoThumbnailPlayer({Key? key, this.url, this.jwtToken, this.file})
-      : super(key: key);
+  final bool isMotionVideo;
+  final VoidCallback onVideoEnded;
+
+  const VideoThumbnailPlayer({
+    Key? key,
+    this.url,
+    this.jwtToken,
+    this.file,
+    required this.onVideoEnded,
+    required this.isMotionVideo,
+  }) : super(key: key);
 
   @override
   State<VideoThumbnailPlayer> createState() => _VideoThumbnailPlayerState();
@@ -88,6 +110,13 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
   void initState() {
     super.initState();
     initializePlayer();
+
+    videoPlayerController.addListener(() {
+      if (videoPlayerController.value.position ==
+          videoPlayerController.value.duration) {
+        widget.onVideoEnded();
+      }
+    });
   }
 
   Future<void> initializePlayer() async {
@@ -115,7 +144,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
       autoPlay: true,
       autoInitialize: true,
       allowFullScreen: true,
-      showControls: true,
+      showControls: !widget.isMotionVideo,
       hideControlsTimer: const Duration(seconds: 5),
     );
   }

+ 35 - 0
mobile/lib/modules/backup/services/backup.service.dart

@@ -2,6 +2,7 @@ import 'dart:async';
 import 'dart:convert';
 import 'dart:io';
 
+import 'package:cancellation_token_http/http.dart';
 import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
 import 'package:hive/hive.dart';
@@ -263,6 +264,13 @@ class BackupService {
 
           req.files.add(assetRawUploadData);
 
+          if (entity.isLivePhoto) {
+            var livePhotoRawUploadData = await _getLivePhotoFile(entity);
+            if (livePhotoRawUploadData != null) {
+              req.files.add(livePhotoRawUploadData);
+            }
+          }
+
           setCurrentUploadAssetCb(
             CurrentUploadAsset(
               id: entity.id,
@@ -322,6 +330,33 @@ class BackupService {
     return !anyErrors;
   }
 
+  Future<MultipartFile?> _getLivePhotoFile(AssetEntity entity) async {
+    var motionFilePath = await entity.getMediaUrl();
+    // var motionFilePath = '/var/mobile/Media/DCIM/103APPLE/IMG_3371.MOV'
+
+    if (motionFilePath != null) {
+      var validPath = motionFilePath.replaceAll('file://', '');
+      var motionFile = File(validPath);
+      var fileStream = motionFile.openRead();
+      String originalFileName = await entity.titleAsync;
+      String fileNameWithoutPath = originalFileName.toString().split(".")[0];
+      var mimeType = FileHelper.getMimeType(validPath);
+
+      return http.MultipartFile(
+        "livePhotoData",
+        fileStream,
+        motionFile.lengthSync(),
+        filename: fileNameWithoutPath,
+        contentType: MediaType(
+          mimeType["type"],
+          mimeType["subType"],
+        ),
+      );
+    }
+
+    return null;
+  }
+
   String _getAssetType(AssetType assetType) {
     switch (assetType) {
       case AssetType.audio:

+ 25 - 5
mobile/lib/routing/router.gr.dart

@@ -65,7 +65,11 @@ class _$AppRouter extends RootStackRouter {
       final args = routeData.argsAs<VideoViewerRouteArgs>();
       return MaterialPageX<dynamic>(
           routeData: routeData,
-          child: VideoViewerPage(key: args.key, asset: args.asset));
+          child: VideoViewerPage(
+              key: args.key,
+              asset: args.asset,
+              isMotionVideo: args.isMotionVideo,
+              onVideoEnded: args.onVideoEnded));
     },
     BackupControllerRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
@@ -340,24 +344,40 @@ class ImageViewerRouteArgs {
 /// generated route for
 /// [VideoViewerPage]
 class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
-  VideoViewerRoute({Key? key, required Asset asset})
+  VideoViewerRoute(
+      {Key? key,
+      required Asset asset,
+      required bool isMotionVideo,
+      required void Function() onVideoEnded})
       : super(VideoViewerRoute.name,
             path: '/video-viewer-page',
-            args: VideoViewerRouteArgs(key: key, asset: asset));
+            args: VideoViewerRouteArgs(
+                key: key,
+                asset: asset,
+                isMotionVideo: isMotionVideo,
+                onVideoEnded: onVideoEnded));
 
   static const String name = 'VideoViewerRoute';
 }
 
 class VideoViewerRouteArgs {
-  const VideoViewerRouteArgs({this.key, required this.asset});
+  const VideoViewerRouteArgs(
+      {this.key,
+      required this.asset,
+      required this.isMotionVideo,
+      required this.onVideoEnded});
 
   final Key? key;
 
   final Asset asset;
 
+  final bool isMotionVideo;
+
+  final void Function() onVideoEnded;
+
   @override
   String toString() {
-    return 'VideoViewerRouteArgs{key: $key, asset: $asset}';
+    return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, onVideoEnded: $onVideoEnded}';
   }
 }
 

+ 1 - 0
mobile/openapi/doc/AssetResponseDto.md

@@ -24,6 +24,7 @@ Name | Type | Description | Notes
 **encodedVideoPath** | **String** |  | 
 **exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) |  | [optional] 
 **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional] 
+**livePhotoVideoId** | **String** |  | 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 53 - 41
mobile/openapi/lib/model/album_response_dto.dart

@@ -43,48 +43,51 @@ class AlbumResponseDto {
   List<AssetResponseDto> assets;
 
   @override
-  bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
-     other.assetCount == assetCount &&
-     other.id == id &&
-     other.ownerId == ownerId &&
-     other.albumName == albumName &&
-     other.createdAt == createdAt &&
-     other.albumThumbnailAssetId == albumThumbnailAssetId &&
-     other.shared == shared &&
-     other.sharedUsers == sharedUsers &&
-     other.assets == assets;
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is AlbumResponseDto &&
+          other.assetCount == assetCount &&
+          other.id == id &&
+          other.ownerId == ownerId &&
+          other.albumName == albumName &&
+          other.createdAt == createdAt &&
+          other.albumThumbnailAssetId == albumThumbnailAssetId &&
+          other.shared == shared &&
+          other.sharedUsers == sharedUsers &&
+          other.assets == assets;
 
   @override
   int get hashCode =>
-    // ignore: unnecessary_parenthesis
-    (assetCount.hashCode) +
-    (id.hashCode) +
-    (ownerId.hashCode) +
-    (albumName.hashCode) +
-    (createdAt.hashCode) +
-    (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
-    (shared.hashCode) +
-    (sharedUsers.hashCode) +
-    (assets.hashCode);
+      // ignore: unnecessary_parenthesis
+      (assetCount.hashCode) +
+      (id.hashCode) +
+      (ownerId.hashCode) +
+      (albumName.hashCode) +
+      (createdAt.hashCode) +
+      (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
+      (shared.hashCode) +
+      (sharedUsers.hashCode) +
+      (assets.hashCode);
 
   @override
-  String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
+  String toString() =>
+      'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
-      _json[r'assetCount'] = assetCount;
-      _json[r'id'] = id;
-      _json[r'ownerId'] = ownerId;
-      _json[r'albumName'] = albumName;
-      _json[r'createdAt'] = createdAt;
+    _json[r'assetCount'] = assetCount;
+    _json[r'id'] = id;
+    _json[r'ownerId'] = ownerId;
+    _json[r'albumName'] = albumName;
+    _json[r'createdAt'] = createdAt;
     if (albumThumbnailAssetId != null) {
       _json[r'albumThumbnailAssetId'] = albumThumbnailAssetId;
     } else {
       _json[r'albumThumbnailAssetId'] = null;
     }
-      _json[r'shared'] = shared;
-      _json[r'sharedUsers'] = sharedUsers;
-      _json[r'assets'] = assets;
+    _json[r'shared'] = shared;
+    _json[r'sharedUsers'] = sharedUsers;
+    _json[r'assets'] = assets;
     return _json;
   }
 
@@ -98,13 +101,13 @@ class AlbumResponseDto {
       // Ensure that the map contains the required keys.
       // Note 1: the values aren't checked for validity beyond being non-null.
       // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
+      // assert(() {
+      //   requiredKeys.forEach((key) {
+      //     assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
+      //     assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
+      //   });
+      //   return true;
+      // }());
 
       return AlbumResponseDto(
         assetCount: mapValueOfType<int>(json, r'assetCount')!,
@@ -112,7 +115,8 @@ class AlbumResponseDto {
         ownerId: mapValueOfType<String>(json, r'ownerId')!,
         albumName: mapValueOfType<String>(json, r'albumName')!,
         createdAt: mapValueOfType<String>(json, r'createdAt')!,
-        albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
+        albumThumbnailAssetId:
+            mapValueOfType<String>(json, r'albumThumbnailAssetId'),
         shared: mapValueOfType<bool>(json, r'shared')!,
         sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!,
         assets: AssetResponseDto.listFromJson(json[r'assets'])!,
@@ -121,7 +125,10 @@ class AlbumResponseDto {
     return null;
   }
 
-  static List<AlbumResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+  static List<AlbumResponseDto>? listFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final result = <AlbumResponseDto>[];
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
@@ -149,12 +156,18 @@ class AlbumResponseDto {
   }
 
   // maps a json object with a list of AlbumResponseDto-objects as value to a dart map
-  static Map<String, List<AlbumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+  static Map<String, List<AlbumResponseDto>> mapListFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final map = <String, List<AlbumResponseDto>>{};
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
-        final value = AlbumResponseDto.listFromJson(entry.value, growable: growable,);
+        final value = AlbumResponseDto.listFromJson(
+          entry.value,
+          growable: growable,
+        );
         if (value != null) {
           map[entry.key] = value;
         }
@@ -176,4 +189,3 @@ class AlbumResponseDto {
     'assets',
   };
 }
-

+ 79 - 56
mobile/openapi/lib/model/asset_response_dto.dart

@@ -29,6 +29,7 @@ class AssetResponseDto {
     required this.encodedVideoPath,
     this.exifInfo,
     this.smartInfo,
+    required this.livePhotoVideoId,
   });
 
   AssetTypeEnum type;
@@ -75,70 +76,77 @@ class AssetResponseDto {
   ///
   SmartInfoResponseDto? smartInfo;
 
+  String? livePhotoVideoId;
+
   @override
-  bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
-     other.type == type &&
-     other.id == id &&
-     other.deviceAssetId == deviceAssetId &&
-     other.ownerId == ownerId &&
-     other.deviceId == deviceId &&
-     other.originalPath == originalPath &&
-     other.resizePath == resizePath &&
-     other.createdAt == createdAt &&
-     other.modifiedAt == modifiedAt &&
-     other.isFavorite == isFavorite &&
-     other.mimeType == mimeType &&
-     other.duration == duration &&
-     other.webpPath == webpPath &&
-     other.encodedVideoPath == encodedVideoPath &&
-     other.exifInfo == exifInfo &&
-     other.smartInfo == smartInfo;
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is AssetResponseDto &&
+          other.type == type &&
+          other.id == id &&
+          other.deviceAssetId == deviceAssetId &&
+          other.ownerId == ownerId &&
+          other.deviceId == deviceId &&
+          other.originalPath == originalPath &&
+          other.resizePath == resizePath &&
+          other.createdAt == createdAt &&
+          other.modifiedAt == modifiedAt &&
+          other.isFavorite == isFavorite &&
+          other.mimeType == mimeType &&
+          other.duration == duration &&
+          other.webpPath == webpPath &&
+          other.encodedVideoPath == encodedVideoPath &&
+          other.exifInfo == exifInfo &&
+          other.smartInfo == smartInfo &&
+          other.livePhotoVideoId == livePhotoVideoId;
 
   @override
   int get hashCode =>
-    // ignore: unnecessary_parenthesis
-    (type.hashCode) +
-    (id.hashCode) +
-    (deviceAssetId.hashCode) +
-    (ownerId.hashCode) +
-    (deviceId.hashCode) +
-    (originalPath.hashCode) +
-    (resizePath == null ? 0 : resizePath!.hashCode) +
-    (createdAt.hashCode) +
-    (modifiedAt.hashCode) +
-    (isFavorite.hashCode) +
-    (mimeType == null ? 0 : mimeType!.hashCode) +
-    (duration.hashCode) +
-    (webpPath == null ? 0 : webpPath!.hashCode) +
-    (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
-    (exifInfo == null ? 0 : exifInfo!.hashCode) +
-    (smartInfo == null ? 0 : smartInfo!.hashCode);
+      // ignore: unnecessary_parenthesis
+      (type.hashCode) +
+      (id.hashCode) +
+      (deviceAssetId.hashCode) +
+      (ownerId.hashCode) +
+      (deviceId.hashCode) +
+      (originalPath.hashCode) +
+      (resizePath == null ? 0 : resizePath!.hashCode) +
+      (createdAt.hashCode) +
+      (modifiedAt.hashCode) +
+      (isFavorite.hashCode) +
+      (mimeType == null ? 0 : mimeType!.hashCode) +
+      (duration.hashCode) +
+      (webpPath == null ? 0 : webpPath!.hashCode) +
+      (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
+      (exifInfo == null ? 0 : exifInfo!.hashCode) +
+      (smartInfo == null ? 0 : smartInfo!.hashCode) +
+      (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
 
   @override
-  String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
+  String toString() =>
+      'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
-      _json[r'type'] = type;
-      _json[r'id'] = id;
-      _json[r'deviceAssetId'] = deviceAssetId;
-      _json[r'ownerId'] = ownerId;
-      _json[r'deviceId'] = deviceId;
-      _json[r'originalPath'] = originalPath;
+    _json[r'type'] = type;
+    _json[r'id'] = id;
+    _json[r'deviceAssetId'] = deviceAssetId;
+    _json[r'ownerId'] = ownerId;
+    _json[r'deviceId'] = deviceId;
+    _json[r'originalPath'] = originalPath;
     if (resizePath != null) {
       _json[r'resizePath'] = resizePath;
     } else {
       _json[r'resizePath'] = null;
     }
-      _json[r'createdAt'] = createdAt;
-      _json[r'modifiedAt'] = modifiedAt;
-      _json[r'isFavorite'] = isFavorite;
+    _json[r'createdAt'] = createdAt;
+    _json[r'modifiedAt'] = modifiedAt;
+    _json[r'isFavorite'] = isFavorite;
     if (mimeType != null) {
       _json[r'mimeType'] = mimeType;
     } else {
       _json[r'mimeType'] = null;
     }
-      _json[r'duration'] = duration;
+    _json[r'duration'] = duration;
     if (webpPath != null) {
       _json[r'webpPath'] = webpPath;
     } else {
@@ -159,6 +167,11 @@ class AssetResponseDto {
     } else {
       _json[r'smartInfo'] = null;
     }
+    if (livePhotoVideoId != null) {
+      _json[r'livePhotoVideoId'] = livePhotoVideoId;
+    } else {
+      _json[r'livePhotoVideoId'] = null;
+    }
     return _json;
   }
 
@@ -172,13 +185,13 @@ class AssetResponseDto {
       // Ensure that the map contains the required keys.
       // Note 1: the values aren't checked for validity beyond being non-null.
       // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
+      // assert(() {
+      //   requiredKeys.forEach((key) {
+      //     assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
+      //     assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
+      //   });
+      //   return true;
+      // }());
 
       return AssetResponseDto(
         type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -197,12 +210,16 @@ class AssetResponseDto {
         encodedVideoPath: mapValueOfType<String>(json, r'encodedVideoPath'),
         exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']),
         smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
+        livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
       );
     }
     return null;
   }
 
-  static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+  static List<AssetResponseDto>? listFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final result = <AssetResponseDto>[];
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
@@ -230,12 +247,18 @@ class AssetResponseDto {
   }
 
   // maps a json object with a list of AssetResponseDto-objects as value to a dart map
-  static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+  static Map<String, List<AssetResponseDto>> mapListFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final map = <String, List<AssetResponseDto>>{};
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
-        final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
+        final value = AssetResponseDto.listFromJson(
+          entry.value,
+          growable: growable,
+        );
         if (value != null) {
           map[entry.key] = value;
         }
@@ -260,6 +283,6 @@ class AssetResponseDto {
     'duration',
     'webpPath',
     'encodedVideoPath',
+    'livePhotoVideoId',
   };
 }
-

+ 1 - 1
mobile/pubspec.lock

@@ -734,7 +734,7 @@ packages:
       name: photo_manager
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "2.2.1"
+    version: "2.5.0"
   photo_view:
     dependency: "direct main"
     description:

+ 1 - 2
mobile/pubspec.yaml

@@ -11,7 +11,7 @@ dependencies:
   flutter:
     sdk: flutter
 
-  photo_manager: ^2.2.1
+  photo_manager: ^2.5.0
   flutter_hooks: ^0.18.0
   hooks_riverpod: ^2.0.0-dev.0
   hive: ^2.2.1
@@ -47,7 +47,6 @@ dependencies:
   # easy to remove packages:
   image_picker: ^0.8.5+3 # only used to select user profile image from system gallery -> we can simply select an image from within immich?
 
-
 dev_dependencies:
   flutter_test:
     sdk: flutter

+ 1 - 0
mobile/test/asset_grid_data_structure_test.dart

@@ -29,6 +29,7 @@ void main() {
           duration: '',
           webpPath: '',
           encodedVideoPath: '',
+          livePhotoVideoId: '',
         ),
       ),
     );

+ 19 - 1
server/apps/immich/src/api-v1/asset/asset-repository.ts

@@ -21,7 +21,9 @@ export interface IAssetRepository {
     ownerId: string,
     originalPath: string,
     mimeType: string,
+    isVisible: boolean,
     checksum?: Buffer,
+    livePhotoAssetEntity?: AssetEntity,
   ): Promise<AssetEntity>;
   update(asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
   getAllByUserId(userId: string, skip?: number): Promise<AssetEntity[]>;
@@ -58,6 +60,7 @@ export class AssetRepository implements IAssetRepository {
       .leftJoinAndSelect('asset.smartInfo', 'si')
       .where('asset.resizePath IS NOT NULL')
       .andWhere('si.id IS NULL')
+      .andWhere('asset.isVisible = true')
       .getMany();
   }
 
@@ -65,6 +68,7 @@ export class AssetRepository implements IAssetRepository {
     return await this.assetRepository
       .createQueryBuilder('asset')
       .where('asset.resizePath IS NULL')
+      .andWhere('asset.isVisible = true')
       .orWhere('asset.resizePath = :resizePath', { resizePath: '' })
       .orWhere('asset.webpPath IS NULL')
       .orWhere('asset.webpPath = :webpPath', { webpPath: '' })
@@ -76,6 +80,7 @@ export class AssetRepository implements IAssetRepository {
       .createQueryBuilder('asset')
       .leftJoinAndSelect('asset.exifInfo', 'ei')
       .where('ei."assetId" IS NULL')
+      .andWhere('asset.isVisible = true')
       .getMany();
   }
 
@@ -86,6 +91,7 @@ export class AssetRepository implements IAssetRepository {
       .select(`COUNT(asset.id)`, 'count')
       .addSelect(`asset.type`, 'type')
       .where('"userId" = :userId', { userId: userId })
+      .andWhere('asset.isVisible = true')
       .groupBy('asset.type')
       .getRawMany();
 
@@ -120,6 +126,7 @@ export class AssetRepository implements IAssetRepository {
         buckets: [...getAssetByTimeBucketDto.timeBucket],
       })
       .andWhere('asset.resizePath is not NULL')
+      .andWhere('asset.isVisible = true')
       .orderBy('asset.createdAt', 'DESC')
       .getMany();
   }
@@ -134,6 +141,7 @@ export class AssetRepository implements IAssetRepository {
         .addSelect(`date_trunc('month', "createdAt")`, 'timeBucket')
         .where('"userId" = :userId', { userId: userId })
         .andWhere('asset.resizePath is not NULL')
+        .andWhere('asset.isVisible = true')
         .groupBy(`date_trunc('month', "createdAt")`)
         .orderBy(`date_trunc('month', "createdAt")`, 'DESC')
         .getRawMany();
@@ -144,6 +152,7 @@ export class AssetRepository implements IAssetRepository {
         .addSelect(`date_trunc('day', "createdAt")`, 'timeBucket')
         .where('"userId" = :userId', { userId: userId })
         .andWhere('asset.resizePath is not NULL')
+        .andWhere('asset.isVisible = true')
         .groupBy(`date_trunc('day', "createdAt")`)
         .orderBy(`date_trunc('day', "createdAt")`, 'DESC')
         .getRawMany();
@@ -156,6 +165,7 @@ export class AssetRepository implements IAssetRepository {
     return await this.assetRepository
       .createQueryBuilder('asset')
       .where('asset.userId = :userId', { userId: userId })
+      .andWhere('asset.isVisible = true')
       .leftJoin('asset.exifInfo', 'ei')
       .leftJoin('asset.smartInfo', 'si')
       .select('si.tags', 'tags')
@@ -179,6 +189,7 @@ export class AssetRepository implements IAssetRepository {
         FROM assets a
         LEFT JOIN smart_info si ON a.id = si."assetId"
         WHERE a."userId" = $1
+        AND a."isVisible" = true
         AND si.objects IS NOT NULL
       `,
       [userId],
@@ -192,6 +203,7 @@ export class AssetRepository implements IAssetRepository {
         FROM assets a
         LEFT JOIN exif e ON a.id = e."assetId"
         WHERE a."userId" = $1
+        AND a."isVisible" = true
         AND e.city IS NOT NULL
         AND a.type = 'IMAGE';
       `,
@@ -222,6 +234,7 @@ export class AssetRepository implements IAssetRepository {
       .createQueryBuilder('asset')
       .where('asset.userId = :userId', { userId: userId })
       .andWhere('asset.resizePath is not NULL')
+      .andWhere('asset.isVisible = true')
       .leftJoinAndSelect('asset.exifInfo', 'exifInfo')
       .skip(skip || 0)
       .orderBy('asset.createdAt', 'DESC');
@@ -242,13 +255,15 @@ export class AssetRepository implements IAssetRepository {
     ownerId: string,
     originalPath: string,
     mimeType: string,
+    isVisible: boolean,
     checksum?: Buffer,
+    livePhotoAssetEntity?: AssetEntity,
   ): Promise<AssetEntity> {
     const asset = new AssetEntity();
     asset.deviceAssetId = createAssetDto.deviceAssetId;
     asset.userId = ownerId;
     asset.deviceId = createAssetDto.deviceId;
-    asset.type = createAssetDto.assetType || AssetType.OTHER;
+    asset.type = !isVisible ? AssetType.VIDEO : createAssetDto.assetType || AssetType.OTHER; // If an asset is not visible, it is a LivePhotos video portion, therefore we can confidently assign the type as VIDEO here
     asset.originalPath = originalPath;
     asset.createdAt = createAssetDto.createdAt;
     asset.modifiedAt = createAssetDto.modifiedAt;
@@ -256,6 +271,8 @@ export class AssetRepository implements IAssetRepository {
     asset.mimeType = mimeType;
     asset.duration = createAssetDto.duration || null;
     asset.checksum = checksum || null;
+    asset.isVisible = isVisible;
+    asset.livePhotoVideoId = livePhotoAssetEntity ? livePhotoAssetEntity.id : null;
 
     const createdAsset = await this.assetRepository.save(asset);
 
@@ -286,6 +303,7 @@ export class AssetRepository implements IAssetRepository {
       where: {
         userId: userId,
         deviceId: deviceId,
+        isVisible: true,
       },
       select: ['deviceAssetId'],
     });

+ 25 - 64
server/apps/immich/src/api-v1/asset/asset.controller.ts

@@ -10,16 +10,14 @@ import {
   Response,
   Headers,
   Delete,
-  Logger,
   HttpCode,
-  BadRequestException,
-  UploadedFile,
   Header,
   Put,
+  UploadedFiles,
 } from '@nestjs/common';
 import { Authenticated } from '../../decorators/authenticated.decorator';
 import { AssetService } from './asset.service';
-import { FileInterceptor } from '@nestjs/platform-express';
+import { FileFieldsInterceptor } from '@nestjs/platform-express';
 import { assetUploadOption } from '../../config/asset-upload.config';
 import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
 import { ServeFileDto } from './dto/serve-file.dto';
@@ -27,12 +25,6 @@ import { Response as Res } from 'express';
 import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
 import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { SearchAssetDto } from './dto/search-asset.dto';
-import { CommunicationGateway } from '../communication/communication.gateway';
-import { InjectQueue } from '@nestjs/bull';
-import { Queue } from 'bull';
-import { IAssetUploadedJob } from '@app/job/index';
-import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
-import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
@@ -47,7 +39,6 @@ import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
 import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
 import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
 import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
-import { QueryFailedError } from 'typeorm';
 import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
 import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
@@ -64,17 +55,18 @@ import {
 @ApiTags('Asset')
 @Controller('asset')
 export class AssetController {
-  constructor(
-    private wsCommunicateionGateway: CommunicationGateway,
-    private assetService: AssetService,
-    private backgroundTaskService: BackgroundTaskService,
-
-    @InjectQueue(QueueNameEnum.ASSET_UPLOADED)
-    private assetUploadedQueue: Queue<IAssetUploadedJob>,
-  ) {}
+  constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {}
 
   @Post('upload')
-  @UseInterceptors(FileInterceptor('assetData', assetUploadOption))
+  @UseInterceptors(
+    FileFieldsInterceptor(
+      [
+        { name: 'assetData', maxCount: 1 },
+        { name: 'livePhotoData', maxCount: 1 },
+      ],
+      assetUploadOption,
+    ),
+  )
   @ApiConsumes('multipart/form-data')
   @ApiBody({
     description: 'Asset Upload Information',
@@ -82,53 +74,14 @@ export class AssetController {
   })
   async uploadFile(
     @GetAuthUser() authUser: AuthUserDto,
-    @UploadedFile() file: Express.Multer.File,
-    @Body(ValidationPipe) assetInfo: CreateAssetDto,
+    @UploadedFiles() files: { assetData: Express.Multer.File[]; livePhotoData?: Express.Multer.File[] },
+    @Body(ValidationPipe) createAssetDto: CreateAssetDto,
     @Response({ passthrough: true }) res: Res,
   ): Promise<AssetFileUploadResponseDto> {
-    const checksum = await this.assetService.calculateChecksum(file.path);
-
-    try {
-      const savedAsset = await this.assetService.createUserAsset(
-        authUser,
-        assetInfo,
-        file.path,
-        file.mimetype,
-        checksum,
-      );
-
-      if (!savedAsset) {
-        await this.backgroundTaskService.deleteFileOnDisk([
-          {
-            originalPath: file.path,
-          } as any,
-        ]); // simulate asset to make use of delete queue (or use fs.unlink instead)
-        throw new BadRequestException('Asset not created');
-      }
-
-      await this.assetUploadedQueue.add(
-        assetUploadedProcessorName,
-        { asset: savedAsset, fileName: file.originalname },
-        { jobId: savedAsset.id },
-      );
-
-      return new AssetFileUploadResponseDto(savedAsset.id);
-    } catch (err) {
-      await this.backgroundTaskService.deleteFileOnDisk([
-        {
-          originalPath: file.path,
-        } as any,
-      ]); // simulate asset to make use of delete queue (or use fs.unlink instead)
+    const originalAssetData = files.assetData[0];
+    const livePhotoAssetData = files.livePhotoData?.[0];
 
-      if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
-        const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum);
-        res.status(200); // normal POST is 201. we use 200 to indicate the asset already exists
-        return new AssetFileUploadResponseDto(existedAsset.id);
-      }
-
-      Logger.error(`Error uploading file ${err}`);
-      throw new BadRequestException(`Error uploading file`, `${err}`);
-    }
+    return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData);
   }
 
   @Get('/download/:assetId')
@@ -270,6 +223,14 @@ export class AssetController {
         continue;
       }
       deleteAssetList.push(assets);
+
+      if (assets.livePhotoVideoId) {
+        const livePhotoVideo = await this.assetService.getAssetById(authUser, assets.livePhotoVideoId);
+        if (livePhotoVideo) {
+          deleteAssetList.push(livePhotoVideo);
+          assetIds.ids = [...assetIds.ids, livePhotoVideo.id];
+        }
+      }
     }
 
     const result = await this.assetService.deleteAssetById(authUser, assetIds);

+ 8 - 0
server/apps/immich/src/api-v1/asset/asset.module.ts

@@ -25,6 +25,14 @@ import { DownloadModule } from '../../modules/download/download.module';
         removeOnFail: false,
       },
     }),
+    BullModule.registerQueue({
+      name: QueueNameEnum.VIDEO_CONVERSION,
+      defaultJobOptions: {
+        attempts: 3,
+        removeOnComplete: true,
+        removeOnFail: false,
+      },
+    }),
   ],
   controllers: [AssetController],
   providers: [

+ 15 - 2
server/apps/immich/src/api-v1/asset/asset.service.spec.ts

@@ -8,13 +8,18 @@ import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group
 import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
 import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
 import { DownloadService } from '../../modules/download/download.service';
+import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
+import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job';
+import { Queue } from 'bull';
 
 describe('AssetService', () => {
   let sui: AssetService;
   let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
   let assetRepositoryMock: jest.Mocked<IAssetRepository>;
   let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
-
+  let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
+  let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>;
+  let videoConversionQueueMock: jest.Mocked<Queue<IVideoTranscodeJob>>;
   const authUser: AuthUserDto = Object.freeze({
     id: 'user_id_1',
     email: 'auth@test.com',
@@ -123,7 +128,14 @@ describe('AssetService', () => {
       downloadArchive: jest.fn(),
     };
 
-    sui = new AssetService(assetRepositoryMock, a, downloadServiceMock as DownloadService);
+    sui = new AssetService(
+      assetRepositoryMock,
+      a,
+      backgroundTaskServiceMock,
+      assetUploadedQueueMock,
+      videoConversionQueueMock,
+      downloadServiceMock as DownloadService,
+    );
   });
 
   // Currently failing due to calculate checksum from a file
@@ -141,6 +153,7 @@ describe('AssetService', () => {
       originalPath,
       mimeType,
       Buffer.from('0x5041E6328F7DF8AFF650BEDAED9251897D9A6241', 'hex'),
+      true,
     );
 
     expect(result.userId).toEqual(authUser.id);

+ 116 - 2
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -10,8 +10,8 @@ import {
   StreamableFile,
 } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
-import { createHash } from 'node:crypto';
-import { Repository } from 'typeorm';
+import { createHash, randomUUID } from 'node:crypto';
+import { QueryFailedError, Repository } from 'typeorm';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
 import { constants, createReadStream, ReadStream, stat } from 'fs';
@@ -41,6 +41,17 @@ import { timeUtils } from '@app/common/utils';
 import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
 import { UpdateAssetDto } from './dto/update-asset.dto';
+import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
+import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
+import {
+  assetUploadedProcessorName,
+  IAssetUploadedJob,
+  IVideoTranscodeJob,
+  mp4ConversionProcessorName,
+  QueueNameEnum,
+} from '@app/job';
+import { InjectQueue } from '@nestjs/bull';
+import { Queue } from 'bull';
 import { DownloadService } from '../../modules/download/download.service';
 import { DownloadDto } from './dto/download-library.dto';
 
@@ -55,15 +66,116 @@ export class AssetService {
     @InjectRepository(AssetEntity)
     private assetRepository: Repository<AssetEntity>,
 
+    private backgroundTaskService: BackgroundTaskService,
+
+    @InjectQueue(QueueNameEnum.ASSET_UPLOADED)
+    private assetUploadedQueue: Queue<IAssetUploadedJob>,
+
+    @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
+    private videoConversionQueue: Queue<IVideoTranscodeJob>,
+
     private downloadService: DownloadService,
   ) {}
 
+  public async handleUploadedAsset(
+    authUser: AuthUserDto,
+    createAssetDto: CreateAssetDto,
+    res: Res,
+    originalAssetData: Express.Multer.File,
+    livePhotoAssetData?: Express.Multer.File,
+  ) {
+    const checksum = await this.calculateChecksum(originalAssetData.path);
+    const isLivePhoto = livePhotoAssetData !== undefined;
+    let livePhotoAssetEntity: AssetEntity | undefined;
+
+    try {
+      if (isLivePhoto) {
+        const livePhotoChecksum = await this.calculateChecksum(livePhotoAssetData.path);
+        livePhotoAssetEntity = await this.createUserAsset(
+          authUser,
+          createAssetDto,
+          livePhotoAssetData.path,
+          livePhotoAssetData.mimetype,
+          livePhotoChecksum,
+          false,
+        );
+
+        if (!livePhotoAssetEntity) {
+          await this.backgroundTaskService.deleteFileOnDisk([
+            {
+              originalPath: livePhotoAssetData.path,
+            } as any,
+          ]);
+          throw new BadRequestException('Asset not created');
+        }
+
+        await this.videoConversionQueue.add(
+          mp4ConversionProcessorName,
+          { asset: livePhotoAssetEntity },
+          { jobId: randomUUID() },
+        );
+      }
+
+      const assetEntity = await this.createUserAsset(
+        authUser,
+        createAssetDto,
+        originalAssetData.path,
+        originalAssetData.mimetype,
+        checksum,
+        true,
+        livePhotoAssetEntity,
+      );
+
+      if (!assetEntity) {
+        await this.backgroundTaskService.deleteFileOnDisk([
+          {
+            originalPath: originalAssetData.path,
+          } as any,
+        ]);
+        throw new BadRequestException('Asset not created');
+      }
+
+      await this.assetUploadedQueue.add(
+        assetUploadedProcessorName,
+        { asset: assetEntity, fileName: originalAssetData.originalname },
+        { jobId: assetEntity.id },
+      );
+
+      return new AssetFileUploadResponseDto(assetEntity.id);
+    } catch (err) {
+      await this.backgroundTaskService.deleteFileOnDisk([
+        {
+          originalPath: originalAssetData.path,
+        } as any,
+      ]); // simulate asset to make use of delete queue (or use fs.unlink instead)
+
+      if (isLivePhoto) {
+        await this.backgroundTaskService.deleteFileOnDisk([
+          {
+            originalPath: livePhotoAssetData.path,
+          } as any,
+        ]);
+      }
+
+      if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
+        const existedAsset = await this.getAssetByChecksum(authUser.id, checksum);
+        res.status(200); // normal POST is 201. we use 200 to indicate the asset already exists
+        return new AssetFileUploadResponseDto(existedAsset.id);
+      }
+
+      Logger.error(`Error uploading file ${err}`);
+      throw new BadRequestException(`Error uploading file`, `${err}`);
+    }
+  }
+
   public async createUserAsset(
     authUser: AuthUserDto,
     createAssetDto: CreateAssetDto,
     originalPath: string,
     mimeType: string,
     checksum: Buffer,
+    isVisible: boolean,
+    livePhotoAssetEntity?: AssetEntity,
   ): Promise<AssetEntity> {
     // Check valid time.
     const createdAt = createAssetDto.createdAt;
@@ -82,7 +194,9 @@ export class AssetService {
       authUser.id,
       originalPath,
       mimeType,
+      isVisible,
       checksum,
+      livePhotoAssetEntity,
     );
 
     return assetEntity;

+ 2 - 0
server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts

@@ -22,6 +22,7 @@ export class AssetResponseDto {
   encodedVideoPath!: string | null;
   exifInfo?: ExifResponseDto;
   smartInfo?: SmartInfoResponseDto;
+  livePhotoVideoId!: string | null;
 }
 
 export function mapAsset(entity: AssetEntity): AssetResponseDto {
@@ -42,5 +43,6 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
     duration: entity.duration ?? '0:00:00.00000',
     exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
+    livePhotoVideoId: entity.livePhotoVideoId,
   };
 }

+ 7 - 2
server/apps/immich/src/config/asset-upload.config.ts

@@ -54,7 +54,12 @@ function filename(req: Request, file: Express.Multer.File, cb: any) {
   }
 
   const fileNameUUID = randomUUID();
+
+  if (file.fieldname === 'livePhotoData') {
+    const livePhotoFileName = `${fileNameUUID}.mov`;
+    return cb(null, sanitize(livePhotoFileName));
+  }
+
   const fileName = `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`;
-  const sanitizedFileName = sanitize(fileName);
-  cb(null, sanitizedFileName);
+  return cb(null, sanitize(fileName));
 }

+ 1 - 1
server/apps/microservices/src/processors/video-transcode.processor.ts

@@ -20,7 +20,7 @@ export class VideoTranscodeProcessor {
     private immichConfigService: ImmichConfigService,
   ) {}
 
-  @Process({ name: mp4ConversionProcessorName, concurrency: 1 })
+  @Process({ name: mp4ConversionProcessorName, concurrency: 2 })
   async mp4Conversion(job: Job<IMp4ConversionProcessor>) {
     const { asset } = job.data;
 

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
server/immich-openapi-specs.json


+ 6 - 0
server/libs/database/src/entities/asset.entity.ts

@@ -51,6 +51,12 @@ export class AssetEntity {
   @Column({ type: 'varchar', nullable: true })
   duration!: string | null;
 
+  @Column({ type: 'boolean', default: true })
+  isVisible!: boolean;
+
+  @Column({ type: 'uuid', nullable: true })
+  livePhotoVideoId!: string | null;
+
   @OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
   exifInfo?: ExifEntity;
 

+ 16 - 0
server/libs/database/src/migrations/1668383120461-AddLivePhotosRelatedColumnToAssetTable.ts

@@ -0,0 +1,16 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddLivePhotosRelatedColumnToAssetTable1668383120461 implements MigrationInterface {
+    name = 'AddLivePhotosRelatedColumnToAssetTable1668383120461'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "assets" ADD "isVisible" boolean NOT NULL DEFAULT true`);
+        await queryRunner.query(`ALTER TABLE "assets" ADD "livePhotoVideoId" uuid`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "livePhotoVideoId"`);
+        await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isVisible"`);
+    }
+
+}

+ 6 - 0
web/src/api/open-api/api.ts

@@ -440,6 +440,12 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      */
     'smartInfo'?: SmartInfoResponseDto;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetResponseDto
+     */
+    'livePhotoVideoId': string | null;
 }
 /**
  * 

+ 1 - 0
web/src/app.d.ts

@@ -13,6 +13,7 @@ declare namespace App {
 // Source: https://stackoverflow.com/questions/63814432/typescript-typing-of-non-standard-window-event-in-svelte
 // To fix the <svelte:window... in components/asset-viewer/photo-viewer.svelte
 declare namespace svelte.JSX {
+	// eslint-disable-next-line @typescript-eslint/no-unused-vars
 	interface HTMLAttributes<T> {
 		oncopyImage?: () => void;
 	}

+ 36 - 4
web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte

@@ -12,12 +12,16 @@
 	import Star from 'svelte-material-icons/Star.svelte';
 	import StarOutline from 'svelte-material-icons/StarOutline.svelte';
 	import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
+	import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
+	import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
 
 	import { page } from '$app/stores';
 	import { AssetResponseDto } from '../../../api';
 
 	export let asset: AssetResponseDto;
 	export let showCopyButton: boolean;
+	export let showMotionPlayButton: boolean;
+	export let isMotionPhotoPlaying = false;
 
 	const isOwner = asset.ownerId === $page.data.user.id;
 
@@ -48,17 +52,41 @@
 		<CircleIconButton logo={ArrowLeft} on:click={() => dispatch('goBack')} />
 	</div>
 	<div class="text-white flex gap-2">
+		{#if showMotionPlayButton}
+			{#if isMotionPhotoPlaying}
+				<CircleIconButton
+					logo={MotionPauseOutline}
+					title="Stop Motion Photo"
+					on:click={() => dispatch('stopMotionPhoto')}
+				/>
+			{:else}
+				<CircleIconButton
+					logo={MotionPlayOutline}
+					title="Play Motion Photo"
+					on:click={() => dispatch('playMotionPhoto')}
+				/>
+			{/if}
+		{/if}
 		{#if showCopyButton}
 			<CircleIconButton
 				logo={ContentCopy}
+				title="Copy Image"
 				on:click={() => {
 					const copyEvent = new CustomEvent('copyImage');
 					window.dispatchEvent(copyEvent);
 				}}
 			/>
 		{/if}
-		<CircleIconButton logo={CloudDownloadOutline} on:click={() => dispatch('download')} />
-		<CircleIconButton logo={InformationOutline} on:click={() => dispatch('showDetail')} />
+		<CircleIconButton
+			logo={CloudDownloadOutline}
+			on:click={() => dispatch('download')}
+			title="Download"
+		/>
+		<CircleIconButton
+			logo={InformationOutline}
+			on:click={() => dispatch('showDetail')}
+			title="Info"
+		/>
 		{#if isOwner}
 			<CircleIconButton
 				logo={asset.isFavorite ? Star : StarOutline}
@@ -66,8 +94,12 @@
 				title="Favorite"
 			/>
 		{/if}
-		<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} />
-		<CircleIconButton logo={DotsVertical} on:click={(event) => showOptionsMenu(event)} />
+		<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
+		<CircleIconButton
+			logo={DotsVertical}
+			on:click={(event) => showOptionsMenu(event)}
+			title="More"
+		/>
 	</div>
 </div>
 

+ 29 - 7
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -39,7 +39,7 @@
 	let appearsInAlbums: AlbumResponseDto[] = [];
 	let isShowAlbumPicker = false;
 	let addToSharedAlbum = true;
-
+	let shouldPlayMotionPhoto = false;
 	const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
 
 	onMount(() => {
@@ -88,10 +88,20 @@
 		isShowDetail = !isShowDetail;
 	};
 
-	const downloadFile = async () => {
+	const handleDownload = () => {
+		if (asset.livePhotoVideoId) {
+			downloadFile(asset.livePhotoVideoId, true);
+			downloadFile(asset.id, false);
+			return;
+		}
+
+		downloadFile(asset.id, false);
+	};
+
+	const downloadFile = async (assetId: string, isLivePhoto: boolean) => {
 		try {
 			const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
-			const imageExtension = asset.originalPath.split('.')[1];
+			const imageExtension = isLivePhoto ? 'mov' : asset.originalPath.split('.')[1];
 			const imageFileName = imageName + '.' + imageExtension;
 
 			// If assets is already download -> return;
@@ -101,7 +111,7 @@
 
 			$downloadAssets[imageFileName] = 0;
 
-			const { data, status } = await api.assetApi.downloadFile(asset.id, false, false, {
+			const { data, status } = await api.assetApi.downloadFile(assetId, false, false, {
 				responseType: 'blob',
 				onDownloadProgress: (progressEvent) => {
 					if (progressEvent.lengthComputable) {
@@ -221,14 +231,18 @@
 	<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
 		<AssetViewerNavBar
 			{asset}
+			isMotionPhotoPlaying={shouldPlayMotionPhoto}
+			showCopyButton={asset.type === AssetTypeEnum.Image}
+			showMotionPlayButton={!!asset.livePhotoVideoId}
 			on:goBack={closeViewer}
 			on:showDetail={showDetailInfoHandler}
-			on:download={downloadFile}
-			showCopyButton={asset.type === AssetTypeEnum.Image}
+			on:download={handleDownload}
 			on:delete={deleteAsset}
 			on:favorite={toggleFavorite}
 			on:addToAlbum={() => openAlbumPicker(false)}
 			on:addToSharedAlbum={() => openAlbumPicker(true)}
+			on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
+			on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
 		/>
 	</div>
 
@@ -257,7 +271,15 @@
 	<div class="row-start-1 row-span-full col-start-1 col-span-4">
 		{#key asset.id}
 			{#if asset.type === AssetTypeEnum.Image}
-				<PhotoViewer assetId={asset.id} on:close={closeViewer} />
+				{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
+					<VideoViewer
+						assetId={asset.livePhotoVideoId}
+						on:close={closeViewer}
+						on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
+					/>
+				{:else}
+					<PhotoViewer assetId={asset.id} on:close={closeViewer} />
+				{/if}
 			{:else}
 				<VideoViewer assetId={asset.id} on:close={closeViewer} />
 			{/if}

+ 3 - 1
web/src/lib/components/asset-viewer/video-viewer.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
 	import { fade } from 'svelte/transition';
 
-	import { onMount } from 'svelte';
+	import { createEventDispatcher, onMount } from 'svelte';
 	import LoadingSpinner from '../shared-components/loading-spinner.svelte';
 	import { api, AssetResponseDto, getFileUrl } from '@api';
 
@@ -12,6 +12,7 @@
 	let videoPlayerNode: HTMLVideoElement;
 	let isVideoLoading = true;
 	let videoUrl: string;
+	const dispatch = createEventDispatcher();
 
 	onMount(async () => {
 		const { data: assetInfo } = await api.assetApi.getAssetById(assetId);
@@ -49,6 +50,7 @@
 			controls
 			class="h-full object-contain"
 			on:canplay={handleCanPlay}
+			on:ended={() => dispatch('onVideoEnded')}
 			bind:this={videoPlayerNode}
 		>
 			<source src={videoUrl} type="video/mp4" />

+ 57 - 3
web/src/lib/components/shared-components/immich-thumbnail.svelte

@@ -5,6 +5,8 @@
 	import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
 	import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
 	import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
+	import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
+	import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
 	import LoadingSpinner from './loading-spinner.svelte';
 	import { AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
 
@@ -19,6 +21,7 @@
 	let imageData: string;
 
 	let mouseOver = false;
+	let playMotionVideo = false;
 	$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
 
 	let mouseOverIcon = false;
@@ -28,10 +31,15 @@
 	let videoProgress = '00:00';
 	let videoUrl: string;
 
-	const loadVideoData = async () => {
+	const loadVideoData = async (isLivePhoto: boolean) => {
 		isThumbnailVideoPlaying = false;
 
-		videoUrl = getFileUrl(asset.id, false, true);
+		if (isLivePhoto && asset.livePhotoVideoId) {
+			console.log('get file url');
+			videoUrl = getFileUrl(asset.livePhotoVideoId, false, true);
+		} else {
+			videoUrl = getFileUrl(asset.id, false, true);
+		}
 	};
 
 	const getVideoDurationInString = (currentTime: number) => {
@@ -202,6 +210,32 @@
 			</div>
 		{/if}
 
+		{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
+			<div
+				class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
+			>
+				<span
+					in:fade={{ duration: 500 }}
+					on:mouseenter={() => {
+						playMotionVideo = true;
+						loadVideoData(true);
+					}}
+					on:mouseleave={() => (playMotionVideo = false)}
+				>
+					{#if playMotionVideo}
+						<span in:fade={{ duration: 500 }}>
+							<MotionPauseOutline size="24" />
+						</span>
+					{:else}
+						<span in:fade={{ duration: 500 }}>
+							<MotionPlayOutline size="24" />
+						</span>
+					{/if}
+				</span>
+				<!-- {/if} -->
+			</div>
+		{/if}
+
 		<!-- Thumbnail -->
 		{#if intersecting}
 			<img
@@ -217,7 +251,27 @@
 		{/if}
 
 		{#if mouseOver && asset.type === AssetTypeEnum.Video}
-			<div class="absolute w-full h-full top-0" on:mouseenter={loadVideoData}>
+			<div class="absolute w-full h-full top-0" on:mouseenter={() => loadVideoData(false)}>
+				{#if videoUrl}
+					<video
+						muted
+						autoplay
+						preload="none"
+						class="h-full object-cover"
+						width="250px"
+						style:width={`${thumbnailSize}px`}
+						on:canplay={handleCanPlay}
+						bind:this={videoPlayerNode}
+					>
+						<source src={videoUrl} type="video/mp4" />
+						<track kind="captions" />
+					</video>
+				{/if}
+			</div>
+		{/if}
+
+		{#if playMotionVideo && asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
+			<div class="absolute w-full h-full top-0">
 				{#if videoUrl}
 					<video
 						muted

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است