1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-28 09:33:27 +02:00

feat: support iOS LivePhoto backup (#950)

This commit is contained in:
Alex 2022-11-18 23:12:54 -06:00 committed by GitHub
parent 83e2cabbcc
commit 8bc64be77b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 545 additions and 146 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
photo_manager: ^2.2.1 photo_manager: ^2.5.0
flutter_hooks: ^0.18.0 flutter_hooks: ^0.18.0
hooks_riverpod: ^2.0.0-dev.0 hooks_riverpod: ^2.0.0-dev.0
hive: ^2.2.1 hive: ^2.2.1
@ -47,7 +47,6 @@ dependencies:
# easy to remove packages: # 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? 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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

View File

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

View File

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

View File

@ -10,16 +10,14 @@ import {
Response, Response,
Headers, Headers,
Delete, Delete,
Logger,
HttpCode, HttpCode,
BadRequestException,
UploadedFile,
Header, Header,
Put, Put,
UploadedFiles,
} from '@nestjs/common'; } from '@nestjs/common';
import { Authenticated } from '../../decorators/authenticated.decorator'; import { Authenticated } from '../../decorators/authenticated.decorator';
import { AssetService } from './asset.service'; 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 { assetUploadOption } from '../../config/asset-upload.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { ServeFileDto } from './dto/serve-file.dto'; 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 { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-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 { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; 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 { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-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 { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto'; import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
@ -64,17 +55,18 @@ import {
@ApiTags('Asset') @ApiTags('Asset')
@Controller('asset') @Controller('asset')
export class AssetController { export class AssetController {
constructor( constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {}
private wsCommunicateionGateway: CommunicationGateway,
private assetService: AssetService,
private backgroundTaskService: BackgroundTaskService,
@InjectQueue(QueueNameEnum.ASSET_UPLOADED)
private assetUploadedQueue: Queue<IAssetUploadedJob>,
) {}
@Post('upload') @Post('upload')
@UseInterceptors(FileInterceptor('assetData', assetUploadOption)) @UseInterceptors(
FileFieldsInterceptor(
[
{ name: 'assetData', maxCount: 1 },
{ name: 'livePhotoData', maxCount: 1 },
],
assetUploadOption,
),
)
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiBody({ @ApiBody({
description: 'Asset Upload Information', description: 'Asset Upload Information',
@ -82,53 +74,14 @@ export class AssetController {
}) })
async uploadFile( async uploadFile(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@UploadedFile() file: Express.Multer.File, @UploadedFiles() files: { assetData: Express.Multer.File[]; livePhotoData?: Express.Multer.File[] },
@Body(ValidationPipe) assetInfo: CreateAssetDto, @Body(ValidationPipe) createAssetDto: CreateAssetDto,
@Response({ passthrough: true }) res: Res, @Response({ passthrough: true }) res: Res,
): Promise<AssetFileUploadResponseDto> { ): Promise<AssetFileUploadResponseDto> {
const checksum = await this.assetService.calculateChecksum(file.path); const originalAssetData = files.assetData[0];
const livePhotoAssetData = files.livePhotoData?.[0];
try { return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData);
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}`);
}
} }
@Get('/download/:assetId') @Get('/download/:assetId')
@ -270,6 +223,14 @@ export class AssetController {
continue; continue;
} }
deleteAssetList.push(assets); 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); const result = await this.assetService.deleteAssetById(authUser, assetIds);

View File

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

View File

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

View File

@ -10,8 +10,8 @@ import {
StreamableFile, StreamableFile,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { createHash } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { Repository } from 'typeorm'; import { QueryFailedError, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { constants, createReadStream, ReadStream, stat } from 'fs'; 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 { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto'; import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { UpdateAssetDto } from './dto/update-asset.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 { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto'; import { DownloadDto } from './dto/download-library.dto';
@ -55,15 +66,116 @@ export class AssetService {
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
private backgroundTaskService: BackgroundTaskService,
@InjectQueue(QueueNameEnum.ASSET_UPLOADED)
private assetUploadedQueue: Queue<IAssetUploadedJob>,
@InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
private videoConversionQueue: Queue<IVideoTranscodeJob>,
private downloadService: DownloadService, 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( public async createUserAsset(
authUser: AuthUserDto, authUser: AuthUserDto,
createAssetDto: CreateAssetDto, createAssetDto: CreateAssetDto,
originalPath: string, originalPath: string,
mimeType: string, mimeType: string,
checksum: Buffer, checksum: Buffer,
isVisible: boolean,
livePhotoAssetEntity?: AssetEntity,
): Promise<AssetEntity> { ): Promise<AssetEntity> {
// Check valid time. // Check valid time.
const createdAt = createAssetDto.createdAt; const createdAt = createAssetDto.createdAt;
@ -82,7 +194,9 @@ export class AssetService {
authUser.id, authUser.id,
originalPath, originalPath,
mimeType, mimeType,
isVisible,
checksum, checksum,
livePhotoAssetEntity,
); );
return assetEntity; return assetEntity;

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

1
web/src/app.d.ts vendored
View File

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

View File

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

View File

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

View File

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

View File

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