diff --git a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart index 30a7e23938..8f90f2d770 100644 --- a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart +++ b/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, + ); - final AssetEntity? entity; + var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo( + asset.livePhotoVideoId!, + isThumb: false, + isWeb: false, + ); - if (asset.type == AssetTypeEnum.IMAGE) { - entity = await PhotoManager.editor.saveImage( - res.bodyBytes, + final AssetEntity? entity; + + 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), ); - } 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; + return entity != null; + } else { + var res = await _apiService.assetApi.downloadFileWithHttpInfo( + asset.id, + isThumb: false, + isWeb: false, + ); + + 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; diff --git a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart b/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart index ff1b16a105..7ddbc3fcda 100644 --- a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart +++ b/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart @@ -37,7 +37,7 @@ class _RemotePhotoViewState extends State { } void handleSwipUpDown(PointerMoveEvent details) { - int sensitivity = 10; + int sensitivity = 15; if (_zoomedIn) { return; diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 3cfd4a683a..d06e90f10e 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/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], ), - ) + ), ], ); } diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 2240a0552d..28e66a6543 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -33,10 +33,10 @@ class GalleryViewerPage extends HookConsumerWidget { final Box box = Hive.box(userInfoBox); final appSettingService = ref.watch(appSettingsServiceProvider); final threeStageLoading = useState(false); - final loading = useState(false); final isZoomed = useState(false); - ValueNotifier isZoomedListener = ValueNotifier(false); final indexOfAsset = useState(assetList.indexOf(asset)); + final isPlayingMotionVideo = useState(false); + ValueNotifier isZoomedListener = ValueNotifier(false); PageController controller = PageController(initialPage: assetList.indexOf(asset)); @@ -45,6 +45,7 @@ class GalleryViewerPage extends HookConsumerWidget { () { threeStageLoading.value = appSettingService .getSetting(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: () {}, + ), ), ); } diff --git a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart index 5a39743213..85a4f62252 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/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 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; + final bool isMotionVideo; + final VoidCallback onVideoEnded; - const VideoThumbnailPlayer({Key? key, this.url, this.jwtToken, this.file}) - : super(key: key); + const VideoThumbnailPlayer({ + Key? key, + this.url, + this.jwtToken, + this.file, + required this.onVideoEnded, + required this.isMotionVideo, + }) : super(key: key); @override State createState() => _VideoThumbnailPlayerState(); @@ -88,6 +110,13 @@ class _VideoThumbnailPlayerState extends State { void initState() { super.initState(); initializePlayer(); + + videoPlayerController.addListener(() { + if (videoPlayerController.value.position == + videoPlayerController.value.duration) { + widget.onVideoEnded(); + } + }); } Future initializePlayer() async { @@ -115,7 +144,7 @@ class _VideoThumbnailPlayerState extends State { autoPlay: true, autoInitialize: true, allowFullScreen: true, - showControls: true, + showControls: !widget.isMotionVideo, hideControlsTimer: const Duration(seconds: 5), ); } diff --git a/mobile/lib/modules/backup/services/backup.service.dart b/mobile/lib/modules/backup/services/backup.service.dart index 653528848a..325e1cbea2 100644 --- a/mobile/lib/modules/backup/services/backup.service.dart +++ b/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 _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: diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index c64861bda6..d280114915 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -65,7 +65,11 @@ class _$AppRouter extends RootStackRouter { final args = routeData.argsAs(); return MaterialPageX( 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( @@ -340,24 +344,40 @@ class ImageViewerRouteArgs { /// generated route for /// [VideoViewerPage] class VideoViewerRoute extends PageRouteInfo { - 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}'; } } diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index e693549f6b..f6e52a709e 100644 --- a/mobile/openapi/doc/AssetResponseDto.md +++ b/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) diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index ac170523d8..f9901a358f 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -43,48 +43,51 @@ class AlbumResponseDto { List 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 toJson() { final _json = {}; - _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(json, r'assetCount')!, @@ -112,7 +115,8 @@ class AlbumResponseDto { ownerId: mapValueOfType(json, r'ownerId')!, albumName: mapValueOfType(json, r'albumName')!, createdAt: mapValueOfType(json, r'createdAt')!, - albumThumbnailAssetId: mapValueOfType(json, r'albumThumbnailAssetId'), + albumThumbnailAssetId: + mapValueOfType(json, r'albumThumbnailAssetId'), shared: mapValueOfType(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? listFromJson(dynamic json, {bool growable = false,}) { + static List? listFromJson( + dynamic json, { + bool growable = false, + }) { final result = []; 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> mapListFromJson(dynamic json, {bool growable = false,}) { + static Map> mapListFromJson( + dynamic json, { + bool growable = false, + }) { final map = >{}; if (json is Map && json.isNotEmpty) { json = json.cast(); // 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', }; } - diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index cd1e83c5f2..f916088ba2 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/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 toJson() { final _json = {}; - _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(json, r'encodedVideoPath'), exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']), smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), + livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), ); } return null; } - static List? listFromJson(dynamic json, {bool growable = false,}) { + static List? listFromJson( + dynamic json, { + bool growable = false, + }) { final result = []; 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> mapListFromJson(dynamic json, {bool growable = false,}) { + static Map> mapListFromJson( + dynamic json, { + bool growable = false, + }) { final map = >{}; if (json is Map && json.isNotEmpty) { json = json.cast(); // 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', }; } - diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index b51cb47b43..8dcc4e87a9 100644 --- a/mobile/pubspec.lock +++ b/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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3df117be65..3e24dbf8f5 100644 --- a/mobile/pubspec.yaml +++ b/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 diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart index 05d552d5a1..0742e22482 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/asset_grid_data_structure_test.dart @@ -29,6 +29,7 @@ void main() { duration: '', webpPath: '', encodedVideoPath: '', + livePhotoVideoId: '', ), ), ); diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index 988ca0ed99..6d0aa2e41d 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/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; update(asset: AssetEntity, dto: UpdateAssetDto): Promise; getAllByUserId(userId: string, skip?: number): Promise; @@ -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 { 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'], }); diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index 9c04e4a82f..6a3bf2d16f 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/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, - ) {} + 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 { - const checksum = await this.assetService.calculateChecksum(file.path); + const originalAssetData = files.assetData[0]; + const livePhotoAssetData = files.livePhotoData?.[0]; - 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) - - 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); diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts index 72d1664e37..f33d7a415b 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/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: [ diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 921c82d62d..4c61c4e997 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/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; // TO BE DELETED AFTER FINISHED REFACTORING let assetRepositoryMock: jest.Mocked; let downloadServiceMock: jest.Mocked>; - + let backgroundTaskServiceMock: jest.Mocked; + let assetUploadedQueueMock: jest.Mocked>; + let videoConversionQueueMock: jest.Mocked>; 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); diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 8a15a6395e..74217abf5b 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/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, + private backgroundTaskService: BackgroundTaskService, + + @InjectQueue(QueueNameEnum.ASSET_UPLOADED) + private assetUploadedQueue: Queue, + + @InjectQueue(QueueNameEnum.VIDEO_CONVERSION) + private videoConversionQueue: Queue, + 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 { // Check valid time. const createdAt = createAssetDto.createdAt; @@ -82,7 +194,9 @@ export class AssetService { authUser.id, originalPath, mimeType, + isVisible, checksum, + livePhotoAssetEntity, ); return assetEntity; diff --git a/server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts b/server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts index 5e4887fcd9..4054d670aa 100644 --- a/server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts +++ b/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, }; } diff --git a/server/apps/immich/src/config/asset-upload.config.ts b/server/apps/immich/src/config/asset-upload.config.ts index 7309a89244..b399e9352a 100644 --- a/server/apps/immich/src/config/asset-upload.config.ts +++ b/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)); } diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index 89dc263ed3..f7a6957b70 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/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) { const { asset } = job.data; diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 6c92c5aba8..66192c92e8 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1 +1 @@ -{"openapi":"3.0.0","paths":{"/user":{"get":{"operationId":"getAllUsers","parameters":[{"name":"isAll","required":true,"in":"query","schema":{"type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}}}}}},"tags":["User"],"security":[{"bearer":[]}]},"post":{"operationId":"createUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]},"put":{"operationId":"updateUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/info/{userId}":{"get":{"operationId":"getUserById","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"]}},"/user/me":{"get":{"operationId":"getMyUserInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/count":{"get":{"operationId":"getUserCount","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCountResponseDto"}}}}},"tags":["User"]}},"/user/{userId}":{"delete":{"operationId":"deleteUser","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/{userId}/restore":{"post":{"operationId":"restoreUser","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image":{"post":{"operationId":"createProfileImage","parameters":[],"requestBody":{"required":true,"description":"A new avatar for the user","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/CreateProfileImageDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileImageResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image/{userId}":{"get":{"operationId":"getProfileImage","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["User"]}},"/asset/upload":{"post":{"operationId":"uploadFile","parameters":[],"requestBody":{"required":true,"description":"Asset Upload Information","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/AssetFileUploadDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetFileUploadResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/download/{assetId}":{"get":{"operationId":"downloadFile","parameters":[{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}},{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/download-library":{"get":{"operationId":"downloadLibrary","parameters":[{"name":"skip","required":false,"in":"query","schema":{"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/file/{assetId}":{"get":{"operationId":"serveFile","parameters":[{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}},{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/thumbnail/{assetId}":{"get":{"operationId":"getAssetThumbnail","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}},{"name":"format","required":false,"in":"query","schema":{"$ref":"#/components/schemas/ThumbnailFormat"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-objects":{"get":{"operationId":"getCuratedObjects","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedObjectsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-locations":{"get":{"operationId":"getCuratedLocations","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedLocationsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search-terms":{"get":{"operationId":"getAssetSearchTerms","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search":{"post":{"operationId":"searchAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchAssetDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-time-bucket":{"post":{"operationId":"getAssetCountByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetCountByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByTimeBucketResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-user-id":{"get":{"operationId":"getAssetCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByUserIdResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset":{"get":{"operationId":"getAllAssets","summary":"","description":"Get all AssetEntity belong to the user","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DeleteAssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/time-bucket":{"post":{"operationId":"getAssetByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/{deviceId}":{"get":{"operationId":"getUserAssetsByDeviceId","summary":"","description":"Get all asset of a device that are in the database, ID only.","parameters":[{"name":"deviceId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/assetById/{assetId}":{"get":{"operationId":"getAssetById","summary":"","description":"Get a single asset's information","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"put":{"operationId":"updateAssetById","summary":"","description":"Update an asset","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/check":{"post":{"operationId":"checkDuplicateAsset","summary":"","description":"Check duplicated asset before uploading - for Web upload used","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/exist":{"post":{"operationId":"checkExistingAssets","summary":"","description":"Checks if multiple assets exist on the server and returns all existing - used by background backup","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckExistingAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckExistingAssetsResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/auth/login":{"post":{"operationId":"login","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginCredentialDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponseDto"}}}}},"tags":["Authentication"]}},"/auth/admin-sign-up":{"post":{"operationId":"adminSignUp","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignUpDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSignupResponseDto"}}}},"400":{"description":"The server already has an admin"}},"tags":["Authentication"]}},"/auth/validateToken":{"post":{"operationId":"validateAccessToken","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateAccessTokenResponseDto"}}}}},"tags":["Authentication"],"security":[{"bearer":[]}]}},"/auth/logout":{"post":{"operationId":"logout","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutResponseDto"}}}}},"tags":["Authentication"]}},"/oauth/config":{"post":{"operationId":"generateConfig","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthConfigDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthConfigResponseDto"}}}}},"tags":["OAuth"]}},"/oauth/callback":{"post":{"operationId":"callback","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthCallbackDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponseDto"}}}}},"tags":["OAuth"]}},"/device-info":{"post":{"operationId":"createDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDeviceInfoDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDeviceInfoDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]}},"/server-info":{"get":{"operationId":"getServerInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerInfoResponseDto"}}}}},"tags":["Server Info"]}},"/server-info/ping":{"get":{"operationId":"pingServer","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerPingResponse"}}}}},"tags":["Server Info"]}},"/server-info/version":{"get":{"operationId":"getServerVersion","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerVersionReponseDto"}}}}},"tags":["Server Info"]}},"/server-info/stats":{"get":{"operationId":"getStats","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerStatsResponseDto"}}}}},"tags":["Server Info"]}},"/album/count-by-user-id":{"get":{"operationId":"getAlbumCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumCountResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album":{"post":{"operationId":"createAlbum","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAlbumDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"get":{"operationId":"getAllAlbums","parameters":[{"name":"shared","required":false,"in":"query","schema":{"type":"boolean"}},{"name":"assetId","required":false,"in":"query","description":"Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/users":{"put":{"operationId":"addUsersToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddUsersDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/assets":{"put":{"operationId":"addAssetsToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"removeAssetFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}":{"get":{"operationId":"getAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlbumDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/user/{userId}":{"delete":{"operationId":"removeUserFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}},{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/download":{"get":{"operationId":"downloadArchive","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}},{"name":"skip","required":false,"in":"query","schema":{"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/jobs":{"get":{"operationId":"getAllJobsStatus","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllJobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}},"/jobs/{jobId}":{"get":{"operationId":"getJobStatus","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]},"put":{"operationId":"sendJobCommand","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobCommandDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"number"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}},"/system-config":{"get":{"operationId":"getConfig","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SystemConfigResponseDto"}}}}},"tags":["System Config"],"security":[{"bearer":[]}]},"put":{"operationId":"updateConfig","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSystemConfigDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SystemConfigResponseDto"}}}}},"tags":["System Config"],"security":[{"bearer":[]}]}}},"info":{"title":"Immich","description":"Immich API","version":"1.17.0","contact":{}},"tags":[],"servers":[{"url":"/api"}],"components":{"securitySchemes":{"bearer":{"scheme":"Bearer","bearerFormat":"JWT","type":"http","name":"JWT","description":"Enter JWT token","in":"header"}},"schemas":{"UserResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"},"profileImagePath":{"type":"string"},"shouldChangePassword":{"type":"boolean"},"isAdmin":{"type":"boolean"},"deletedAt":{"format":"date-time","type":"string","nullable":true}},"required":["id","email","firstName","lastName","createdAt","profileImagePath","shouldChangePassword","isAdmin","deletedAt"]},"CreateUserDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"John"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"UserCountResponseDto":{"type":"object","properties":{"userCount":{"type":"integer"}},"required":["userCount"]},"UpdateUserDto":{"type":"object","properties":{"id":{"type":"string"},"password":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"isAdmin":{"type":"boolean"},"shouldChangePassword":{"type":"boolean"},"profileImagePath":{"type":"string"}},"required":["id"]},"CreateProfileImageDto":{"type":"object","properties":{"file":{"type":"string","format":"binary"}},"required":["file"]},"CreateProfileImageResponseDto":{"type":"object","properties":{"userId":{"type":"string"},"profileImagePath":{"type":"string"}},"required":["userId","profileImagePath"]},"AssetFileUploadDto":{"type":"object","properties":{"assetData":{"type":"string","format":"binary"}},"required":["assetData"]},"AssetFileUploadResponseDto":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"ThumbnailFormat":{"type":"string","enum":["JPEG","WEBP"]},"CuratedObjectsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","object","resizePath","deviceAssetId","deviceId"]},"CuratedLocationsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"city":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","city","resizePath","deviceAssetId","deviceId"]},"SearchAssetDto":{"type":"object","properties":{"searchTerm":{"type":"string"}},"required":["searchTerm"]},"AssetTypeEnum":{"type":"string","enum":["IMAGE","VIDEO","AUDIO","OTHER"]},"ExifResponseDto":{"type":"object","properties":{"id":{"type":"integer","nullable":true,"default":null,"format":"int64"},"fileSizeInByte":{"type":"integer","nullable":true,"default":null,"format":"int64"},"make":{"type":"string","nullable":true,"default":null},"model":{"type":"string","nullable":true,"default":null},"imageName":{"type":"string","nullable":true,"default":null},"exifImageWidth":{"type":"number","nullable":true,"default":null},"exifImageHeight":{"type":"number","nullable":true,"default":null},"orientation":{"type":"string","nullable":true,"default":null},"dateTimeOriginal":{"format":"date-time","type":"string","nullable":true,"default":null},"modifyDate":{"format":"date-time","type":"string","nullable":true,"default":null},"lensModel":{"type":"string","nullable":true,"default":null},"fNumber":{"type":"number","nullable":true,"default":null},"focalLength":{"type":"number","nullable":true,"default":null},"iso":{"type":"number","nullable":true,"default":null},"exposureTime":{"type":"number","nullable":true,"default":null},"latitude":{"type":"number","nullable":true,"default":null},"longitude":{"type":"number","nullable":true,"default":null},"city":{"type":"string","nullable":true,"default":null},"state":{"type":"string","nullable":true,"default":null},"country":{"type":"string","nullable":true,"default":null}}},"SmartInfoResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tags":{"nullable":true,"type":"array","items":{"type":"string"}},"objects":{"nullable":true,"type":"array","items":{"type":"string"}}}},"AssetResponseDto":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/AssetTypeEnum"},"id":{"type":"string"},"deviceAssetId":{"type":"string"},"ownerId":{"type":"string"},"deviceId":{"type":"string"},"originalPath":{"type":"string"},"resizePath":{"type":"string","nullable":true},"createdAt":{"type":"string"},"modifiedAt":{"type":"string"},"isFavorite":{"type":"boolean"},"mimeType":{"type":"string","nullable":true},"duration":{"type":"string"},"webpPath":{"type":"string","nullable":true},"encodedVideoPath":{"type":"string","nullable":true},"exifInfo":{"$ref":"#/components/schemas/ExifResponseDto"},"smartInfo":{"$ref":"#/components/schemas/SmartInfoResponseDto"}},"required":["type","id","deviceAssetId","ownerId","deviceId","originalPath","resizePath","createdAt","modifiedAt","isFavorite","mimeType","duration","webpPath","encodedVideoPath"]},"TimeGroupEnum":{"type":"string","enum":["day","month"]},"GetAssetCountByTimeBucketDto":{"type":"object","properties":{"timeGroup":{"$ref":"#/components/schemas/TimeGroupEnum"}},"required":["timeGroup"]},"AssetCountByTimeBucket":{"type":"object","properties":{"timeBucket":{"type":"string"},"count":{"type":"integer"}},"required":["timeBucket","count"]},"AssetCountByTimeBucketResponseDto":{"type":"object","properties":{"totalCount":{"type":"integer"},"buckets":{"type":"array","items":{"$ref":"#/components/schemas/AssetCountByTimeBucket"}}},"required":["totalCount","buckets"]},"AssetCountByUserIdResponseDto":{"type":"object","properties":{"audio":{"type":"integer","default":0},"photos":{"type":"integer","default":0},"videos":{"type":"integer","default":0},"other":{"type":"integer","default":0},"total":{"type":"integer","default":0}},"required":["audio","photos","videos","other","total"]},"GetAssetByTimeBucketDto":{"type":"object","properties":{"timeBucket":{"title":"Array of date time buckets","example":["2015-06-01T00:00:00.000Z","2016-02-01T00:00:00.000Z","2016-03-01T00:00:00.000Z"],"type":"array","items":{"type":"string"}}},"required":["timeBucket"]},"UpdateAssetDto":{"type":"object","properties":{"isFavorite":{"type":"boolean"}},"required":["isFavorite"]},"DeleteAssetDto":{"type":"object","properties":{"ids":{"title":"Array of asset IDs to delete","example":["bf973405-3f2a-48d2-a687-2ed4167164be","dd41870b-5d00-46d2-924e-1d8489a0aa0f","fad77c3f-deef-4e7e-9608-14c1aa4e559a"],"type":"array","items":{"type":"string"}}},"required":["ids"]},"DeleteAssetStatus":{"type":"string","enum":["SUCCESS","FAILED"]},"DeleteAssetResponseDto":{"type":"object","properties":{"status":{"$ref":"#/components/schemas/DeleteAssetStatus"},"id":{"type":"string"}},"required":["status","id"]},"CheckDuplicateAssetDto":{"type":"object","properties":{"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["deviceAssetId","deviceId"]},"CheckDuplicateAssetResponseDto":{"type":"object","properties":{"isExist":{"type":"boolean"},"id":{"type":"string"}},"required":["isExist"]},"CheckExistingAssetsDto":{"type":"object","properties":{"deviceAssetIds":{"type":"array","items":{"type":"string"}},"deviceId":{"type":"string"}},"required":["deviceAssetIds","deviceId"]},"CheckExistingAssetsResponseDto":{"type":"object","properties":{"existingIds":{"type":"array","items":{"type":"string"}}},"required":["existingIds"]},"LoginCredentialDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"}},"required":["email","password"]},"LoginResponseDto":{"type":"object","properties":{"accessToken":{"type":"string","readOnly":true},"userId":{"type":"string","readOnly":true},"userEmail":{"type":"string","readOnly":true},"firstName":{"type":"string","readOnly":true},"lastName":{"type":"string","readOnly":true},"profileImagePath":{"type":"string","readOnly":true},"isAdmin":{"type":"boolean","readOnly":true},"shouldChangePassword":{"type":"boolean","readOnly":true}},"required":["accessToken","userId","userEmail","firstName","lastName","profileImagePath","isAdmin","shouldChangePassword"]},"SignUpDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"Admin"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"AdminSignupResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"}},"required":["id","email","firstName","lastName","createdAt"]},"ValidateAccessTokenResponseDto":{"type":"object","properties":{"authStatus":{"type":"boolean"}},"required":["authStatus"]},"LogoutResponseDto":{"type":"object","properties":{"successful":{"type":"boolean","readOnly":true},"redirectUri":{"type":"string","readOnly":true}},"required":["successful","redirectUri"]},"OAuthConfigDto":{"type":"object","properties":{"redirectUri":{"type":"string"}},"required":["redirectUri"]},"OAuthConfigResponseDto":{"type":"object","properties":{"enabled":{"type":"boolean","readOnly":true},"url":{"type":"string","readOnly":true},"buttonText":{"type":"string","readOnly":true}},"required":["enabled"]},"OAuthCallbackDto":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]},"DeviceTypeEnum":{"type":"string","enum":["IOS","ANDROID","WEB"]},"CreateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"DeviceInfoResponseDto":{"type":"object","properties":{"id":{"type":"integer"},"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"userId":{"type":"string"},"deviceId":{"type":"string"},"createdAt":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["id","deviceType","userId","deviceId","createdAt","isAutoBackup"]},"UpdateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"ServerInfoResponseDto":{"type":"object","properties":{"diskSizeRaw":{"type":"integer","format":"int64"},"diskUseRaw":{"type":"integer","format":"int64"},"diskAvailableRaw":{"type":"integer","format":"int64"},"diskUsagePercentage":{"type":"number","format":"float"},"diskSize":{"type":"string"},"diskUse":{"type":"string"},"diskAvailable":{"type":"string"}},"required":["diskSizeRaw","diskUseRaw","diskAvailableRaw","diskUsagePercentage","diskSize","diskUse","diskAvailable"]},"ServerPingResponse":{"type":"object","properties":{"res":{"type":"string","readOnly":true,"example":"pong"}},"required":["res"]},"ServerVersionReponseDto":{"type":"object","properties":{"major":{"type":"integer"},"minor":{"type":"integer"},"patch":{"type":"integer"},"build":{"type":"integer"}},"required":["major","minor","patch","build"]},"UsageByUserDto":{"type":"object","properties":{"userId":{"type":"string"},"objects":{"type":"integer"},"videos":{"type":"integer"},"photos":{"type":"integer"},"usageRaw":{"type":"integer","format":"int64"},"usage":{"type":"string"}},"required":["userId","objects","videos","photos","usageRaw","usage"]},"ServerStatsResponseDto":{"type":"object","properties":{"photos":{"type":"integer"},"videos":{"type":"integer"},"objects":{"type":"integer"},"usageRaw":{"type":"integer","format":"int64"},"usage":{"type":"string"},"usageByUser":{"title":"Array of usage for each user","example":[{"photos":1,"videos":1,"objects":1,"diskUsageRaw":1}],"type":"array","items":{"$ref":"#/components/schemas/UsageByUserDto"}}},"required":["photos","videos","objects","usageRaw","usage","usageByUser"]},"AlbumCountResponseDto":{"type":"object","properties":{"owned":{"type":"integer"},"shared":{"type":"integer"},"sharing":{"type":"integer"}},"required":["owned","shared","sharing"]},"CreateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"sharedWithUserIds":{"type":"array","items":{"type":"string"}},"assetIds":{"type":"array","items":{"type":"string"}}},"required":["albumName"]},"AlbumResponseDto":{"type":"object","properties":{"assetCount":{"type":"integer"},"id":{"type":"string"},"ownerId":{"type":"string"},"albumName":{"type":"string"},"createdAt":{"type":"string"},"albumThumbnailAssetId":{"type":"string","nullable":true},"shared":{"type":"boolean"},"sharedUsers":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}},"assets":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}},"required":["assetCount","id","ownerId","albumName","createdAt","albumThumbnailAssetId","shared","sharedUsers","assets"]},"AddUsersDto":{"type":"object","properties":{"sharedUserIds":{"type":"array","items":{"type":"string"}}},"required":["sharedUserIds"]},"AddAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"AddAssetsResponseDto":{"type":"object","properties":{"successfullyAdded":{"type":"integer"},"alreadyInAlbum":{"type":"array","items":{"type":"string"}},"album":{"$ref":"#/components/schemas/AlbumResponseDto"}},"required":["successfullyAdded","alreadyInAlbum"]},"RemoveAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"UpdateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"albumThumbnailAssetId":{"type":"string"}}},"JobCounts":{"type":"object","properties":{"active":{"type":"integer"},"completed":{"type":"integer"},"failed":{"type":"integer"},"delayed":{"type":"integer"},"waiting":{"type":"integer"}},"required":["active","completed","failed","delayed","waiting"]},"AllJobStatusResponseDto":{"type":"object","properties":{"thumbnailGenerationQueueCount":{"$ref":"#/components/schemas/JobCounts"},"metadataExtractionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"videoConversionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"machineLearningQueueCount":{"$ref":"#/components/schemas/JobCounts"},"isThumbnailGenerationActive":{"type":"boolean"},"isMetadataExtractionActive":{"type":"boolean"},"isVideoConversionActive":{"type":"boolean"},"isMachineLearningActive":{"type":"boolean"}},"required":["thumbnailGenerationQueueCount","metadataExtractionQueueCount","videoConversionQueueCount","machineLearningQueueCount","isThumbnailGenerationActive","isMetadataExtractionActive","isVideoConversionActive","isMachineLearningActive"]},"JobId":{"type":"string","enum":["thumbnail-generation","metadata-extraction","video-conversion","machine-learning"]},"JobStatusResponseDto":{"type":"object","properties":{"isActive":{"type":"boolean"},"queueCount":{"type":"object"}},"required":["isActive","queueCount"]},"JobCommand":{"type":"string","enum":["start","stop"]},"JobCommandDto":{"type":"object","properties":{"command":{"$ref":"#/components/schemas/JobCommand"}},"required":["command"]},"SystemConfigKey":{"type":"string","enum":["ffmpeg_crf","ffmpeg_preset","ffmpeg_target_video_codec","ffmpeg_target_audio_codec","ffmpeg_target_scaling"]},"SystemConfigResponseItem":{"type":"object","properties":{"name":{"type":"string"},"key":{"$ref":"#/components/schemas/SystemConfigKey"},"value":{"type":"string"},"defaultValue":{"type":"string"}},"required":["name","key","value","defaultValue"]},"SystemConfigResponseDto":{"type":"object","properties":{"config":{"type":"array","items":{"$ref":"#/components/schemas/SystemConfigResponseItem"}}},"required":["config"]},"UpdateSystemConfigDto":{"type":"object","properties":{}}}}} \ No newline at end of file +{"openapi":"3.0.0","paths":{"/user":{"get":{"operationId":"getAllUsers","parameters":[{"name":"isAll","required":true,"in":"query","schema":{"type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}}}}}},"tags":["User"],"security":[{"bearer":[]}]},"post":{"operationId":"createUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]},"put":{"operationId":"updateUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/info/{userId}":{"get":{"operationId":"getUserById","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"]}},"/user/me":{"get":{"operationId":"getMyUserInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/count":{"get":{"operationId":"getUserCount","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCountResponseDto"}}}}},"tags":["User"]}},"/user/{userId}":{"delete":{"operationId":"deleteUser","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/{userId}/restore":{"post":{"operationId":"restoreUser","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image":{"post":{"operationId":"createProfileImage","parameters":[],"requestBody":{"required":true,"description":"A new avatar for the user","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/CreateProfileImageDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileImageResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image/{userId}":{"get":{"operationId":"getProfileImage","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["User"]}},"/asset/upload":{"post":{"operationId":"uploadFile","parameters":[],"requestBody":{"required":true,"description":"Asset Upload Information","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/AssetFileUploadDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetFileUploadResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/download/{assetId}":{"get":{"operationId":"downloadFile","parameters":[{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}},{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/download-library":{"get":{"operationId":"downloadLibrary","parameters":[{"name":"skip","required":false,"in":"query","schema":{"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/file/{assetId}":{"get":{"operationId":"serveFile","parameters":[{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}},{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/thumbnail/{assetId}":{"get":{"operationId":"getAssetThumbnail","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}},{"name":"format","required":false,"in":"query","schema":{"$ref":"#/components/schemas/ThumbnailFormat"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-objects":{"get":{"operationId":"getCuratedObjects","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedObjectsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-locations":{"get":{"operationId":"getCuratedLocations","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedLocationsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search-terms":{"get":{"operationId":"getAssetSearchTerms","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search":{"post":{"operationId":"searchAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchAssetDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-time-bucket":{"post":{"operationId":"getAssetCountByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetCountByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByTimeBucketResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-user-id":{"get":{"operationId":"getAssetCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByUserIdResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset":{"get":{"operationId":"getAllAssets","summary":"","description":"Get all AssetEntity belong to the user","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DeleteAssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/time-bucket":{"post":{"operationId":"getAssetByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/{deviceId}":{"get":{"operationId":"getUserAssetsByDeviceId","summary":"","description":"Get all asset of a device that are in the database, ID only.","parameters":[{"name":"deviceId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/assetById/{assetId}":{"get":{"operationId":"getAssetById","summary":"","description":"Get a single asset's information","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"put":{"operationId":"updateAssetById","summary":"","description":"Update an asset","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/check":{"post":{"operationId":"checkDuplicateAsset","summary":"","description":"Check duplicated asset before uploading - for Web upload used","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/exist":{"post":{"operationId":"checkExistingAssets","summary":"","description":"Checks if multiple assets exist on the server and returns all existing - used by background backup","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckExistingAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckExistingAssetsResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/auth/login":{"post":{"operationId":"login","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginCredentialDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponseDto"}}}}},"tags":["Authentication"]}},"/auth/admin-sign-up":{"post":{"operationId":"adminSignUp","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignUpDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSignupResponseDto"}}}},"400":{"description":"The server already has an admin"}},"tags":["Authentication"]}},"/auth/validateToken":{"post":{"operationId":"validateAccessToken","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateAccessTokenResponseDto"}}}}},"tags":["Authentication"],"security":[{"bearer":[]}]}},"/auth/logout":{"post":{"operationId":"logout","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutResponseDto"}}}}},"tags":["Authentication"]}},"/oauth/config":{"post":{"operationId":"generateConfig","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthConfigDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthConfigResponseDto"}}}}},"tags":["OAuth"]}},"/oauth/callback":{"post":{"operationId":"callback","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthCallbackDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponseDto"}}}}},"tags":["OAuth"]}},"/device-info":{"post":{"operationId":"createDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDeviceInfoDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDeviceInfoDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]}},"/server-info":{"get":{"operationId":"getServerInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerInfoResponseDto"}}}}},"tags":["Server Info"]}},"/server-info/ping":{"get":{"operationId":"pingServer","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerPingResponse"}}}}},"tags":["Server Info"]}},"/server-info/version":{"get":{"operationId":"getServerVersion","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerVersionReponseDto"}}}}},"tags":["Server Info"]}},"/server-info/stats":{"get":{"operationId":"getStats","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerStatsResponseDto"}}}}},"tags":["Server Info"]}},"/album/count-by-user-id":{"get":{"operationId":"getAlbumCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumCountResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album":{"post":{"operationId":"createAlbum","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAlbumDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"get":{"operationId":"getAllAlbums","parameters":[{"name":"shared","required":false,"in":"query","schema":{"type":"boolean"}},{"name":"assetId","required":false,"in":"query","description":"Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/users":{"put":{"operationId":"addUsersToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddUsersDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/assets":{"put":{"operationId":"addAssetsToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"removeAssetFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}":{"get":{"operationId":"getAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlbumDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/user/{userId}":{"delete":{"operationId":"removeUserFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}},{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/download":{"get":{"operationId":"downloadArchive","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}},{"name":"skip","required":false,"in":"query","schema":{"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/jobs":{"get":{"operationId":"getAllJobsStatus","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllJobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}},"/jobs/{jobId}":{"get":{"operationId":"getJobStatus","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]},"put":{"operationId":"sendJobCommand","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobCommandDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"number"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}},"/system-config":{"get":{"operationId":"getConfig","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SystemConfigResponseDto"}}}}},"tags":["System Config"],"security":[{"bearer":[]}]},"put":{"operationId":"updateConfig","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSystemConfigDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SystemConfigResponseDto"}}}}},"tags":["System Config"],"security":[{"bearer":[]}]}}},"info":{"title":"Immich","description":"Immich API","version":"1.17.0","contact":{}},"tags":[],"servers":[{"url":"/api"}],"components":{"securitySchemes":{"bearer":{"scheme":"Bearer","bearerFormat":"JWT","type":"http","name":"JWT","description":"Enter JWT token","in":"header"}},"schemas":{"UserResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"},"profileImagePath":{"type":"string"},"shouldChangePassword":{"type":"boolean"},"isAdmin":{"type":"boolean"},"deletedAt":{"format":"date-time","type":"string","nullable":true}},"required":["id","email","firstName","lastName","createdAt","profileImagePath","shouldChangePassword","isAdmin","deletedAt"]},"CreateUserDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"John"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"UserCountResponseDto":{"type":"object","properties":{"userCount":{"type":"integer"}},"required":["userCount"]},"UpdateUserDto":{"type":"object","properties":{"id":{"type":"string"},"password":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"isAdmin":{"type":"boolean"},"shouldChangePassword":{"type":"boolean"},"profileImagePath":{"type":"string"}},"required":["id"]},"CreateProfileImageDto":{"type":"object","properties":{"file":{"type":"string","format":"binary"}},"required":["file"]},"CreateProfileImageResponseDto":{"type":"object","properties":{"userId":{"type":"string"},"profileImagePath":{"type":"string"}},"required":["userId","profileImagePath"]},"AssetFileUploadDto":{"type":"object","properties":{"assetData":{"type":"string","format":"binary"}},"required":["assetData"]},"AssetFileUploadResponseDto":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"ThumbnailFormat":{"type":"string","enum":["JPEG","WEBP"]},"CuratedObjectsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","object","resizePath","deviceAssetId","deviceId"]},"CuratedLocationsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"city":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","city","resizePath","deviceAssetId","deviceId"]},"SearchAssetDto":{"type":"object","properties":{"searchTerm":{"type":"string"}},"required":["searchTerm"]},"AssetTypeEnum":{"type":"string","enum":["IMAGE","VIDEO","AUDIO","OTHER"]},"ExifResponseDto":{"type":"object","properties":{"id":{"type":"integer","nullable":true,"default":null,"format":"int64"},"fileSizeInByte":{"type":"integer","nullable":true,"default":null,"format":"int64"},"make":{"type":"string","nullable":true,"default":null},"model":{"type":"string","nullable":true,"default":null},"imageName":{"type":"string","nullable":true,"default":null},"exifImageWidth":{"type":"number","nullable":true,"default":null},"exifImageHeight":{"type":"number","nullable":true,"default":null},"orientation":{"type":"string","nullable":true,"default":null},"dateTimeOriginal":{"format":"date-time","type":"string","nullable":true,"default":null},"modifyDate":{"format":"date-time","type":"string","nullable":true,"default":null},"lensModel":{"type":"string","nullable":true,"default":null},"fNumber":{"type":"number","nullable":true,"default":null},"focalLength":{"type":"number","nullable":true,"default":null},"iso":{"type":"number","nullable":true,"default":null},"exposureTime":{"type":"number","nullable":true,"default":null},"latitude":{"type":"number","nullable":true,"default":null},"longitude":{"type":"number","nullable":true,"default":null},"city":{"type":"string","nullable":true,"default":null},"state":{"type":"string","nullable":true,"default":null},"country":{"type":"string","nullable":true,"default":null}}},"SmartInfoResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tags":{"nullable":true,"type":"array","items":{"type":"string"}},"objects":{"nullable":true,"type":"array","items":{"type":"string"}}}},"AssetResponseDto":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/AssetTypeEnum"},"id":{"type":"string"},"deviceAssetId":{"type":"string"},"ownerId":{"type":"string"},"deviceId":{"type":"string"},"originalPath":{"type":"string"},"resizePath":{"type":"string","nullable":true},"createdAt":{"type":"string"},"modifiedAt":{"type":"string"},"isFavorite":{"type":"boolean"},"mimeType":{"type":"string","nullable":true},"duration":{"type":"string"},"webpPath":{"type":"string","nullable":true},"encodedVideoPath":{"type":"string","nullable":true},"exifInfo":{"$ref":"#/components/schemas/ExifResponseDto"},"smartInfo":{"$ref":"#/components/schemas/SmartInfoResponseDto"},"livePhotoVideoId":{"type":"string","nullable":true}},"required":["type","id","deviceAssetId","ownerId","deviceId","originalPath","resizePath","createdAt","modifiedAt","isFavorite","mimeType","duration","webpPath","encodedVideoPath","livePhotoVideoId"]},"TimeGroupEnum":{"type":"string","enum":["day","month"]},"GetAssetCountByTimeBucketDto":{"type":"object","properties":{"timeGroup":{"$ref":"#/components/schemas/TimeGroupEnum"}},"required":["timeGroup"]},"AssetCountByTimeBucket":{"type":"object","properties":{"timeBucket":{"type":"string"},"count":{"type":"integer"}},"required":["timeBucket","count"]},"AssetCountByTimeBucketResponseDto":{"type":"object","properties":{"totalCount":{"type":"integer"},"buckets":{"type":"array","items":{"$ref":"#/components/schemas/AssetCountByTimeBucket"}}},"required":["totalCount","buckets"]},"AssetCountByUserIdResponseDto":{"type":"object","properties":{"audio":{"type":"integer","default":0},"photos":{"type":"integer","default":0},"videos":{"type":"integer","default":0},"other":{"type":"integer","default":0},"total":{"type":"integer","default":0}},"required":["audio","photos","videos","other","total"]},"GetAssetByTimeBucketDto":{"type":"object","properties":{"timeBucket":{"title":"Array of date time buckets","example":["2015-06-01T00:00:00.000Z","2016-02-01T00:00:00.000Z","2016-03-01T00:00:00.000Z"],"type":"array","items":{"type":"string"}}},"required":["timeBucket"]},"UpdateAssetDto":{"type":"object","properties":{"isFavorite":{"type":"boolean"}},"required":["isFavorite"]},"DeleteAssetDto":{"type":"object","properties":{"ids":{"title":"Array of asset IDs to delete","example":["bf973405-3f2a-48d2-a687-2ed4167164be","dd41870b-5d00-46d2-924e-1d8489a0aa0f","fad77c3f-deef-4e7e-9608-14c1aa4e559a"],"type":"array","items":{"type":"string"}}},"required":["ids"]},"DeleteAssetStatus":{"type":"string","enum":["SUCCESS","FAILED"]},"DeleteAssetResponseDto":{"type":"object","properties":{"status":{"$ref":"#/components/schemas/DeleteAssetStatus"},"id":{"type":"string"}},"required":["status","id"]},"CheckDuplicateAssetDto":{"type":"object","properties":{"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["deviceAssetId","deviceId"]},"CheckDuplicateAssetResponseDto":{"type":"object","properties":{"isExist":{"type":"boolean"},"id":{"type":"string"}},"required":["isExist"]},"CheckExistingAssetsDto":{"type":"object","properties":{"deviceAssetIds":{"type":"array","items":{"type":"string"}},"deviceId":{"type":"string"}},"required":["deviceAssetIds","deviceId"]},"CheckExistingAssetsResponseDto":{"type":"object","properties":{"existingIds":{"type":"array","items":{"type":"string"}}},"required":["existingIds"]},"LoginCredentialDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"}},"required":["email","password"]},"LoginResponseDto":{"type":"object","properties":{"accessToken":{"type":"string","readOnly":true},"userId":{"type":"string","readOnly":true},"userEmail":{"type":"string","readOnly":true},"firstName":{"type":"string","readOnly":true},"lastName":{"type":"string","readOnly":true},"profileImagePath":{"type":"string","readOnly":true},"isAdmin":{"type":"boolean","readOnly":true},"shouldChangePassword":{"type":"boolean","readOnly":true}},"required":["accessToken","userId","userEmail","firstName","lastName","profileImagePath","isAdmin","shouldChangePassword"]},"SignUpDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"Admin"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"AdminSignupResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"}},"required":["id","email","firstName","lastName","createdAt"]},"ValidateAccessTokenResponseDto":{"type":"object","properties":{"authStatus":{"type":"boolean"}},"required":["authStatus"]},"LogoutResponseDto":{"type":"object","properties":{"successful":{"type":"boolean","readOnly":true},"redirectUri":{"type":"string","readOnly":true}},"required":["successful","redirectUri"]},"OAuthConfigDto":{"type":"object","properties":{"redirectUri":{"type":"string"}},"required":["redirectUri"]},"OAuthConfigResponseDto":{"type":"object","properties":{"enabled":{"type":"boolean","readOnly":true},"url":{"type":"string","readOnly":true},"buttonText":{"type":"string","readOnly":true}},"required":["enabled"]},"OAuthCallbackDto":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]},"DeviceTypeEnum":{"type":"string","enum":["IOS","ANDROID","WEB"]},"CreateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"DeviceInfoResponseDto":{"type":"object","properties":{"id":{"type":"integer"},"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"userId":{"type":"string"},"deviceId":{"type":"string"},"createdAt":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["id","deviceType","userId","deviceId","createdAt","isAutoBackup"]},"UpdateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"ServerInfoResponseDto":{"type":"object","properties":{"diskSizeRaw":{"type":"integer","format":"int64"},"diskUseRaw":{"type":"integer","format":"int64"},"diskAvailableRaw":{"type":"integer","format":"int64"},"diskUsagePercentage":{"type":"number","format":"float"},"diskSize":{"type":"string"},"diskUse":{"type":"string"},"diskAvailable":{"type":"string"}},"required":["diskSizeRaw","diskUseRaw","diskAvailableRaw","diskUsagePercentage","diskSize","diskUse","diskAvailable"]},"ServerPingResponse":{"type":"object","properties":{"res":{"type":"string","readOnly":true,"example":"pong"}},"required":["res"]},"ServerVersionReponseDto":{"type":"object","properties":{"major":{"type":"integer"},"minor":{"type":"integer"},"patch":{"type":"integer"},"build":{"type":"integer"}},"required":["major","minor","patch","build"]},"UsageByUserDto":{"type":"object","properties":{"userId":{"type":"string"},"objects":{"type":"integer"},"videos":{"type":"integer"},"photos":{"type":"integer"},"usageRaw":{"type":"integer","format":"int64"},"usage":{"type":"string"}},"required":["userId","objects","videos","photos","usageRaw","usage"]},"ServerStatsResponseDto":{"type":"object","properties":{"photos":{"type":"integer"},"videos":{"type":"integer"},"objects":{"type":"integer"},"usageRaw":{"type":"integer","format":"int64"},"usage":{"type":"string"},"usageByUser":{"title":"Array of usage for each user","example":[{"photos":1,"videos":1,"objects":1,"diskUsageRaw":1}],"type":"array","items":{"$ref":"#/components/schemas/UsageByUserDto"}}},"required":["photos","videos","objects","usageRaw","usage","usageByUser"]},"AlbumCountResponseDto":{"type":"object","properties":{"owned":{"type":"integer"},"shared":{"type":"integer"},"sharing":{"type":"integer"}},"required":["owned","shared","sharing"]},"CreateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"sharedWithUserIds":{"type":"array","items":{"type":"string"}},"assetIds":{"type":"array","items":{"type":"string"}}},"required":["albumName"]},"AlbumResponseDto":{"type":"object","properties":{"assetCount":{"type":"integer"},"id":{"type":"string"},"ownerId":{"type":"string"},"albumName":{"type":"string"},"createdAt":{"type":"string"},"albumThumbnailAssetId":{"type":"string","nullable":true},"shared":{"type":"boolean"},"sharedUsers":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}},"assets":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}},"required":["assetCount","id","ownerId","albumName","createdAt","albumThumbnailAssetId","shared","sharedUsers","assets"]},"AddUsersDto":{"type":"object","properties":{"sharedUserIds":{"type":"array","items":{"type":"string"}}},"required":["sharedUserIds"]},"AddAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"AddAssetsResponseDto":{"type":"object","properties":{"successfullyAdded":{"type":"integer"},"alreadyInAlbum":{"type":"array","items":{"type":"string"}},"album":{"$ref":"#/components/schemas/AlbumResponseDto"}},"required":["successfullyAdded","alreadyInAlbum"]},"RemoveAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"UpdateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"albumThumbnailAssetId":{"type":"string"}}},"JobCounts":{"type":"object","properties":{"active":{"type":"integer"},"completed":{"type":"integer"},"failed":{"type":"integer"},"delayed":{"type":"integer"},"waiting":{"type":"integer"}},"required":["active","completed","failed","delayed","waiting"]},"AllJobStatusResponseDto":{"type":"object","properties":{"thumbnailGenerationQueueCount":{"$ref":"#/components/schemas/JobCounts"},"metadataExtractionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"videoConversionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"machineLearningQueueCount":{"$ref":"#/components/schemas/JobCounts"},"isThumbnailGenerationActive":{"type":"boolean"},"isMetadataExtractionActive":{"type":"boolean"},"isVideoConversionActive":{"type":"boolean"},"isMachineLearningActive":{"type":"boolean"}},"required":["thumbnailGenerationQueueCount","metadataExtractionQueueCount","videoConversionQueueCount","machineLearningQueueCount","isThumbnailGenerationActive","isMetadataExtractionActive","isVideoConversionActive","isMachineLearningActive"]},"JobId":{"type":"string","enum":["thumbnail-generation","metadata-extraction","video-conversion","machine-learning"]},"JobStatusResponseDto":{"type":"object","properties":{"isActive":{"type":"boolean"},"queueCount":{"type":"object"}},"required":["isActive","queueCount"]},"JobCommand":{"type":"string","enum":["start","stop"]},"JobCommandDto":{"type":"object","properties":{"command":{"$ref":"#/components/schemas/JobCommand"}},"required":["command"]},"SystemConfigKey":{"type":"string","enum":["ffmpeg_crf","ffmpeg_preset","ffmpeg_target_video_codec","ffmpeg_target_audio_codec","ffmpeg_target_scaling"]},"SystemConfigResponseItem":{"type":"object","properties":{"name":{"type":"string"},"key":{"$ref":"#/components/schemas/SystemConfigKey"},"value":{"type":"string"},"defaultValue":{"type":"string"}},"required":["name","key","value","defaultValue"]},"SystemConfigResponseDto":{"type":"object","properties":{"config":{"type":"array","items":{"$ref":"#/components/schemas/SystemConfigResponseItem"}}},"required":["config"]},"UpdateSystemConfigDto":{"type":"object","properties":{}}}}} \ No newline at end of file diff --git a/server/libs/database/src/entities/asset.entity.ts b/server/libs/database/src/entities/asset.entity.ts index 97e218b256..02c265e677 100644 --- a/server/libs/database/src/entities/asset.entity.ts +++ b/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; diff --git a/server/libs/database/src/migrations/1668383120461-AddLivePhotosRelatedColumnToAssetTable.ts b/server/libs/database/src/migrations/1668383120461-AddLivePhotosRelatedColumnToAssetTable.ts new file mode 100644 index 0000000000..62ce314f30 --- /dev/null +++ b/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 { + 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 { + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "livePhotoVideoId"`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isVisible"`); + } + +} diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 8906416a13..98539841fa 100644 --- a/web/src/api/open-api/api.ts +++ b/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; } /** * diff --git a/web/src/app.d.ts b/web/src/app.d.ts index 947bb146a6..acacd3506f 100644 --- a/web/src/app.d.ts +++ b/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 { oncopyImage?: () => void; } diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index c4c49e965c..93a929fbac 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/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 @@ dispatch('goBack')} />
+ {#if showMotionPlayButton} + {#if isMotionPhotoPlaying} + dispatch('stopMotionPhoto')} + /> + {:else} + dispatch('playMotionPhoto')} + /> + {/if} + {/if} {#if showCopyButton} { const copyEvent = new CustomEvent('copyImage'); window.dispatchEvent(copyEvent); }} /> {/if} - dispatch('download')} /> - dispatch('showDetail')} /> + dispatch('download')} + title="Download" + /> + dispatch('showDetail')} + title="Info" + /> {#if isOwner} {/if} - dispatch('delete')} /> - showOptionsMenu(event)} /> + dispatch('delete')} title="Delete" /> + showOptionsMenu(event)} + title="More" + />
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index df251283e5..cd46776e9b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/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 @@
openAlbumPicker(false)} on:addToSharedAlbum={() => openAlbumPicker(true)} + on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)} + on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)} />
@@ -257,7 +271,15 @@
{#key asset.id} {#if asset.type === AssetTypeEnum.Image} - + {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} + (shouldPlayMotionPhoto = false)} + /> + {:else} + + {/if} {:else} {/if} diff --git a/web/src/lib/components/asset-viewer/video-viewer.svelte b/web/src/lib/components/asset-viewer/video-viewer.svelte index 43b1dcdae9..1c365aa8e2 100644 --- a/web/src/lib/components/asset-viewer/video-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-viewer.svelte @@ -1,7 +1,7 @@