diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 5f93e37958de4..875b7663530d2 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ = 2.7.5) - Toast (4.0.0) + - video_player_avfoundation (0.0.1): + - Flutter + - wakelock (0.0.1): + - Flutter DEPENDENCIES: - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) @@ -25,6 +29,8 @@ DEPENDENCIES: - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`) + - wakelock (from `.symlinks/plugins/wakelock/ios`) SPEC REPOS: trunk: @@ -44,6 +50,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/photo_manager/ios" sqflite: :path: ".symlinks/plugins/sqflite/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/ios" + wakelock: + :path: ".symlinks/plugins/wakelock/ios" SPEC CHECKSUMS: device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed @@ -54,6 +64,8 @@ SPEC CHECKSUMS: photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463 sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 + video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff + wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index ded59d2292bf0..76dc408577ee1 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -41,9 +41,22 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance - + + NSPhotoLibraryUsageDescription - App need your agree, can visit your album + We need to manage backup your photos album + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + \ No newline at end of file diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 47222e5791208..c05e9384e611e 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -12,6 +13,12 @@ void main() async { // Hive.registerAdapter(ImmichBackUpAssetAdapter()); // Hive.deleteBoxFromDisk(hiveImmichBox); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarIconBrightness: Brightness.light, + ), + ); + runApp(const ProviderScope(child: ImmichApp())); } @@ -69,6 +76,7 @@ class _ImmichAppState extends ConsumerState with WidgetsBindingObserv title: 'Immich', debugShowCheckedModeBanner: false, theme: ThemeData( + brightness: Brightness.light, primarySwatch: Colors.indigo, textTheme: GoogleFonts.workSansTextTheme( Theme.of(context).textTheme.apply(fontSizeFactor: 1.0), @@ -79,6 +87,7 @@ class _ImmichAppState extends ConsumerState with WidgetsBindingObserv foregroundColor: Colors.indigo, elevation: 1, centerTitle: true, + systemOverlayStyle: SystemUiOverlayStyle.dark, ), ), routeInformationParser: _immichRouter.defaultRouteParser(), diff --git a/mobile/lib/modules/home/ui/image_grid.dart b/mobile/lib/modules/home/ui/image_grid.dart index e5e0411e6ee1a..32233a794403f 100644 --- a/mobile/lib/modules/home/ui/image_grid.dart +++ b/mobile/lib/modules/home/ui/image_grid.dart @@ -1,23 +1,50 @@ import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart'; -class ImageGrid extends StatelessWidget { +class ImageGrid extends ConsumerWidget { final List assetGroup; const ImageGrid({Key? key, required this.assetGroup}) : super(key: key); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 5.0, mainAxisSpacing: 5), delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { + var assetType = assetGroup[index].type; + return GestureDetector( - onTap: () {}, - child: ThumbnailImage(asset: assetGroup[index]), - ); + onTap: () {}, + child: Stack( + children: [ + ThumbnailImage(asset: assetGroup[index]), + assetType == 'IMAGE' + ? Container() + : Positioned( + top: 5, + right: 5, + child: Row( + children: [ + Text( + assetGroup[index].duration.toString().substring(0, 7), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + const Icon( + Icons.play_circle_outline_rounded, + color: Colors.white, + ), + ], + ), + ) + ], + )); }, childCount: assetGroup.length, ), diff --git a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart index ad9bda06bdbdf..055403034b65d 100644 --- a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart +++ b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart @@ -1,6 +1,5 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; @@ -100,14 +99,6 @@ class ImmichSliverAppBar extends ConsumerWidget { ], ), ], - systemOverlayStyle: const SystemUiOverlayStyle( - // Status bar color - statusBarColor: Colors.indigo, - - // Status bar brightness (optional) - statusBarIconBrightness: Brightness.light, // For Android (dark icons) - statusBarBrightness: Brightness.dark, - ), ), ); } diff --git a/mobile/lib/modules/home/ui/thumbnail_image.dart b/mobile/lib/modules/home/ui/thumbnail_image.dart index ca8ae1d6a7cbe..64857cc81e5e5 100644 --- a/mobile/lib/modules/home/ui/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/thumbnail_image.dart @@ -1,39 +1,53 @@ import 'package:auto_route/auto_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/routing/router.dart'; -class ThumbnailImage extends StatelessWidget { +class ThumbnailImage extends HookWidget { final ImmichAsset asset; const ThumbnailImage({Key? key, required this.asset}) : super(key: key); @override Widget build(BuildContext context) { + final cacheKey = useState(1); + var box = Hive.box(userInfoBox); var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true'; return GestureDetector( onTap: () { - AutoRouter.of(context).push( - ImageViewerRoute( - imageUrl: - '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false', - heroTag: asset.id, - thumbnailUrl: thumbnailRequestUrl, - ), - ); + if (asset.type == 'IMAGE') { + AutoRouter.of(context).push( + ImageViewerRoute( + imageUrl: + '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false', + heroTag: asset.id, + thumbnailUrl: thumbnailRequestUrl, + ), + ); + } else { + debugPrint("Navigate to video player"); + + AutoRouter.of(context).push( + VideoViewerRoute( + videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}', + ), + ); + } }, onLongPress: () {}, child: Hero( tag: asset.id, child: CachedNetworkImage( + cacheKey: "${asset.id}-${cacheKey.value}", width: 300, height: 300, - memCacheHeight: 250, + memCacheHeight: asset.type == 'IMAGE' ? 250 : 400, fit: BoxFit.cover, imageUrl: thumbnailRequestUrl, httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, @@ -44,6 +58,7 @@ class ThumbnailImage extends StatelessWidget { ), errorWidget: (context, url, error) { debugPrint("Error Loading Thumbnail Widget $error"); + cacheKey.value += 1; return const Icon(Icons.error); }, ), diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 4ef968870e200..7978d4c73217f 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; +import 'package:immich_mobile/modules/home/ui/image_grid.dart'; import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer.dart'; import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; -import 'package:immich_mobile/modules/home/ui/image_grid.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:intl/intl.dart'; @@ -16,9 +16,9 @@ class HomePage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final ValueNotifier _showBackToTopBtn = useState(false); ScrollController _scrollController = useScrollController(); + List assetGroup = ref.watch(assetProvider); List imageGridGroup = []; - final scrollLabelText = useState(""); _scrollControllerCallback() { var endOfPage = _scrollController.position.maxScrollExtent; @@ -40,39 +40,10 @@ class HomePage extends HookConsumerWidget { _scrollController.addListener(_scrollControllerCallback); return () { - debugPrint("Remove scroll listener"); _scrollController.removeListener(_scrollControllerCallback); }; }, []); - SliverToBoxAdapter _buildDateGroupTitle(String dateTitle) { - var currentYear = DateTime.now().year; - var groupYear = DateTime.parse(dateTitle).year; - var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy'; - var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(dateTitle)); - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 24.0, bottom: 24.0, left: 3.0), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(left: 8.0, bottom: 5.0, top: 5.0), - child: Text( - dateText, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - ), - ], - ), - ), - ); - } - Widget _buildBody() { if (assetGroup.isNotEmpty) { String lastGroupDate = assetGroup[0].date; @@ -86,44 +57,27 @@ class HomePage extends HookConsumerWidget { // Add Monthly Title Group if started at the beginning of the month if ((currentMonth! - previousMonth!) != 0) { - var monthTitleText = DateFormat('MMMM, y').format(DateTime.parse(dateTitle)); - imageGridGroup.add( - MonthlyTitleText(monthTitleText: monthTitleText), + MonthlyTitleText(isoDate: dateTitle), ); } // Add Daily Title Group imageGridGroup.add( - DailyTitleText(dateTitle: dateTitle), + DailyTitleText(isoDate: dateTitle), ); // Add Image Group imageGridGroup.add( ImageGrid(assetGroup: assetGroup), ); - + // lastGroupDate = dateTitle; } } return SafeArea( child: DraggableScrollbar.semicircle( - // labelTextBuilder: (offset) { - // final int currentItem = _scrollController.hasClients - // ? (_scrollController.offset / _scrollController.position.maxScrollExtent * imageGridGroup.length) - // .floor() - // : 0; - - // if (imageGridGroup[currentItem] is MonthlyTitleText) { - // MonthlyTitleText item = imageGridGroup[currentItem] as MonthlyTitleText; - - // scrollLabelText.value = item.monthTitleText; - // } - - // return Text(scrollLabelText.value); - // }, - // labelConstraints: const BoxConstraints.tightFor(width: 200.0, height: 30.0), backgroundColor: Theme.of(context).primaryColor, controller: _scrollController, heightScrollThumb: 48.0, @@ -148,13 +102,15 @@ class HomePage extends HookConsumerWidget { class MonthlyTitleText extends StatelessWidget { const MonthlyTitleText({ Key? key, - required this.monthTitleText, + required this.isoDate, }) : super(key: key); - final String monthTitleText; + final String isoDate; @override Widget build(BuildContext context) { + var monthTitleText = DateFormat('MMMM, y').format(DateTime.parse(isoDate)); + return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 10.0, top: 32), @@ -174,17 +130,17 @@ class MonthlyTitleText extends StatelessWidget { class DailyTitleText extends StatelessWidget { const DailyTitleText({ Key? key, - required this.dateTitle, + required this.isoDate, }) : super(key: key); - final String dateTitle; + final String isoDate; @override Widget build(BuildContext context) { var currentYear = DateTime.now().year; - var groupYear = DateTime.parse(dateTitle).year; + var groupYear = DateTime.parse(isoDate).year; var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy'; - var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(dateTitle)); + var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate)); return SliverToBoxAdapter( child: Padding( diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index 47c8f66e6140a..002e3c072cf1d 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -13,7 +13,7 @@ class LoginForm extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final usernameController = useTextEditingController(text: 'testuser@email.com'); final passwordController = useTextEditingController(text: 'password'); - final serverEndpointController = useTextEditingController(text: 'http://192.168.1.216:3000'); + final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283'); return Center( child: ConstrainedBox( diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 797117d9140a4..367352a27ab09 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/modules/home/views/home_page.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/shared/views/backup_controller_page.dart'; import 'package:immich_mobile/shared/views/image_viewer_page.dart'; +import 'package:immich_mobile/shared/views/video_viewer_page.dart'; part 'router.gr.dart'; @@ -15,6 +16,7 @@ part 'router.gr.dart'; AutoRoute(page: HomePage, guards: [AuthGuard]), AutoRoute(page: BackupControllerPage, guards: [AuthGuard]), AutoRoute(page: ImageViewerPage, guards: [AuthGuard]), + AutoRoute(page: VideoViewerPage, guards: [AuthGuard]), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index cc76a25b749f7..ce8ceb5d61e85 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -42,6 +42,12 @@ class _$AppRouter extends RootStackRouter { imageUrl: args.imageUrl, heroTag: args.heroTag, thumbnailUrl: args.thumbnailUrl)); + }, + VideoViewerRoute.name: (routeData) { + final args = routeData.argsAs(); + return MaterialPageX( + routeData: routeData, + child: VideoViewerPage(key: args.key, videoUrl: args.videoUrl)); } }; @@ -52,7 +58,9 @@ class _$AppRouter extends RootStackRouter { RouteConfig(BackupControllerRoute.name, path: '/backup-controller-page', guards: [authGuard]), RouteConfig(ImageViewerRoute.name, - path: '/image-viewer-page', guards: [authGuard]) + path: '/image-viewer-page', guards: [authGuard]), + RouteConfig(VideoViewerRoute.name, + path: '/video-viewer-page', guards: [authGuard]) ]; } @@ -120,3 +128,27 @@ class ImageViewerRouteArgs { return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl}'; } } + +/// generated route for +/// [VideoViewerPage] +class VideoViewerRoute extends PageRouteInfo { + VideoViewerRoute({Key? key, required String videoUrl}) + : super(VideoViewerRoute.name, + path: '/video-viewer-page', + args: VideoViewerRouteArgs(key: key, videoUrl: videoUrl)); + + static const String name = 'VideoViewerRoute'; +} + +class VideoViewerRouteArgs { + const VideoViewerRouteArgs({this.key, required this.videoUrl}); + + final Key? key; + + final String videoUrl; + + @override + String toString() { + return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl}'; + } +} diff --git a/mobile/lib/shared/models/immich_asset.model.dart b/mobile/lib/shared/models/immich_asset.model.dart index 85c0f89a72ddb..0073dfccc76cc 100644 --- a/mobile/lib/shared/models/immich_asset.model.dart +++ b/mobile/lib/shared/models/immich_asset.model.dart @@ -5,26 +5,22 @@ class ImmichAsset { final String deviceAssetId; final String userId; final String deviceId; - final String assetType; - final String localPath; - final String remotePath; + final String type; final String createdAt; final String modifiedAt; final bool isFavorite; - final String? description; + final String? duration; ImmichAsset({ required this.id, required this.deviceAssetId, required this.userId, required this.deviceId, - required this.assetType, - required this.localPath, - required this.remotePath, + required this.type, required this.createdAt, required this.modifiedAt, required this.isFavorite, - this.description, + this.duration, }); ImmichAsset copyWith({ @@ -32,26 +28,22 @@ class ImmichAsset { String? deviceAssetId, String? userId, String? deviceId, - String? assetType, - String? localPath, - String? remotePath, + String? type, String? createdAt, String? modifiedAt, bool? isFavorite, - String? description, + String? duration, }) { return ImmichAsset( id: id ?? this.id, deviceAssetId: deviceAssetId ?? this.deviceAssetId, userId: userId ?? this.userId, deviceId: deviceId ?? this.deviceId, - assetType: assetType ?? this.assetType, - localPath: localPath ?? this.localPath, - remotePath: remotePath ?? this.remotePath, + type: type ?? this.type, createdAt: createdAt ?? this.createdAt, modifiedAt: modifiedAt ?? this.modifiedAt, isFavorite: isFavorite ?? this.isFavorite, - description: description ?? this.description, + duration: duration ?? this.duration, ); } @@ -61,13 +53,11 @@ class ImmichAsset { 'deviceAssetId': deviceAssetId, 'userId': userId, 'deviceId': deviceId, - 'assetType': assetType, - 'localPath': localPath, - 'remotePath': remotePath, + 'type': type, 'createdAt': createdAt, 'modifiedAt': modifiedAt, 'isFavorite': isFavorite, - 'description': description, + 'duration': duration, }; } @@ -77,13 +67,11 @@ class ImmichAsset { deviceAssetId: map['deviceAssetId'] ?? '', userId: map['userId'] ?? '', deviceId: map['deviceId'] ?? '', - assetType: map['assetType'] ?? '', - localPath: map['localPath'] ?? '', - remotePath: map['remotePath'] ?? '', + type: map['type'] ?? '', createdAt: map['createdAt'] ?? '', modifiedAt: map['modifiedAt'] ?? '', isFavorite: map['isFavorite'] ?? false, - description: map['description'], + duration: map['duration'], ); } @@ -93,7 +81,7 @@ class ImmichAsset { @override String toString() { - return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, assetType: $assetType, localPath: $localPath, remotePath: $remotePath, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, description: $description)'; + return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, duration: $duration)'; } @override @@ -105,13 +93,11 @@ class ImmichAsset { other.deviceAssetId == deviceAssetId && other.userId == userId && other.deviceId == deviceId && - other.assetType == assetType && - other.localPath == localPath && - other.remotePath == remotePath && + other.type == type && other.createdAt == createdAt && other.modifiedAt == modifiedAt && other.isFavorite == isFavorite && - other.description == description; + other.duration == duration; } @override @@ -120,12 +106,10 @@ class ImmichAsset { deviceAssetId.hashCode ^ userId.hashCode ^ deviceId.hashCode ^ - assetType.hashCode ^ - localPath.hashCode ^ - remotePath.hashCode ^ + type.hashCode ^ createdAt.hashCode ^ modifiedAt.hashCode ^ isFavorite.hashCode ^ - description.hashCode; + duration.hashCode; } } diff --git a/mobile/lib/shared/providers/backup.provider.dart b/mobile/lib/shared/providers/backup.provider.dart index e17466e053815..61477e79b5ca1 100644 --- a/mobile/lib/shared/providers/backup.provider.dart +++ b/mobile/lib/shared/providers/backup.provider.dart @@ -35,7 +35,7 @@ class BackupNotifier extends StateNotifier { void getBackupInfo() async { _updateServerInfo(); - List list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.image); + List list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.common); if (list.isEmpty) { debugPrint("No Asset On Device"); @@ -59,7 +59,7 @@ class BackupNotifier extends StateNotifier { // await PhotoManager.presentLimited(); // Gather assets info List list = - await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.image); + await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common); if (list.isEmpty) { debugPrint("No Asset On Device - Abort Backup Process"); diff --git a/mobile/lib/shared/services/backup.service.dart b/mobile/lib/shared/services/backup.service.dart index 0058dfadef409..08195ec9e25a3 100644 --- a/mobile/lib/shared/services/backup.service.dart +++ b/mobile/lib/shared/services/backup.service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -12,7 +13,6 @@ import 'package:immich_mobile/utils/files_helper.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:http_parser/http_parser.dart'; import 'package:path/path.dart' as p; -import 'package:exif/exif.dart'; class BackupService { final NetworkService _networkService = NetworkService(); @@ -36,7 +36,11 @@ class BackupService { for (var entity in assetList) { try { - file = await entity.file.timeout(const Duration(seconds: 5)); + if (entity.type == AssetType.video) { + file = await entity.file; + } else { + file = await entity.file.timeout(const Duration(seconds: 5)); + } if (file != null) { // reading exif @@ -50,8 +54,8 @@ class BackupService { String originalFileName = await entity.titleAsync; String fileNameWithoutPath = originalFileName.toString().split(".")[0]; var fileExtension = p.extension(file.path); - LatLng coordinate = await entity.latlngAsync(); var mimeType = FileHelper.getMimeType(file.path); + var formData = FormData.fromMap({ 'deviceAssetId': entity.id, 'deviceId': deviceId, @@ -60,8 +64,7 @@ class BackupService { 'modifiedAt': entity.modifiedDateTime.toIso8601String(), 'isFavorite': entity.isFavorite, 'fileExtension': fileExtension, - 'lat': coordinate.latitude, - 'lon': coordinate.longitude, + 'duration': entity.videoDuration, 'files': [ await MultipartFile.fromFile( file.path, diff --git a/mobile/lib/shared/views/video_viewer_page.dart b/mobile/lib/shared/views/video_viewer_page.dart new file mode 100644 index 0000000000000..9f42a52204346 --- /dev/null +++ b/mobile/lib/shared/views/video_viewer_page.dart @@ -0,0 +1,105 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:chewie/chewie.dart'; +import 'package:video_player/video_player.dart'; + +class VideoViewerPage extends StatelessWidget { + final String videoUrl; + + const VideoViewerPage({Key? key, required this.videoUrl}) : super(key: key); + + @override + Widget build(BuildContext context) { + String jwtToken = Hive.box(userInfoBox).get(accessTokenKey); + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + leading: IconButton( + onPressed: () { + AutoRouter.of(context).pop(); + }, + icon: const Icon(Icons.arrow_back_ios)), + ), + body: Center( + child: VideoThumbnailPlayer( + url: videoUrl, + jwtToken: jwtToken, + ), + ), + ); + } +} + +class VideoThumbnailPlayer extends StatefulWidget { + final String url; + final String? jwtToken; + + const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken}) : super(key: key); + + @override + State createState() => _VideoThumbnailPlayerState(); +} + +class _VideoThumbnailPlayerState extends State { + late VideoPlayerController videoPlayerController; + ChewieController? chewieController; + + @override + void initState() { + super.initState(); + initializePlayer(); + } + + Future initializePlayer() async { + try { + videoPlayerController = + VideoPlayerController.network(widget.url, httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"}); + + await videoPlayerController.initialize(); + _createChewieController(); + setState(() {}); + } catch (e) { + debugPrint("ERROR initialize video player"); + print(e); + } + } + + _createChewieController() { + chewieController = ChewieController( + showOptions: true, + showControlsOnInitialize: false, + videoPlayerController: videoPlayerController, + autoPlay: true, + autoInitialize: false, + ); + } + + @override + void dispose() { + super.dispose(); + videoPlayerController.pause(); + videoPlayerController.dispose(); + chewieController?.dispose(); + } + + @override + Widget build(BuildContext context) { + return chewieController != null && chewieController!.videoPlayerController.value.isInitialized + ? SizedBox( + child: Chewie( + controller: chewieController!, + ), + ) + : const SizedBox( + width: 75, + height: 75, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ); + } +} diff --git a/mobile/lib/utils/files_helper.dart b/mobile/lib/utils/files_helper.dart index bede193907321..787a67a716ba8 100644 --- a/mobile/lib/utils/files_helper.dart +++ b/mobile/lib/utils/files_helper.dart @@ -1,10 +1,12 @@ +import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; class FileHelper { static getMimeType(String filePath) { + debugPrint(filePath); var fileExtension = p.extension(filePath).split(".")[1]; - switch (fileExtension) { + switch (fileExtension.toLowerCase()) { case 'gif': return {"type": "image", "subType": "gif"}; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5b51a0e06af33..dad4be52300ec 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -155,6 +155,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" + chewie: + dependency: "direct main" + description: + name: chewie + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.2" cli_util: dependency: transitive description: @@ -527,6 +534,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" octo_image: dependency: transitive description: @@ -653,6 +667,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.2.4" + provider: + dependency: transitive + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.0" pub_semver: dependency: transitive description: @@ -847,6 +868,41 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + video_player: + dependency: "direct main" + description: + name: video_player + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.18" + video_player_android: + dependency: transitive + description: + name: video_player_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.17" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.18" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.1" + video_player_web: + dependency: transitive + description: + name: video_player_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" visibility_detector: dependency: "direct main" description: @@ -854,6 +910,41 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.2" + wakelock: + dependency: transitive + description: + name: wakelock + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.6" + wakelock_macos: + dependency: transitive + description: + name: wakelock_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.0" + wakelock_platform_interface: + dependency: transitive + description: + name: wakelock_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + wakelock_web: + dependency: transitive + description: + name: wakelock_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.0" + wakelock_windows: + dependency: transitive + description: + name: wakelock_windows + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" watcher: dependency: transitive description: @@ -898,4 +989,4 @@ packages: version: "3.1.0" sdks: dart: ">=2.15.1 <3.0.0" - flutter: ">=2.5.0" + flutter: ">=2.8.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index abee88f5747e8..abf1410ac6817 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: visibility_detector: ^0.2.2 flutter_launcher_icons: "^0.9.2" fluttertoast: ^8.0.8 + video_player: ^2.2.18 + chewie: ^1.2.2 dev_dependencies: flutter_test: diff --git a/server/.dockerignore b/server/.dockerignore index 2bf223c18ac19..834ab88b614c1 100644 --- a/server/.dockerignore +++ b/server/.dockerignore @@ -1,3 +1,4 @@ node_modules/ upload/ -dist/ \ No newline at end of file +dist/ + diff --git a/server/docker-compose.yml b/server/docker-compose.yml index fae9111f3cb4a..93600000c8ccb 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -12,6 +12,8 @@ services: command: yarn start:dev ports: - "3000:3000" + # expose: + # - 3000 volumes: - .:/usr/src/app - userdata:/usr/src/app/upload @@ -47,6 +49,21 @@ services: networks: - immich_network + nginx: + container_name: proxy_nginx + image: nginx:latest + volumes: + - ./settings/nginx-conf:/etc/nginx/conf.d + ports: + - 2283:80 + - 2284:443 + logging: + driver: none + networks: + - immich_network + depends_on: + - server + networks: immich_network: volumes: diff --git a/server/package.json b/server/package.json index 80214501fd590..b758704676d6f 100644 --- a/server/package.json +++ b/server/package.json @@ -61,16 +61,17 @@ "@types/bcrypt": "^5.0.0", "@types/bull": "^3.15.7", "@types/express": "^4.17.13", + "@types/fluent-ffmpeg": "^2.1.20", "@types/imagemin": "^8.0.0", "@types/jest": "27.0.2", "@types/lodash": "^4.14.178", "@types/multer": "^1.4.7", "@types/node": "^16.0.0", "@types/passport-jwt": "^3.0.6", + "@types/sharp": "^0.29.5", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", - "@types/sharp": "^0.29.5", "eslint": "^8.0.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", diff --git a/server/settings/nginx-conf/nginx.conf b/server/settings/nginx-conf/nginx.conf new file mode 100644 index 0000000000000..225164dd98bb3 --- /dev/null +++ b/server/settings/nginx-conf/nginx.conf @@ -0,0 +1,20 @@ +server { + client_max_body_size 50000M; + + listen 80; + + location / { + proxy_buffering off; + proxy_buffer_size 16k; + proxy_busy_buffers_size 24k; + proxy_buffers 64 4k; + proxy_force_ranges on; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_pass http://immich_server:3000; + } +} \ No newline at end of file diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts index 9acb48b2875b8..acc1e5a052385 100644 --- a/server/src/api-v1/asset/asset.controller.ts +++ b/server/src/api-v1/asset/asset.controller.ts @@ -9,10 +9,10 @@ import { Param, ValidationPipe, StreamableFile, - Response, Query, - Logger, - UploadedFile, + Response, + Headers, + BadRequestException, } from '@nestjs/common'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { AssetService } from './asset.service'; @@ -22,16 +22,22 @@ import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { CreateAssetDto } from './dto/create-asset.dto'; import { createReadStream } from 'fs'; import { ServeFileDto } from './dto/serve-file.dto'; -import { ImageOptimizeService } from '../../modules/image-optimize/image-optimize.service'; +import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service'; import { AssetType } from './entities/asset.entity'; import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto'; +import { Response as Res } from 'express'; +import { promisify } from 'util'; +import { stat } from 'fs'; +import { pipeline } from 'stream'; + +const fileInfo = promisify(stat); @UseGuards(JwtAuthGuard) @Controller('asset') export class AssetController { constructor( private readonly assetService: AssetService, - private readonly imageOptimizeService: ImageOptimizeService, + private readonly assetOptimizeService: AssetOptimizeService, ) {} @Post('upload') @@ -45,7 +51,11 @@ export class AssetController { const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype); if (savedAsset && savedAsset.type == AssetType.IMAGE) { - await this.imageOptimizeService.resizeImage(savedAsset); + await this.assetOptimizeService.resizeImage(savedAsset); + } + + if (savedAsset && savedAsset.type == AssetType.VIDEO) { + await this.assetOptimizeService.getVideoThumbnail(savedAsset, file.originalname); } }); @@ -54,23 +64,81 @@ export class AssetController { @Get('/file') async serveFile( + @Headers() headers, @GetAuthUser() authUser: AuthUserDto, - @Response({ passthrough: true }) res, + @Response({ passthrough: true }) res: Res, @Query(ValidationPipe) query: ServeFileDto, ): Promise { let file = null; const asset = await this.assetService.findOne(authUser, query.did, query.aid); - res.set({ - 'Content-Type': asset.mimeType, - }); - if (query.isThumb === 'false' || !query.isThumb) { - file = createReadStream(asset.originalPath); - } else { - file = createReadStream(asset.resizePath); + // Handle Sending Images + if (asset.type == AssetType.IMAGE || query.isThumb == 'true') { + res.set({ + 'Content-Type': asset.mimeType, + }); + + if (query.isThumb === 'false' || !query.isThumb) { + file = createReadStream(asset.originalPath); + } else { + file = createReadStream(asset.resizePath); + } + + return new StreamableFile(file); + } else if (asset.type == AssetType.VIDEO) { + // Handle Handling Video + const { size } = await fileInfo(asset.originalPath); + const range = headers.range; + + if (range) { + /** Extracting Start and End value from Range Header */ + let [start, end] = range.replace(/bytes=/, '').split('-'); + start = parseInt(start, 10); + end = end ? parseInt(end, 10) : size - 1; + + if (!isNaN(start) && isNaN(end)) { + start = start; + end = size - 1; + } + if (isNaN(start) && !isNaN(end)) { + start = size - end; + end = size - 1; + } + + // Handle unavailable range request + if (start >= size || end >= size) { + console.error('Bad Request'); + // Return the 416 Range Not Satisfiable. + res.status(416).set({ + 'Content-Range': `bytes */${size}`, + }); + + throw new BadRequestException('Bad Request Range'); + } + + /** Sending Partial Content With HTTP Code 206 */ + console.log('Sendinf file with type ', asset.mimeType); + + res.status(206).set({ + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Type': asset.mimeType, + }); + + const videoStream = createReadStream(asset.originalPath, { start: start, end: end }); + + return new StreamableFile(videoStream); + } else { + res.set({ + 'Content-Type': asset.mimeType, + }); + + return new StreamableFile(createReadStream(asset.originalPath)); + } } - return new StreamableFile(file); + console.log('SHOULD NOT BE HERE'); } @Get('/all') diff --git a/server/src/api-v1/asset/asset.module.ts b/server/src/api-v1/asset/asset.module.ts index 2665845090e40..88e1a28e1d34b 100644 --- a/server/src/api-v1/asset/asset.module.ts +++ b/server/src/api-v1/asset/asset.module.ts @@ -4,13 +4,13 @@ import { AssetController } from './asset.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AssetEntity } from './entities/asset.entity'; import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module'; -import { ImageOptimizeService } from '../../modules/image-optimize/image-optimize.service'; +import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service'; import { BullModule } from '@nestjs/bull'; @Module({ imports: [ BullModule.registerQueue({ - name: 'image', + name: 'optimize', defaultJobOptions: { attempts: 3, removeOnComplete: true, @@ -29,7 +29,7 @@ import { BullModule } from '@nestjs/bull'; ImageOptimizeModule, ], controllers: [AssetController], - providers: [AssetService, ImageOptimizeService], + providers: [AssetService, AssetOptimizeService], exports: [], }) export class AssetModule {} diff --git a/server/src/api-v1/asset/asset.service.ts b/server/src/api-v1/asset/asset.service.ts index 8c8532c3c9421..aea2970ba2fce 100644 --- a/server/src/api-v1/asset/asset.service.ts +++ b/server/src/api-v1/asset/asset.service.ts @@ -26,9 +26,9 @@ export class AssetService { asset.createdAt = assetInfo.createdAt; asset.modifiedAt = assetInfo.modifiedAt; asset.isFavorite = assetInfo.isFavorite; - asset.lat = assetInfo.lat; - asset.lon = assetInfo.lon; asset.mimeType = mimeType; + asset.duration = assetInfo.duration; + try { const res = await this.assetRepository.save(asset); @@ -63,7 +63,7 @@ export class AssetService { lastQueryCreatedAt: query.nextPageKey || new Date().toISOString(), }) .orderBy('a."createdAt"::date', 'DESC') - .take(10000) + // .take(500) .getMany(); if (assets.length > 0) { diff --git a/server/src/api-v1/asset/dto/create-asset.dto.ts b/server/src/api-v1/asset/dto/create-asset.dto.ts index ce954217835f6..f3e469ed00e8e 100644 --- a/server/src/api-v1/asset/dto/create-asset.dto.ts +++ b/server/src/api-v1/asset/dto/create-asset.dto.ts @@ -24,8 +24,5 @@ export class CreateAssetDto { fileExtension: string; @IsOptional() - lat: string; - - @IsOptional() - lon: string; + duration: string; } diff --git a/server/src/api-v1/asset/entities/asset.entity.ts b/server/src/api-v1/asset/entities/asset.entity.ts index 97343004fabb9..4d78096d25d35 100644 --- a/server/src/api-v1/asset/entities/asset.entity.ts +++ b/server/src/api-v1/asset/entities/asset.entity.ts @@ -34,16 +34,10 @@ export class AssetEntity { isFavorite: boolean; @Column({ nullable: true }) - description: string; - - @Column({ nullable: true }) - lat: string; - - @Column({ nullable: true }) - lon: string; + mimeType: string; @Column({ nullable: true }) - mimeType: string; + duration: string; } export enum AssetType { diff --git a/server/src/main.ts b/server/src/main.ts index 84e53e342d2e6..e82e03ac1d4a3 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,7 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; -import ffmpeg from 'fluent-ffmpeg'; async function bootstrap() { const app = await NestFactory.create(AppModule); diff --git a/server/src/modules/image-optimize/image-optimize.module.ts b/server/src/modules/image-optimize/image-optimize.module.ts index 2b0222ec7f1fb..5d378e3aa0ff6 100644 --- a/server/src/modules/image-optimize/image-optimize.module.ts +++ b/server/src/modules/image-optimize/image-optimize.module.ts @@ -6,13 +6,13 @@ import { AssetModule } from '../../api-v1/asset/asset.module'; import { AssetService } from '../../api-v1/asset/asset.service'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import { ImageOptimizeProcessor } from './image-optimize.processor'; -import { ImageOptimizeService } from './image-optimize.service'; +import { AssetOptimizeService } from './image-optimize.service'; import { MachineLearningProcessor } from './machine-learning.processor'; @Module({ imports: [ BullModule.registerQueue({ - name: 'image', + name: 'optimize', defaultJobOptions: { attempts: 3, removeOnComplete: true, @@ -30,7 +30,7 @@ import { MachineLearningProcessor } from './machine-learning.processor'; TypeOrmModule.forFeature([AssetEntity]), ], - providers: [ImageOptimizeService, ImageOptimizeProcessor, MachineLearningProcessor], - exports: [ImageOptimizeService], + providers: [AssetOptimizeService, ImageOptimizeProcessor, MachineLearningProcessor], + exports: [AssetOptimizeService], }) export class ImageOptimizeModule {} diff --git a/server/src/modules/image-optimize/image-optimize.processor.ts b/server/src/modules/image-optimize/image-optimize.processor.ts index 87fac6aedb4a2..c53b1123caa94 100644 --- a/server/src/modules/image-optimize/image-optimize.processor.ts +++ b/server/src/modules/image-optimize/image-optimize.processor.ts @@ -6,9 +6,10 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import sharp from 'sharp'; import fs, { existsSync, mkdirSync } from 'fs'; import { ConfigService } from '@nestjs/config'; -import { randomUUID } from 'crypto'; +import ffmpeg from 'fluent-ffmpeg'; +import { Logger } from '@nestjs/common'; -@Processor('image') +@Processor('optimize') export class ImageOptimizeProcessor { constructor( @InjectRepository(AssetEntity) private assetRepository: Repository, @@ -16,8 +17,8 @@ export class ImageOptimizeProcessor { private configService: ConfigService, ) {} - @Process('optimize') - async handleOptimization(job: Job) { + @Process('resize-image') + async resizeUploadedImage(job: Job) { const { savedAsset }: { savedAsset: AssetEntity } = job.data; const basePath = this.configService.get('UPLOAD_LOCATION'); @@ -58,4 +59,32 @@ export class ImageOptimizeProcessor { return 'ok'; } + + @Process('get-video-thumbnail') + async resizeUploadedVideo(job: Job) { + const { savedAsset, filename }: { savedAsset: AssetEntity; filename: String } = job.data; + + const basePath = this.configService.get('UPLOAD_LOCATION'); + // const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/'); + console.log(filename); + // Create folder for thumb image if not exist + const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`; + + if (!existsSync(resizeDir)) { + mkdirSync(resizeDir, { recursive: true }); + } + + ffmpeg(savedAsset.originalPath) + .thumbnail({ + count: 1, + timestamps: [1], + folder: resizeDir, + filename: `${filename}.png`, + }) + .on('end', async (a) => { + await this.assetRepository.update(savedAsset, { resizePath: `${resizeDir}/${filename}.png` }); + }); + + return 'ok'; + } } diff --git a/server/src/modules/image-optimize/image-optimize.service.ts b/server/src/modules/image-optimize/image-optimize.service.ts index 3425980baf4c2..37f7a488bedd6 100644 --- a/server/src/modules/image-optimize/image-optimize.service.ts +++ b/server/src/modules/image-optimize/image-optimize.service.ts @@ -2,17 +2,15 @@ import { InjectQueue } from '@nestjs/bull'; import { Injectable } from '@nestjs/common'; import { Queue } from 'bull'; import { randomUUID } from 'crypto'; -import { join } from 'path'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; -import { AuthUserDto } from '../../decorators/auth-user.decorator'; @Injectable() -export class ImageOptimizeService { - constructor(@InjectQueue('image') private imageQueue: Queue) {} +export class AssetOptimizeService { + constructor(@InjectQueue('optimize') private optimizeQueue: Queue) {} public async resizeImage(savedAsset: AssetEntity) { - const job = await this.imageQueue.add( - 'optimize', + const job = await this.optimizeQueue.add( + 'resize-image', { savedAsset, }, @@ -23,4 +21,19 @@ export class ImageOptimizeService { jobId: job.id, }; } + + public async getVideoThumbnail(savedAsset: AssetEntity, filename: String) { + const job = await this.optimizeQueue.add( + 'get-video-thumbnail', + { + savedAsset, + filename, + }, + { jobId: randomUUID() }, + ); + + return { + jobId: job.id, + }; + } }