diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 7c5d996ae9..0000000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..496ee2ca6a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store \ No newline at end of file diff --git a/PR_CHECKLIST.md b/PR_CHECKLIST.md new file mode 100644 index 0000000000..e7d8b9a6f8 --- /dev/null +++ b/PR_CHECKLIST.md @@ -0,0 +1,13 @@ +# Deployment checklist for iOS/Android/Server + +[] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml) + +[] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service + +[] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service + +[] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts) + +[] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile) + +All of the version should be the same. \ No newline at end of file diff --git a/README.md b/README.md index 9c6c9f6d1f..01b0c3d540 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ This project is under heavy development, there will be continous functions, feat # Features -- Upload assets(videos/images). -- View assets. +- Upload and view assets(videos/images). +- Multi-user supported. - Quick navigation with drag scroll bar. - Auto Backup. - Support HEIC/HEIF Backup. @@ -59,6 +59,7 @@ This project is under heavy development, there will be continous functions, feat - Upload assets from your local computer/server using [immich cli tools](https://www.npmjs.com/package/immich) - [Optional] Reserve geocoding using Mapbox (Generous free-tier of 100,000 search/month) - Show asset's location information on map (OpenStreetMap). +- Show curated places on the search page # Development diff --git a/docker/docker-compose.gpu.yml b/docker/docker-compose.gpu.yml index 3b64483db2..ff7ad1d4ee 100644 --- a/docker/docker-compose.gpu.yml +++ b/docker/docker-compose.gpu.yml @@ -2,7 +2,7 @@ version: "3.8" services: immich_server: - image: immich-server-dev:1.0.0 + image: immich-server-dev:1.3.0 build: context: ../server target: development diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8298eac721..f72e0bc722 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.8" services: immich_server: - image: immich-server-dev:1.0.0 + image: immich-server-dev:1.3.0 build: context: ../server target: development diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index dd0793fe49..d9cabe49a7 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -9,6 +9,8 @@ PODS: - FMDB (2.7.5): - FMDB/standard (= 2.7.5) - FMDB/standard (2.7.5) + - package_info_plus (0.4.5): + - Flutter - path_provider_ios (0.0.1): - Flutter - photo_manager (1.0.0): @@ -28,6 +30,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) @@ -47,6 +50,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_udid/ios" fluttertoast: :path: ".symlinks/plugins/fluttertoast/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" path_provider_ios: :path: ".symlinks/plugins/path_provider_ios/ios" photo_manager: @@ -63,6 +68,7 @@ SPEC CHECKSUMS: flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c fluttertoast: 6122fa75143e992b1d3470f61000f591a798cc58 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 3f8509d639..ea193c1f31 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -18,6 +18,9 @@ default_platform(:ios) platform :ios do desc "iOS deployment" lane :beta do + increment_version_number( + version_number: "1.3.0" # Set a specific version number + ) increment_build_number({ build_number: latest_testflight_build_number + 1 }) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index f57849f2ea..194cd42a96 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/providers/backup.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'constants/hive_box.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -43,7 +44,10 @@ class _ImmichAppState extends ConsumerState with WidgetsBindingObserv ref.watch(backupProvider.notifier).resumeBackup(); ref.watch(websocketProvider.notifier).connect(); ref.watch(assetProvider.notifier).getAllAsset(); + ref.watch(serverInfoProvider.notifier).getServerVersion(); + break; + case AppLifecycleState.inactive: debugPrint("[APP STATE] inactive"); ref.watch(appStateProvider.notifier).state = AppStateEnum.inactive; @@ -51,10 +55,12 @@ class _ImmichAppState extends ConsumerState with WidgetsBindingObserv ref.watch(backupProvider.notifier).cancelBackup(); break; + case AppLifecycleState.paused: debugPrint("[APP STATE] paused"); ref.watch(appStateProvider.notifier).state = AppStateEnum.paused; break; + case AppLifecycleState.detached: debugPrint("[APP STATE] detached"); ref.watch(appStateProvider.notifier).state = AppStateEnum.detached; diff --git a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart index 59e898f358..72d95b854c 100644 --- a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart +++ b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart @@ -7,7 +7,9 @@ import 'package:immich_mobile/modules/login/providers/authentication.provider.da import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/backup_state.model.dart'; +import 'package:immich_mobile/shared/models/server_info_state.model.dart'; import 'package:immich_mobile/shared/providers/backup.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; class ImmichSliverAppBar extends ConsumerWidget { const ImmichSliverAppBar({ @@ -21,6 +23,8 @@ class ImmichSliverAppBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final BackUpState _backupState = ref.watch(backupProvider); bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup; + final ServerInfoState _serverInfoState = ref.watch(serverInfoProvider); + return SliverAppBar( centerTitle: true, floating: true, @@ -30,12 +34,46 @@ class ImmichSliverAppBar extends ConsumerWidget { shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), leading: Builder( builder: (BuildContext context) { - return IconButton( - icon: const Icon(Icons.account_circle_rounded), - onPressed: () { - Scaffold.of(context).openDrawer(); - }, - tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip, + return Stack( + children: [ + Positioned( + top: 5, + child: IconButton( + splashRadius: 25, + icon: const Icon( + Icons.account_circle_rounded, + size: 30, + ), + onPressed: () { + Scaffold.of(context).openDrawer(); + }, + ), + ), + _serverInfoState.isVersionMismatch + ? Positioned( + bottom: 12, + right: 12, + child: GestureDetector( + onTap: () => Scaffold.of(context).openDrawer(), + child: Material( + color: Colors.grey[200], + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50.0), + ), + child: const Padding( + padding: EdgeInsets.all(2.0), + child: Icon( + Icons.info, + color: Color.fromARGB(255, 243, 188, 106), + size: 15, + ), + ), + ), + ), + ) + : Container(), + ], ); }, ), diff --git a/mobile/lib/modules/home/ui/profile_drawer.dart b/mobile/lib/modules/home/ui/profile_drawer.dart index 6e6e406280..c9a586fc17 100644 --- a/mobile/lib/modules/home/ui/profile_drawer.dart +++ b/mobile/lib/modules/home/ui/profile_drawer.dart @@ -5,7 +5,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; +import 'package:immich_mobile/shared/models/server_info_state.model.dart'; import 'package:immich_mobile/shared/providers/backup.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -15,6 +17,8 @@ class ProfileDrawer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { AuthenticationState _authState = ref.watch(authenticationProvider); + ServerInfoState _serverInfoState = ref.watch(serverInfoProvider); + final appInfo = useState({}); _getPackageInfo() async { @@ -92,13 +96,70 @@ class ProfileDrawer extends HookConsumerWidget { ), Padding( padding: const EdgeInsets.all(8.0), - child: Text( - "Version V${appInfo.value["version"]}+${appInfo.value["buildNumber"]}", - style: TextStyle( - fontSize: 12, - color: Colors.grey[400], - fontWeight: FontWeight.bold, - fontStyle: FontStyle.italic, + child: Card( + color: Colors.grey[100], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + _serverInfoState.isVersionMismatch + ? _serverInfoState.versionMismatchErrorMessage + : "Client and Server are up-to-date", + textAlign: TextAlign.center, + style: + TextStyle(fontSize: 11, color: Theme.of(context).primaryColor, fontWeight: FontWeight.w600), + ), + ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "App Version", + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], + fontWeight: FontWeight.bold, + ), + ), + Text( + "${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}", + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Server Version", + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], + fontWeight: FontWeight.bold, + ), + ), + Text( + "${_serverInfoState.serverVersion.major}.${_serverInfoState.serverVersion.minor}.${_serverInfoState.serverVersion.patch}", + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), ), ), ) diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 7a64013313..792b286572 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart'; import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:sliver_tools/sliver_tools.dart'; @@ -28,6 +29,7 @@ class HomePage extends HookConsumerWidget { useEffect(() { ref.read(websocketProvider.notifier).connect(); ref.read(assetProvider.notifier).getAllAsset(); + ref.watch(serverInfoProvider.notifier).getServerVersion(); return null; }, []); diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index 70f6304156..1d2de0f0fb 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; class TabNavigationObserver extends AutoRouterObserver { /// Riverpod Instance @@ -26,5 +27,7 @@ class TabNavigationObserver extends AutoRouterObserver { // Refresh Location State ref.refresh(getCuratedLocationProvider); } + + ref.watch(serverInfoProvider.notifier).getServerVersion(); } } diff --git a/mobile/lib/shared/models/server_info_state.model.dart b/mobile/lib/shared/models/server_info_state.model.dart new file mode 100644 index 0000000000..5c6074ee74 --- /dev/null +++ b/mobile/lib/shared/models/server_info_state.model.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:immich_mobile/shared/models/mapbox_info.model.dart'; +import 'package:immich_mobile/shared/models/server_version.model.dart'; + +class ServerInfoState { + final MapboxInfo mapboxInfo; + final ServerVersion serverVersion; + final bool isVersionMismatch; + final String versionMismatchErrorMessage; + + ServerInfoState({ + required this.mapboxInfo, + required this.serverVersion, + required this.isVersionMismatch, + required this.versionMismatchErrorMessage, + }); + + ServerInfoState copyWith({ + MapboxInfo? mapboxInfo, + ServerVersion? serverVersion, + bool? isVersionMismatch, + String? versionMismatchErrorMessage, + }) { + return ServerInfoState( + mapboxInfo: mapboxInfo ?? this.mapboxInfo, + serverVersion: serverVersion ?? this.serverVersion, + isVersionMismatch: isVersionMismatch ?? this.isVersionMismatch, + versionMismatchErrorMessage: versionMismatchErrorMessage ?? this.versionMismatchErrorMessage, + ); + } + + Map toMap() { + return { + 'mapboxInfo': mapboxInfo.toMap(), + 'serverVersion': serverVersion.toMap(), + 'isVersionMismatch': isVersionMismatch, + 'versionMismatchErrorMessage': versionMismatchErrorMessage, + }; + } + + factory ServerInfoState.fromMap(Map map) { + return ServerInfoState( + mapboxInfo: MapboxInfo.fromMap(map['mapboxInfo']), + serverVersion: ServerVersion.fromMap(map['serverVersion']), + isVersionMismatch: map['isVersionMismatch'] ?? false, + versionMismatchErrorMessage: map['versionMismatchErrorMessage'] ?? '', + ); + } + + String toJson() => json.encode(toMap()); + + factory ServerInfoState.fromJson(String source) => ServerInfoState.fromMap(json.decode(source)); + + @override + String toString() { + return 'ServerInfoState(mapboxInfo: $mapboxInfo, serverVersion: $serverVersion, isVersionMismatch: $isVersionMismatch, versionMismatchErrorMessage: $versionMismatchErrorMessage)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ServerInfoState && + other.mapboxInfo == mapboxInfo && + other.serverVersion == serverVersion && + other.isVersionMismatch == isVersionMismatch && + other.versionMismatchErrorMessage == versionMismatchErrorMessage; + } + + @override + int get hashCode { + return mapboxInfo.hashCode ^ + serverVersion.hashCode ^ + isVersionMismatch.hashCode ^ + versionMismatchErrorMessage.hashCode; + } +} diff --git a/mobile/lib/shared/models/server_version.model.dart b/mobile/lib/shared/models/server_version.model.dart new file mode 100644 index 0000000000..176e939808 --- /dev/null +++ b/mobile/lib/shared/models/server_version.model.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +class ServerVersion { + final int major; + final int minor; + final int patch; + final int build; + + ServerVersion({ + required this.major, + required this.minor, + required this.patch, + required this.build, + }); + + ServerVersion copyWith({ + int? major, + int? minor, + int? patch, + int? build, + }) { + return ServerVersion( + major: major ?? this.major, + minor: minor ?? this.minor, + patch: patch ?? this.patch, + build: build ?? this.build, + ); + } + + Map toMap() { + return { + 'major': major, + 'minor': minor, + 'patch': patch, + 'build': build, + }; + } + + factory ServerVersion.fromMap(Map map) { + return ServerVersion( + major: map['major']?.toInt() ?? 0, + minor: map['minor']?.toInt() ?? 0, + patch: map['patch']?.toInt() ?? 0, + build: map['build']?.toInt() ?? 0, + ); + } + + String toJson() => json.encode(toMap()); + + factory ServerVersion.fromJson(String source) => ServerVersion.fromMap(json.decode(source)); + + @override + String toString() { + return 'ServerVersion(major: $major, minor: $minor, patch: $patch, build: $build)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ServerVersion && + other.major == major && + other.minor == minor && + other.patch == patch && + other.build == build; + } + + @override + int get hashCode { + return major.hashCode ^ minor.hashCode ^ patch.hashCode ^ build.hashCode; + } +} diff --git a/mobile/lib/shared/providers/server_info.provider.dart b/mobile/lib/shared/providers/server_info.provider.dart index 06b11c2fa0..edba9cdc27 100644 --- a/mobile/lib/shared/providers/server_info.provider.dart +++ b/mobile/lib/shared/providers/server_info.provider.dart @@ -1,59 +1,19 @@ -import 'dart:convert'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/mapbox_info.model.dart'; +import 'package:immich_mobile/shared/models/server_info_state.model.dart'; +import 'package:immich_mobile/shared/models/server_version.model.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart'; - -class ServerInfoState { - final MapboxInfo mapboxInfo; - ServerInfoState({ - required this.mapboxInfo, - }); - - ServerInfoState copyWith({ - MapboxInfo? mapboxInfo, - }) { - return ServerInfoState( - mapboxInfo: mapboxInfo ?? this.mapboxInfo, - ); - } - - Map toMap() { - return { - 'mapboxInfo': mapboxInfo.toMap(), - }; - } - - factory ServerInfoState.fromMap(Map map) { - return ServerInfoState( - mapboxInfo: MapboxInfo.fromMap(map['mapboxInfo']), - ); - } - - String toJson() => json.encode(toMap()); - - factory ServerInfoState.fromJson(String source) => ServerInfoState.fromMap(json.decode(source)); - - @override - String toString() => 'ServerInfoState(mapboxInfo: $mapboxInfo)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is ServerInfoState && other.mapboxInfo == mapboxInfo; - } - - @override - int get hashCode => mapboxInfo.hashCode; -} +import 'package:package_info_plus/package_info_plus.dart'; class ServerInfoNotifier extends StateNotifier { ServerInfoNotifier() : super( ServerInfoState( mapboxInfo: MapboxInfo(isEnable: false, mapboxSecret: ""), + serverVersion: ServerVersion(major: 0, patch: 0, minor: 0, build: 0), + isVersionMismatch: false, + versionMismatchErrorMessage: "", ), ); @@ -61,9 +21,63 @@ class ServerInfoNotifier extends StateNotifier { getMapboxInfo() async { MapboxInfo mapboxInfoRes = await _serverInfoService.getMapboxInfo(); - print(mapboxInfoRes); state = state.copyWith(mapboxInfo: mapboxInfoRes); } + + getServerVersion() async { + ServerVersion? serverVersion = await _serverInfoService.getServerVersion(); + + if (serverVersion == null) { + state = state.copyWith( + isVersionMismatch: true, + versionMismatchErrorMessage: + "Server is out of date. Some functionalities might not working correctly. Download and rebuild server", + ); + return; + } + + state = state.copyWith(serverVersion: serverVersion); + + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + + Map appVersion = _getDetailVersion(packageInfo.version); + + if (appVersion["major"]! > serverVersion.major) { + state = state.copyWith( + isVersionMismatch: true, + versionMismatchErrorMessage: + "Server is out of date in major version. Some functionalities might not work correctly. Download and rebuild server", + ); + + return; + } + + if (appVersion["minor"]! > serverVersion.minor) { + state = state.copyWith( + isVersionMismatch: true, + versionMismatchErrorMessage: + "Server is out of date in minor version. Some functionalities might not work correctly. Consider download and rebuild server", + ); + + return; + } + + state = state.copyWith(isVersionMismatch: false, versionMismatchErrorMessage: ""); + } + + Map _getDetailVersion(String version) { + List detail = version.split("."); + + var major = detail[0]; + var minor = detail[1]; + var patch = detail[2]; + + return { + "major": int.parse(major), + "minor": int.parse(minor), + "patch": int.parse(patch), + }; + } } final serverInfoProvider = StateNotifierProvider((ref) { diff --git a/mobile/lib/shared/services/backup.service.dart b/mobile/lib/shared/services/backup.service.dart index 584eb2d1d4..c2e0a3552e 100644 --- a/mobile/lib/shared/services/backup.service.dart +++ b/mobile/lib/shared/services/backup.service.dart @@ -30,10 +30,14 @@ class BackupService { Function(int, int) uploadProgress) async { var dio = Dio(); dio.interceptors.add(AuthenticatedRequestInterceptor()); + String deviceId = Hive.box(userInfoBox).get(deviceIdKey); String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); File? file; + MultipartFile assetRawUploadData; + MultipartFile thumbnailUploadData; + for (var entity in assetList) { try { if (entity.type == AssetType.video) { @@ -43,12 +47,20 @@ class BackupService { } if (file != null) { + FormData formData; String originalFileName = await entity.titleAsync; String fileNameWithoutPath = originalFileName.toString().split(".")[0]; var fileExtension = p.extension(file.path); var mimeType = FileHelper.getMimeType(file.path); - - var formData = FormData.fromMap({ + assetRawUploadData = await MultipartFile.fromFile( + file.path, + filename: fileNameWithoutPath, + contentType: MediaType( + mimeType["type"], + mimeType["subType"], + ), + ); + formData = FormData.fromMap({ 'deviceAssetId': entity.id, 'deviceId': deviceId, 'assetType': _getAssetType(entity.type), @@ -57,18 +69,36 @@ class BackupService { 'isFavorite': entity.isFavorite, 'fileExtension': fileExtension, 'duration': entity.videoDuration, - 'files': [ - await MultipartFile.fromFile( - file.path, - filename: fileNameWithoutPath, - contentType: MediaType( - mimeType["type"], - mimeType["subType"], - ), - ), - ] + 'assetData': [assetRawUploadData] }); + // Build thumbnail multipart data + var thumbnailData = await entity.thumbDataWithSize(1280, 720); + if (thumbnailData != null) { + thumbnailUploadData = MultipartFile.fromBytes( + List.from(thumbnailData), + filename: fileNameWithoutPath, + contentType: MediaType( + "image", + "jpeg", + ), + ); + + // Send thumbnail data if it is exist + formData = FormData.fromMap({ + 'deviceAssetId': entity.id, + 'deviceId': deviceId, + 'assetType': _getAssetType(entity.type), + 'createdAt': entity.createDateTime.toIso8601String(), + 'modifiedAt': entity.modifiedDateTime.toIso8601String(), + 'isFavorite': entity.isFavorite, + 'fileExtension': fileExtension, + 'duration': entity.videoDuration, + 'thumbnailData': [thumbnailUploadData], + 'assetData': [assetRawUploadData] + }); + } + Response res = await dio.post( '$savedEndpoint/asset/upload', data: formData, diff --git a/mobile/lib/shared/services/server_info.service.dart b/mobile/lib/shared/services/server_info.service.dart index 4dd11ead33..31d127795b 100644 --- a/mobile/lib/shared/services/server_info.service.dart +++ b/mobile/lib/shared/services/server_info.service.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:immich_mobile/shared/models/mapbox_info.model.dart'; +import 'package:immich_mobile/shared/models/server_version.model.dart'; import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/models/server_info.model.dart'; @@ -17,4 +18,10 @@ class ServerInfoService { return MapboxInfo.fromJson(response.toString()); } + + Future getServerVersion() async { + Response response = await _networkService.getRequest(url: 'server-info/version'); + + return ServerVersion.fromJson(response.toString()); + } } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 173065add7..50c59aa756 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: A new Flutter project. publish_to: "none" -version: 1.1.0+1 +version: 1.3.0+0 environment: sdk: ">=2.15.1 <3.0.0" diff --git a/server/Dockerfile b/server/Dockerfile index 5e8914b815..9ba76d4f4c 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -9,7 +9,7 @@ WORKDIR /usr/src/app COPY package.json package-lock.json ./ -RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg +# RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg RUN npm install @@ -30,7 +30,7 @@ WORKDIR /usr/src/app COPY package.json package-lock.json ./ -RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg +# RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg RUN npm install --only=production diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts index 444bac7622..2158ade31c 100644 --- a/server/src/api-v1/asset/asset.controller.ts +++ b/server/src/api-v1/asset/asset.controller.ts @@ -16,7 +16,7 @@ import { } from '@nestjs/common'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { AssetService } from './asset.service'; -import { FilesInterceptor } from '@nestjs/platform-express'; +import { FileFieldsInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { multerOption } from '../../config/multer-option.config'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { CreateAssetDto } from './dto/create-asset.dto'; @@ -29,34 +29,43 @@ import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto'; import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { DeleteAssetDto } from './dto/delete-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto'; +import { CommunicationGateway } from '../communication/communication.gateway'; @UseGuards(JwtAuthGuard) @Controller('asset') export class AssetController { constructor( + private wsCommunicateionGateway: CommunicationGateway, private assetService: AssetService, - private assetOptimizeService: AssetOptimizeService, private backgroundTaskService: BackgroundTaskService, ) {} @Post('upload') - @UseInterceptors(FilesInterceptor('files', 30, multerOption)) + @UseInterceptors( + FileFieldsInterceptor( + [ + { name: 'assetData', maxCount: 1 }, + { name: 'thumbnailData', maxCount: 1 }, + ], + multerOption, + ), + ) async uploadFile( @GetAuthUser() authUser, - @UploadedFiles() files: Express.Multer.File[], + @UploadedFiles() uploadFiles: { assetData: Express.Multer.File[]; thumbnailData?: Express.Multer.File[] }, @Body(ValidationPipe) assetInfo: CreateAssetDto, ) { - files.forEach(async (file) => { + uploadFiles.assetData.forEach(async (file) => { const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype); - if (savedAsset && savedAsset.type == AssetType.IMAGE) { - await this.assetOptimizeService.resizeImage(savedAsset); - await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size); + if (uploadFiles.thumbnailData != null) { + await this.assetService.updateThumbnailInfo(savedAsset.id, uploadFiles.thumbnailData[0].path); + await this.backgroundTaskService.tagImage(uploadFiles.thumbnailData[0].path, savedAsset); } - if (savedAsset && savedAsset.type == AssetType.VIDEO) { - await this.assetOptimizeService.getVideoThumbnail(savedAsset, file.originalname); - } + await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size); + + this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset)); }); return 'ok'; diff --git a/server/src/api-v1/asset/asset.module.ts b/server/src/api-v1/asset/asset.module.ts index 0d4466b9a5..dda0958fb4 100644 --- a/server/src/api-v1/asset/asset.module.ts +++ b/server/src/api-v1/asset/asset.module.ts @@ -8,9 +8,12 @@ import { AssetOptimizeService } from '../../modules/image-optimize/image-optimiz import { BullModule } from '@nestjs/bull'; import { BackgroundTaskModule } from '../../modules/background-task/background-task.module'; import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; +import { CommunicationModule } from '../communication/communication.module'; @Module({ imports: [ + CommunicationModule, + BullModule.registerQueue({ name: 'optimize', defaultJobOptions: { diff --git a/server/src/api-v1/asset/asset.service.ts b/server/src/api-v1/asset/asset.service.ts index aff091012a..2bc6299fa6 100644 --- a/server/src/api-v1/asset/asset.service.ts +++ b/server/src/api-v1/asset/asset.service.ts @@ -24,6 +24,12 @@ export class AssetService { private assetRepository: Repository, ) {} + public async updateThumbnailInfo(assetId: string, path: string) { + return await this.assetRepository.update(assetId, { + resizePath: path, + }); + } + public async createUserAsset(authUser: AuthUserDto, assetInfo: CreateAssetDto, path: string, mimeType: string) { const asset = new AssetEntity(); asset.deviceAssetId = assetInfo.deviceAssetId; diff --git a/server/src/api-v1/server-info/server-info.controller.ts b/server/src/api-v1/server-info/server-info.controller.ts index f49b63ae73..211d743763 100644 --- a/server/src/api-v1/server-info/server-info.controller.ts +++ b/server/src/api-v1/server-info/server-info.controller.ts @@ -5,6 +5,7 @@ import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { ServerInfoService } from './server-info.service'; import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding'; import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response'; +import { serverVersion } from '../../constants/server_version.constant'; @Controller('server-info') export class ServerInfoController { @@ -30,4 +31,9 @@ export class ServerInfoController { mapboxSecret: this.configService.get('MAPBOX_KEY'), }; } + + @Get('/version') + async getServerVersion() { + return serverVersion; + } } diff --git a/server/src/config/multer-option.config.ts b/server/src/config/multer-option.config.ts index 791de71575..281c825ee2 100644 --- a/server/src/config/multer-option.config.ts +++ b/server/src/config/multer-option.config.ts @@ -23,17 +23,33 @@ export const multerOption: MulterOptions = { destination: (req: Request, file: Express.Multer.File, cb: any) => { const uploadPath = multerConfig.dest; - const userPath = `${uploadPath}/${req.user['id']}/original/${req.body['deviceId']}`; + if (file.fieldname == 'assetData') { + const originalUploadFolder = `${uploadPath}/${req.user['id']}/original/${req.body['deviceId']}`; - if (!existsSync(userPath)) { - mkdirSync(userPath, { recursive: true }); + if (!existsSync(originalUploadFolder)) { + mkdirSync(originalUploadFolder, { recursive: true }); + } + + cb(null, originalUploadFolder); + } else if (file.fieldname == 'thumbnailData') { + const thumbnailUploadFolder = `${uploadPath}/${req.user['id']}/thumb/${req.body['deviceId']}`; + + if (!existsSync(thumbnailUploadFolder)) { + mkdirSync(thumbnailUploadFolder, { recursive: true }); + } + + cb(null, thumbnailUploadFolder); } - - cb(null, userPath); }, filename: (req: Request, file: Express.Multer.File, cb: any) => { - cb(null, `${file.originalname.split('.')[0]}${req.body['fileExtension']}`); + // console.log(req, file); + + if (file.fieldname == 'assetData') { + cb(null, `${file.originalname.split('.')[0]}${req.body['fileExtension']}`); + } else if (file.fieldname == 'thumbnailData') { + cb(null, `${file.originalname.split('.')[0]}.jpeg`); + } }, }), }; diff --git a/server/src/constants/server_version.constant.ts b/server/src/constants/server_version.constant.ts new file mode 100644 index 0000000000..42364c6b39 --- /dev/null +++ b/server/src/constants/server_version.constant.ts @@ -0,0 +1,9 @@ +// major.minor.patch+build +// check mobile/pubspec.yml for current release version + +export const serverVersion = { + major: 1, + minor: 3, + patch: 0, + build: 0, +}; diff --git a/server/src/modules/background-task/background-task.processor.ts b/server/src/modules/background-task/background-task.processor.ts index 9a006c320c..7946143a49 100644 --- a/server/src/modules/background-task/background-task.processor.ts +++ b/server/src/modules/background-task/background-task.processor.ts @@ -41,7 +41,6 @@ export class BackgroundTaskProcessor { async extractExif(job: Job) { const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } = job.data; - const fileBuffer = await readFile(savedAsset.originalPath); const exifData = await exifr.parse(fileBuffer); diff --git a/server/src/modules/image-optimize/image-optimize.processor.ts b/server/src/modules/image-optimize/image-optimize.processor.ts index d646c7b5a2..f547985828 100644 --- a/server/src/modules/image-optimize/image-optimize.processor.ts +++ b/server/src/modules/image-optimize/image-optimize.processor.ts @@ -22,122 +22,4 @@ export class ImageOptimizeProcessor { private backgroundTaskService: BackgroundTaskService, ) {} - - @Process('resize-image') - async resizeUploadedImage(job: Job) { - const { savedAsset }: { savedAsset: AssetEntity } = job.data; - - const basePath = APP_UPLOAD_LOCATION; - const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/'); - - // Create folder for thumb image if not exist - - const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`; - - if (!existsSync(resizeDir)) { - mkdirSync(resizeDir, { recursive: true }); - } - - readFile(savedAsset.originalPath, async (err, data) => { - if (err) { - console.error('Error Reading File'); - } - - // Special Assets Type - ios - if ( - savedAsset.mimeType == 'image/heic' || - savedAsset.mimeType == 'image/heif' || - savedAsset.mimeType == 'image/dng' - ) { - let desitnation = ''; - if (savedAsset.mimeType == 'image/heic') { - desitnation = resizePath.replace('.HEIC', '.jpeg'); - } else if (savedAsset.mimeType == 'image/heif') { - desitnation = resizePath.replace('.HEIF', '.jpeg'); - } else if (savedAsset.mimeType == 'image/dng') { - desitnation = resizePath.replace('.DNG', '.jpeg'); - } - - sharp(data) - .toFormat('jpeg') - .resize(512, 512, { fit: 'outside' }) - .toFile(desitnation, async (err, info) => { - if (err) { - console.error('Error resizing file ', err); - return; - } - - const res = await this.assetRepository.update(savedAsset, { resizePath: desitnation }); - - if (res.affected) { - this.wsCommunicateionGateway.server - .to(savedAsset.userId) - .emit('on_upload_success', JSON.stringify(savedAsset)); - } - - // Tag Image - this.backgroundTaskService.tagImage(desitnation, savedAsset); - }); - } else { - sharp(data) - .resize(512, 512, { fit: 'outside' }) - .toFile(resizePath, async (err, info) => { - if (err) { - console.error('Error resizing file ', err); - return; - } - - const res = await this.assetRepository.update(savedAsset, { resizePath: resizePath }); - if (res.affected) { - this.wsCommunicateionGateway.server - .to(savedAsset.userId) - .emit('on_upload_success', JSON.stringify(savedAsset)); - } - - // Tag Image - this.backgroundTaskService.tagImage(resizePath, savedAsset); - }); - } - }); - - return 'ok'; - } - - @Process('get-video-thumbnail') - async resizeUploadedVideo(job: Job) { - const { savedAsset, filename }: { savedAsset: AssetEntity; filename: String } = job.data; - - const basePath = APP_UPLOAD_LOCATION; - // const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/'); - // 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) => { - const thumbnailPath = `${resizeDir}/${filename}.png`; - - const res = await this.assetRepository.update(savedAsset, { resizePath: `${resizeDir}/${filename}.png` }); - - if (res.affected) { - this.wsCommunicateionGateway.server - .to(savedAsset.userId) - .emit('on_upload_success', JSON.stringify(savedAsset)); - } - - // Tag Image - this.backgroundTaskService.tagImage(thumbnailPath, savedAsset); - }); - - 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 31c0a466c0..ae8b711e3d 100644 --- a/server/src/modules/image-optimize/image-optimize.service.ts +++ b/server/src/modules/image-optimize/image-optimize.service.ts @@ -7,33 +7,4 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; @Injectable() export class AssetOptimizeService { constructor(@InjectQueue('optimize') private optimizeQueue: Queue) {} - - public async resizeImage(savedAsset: AssetEntity) { - const job = await this.optimizeQueue.add( - 'resize-image', - { - savedAsset, - }, - { jobId: randomUUID() }, - ); - - return { - 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, - }; - } }