mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	Implemented Video Upload and Player (#2)
* Implementing video upload features * setup image resize processor * Add video thumbnail with duration and icon * Fixed issue with video upload timeout and upper case file type on ios * Added video player page * Added video player page * Fixing video player not play on ios * Added partial file streaming for ios/android video request * Added nginx as proxy server for better file serving * update nginx and docker-compose file * Video player working correctly * Video player working correctly * Split duration to the second
This commit is contained in:
		| @@ -3,6 +3,7 @@ | ||||
|     <application | ||||
|             android:label="Immich" | ||||
|             android:name="${applicationName}" | ||||
|             android:usesCleartextTraffic="true" | ||||
|             android:icon="@mipmap/ic_launcher"> | ||||
|         <activity | ||||
|                 android:name=".MainActivity" | ||||
|   | ||||
| @@ -0,0 +1,20 @@ | ||||
| // Generated file. | ||||
| // If you wish to remove Flutter's multidex support, delete this entire file. | ||||
|  | ||||
| package io.flutter.app; | ||||
|  | ||||
| import android.content.Context; | ||||
| import androidx.annotation.CallSuper; | ||||
| import androidx.multidex.MultiDex; | ||||
|  | ||||
| /** | ||||
|  * Extension of {@link io.flutter.app.FlutterApplication}, adding multidex support. | ||||
|  */ | ||||
| public class FlutterMultiDexApplication extends FlutterApplication { | ||||
|   @Override | ||||
|   @CallSuper | ||||
|   protected void attachBaseContext(Context base) { | ||||
|     super.attachBaseContext(base); | ||||
|     MultiDex.install(this); | ||||
|   } | ||||
| } | ||||
| @@ -17,6 +17,10 @@ PODS: | ||||
|     - Flutter | ||||
|     - FMDB (>= 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 | ||||
|  | ||||
|   | ||||
| @@ -41,9 +41,22 @@ | ||||
|       <string>UIInterfaceOrientationLandscapeLeft</string> | ||||
|       <string>UIInterfaceOrientationLandscapeRight</string> | ||||
|     </array> | ||||
|  | ||||
|     <key>UIUserInterfaceStyle</key> | ||||
|       <string>Light</string> | ||||
|  | ||||
|     <key>UIViewControllerBasedStatusBarAppearance</key> | ||||
|       <true /> | ||||
|  | ||||
|     <key>NSPhotoLibraryUsageDescription</key> | ||||
|     <string>App need your agree, can visit your album</string> | ||||
|       <string>We need to manage backup your photos album</string> | ||||
|  | ||||
|     <key>NSAppTransportSecurity</key> | ||||
| 		<dict> | ||||
|       <key>NSAllowsArbitraryLoads</key> | ||||
|         <true/> | ||||
|     </dict> | ||||
|  | ||||
|  | ||||
|   </dict> | ||||
| </plist> | ||||
| @@ -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<ImmichApp> 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<ImmichApp> with WidgetsBindingObserv | ||||
|           foregroundColor: Colors.indigo, | ||||
|           elevation: 1, | ||||
|           centerTitle: true, | ||||
|           systemOverlayStyle: SystemUiOverlayStyle.dark, | ||||
|         ), | ||||
|       ), | ||||
|       routeInformationParser: _immichRouter.defaultRouteParser(), | ||||
|   | ||||
| @@ -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<ImmichAsset> 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]), | ||||
|           ); | ||||
|               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, | ||||
|       ), | ||||
|   | ||||
| @@ -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, | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -1,23 +1,27 @@ | ||||
| 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: () { | ||||
|         if (asset.type == 'IMAGE') { | ||||
|           AutoRouter.of(context).push( | ||||
|             ImageViewerRoute( | ||||
|               imageUrl: | ||||
| @@ -26,14 +30,24 @@ class ThumbnailImage extends StatelessWidget { | ||||
|               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); | ||||
|           }, | ||||
|         ), | ||||
|   | ||||
| @@ -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<bool> _showBackToTopBtn = useState(false); | ||||
|     ScrollController _scrollController = useScrollController(); | ||||
|  | ||||
|     List<ImmichAssetGroupByDate> assetGroup = ref.watch(assetProvider); | ||||
|     List<Widget> 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( | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -42,6 +42,12 @@ class _$AppRouter extends RootStackRouter { | ||||
|               imageUrl: args.imageUrl, | ||||
|               heroTag: args.heroTag, | ||||
|               thumbnailUrl: args.thumbnailUrl)); | ||||
|     }, | ||||
|     VideoViewerRoute.name: (routeData) { | ||||
|       final args = routeData.argsAs<VideoViewerRouteArgs>(); | ||||
|       return MaterialPageX<dynamic>( | ||||
|           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<VideoViewerRouteArgs> { | ||||
|   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}'; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -35,7 +35,7 @@ class BackupNotifier extends StateNotifier<BackUpState> { | ||||
|   void getBackupInfo() async { | ||||
|     _updateServerInfo(); | ||||
|  | ||||
|     List<AssetPathEntity> list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.image); | ||||
|     List<AssetPathEntity> 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<BackUpState> { | ||||
|       // await PhotoManager.presentLimited(); | ||||
|       // Gather assets info | ||||
|       List<AssetPathEntity> 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"); | ||||
|   | ||||
| @@ -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 { | ||||
|         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, | ||||
|   | ||||
							
								
								
									
										105
									
								
								mobile/lib/shared/views/video_viewer_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								mobile/lib/shared/views/video_viewer_page.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<VideoThumbnailPlayer> createState() => _VideoThumbnailPlayerState(); | ||||
| } | ||||
|  | ||||
| class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> { | ||||
|   late VideoPlayerController videoPlayerController; | ||||
|   ChewieController? chewieController; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     initializePlayer(); | ||||
|   } | ||||
|  | ||||
|   Future<void> 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, | ||||
|             ), | ||||
|           ); | ||||
|   } | ||||
| } | ||||
| @@ -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"}; | ||||
|  | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| node_modules/ | ||||
| upload/ | ||||
| dist/ | ||||
|  | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
							
								
								
									
										20
									
								
								server/settings/nginx-conf/nginx.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								server/settings/nginx-conf/nginx.conf
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
|   } | ||||
| } | ||||
| @@ -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,12 +64,16 @@ 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<StreamableFile> { | ||||
|     let file = null; | ||||
|     const asset = await this.assetService.findOne(authUser, query.did, query.aid); | ||||
|  | ||||
|     // Handle Sending Images | ||||
|     if (asset.type == AssetType.IMAGE || query.isThumb == 'true') { | ||||
|       res.set({ | ||||
|         'Content-Type': asset.mimeType, | ||||
|       }); | ||||
| @@ -71,6 +85,60 @@ export class AssetController { | ||||
|       } | ||||
|  | ||||
|       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)); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     console.log('SHOULD NOT BE HERE'); | ||||
|   } | ||||
|  | ||||
|   @Get('/all') | ||||
|   | ||||
| @@ -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 {} | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -24,8 +24,5 @@ export class CreateAssetDto { | ||||
|   fileExtension: string; | ||||
|  | ||||
|   @IsOptional() | ||||
|   lat: string; | ||||
|  | ||||
|   @IsOptional() | ||||
|   lon: string; | ||||
|   duration: string; | ||||
| } | ||||
|   | ||||
| @@ -33,17 +33,11 @@ export class AssetEntity { | ||||
|   @Column({ type: 'boolean', default: false }) | ||||
|   isFavorite: boolean; | ||||
|  | ||||
|   @Column({ nullable: true }) | ||||
|   description: string; | ||||
|  | ||||
|   @Column({ nullable: true }) | ||||
|   lat: string; | ||||
|  | ||||
|   @Column({ nullable: true }) | ||||
|   lon: string; | ||||
|  | ||||
|   @Column({ nullable: true }) | ||||
|   mimeType: string; | ||||
|  | ||||
|   @Column({ nullable: true }) | ||||
|   duration: string; | ||||
| } | ||||
|  | ||||
| export enum AssetType { | ||||
|   | ||||
| @@ -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<NestExpressApplication>(AppModule); | ||||
|   | ||||
| @@ -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 {} | ||||
|   | ||||
| @@ -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<AssetEntity>, | ||||
| @@ -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'; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user