From bcb0056b55107f9af89f9b54e30226db6b7ee7e1 Mon Sep 17 00:00:00 2001 From: Matthias Rupp Date: Thu, 26 Jan 2023 15:40:19 +0100 Subject: [PATCH 01/16] chore(mobile): Run dart analyze in CI (#1425) * Run dart analyze in CI * Add pub get * Fix linter errors in mobile code --- .github/workflows/static_analysis.yml | 31 +++++++++++++++++++ .../login_input_validation_test.dart | 7 ++--- .../module_login/login_test.dart | 16 +++++----- .../test_utils/general_helper.dart | 27 ++++++++-------- .../test_utils/login_helper.dart | 2 -- .../modules/album/ui/album_viewer_appbar.dart | 2 +- .../album/views/album_viewer_page.dart | 4 +-- .../lib/modules/album/views/library_page.dart | 8 ++--- .../home/services/asset_cache.service.dart | 3 +- .../asset_grid/asset_grid_data_structure.dart | 12 +++++-- .../home/ui/asset_grid/daily_title_text.dart | 1 - mobile/lib/modules/login/ui/login_form.dart | 2 +- .../lib/routing/tab_navigation_observer.dart | 4 +-- .../services/immich_logger.service.dart | 1 + mobile/lib/shared/services/share.service.dart | 1 + mobile/lib/utils/image_url_builder.dart | 6 ++-- mobile/lib/utils/openapi_extensions.dart | 4 ++- 17 files changed, 87 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/static_analysis.yml diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml new file mode 100644 index 0000000000..896b740b9b --- /dev/null +++ b/.github/workflows/static_analysis.yml @@ -0,0 +1,31 @@ +name: Static Code Analysis +on: + workflow_dispatch: + pull_request: + push: + branches: [main] + +jobs: + mobile-dart-analyze: + name: Run Dart Code Analysis + + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Flutter SDK + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version: '3.3.10' + + - name: Install dependencies + run: dart pub get + working-directory: ./mobile + + - name: Run dart analyze + run: dart analyze --fatal-infos + working-directory: ./mobile + diff --git a/mobile/integration_test/module_login/login_input_validation_test.dart b/mobile/integration_test/module_login/login_input_validation_test.dart index a70afcbdc3..e8f37acfc2 100644 --- a/mobile/integration_test/module_login/login_input_validation_test.dart +++ b/mobile/integration_test/module_login/login_input_validation_test.dart @@ -2,7 +2,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import '../test_utils/general_helper.dart'; -import '../test_utils/login_helper.dart'; void main() async { await ImmichTestHelper.initialize(); @@ -13,7 +12,7 @@ void main() async { await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.enterCredentials( - email: " demo@immich.app" + email: " demo@immich.app", ); await tester.pump(const Duration(milliseconds: 300)); @@ -21,7 +20,7 @@ void main() async { expect(find.text("login_form_err_leading_whitespace".tr()), findsOneWidget); await helper.loginHelper.enterCredentials( - email: "demo@immich.app " + email: "demo@immich.app ", ); await tester.pump(const Duration(milliseconds: 300)); @@ -34,7 +33,7 @@ void main() async { await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.enterCredentials( - email: "demo.immich.app" + email: "demo.immich.app", ); await tester.pump(const Duration(milliseconds: 300)); diff --git a/mobile/integration_test/module_login/login_test.dart b/mobile/integration_test/module_login/login_test.dart index f317b12ca6..c9e6ecc089 100644 --- a/mobile/integration_test/module_login/login_test.dart +++ b/mobile/integration_test/module_login/login_test.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter_test/flutter_test.dart'; import '../test_utils/general_helper.dart'; @@ -12,8 +10,9 @@ void main() async { immichWidgetTest("Test correct credentials", (tester, helper) async { await helper.loginHelper.waitForLoginScreen(); await helper.loginHelper.acknowledgeNewServerVersion(); - await helper.loginHelper - .enterCredentialsOf(LoginCredentials.testInstance); + await helper.loginHelper.enterCredentialsOf( + LoginCredentials.testInstance, + ); await helper.loginHelper.pressLoginButton(); await helper.loginHelper.assertLoginSuccess(); }); @@ -22,16 +21,19 @@ void main() async { await helper.loginHelper.waitForLoginScreen(); await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.enterCredentialsOf( - LoginCredentials.testInstanceButWithWrongPassword); + LoginCredentials.testInstanceButWithWrongPassword, + ); await helper.loginHelper.pressLoginButton(); await helper.loginHelper.assertLoginFailed(); }); - immichWidgetTest("Test login with wrong server URL", (tester, helper) async { + immichWidgetTest("Test login with wrong server URL", + (tester, helper) async { await helper.loginHelper.waitForLoginScreen(); await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.enterCredentialsOf( - LoginCredentials.wrongInstanceUrl); + LoginCredentials.wrongInstanceUrl, + ); await helper.loginHelper.pressLoginButton(); await helper.loginHelper.assertLoginFailed(); }); diff --git a/mobile/integration_test/test_utils/general_helper.dart b/mobile/integration_test/test_utils/general_helper.dart index 0555bdda92..0ce776ce97 100644 --- a/mobile/integration_test/test_utils/general_helper.dart +++ b/mobile/integration_test/test_utils/general_helper.dart @@ -1,17 +1,14 @@ - import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive/hive.dart'; -import 'package:immich_mobile/main.dart'; import 'package:integration_test/integration_test.dart'; +// ignore: depend_on_referenced_packages import 'package:meta/meta.dart'; import 'package:immich_mobile/main.dart' as app; import 'login_helper.dart'; class ImmichTestHelper { - final WidgetTester tester; ImmichTestHelper(this.tester); @@ -43,15 +40,19 @@ class ImmichTestHelper { await tester.pumpAndSettle(); await EasyLocalization.ensureInitialized(); } - } @isTest -void immichWidgetTest(String description, Future Function(WidgetTester, ImmichTestHelper) test) { - - testWidgets(description, (widgetTester) async { - await ImmichTestHelper.loadApp(widgetTester); - await test(widgetTester, ImmichTestHelper(widgetTester)); - }, semanticsEnabled: false); - -} \ No newline at end of file +void immichWidgetTest( + String description, + Future Function(WidgetTester, ImmichTestHelper) test, +) { + testWidgets( + description, + (widgetTester) async { + await ImmichTestHelper.loadApp(widgetTester); + await test(widgetTester, ImmichTestHelper(widgetTester)); + }, + semanticsEnabled: false, + ); +} diff --git a/mobile/integration_test/test_utils/login_helper.dart b/mobile/integration_test/test_utils/login_helper.dart index 244b288b7b..5e6cb547c7 100644 --- a/mobile/integration_test/test_utils/login_helper.dart +++ b/mobile/integration_test/test_utils/login_helper.dart @@ -1,8 +1,6 @@ -import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; class ImmichTestLoginHelper { final WidgetTester tester; diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index f49082559a..5c41b08b6e 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -96,7 +96,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget { if (isSuccess) { Navigator.pop(context); ref.watch(assetSelectionProvider.notifier).disableMultiselection(); - ref.refresh(sharedAlbumDetailProvider(albumId)); + ref.invalidate(sharedAlbumDetailProvider(albumId)); } else { Navigator.pop(context); ImmichToast.show( diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index 65db82eb6b..78b9896362 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -62,7 +62,7 @@ class AlbumViewerPage extends HookConsumerWidget { if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) { - ref.refresh(sharedAlbumDetailProvider(albumId)); + ref.invalidate(sharedAlbumDetailProvider(albumId)); } ImmichLoadingOverlayController.appLoader.hide(); @@ -88,7 +88,7 @@ class AlbumViewerPage extends HookConsumerWidget { .addAdditionalUserToAlbum(sharedUserIds, albumId); if (isSuccess) { - ref.refresh(sharedAlbumDetailProvider(albumId)); + ref.invalidate(sharedAlbumDetailProvider(albumId)); } ImmichLoadingOverlayController.appLoader.hide(); diff --git a/mobile/lib/modules/album/views/library_page.dart b/mobile/lib/modules/album/views/library_page.dart index 7bb91bc815..8b40fbf7bc 100644 --- a/mobile/lib/modules/album/views/library_page.dart +++ b/mobile/lib/modules/album/views/library_page.dart @@ -22,7 +22,7 @@ class LibraryPage extends HookConsumerWidget { [], ); - Widget _buildAppBar() { + Widget buildAppBar() { return const SliverAppBar( centerTitle: true, floating: true, @@ -40,7 +40,7 @@ class LibraryPage extends HookConsumerWidget { ); } - Widget _buildCreateAlbumButton() { + Widget buildCreateAlbumButton() { return GestureDetector( onTap: () { AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false)); @@ -83,7 +83,7 @@ class LibraryPage extends HookConsumerWidget { return Scaffold( body: CustomScrollView( slivers: [ - _buildAppBar(), + buildAppBar(), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(12.0), @@ -99,7 +99,7 @@ class LibraryPage extends HookConsumerWidget { child: Wrap( spacing: 12, children: [ - _buildCreateAlbumButton(), + buildCreateAlbumButton(), for (var album in albums) AlbumThumbnailCard( album: album, diff --git a/mobile/lib/modules/home/services/asset_cache.service.dart b/mobile/lib/modules/home/services/asset_cache.service.dart index 3eb684f6ea..d7a6af5ccb 100644 --- a/mobile/lib/modules/home/services/asset_cache.service.dart +++ b/mobile/lib/modules/home/services/asset_cache.service.dart @@ -8,7 +8,8 @@ class AssetCacheService extends JsonCache> { AssetCacheService() : super("asset_cache"); static Future>> _computeSerialize( - List assets) async { + List assets, + ) async { return assets.map((e) => e.toJson()).toList(); } diff --git a/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart b/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart index a2461eb385..9c68cf7cbd 100644 --- a/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart +++ b/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart @@ -42,8 +42,13 @@ class _AssetGroupsToRenderListComputeParameters { final Map> groups; final int perRow; - _AssetGroupsToRenderListComputeParameters(this.monthFormat, this.dayFormat, - this.dayFormatYear, this.groups, this.perRow); + _AssetGroupsToRenderListComputeParameters( + this.monthFormat, + this.dayFormat, + this.dayFormatYear, + this.groups, + this.perRow, + ); } class RenderList { @@ -52,7 +57,8 @@ class RenderList { RenderList(this.elements); static Future _processAssetGroupData( - _AssetGroupsToRenderListComputeParameters data) async { + _AssetGroupsToRenderListComputeParameters data, + ) async { final monthFormat = DateFormat(data.monthFormat); final dayFormatSameYear = DateFormat(data.dayFormat); final dayFormatOtherYear = DateFormat(data.dayFormatYear); diff --git a/mobile/lib/modules/home/ui/asset_grid/daily_title_text.dart b/mobile/lib/modules/home/ui/asset_grid/daily_title_text.dart index f61d06cac3..7cee410a19 100644 --- a/mobile/lib/modules/home/ui/asset_grid/daily_title_text.dart +++ b/mobile/lib/modules/home/ui/asset_grid/daily_title_text.dart @@ -1,4 +1,3 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index e98927ef0d..b47e64bd0d 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -235,7 +235,7 @@ class ServerEndpointInput extends StatelessWidget { labelText: 'login_form_endpoint_url'.tr(), border: const OutlineInputBorder(), hintText: 'login_form_endpoint_hint'.tr(), - errorMaxLines: 4 + errorMaxLines: 4, ), validator: _validateInput, autovalidateMode: AutovalidateMode.always, diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index 25db941848..1085082713 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -30,8 +30,8 @@ class TabNavigationObserver extends AutoRouterObserver { // Perform tasks on re-visit to SearchRoute if (route.name == 'SearchRoute') { // Refresh Location State - ref.refresh(getCuratedLocationProvider); - ref.refresh(getCuratedObjectProvider); + ref.invalidate(getCuratedLocationProvider); + ref.invalidate(getCuratedObjectProvider); } if (route.name == 'SharingRoute') { diff --git a/mobile/lib/shared/services/immich_logger.service.dart b/mobile/lib/shared/services/immich_logger.service.dart index 4e7d3bf71c..75e7cf8d7e 100644 --- a/mobile/lib/shared/services/immich_logger.service.dart +++ b/mobile/lib/shared/services/immich_logger.service.dart @@ -83,6 +83,7 @@ class ImmichLogger { } // Share file + // ignore: deprecated_member_use await Share.shareFiles( [filePath], subject: "Immich logs $dateTime", diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart index 007d76d1d0..c0e61c9d9d 100644 --- a/mobile/lib/shared/services/share.service.dart +++ b/mobile/lib/shared/services/share.service.dart @@ -40,6 +40,7 @@ class ShareService { } }); + // ignore: deprecated_member_use Share.shareFiles( await Future.wait(downloadedFilePaths), sharePositionOrigin: Rect.zero, diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 592c8ebbc5..5fb6408d27 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -10,8 +10,10 @@ String getThumbnailUrl( return _getThumbnailUrl(asset.id, type: type); } -String getThumbnailCacheKey(final AssetResponseDto asset, - {ThumbnailFormat type = ThumbnailFormat.WEBP}) { +String getThumbnailCacheKey( + final AssetResponseDto asset, { + ThumbnailFormat type = ThumbnailFormat.WEBP, +}) { return _getThumbnailCacheKey(asset.id, type); } diff --git a/mobile/lib/utils/openapi_extensions.dart b/mobile/lib/utils/openapi_extensions.dart index 2959be3d10..96623514dc 100644 --- a/mobile/lib/utils/openapi_extensions.dart +++ b/mobile/lib/utils/openapi_extensions.dart @@ -31,7 +31,9 @@ extension WithETag on AssetApi { final responseBody = await _decodeBodyBytes(response); final etag = response.headers[HttpHeaders.etagHeader]; final data = (await apiClient.deserializeAsync( - responseBody, 'List') as List) + responseBody, + 'List', + ) as List) .cast() .toList(); return Pair(data, etag); From 8b73c2bf8a29b18bf3848c3e3f0609d86e51e7ac Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 26 Jan 2023 13:14:05 -0600 Subject: [PATCH 02/16] fix(server): Handle exposure time correctly (#1432) --- .../asset_viewer/ui/exif_bottom_sheet.dart | 2 +- mobile/openapi/doc/ExifResponseDto.md | 2 +- mobile/openapi/lib/model/exif_response_dto.dart | 6 ++---- mobile/openapi/test/exif_response_dto_test.dart | 2 +- .../processors/metadata-extraction.processor.ts | 9 +-------- server/immich-openapi-specs.json | 2 +- .../src/asset/response-dto/exif-response.dto.ts | 2 +- server/libs/domain/test/fixtures.ts | 4 ++-- server/libs/infra/src/db/entities/exif.entity.ts | 4 ++-- ...674757936889-AlterExifExposureTimeToString.ts | 16 ++++++++++++++++ web/src/api/open-api/api.ts | 4 ++-- .../components/asset-viewer/detail-panel.svelte | 2 +- 12 files changed, 31 insertions(+), 24 deletions(-) create mode 100644 server/libs/infra/src/db/migrations/1674757936889-AlterExifExposureTimeToString.ts diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index f7fa863dde..bdeab2be32 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -188,7 +188,7 @@ class ExifBottomSheet extends HookConsumerWidget { ), ), subtitle: Text( - "ƒ/${exifInfo.fNumber} 1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ", + "ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ", ), ), ], diff --git a/mobile/openapi/doc/ExifResponseDto.md b/mobile/openapi/doc/ExifResponseDto.md index af4bb349ec..3cea9375b4 100644 --- a/mobile/openapi/doc/ExifResponseDto.md +++ b/mobile/openapi/doc/ExifResponseDto.md @@ -22,7 +22,7 @@ Name | Type | Description | Notes **fNumber** | **num** | | [optional] **focalLength** | **num** | | [optional] **iso** | **num** | | [optional] -**exposureTime** | **num** | | [optional] +**exposureTime** | **String** | | [optional] **latitude** | **num** | | [optional] **longitude** | **num** | | [optional] **city** | **String** | | [optional] diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 8423aa56a7..6c80667d5d 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -63,7 +63,7 @@ class ExifResponseDto { num? iso; - num? exposureTime; + String? exposureTime; num? latitude; @@ -273,9 +273,7 @@ class ExifResponseDto { iso: json[r'iso'] == null ? null : num.parse(json[r'iso'].toString()), - exposureTime: json[r'exposureTime'] == null - ? null - : num.parse(json[r'exposureTime'].toString()), + exposureTime: mapValueOfType(json, r'exposureTime'), latitude: json[r'latitude'] == null ? null : num.parse(json[r'latitude'].toString()), diff --git a/mobile/openapi/test/exif_response_dto_test.dart b/mobile/openapi/test/exif_response_dto_test.dart index a7ee8c52ec..8d38e88e08 100644 --- a/mobile/openapi/test/exif_response_dto_test.dart +++ b/mobile/openapi/test/exif_response_dto_test.dart @@ -86,7 +86,7 @@ void main() { // TODO }); - // num exposureTime + // String exposureTime test('to test the property `exposureTime`', () async { // TODO }); diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index b409e6af07..7e6e9dadd7 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -154,13 +154,6 @@ export class MetadataExtractionProcessor { return exifDate.toDate(); }; - const getExposureTimeDenominator = (exposureTime: string | undefined) => { - if (!exposureTime) return null; - - const exposureTimeSplit = exposureTime.split('/'); - return exposureTimeSplit.length === 2 ? parseInt(exposureTimeSplit[1]) : null; - }; - const createdAt = exifToDate(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.createdAt); const modifyDate = exifToDate(exifData?.ModifyDate ?? asset.modifiedAt); const fileStats = fs.statSync(asset.originalPath); @@ -174,7 +167,7 @@ export class MetadataExtractionProcessor { newExif.model = exifData?.Model || null; newExif.exifImageHeight = exifData?.ExifImageHeight || exifData?.ImageHeight || null; newExif.exifImageWidth = exifData?.ExifImageWidth || exifData?.ImageWidth || null; - newExif.exposureTime = getExposureTimeDenominator(exifData?.ExposureTime); + newExif.exposureTime = exifData?.ExposureTime || null; newExif.orientation = exifData?.Orientation?.toString() || null; newExif.dateTimeOriginal = createdAt; newExif.modifyDate = modifyDate; diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 73aba74a58..ca2277dec9 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3207,7 +3207,7 @@ "default": null }, "exposureTime": { - "type": "number", + "type": "string", "nullable": true, "default": null }, diff --git a/server/libs/domain/src/asset/response-dto/exif-response.dto.ts b/server/libs/domain/src/asset/response-dto/exif-response.dto.ts index 0dd97a3111..1695157af5 100644 --- a/server/libs/domain/src/asset/response-dto/exif-response.dto.ts +++ b/server/libs/domain/src/asset/response-dto/exif-response.dto.ts @@ -19,7 +19,7 @@ export class ExifResponseDto { fNumber?: number | null = null; focalLength?: number | null = null; iso?: number | null = null; - exposureTime?: number | null = null; + exposureTime?: string | null = null; latitude?: number | null = null; longitude?: number | null = null; city?: string | null = null; diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index f5d622fcc4..3f267f4274 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -22,7 +22,7 @@ const assetInfo: ExifResponseDto = { fNumber: 100, focalLength: 100, iso: 100, - exposureTime: 100, + exposureTime: '1/16', latitude: 100, longitude: 100, city: 'city', @@ -349,7 +349,7 @@ export const sharedLinkStub = { fNumber: 100, focalLength: 100, iso: 100, - exposureTime: 100, + exposureTime: '1/16', fps: 100, asset: null as any, exifTextSearchableColumn: '', diff --git a/server/libs/infra/src/db/entities/exif.entity.ts b/server/libs/infra/src/db/entities/exif.entity.ts index a086260147..a78323f5ad 100644 --- a/server/libs/infra/src/db/entities/exif.entity.ts +++ b/server/libs/infra/src/db/entities/exif.entity.ts @@ -72,8 +72,8 @@ export class ExifEntity { @Column({ type: 'integer', nullable: true }) iso!: number | null; - @Column({ type: 'float', nullable: true }) - exposureTime!: number | null; + @Column({ type: 'varchar', nullable: true }) + exposureTime!: string | null; /* Video info */ @Column({ type: 'float8', nullable: true }) diff --git a/server/libs/infra/src/db/migrations/1674757936889-AlterExifExposureTimeToString.ts b/server/libs/infra/src/db/migrations/1674757936889-AlterExifExposureTimeToString.ts new file mode 100644 index 0000000000..de21e180b7 --- /dev/null +++ b/server/libs/infra/src/db/migrations/1674757936889-AlterExifExposureTimeToString.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AlterExifExposureTimeToString1674757936889 implements MigrationInterface { + name = 'AlterExifExposureTimeToString1674757936889' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exposureTime"`); + await queryRunner.query(`ALTER TABLE "exif" ADD "exposureTime" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exposureTime"`); + await queryRunner.query(`ALTER TABLE "exif" ADD "exposureTime" double precision`); + } + +} diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index f3309115f0..9bacb5d165 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1116,10 +1116,10 @@ export interface ExifResponseDto { 'iso'?: number | null; /** * - * @type {number} + * @type {string} * @memberof ExifResponseDto */ - 'exposureTime'?: number | null; + 'exposureTime'?: string | null; /** * * @type {number} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 59c0c95348..7e57158705 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -152,7 +152,7 @@

{`ƒ/${asset.exifInfo.fNumber.toLocaleString(locale)}` || ''}

{#if asset.exifInfo.exposureTime} -

{`1/${asset.exifInfo.exposureTime}`}

+

{`${asset.exifInfo.exposureTime}`}

{/if} {#if asset.exifInfo.focalLength} From 7e53e33e0f8f6e4dd99dfa7ba754779522c48a5b Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Thu, 26 Jan 2023 20:22:46 +0100 Subject: [PATCH 03/16] build(docker): Use ghcr.io as build cache instead of gha (#1429) * build(docker): Use github registry as build cache * build(docker): Only write to cache if not PR --- .github/workflows/docker.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b22b2c2344..22bb16ffad 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -78,6 +78,16 @@ jobs: type=ref,event=tag type=raw,value=release,enable=${{ github.event_name == 'release' }} + - name: Determine build cache output + id: cache-target + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + # Essentially just ignore the cache output (PR can't write to registry cache) + echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT + else + echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ matrix.image }}" >> $GITHUB_OUTPUT + fi + - name: Build and push image uses: docker/build-push-action@v3.3.0 with: @@ -85,6 +95,6 @@ jobs: platforms: linux/arm/v7,linux/amd64,linux/arm64 # Skip pushing when PR from a fork push: ${{ !github.event.pull_request.head.repo.fork }} - cache-from: type=gha - cache-to: type=gha,mode=max - tags: ${{ steps.metadata.outputs.tags }} \ No newline at end of file + cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}} + cache-to: ${{ steps.cache-target.outputs.cache-to }} + tags: ${{ steps.metadata.outputs.tags }} From c4e1bc35b4e37fac20620638cac016a8a2d5b2d4 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 26 Jan 2023 14:29:44 -0500 Subject: [PATCH 04/16] feature(server): compute sha1 during upload (#1424) * feature(server): compute sha1 during upload * fix: clean up stream on error --- .../src/api-v1/asset/asset.controller.ts | 4 +- .../immich/src/api-v1/asset/asset.service.ts | 9 +++-- .../immich/src/config/asset-upload.config.ts | 38 ++++++++++++++++--- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index 2176ebe26a..5db2aee25b 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts @@ -19,7 +19,7 @@ import { import { Authenticated } from '../../decorators/authenticated.decorator'; import { AssetService } from './asset.service'; import { FileFieldsInterceptor } from '@nestjs/platform-express'; -import { assetUploadOption } from '../../config/asset-upload.config'; +import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { ServeFileDto } from './dto/serve-file.dto'; import { Response as Res } from 'express'; @@ -80,7 +80,7 @@ export class AssetController { }) async uploadFile( @GetAuthUser() authUser: AuthUserDto, - @UploadedFiles() files: { assetData: Express.Multer.File[]; livePhotoData?: Express.Multer.File[] }, + @UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] }, @Body(ValidationPipe) createAssetDto: CreateAssetDto, @Response({ passthrough: true }) res: Res, ): Promise { diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 534cf5234a..b7765f49fd 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -55,6 +55,7 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto'; import { mapSharedLink, SharedLinkResponseDto } from '@app/domain'; import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto'; import { AssetSearchDto } from './dto/asset-search.dto'; +import { ImmichFile } from '../../config/asset-upload.config'; const fileInfo = promisify(stat); @@ -82,16 +83,16 @@ export class AssetService { authUser: AuthUserDto, createAssetDto: CreateAssetDto, res: Res, - originalAssetData: Express.Multer.File, - livePhotoAssetData?: Express.Multer.File, + originalAssetData: ImmichFile, + livePhotoAssetData?: ImmichFile, ) { - const checksum = await this.calculateChecksum(originalAssetData.path); + const checksum = originalAssetData.checksum; const isLivePhoto = livePhotoAssetData !== undefined; let livePhotoAssetEntity: AssetEntity | undefined; try { if (isLivePhoto) { - const livePhotoChecksum = await this.calculateChecksum(livePhotoAssetData.path); + const livePhotoChecksum = livePhotoAssetData.checksum; livePhotoAssetEntity = await this.createUserAsset( authUser, createAssetDto, diff --git a/server/apps/immich/src/config/asset-upload.config.ts b/server/apps/immich/src/config/asset-upload.config.ts index de09d0811b..770ffc97d5 100644 --- a/server/apps/immich/src/config/asset-upload.config.ts +++ b/server/apps/immich/src/config/asset-upload.config.ts @@ -1,10 +1,10 @@ import { APP_UPLOAD_LOCATION } from '@app/common/constants'; import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common'; import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; -import { randomUUID } from 'crypto'; +import { createHash, randomUUID } from 'crypto'; import { Request } from 'express'; import { existsSync, mkdirSync } from 'fs'; -import { diskStorage } from 'multer'; +import { diskStorage, StorageEngine } from 'multer'; import { extname, join } from 'path'; import sanitize from 'sanitize-filename'; import { AuthUserDto } from '../decorators/auth-user.decorator'; @@ -12,14 +12,40 @@ import { patchFormData } from '../utils/path-form-data.util'; const logger = new Logger('AssetUploadConfig'); +export interface ImmichFile extends Express.Multer.File { + /** sha1 hash of file */ + checksum: Buffer; +} + export const assetUploadOption: MulterOptions = { fileFilter, - storage: diskStorage({ - destination, - filename, - }), + storage: customStorage(), }; +export function customStorage(): StorageEngine { + const storage = diskStorage({ destination, filename }); + + return { + _handleFile(req, file, callback) { + const hash = createHash('sha1'); + file.stream.on('data', (chunk) => hash.update(chunk)); + + storage._handleFile(req, file, (error, response) => { + if (error) { + hash.destroy(); + callback(error); + } else { + callback(null, { ...response, checksum: hash.digest() } as ImmichFile); + } + }); + }, + + _removeFile(req, file, callback) { + storage._removeFile(req, file, callback); + }, + }; +} + export const multerUtils = { fileFilter, filename, destination }; function fileFilter(req: Request, file: any, cb: any) { From 89aff7764dfd7c3e85b1c12f79f363f06236b092 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 26 Jan 2023 21:51:22 -0500 Subject: [PATCH 05/16] chore(server): remove deprecated device endpoints (#1436) * chore: remove endpoints * chore: generate open-api --- mobile/openapi/README.md | 2 - mobile/openapi/doc/DeviceInfoApi.md | 100 ------------ mobile/openapi/lib/api/device_info_api.dart | 104 ------------- mobile/openapi/test/device_info_api_test.dart | 14 -- .../device-info/device-info.controller.ts | 20 +-- server/immich-openapi-specs.json | 72 --------- web/src/api/open-api/api.ts | 146 ------------------ 7 files changed, 1 insertion(+), 457 deletions(-) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 30bcbc8b3f..0d94740bd5 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -102,8 +102,6 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | -*DeviceInfoApi* | [**createDeviceInfo**](doc//DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info | -*DeviceInfoApi* | [**updateDeviceInfo**](doc//DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info | *DeviceInfoApi* | [**upsertDeviceInfo**](doc//DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info | *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} | diff --git a/mobile/openapi/doc/DeviceInfoApi.md b/mobile/openapi/doc/DeviceInfoApi.md index 1ee91414a3..47dbd7712b 100644 --- a/mobile/openapi/doc/DeviceInfoApi.md +++ b/mobile/openapi/doc/DeviceInfoApi.md @@ -9,109 +9,9 @@ All URIs are relative to */api* Method | HTTP request | Description ------------- | ------------- | ------------- -[**createDeviceInfo**](DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info | -[**updateDeviceInfo**](DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info | [**upsertDeviceInfo**](DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info | -# **createDeviceInfo** -> DeviceInfoResponseDto createDeviceInfo(upsertDeviceInfoDto) - - - -@deprecated - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = DeviceInfoApi(); -final upsertDeviceInfoDto = UpsertDeviceInfoDto(); // UpsertDeviceInfoDto | - -try { - final result = api_instance.createDeviceInfo(upsertDeviceInfoDto); - print(result); -} catch (e) { - print('Exception when calling DeviceInfoApi->createDeviceInfo: $e\n'); -} -``` - -### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **upsertDeviceInfoDto** | [**UpsertDeviceInfoDto**](UpsertDeviceInfoDto.md)| | - -### Return type - -[**DeviceInfoResponseDto**](DeviceInfoResponseDto.md) - -### Authorization - -[bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **updateDeviceInfo** -> DeviceInfoResponseDto updateDeviceInfo(upsertDeviceInfoDto) - - - -@deprecated - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = DeviceInfoApi(); -final upsertDeviceInfoDto = UpsertDeviceInfoDto(); // UpsertDeviceInfoDto | - -try { - final result = api_instance.updateDeviceInfo(upsertDeviceInfoDto); - print(result); -} catch (e) { - print('Exception when calling DeviceInfoApi->updateDeviceInfo: $e\n'); -} -``` - -### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **upsertDeviceInfoDto** | [**UpsertDeviceInfoDto**](UpsertDeviceInfoDto.md)| | - -### Return type - -[**DeviceInfoResponseDto**](DeviceInfoResponseDto.md) - -### Authorization - -[bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - # **upsertDeviceInfo** > DeviceInfoResponseDto upsertDeviceInfo(upsertDeviceInfoDto) diff --git a/mobile/openapi/lib/api/device_info_api.dart b/mobile/openapi/lib/api/device_info_api.dart index 4cde7c5e4a..ac3c81842d 100644 --- a/mobile/openapi/lib/api/device_info_api.dart +++ b/mobile/openapi/lib/api/device_info_api.dart @@ -16,110 +16,6 @@ class DeviceInfoApi { final ApiClient apiClient; - /// @deprecated - /// - /// Note: This method returns the HTTP [Response]. - /// - /// Parameters: - /// - /// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required): - Future createDeviceInfoWithHttpInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async { - // ignore: prefer_const_declarations - final path = r'/device-info'; - - // ignore: prefer_final_locals - Object? postBody = upsertDeviceInfoDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// @deprecated - /// - /// Parameters: - /// - /// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required): - Future createDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async { - final response = await createDeviceInfoWithHttpInfo(upsertDeviceInfoDto,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DeviceInfoResponseDto',) as DeviceInfoResponseDto; - - } - return null; - } - - /// @deprecated - /// - /// Note: This method returns the HTTP [Response]. - /// - /// Parameters: - /// - /// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required): - Future updateDeviceInfoWithHttpInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async { - // ignore: prefer_const_declarations - final path = r'/device-info'; - - // ignore: prefer_final_locals - Object? postBody = upsertDeviceInfoDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'PATCH', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// @deprecated - /// - /// Parameters: - /// - /// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required): - Future updateDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async { - final response = await updateDeviceInfoWithHttpInfo(upsertDeviceInfoDto,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DeviceInfoResponseDto',) as DeviceInfoResponseDto; - - } - return null; - } - /// /// /// Note: This method returns the HTTP [Response]. diff --git a/mobile/openapi/test/device_info_api_test.dart b/mobile/openapi/test/device_info_api_test.dart index 5bfbb4c328..94897849a9 100644 --- a/mobile/openapi/test/device_info_api_test.dart +++ b/mobile/openapi/test/device_info_api_test.dart @@ -17,20 +17,6 @@ void main() { // final instance = DeviceInfoApi(); group('tests for DeviceInfoApi', () { - // @deprecated - // - //Future createDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async - test('test createDeviceInfo', () async { - // TODO - }); - - // @deprecated - // - //Future updateDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async - test('test updateDeviceInfo', () async { - // TODO - }); - // // //Future upsertDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async diff --git a/server/apps/immich/src/api-v1/device-info/device-info.controller.ts b/server/apps/immich/src/api-v1/device-info/device-info.controller.ts index 3e57f47e5f..779b3fbe91 100644 --- a/server/apps/immich/src/api-v1/device-info/device-info.controller.ts +++ b/server/apps/immich/src/api-v1/device-info/device-info.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Patch, Post, Put, ValidationPipe } from '@nestjs/common'; +import { Body, Controller, Put, ValidationPipe } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { Authenticated } from '../../decorators/authenticated.decorator'; @@ -13,24 +13,6 @@ import { DeviceInfoResponseDto, mapDeviceInfoResponse } from './response-dto/dev export class DeviceInfoController { constructor(private readonly deviceInfoService: DeviceInfoService) {} - /** @deprecated */ - @Post() - public async createDeviceInfo( - @GetAuthUser() user: AuthUserDto, - @Body(ValidationPipe) dto: UpsertDeviceInfoDto, - ): Promise { - return this.upsertDeviceInfo(user, dto); - } - - /** @deprecated */ - @Patch() - public async updateDeviceInfo( - @GetAuthUser() user: AuthUserDto, - @Body(ValidationPipe) dto: UpsertDeviceInfoDto, - ): Promise { - return this.upsertDeviceInfo(user, dto); - } - @Put() public async upsertDeviceInfo( @GetAuthUser() user: AuthUserDto, diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index ca2277dec9..eedc18f5c2 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2506,78 +2506,6 @@ } }, "/device-info": { - "post": { - "operationId": "createDeviceInfo", - "description": "@deprecated", - "deprecated": true, - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpsertDeviceInfoDto" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceInfoResponseDto" - } - } - } - } - }, - "tags": [ - "Device Info" - ], - "security": [ - { - "bearer": [] - } - ] - }, - "patch": { - "operationId": "updateDeviceInfo", - "description": "@deprecated", - "deprecated": true, - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpsertDeviceInfoDto" - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceInfoResponseDto" - } - } - } - } - }, - "tags": [ - "Device Info" - ], - "security": [ - { - "bearer": [] - } - ] - }, "put": { "operationId": "upsertDeviceInfo", "description": "", diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 9bacb5d165..f7333f306d 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -5487,86 +5487,6 @@ export class AuthenticationApi extends BaseAPI { */ export const DeviceInfoApiAxiosParamCreator = function (configuration?: Configuration) { return { - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - createDeviceInfo: async (upsertDeviceInfoDto: UpsertDeviceInfoDto, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'upsertDeviceInfoDto' is not null or undefined - assertParamExists('createDeviceInfo', 'upsertDeviceInfoDto', upsertDeviceInfoDto) - const localVarPath = `/device-info`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(upsertDeviceInfoDto, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - updateDeviceInfo: async (upsertDeviceInfoDto: UpsertDeviceInfoDto, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'upsertDeviceInfoDto' is not null or undefined - assertParamExists('updateDeviceInfo', 'upsertDeviceInfoDto', upsertDeviceInfoDto) - const localVarPath = `/device-info`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(upsertDeviceInfoDto, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, /** * * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto @@ -5616,28 +5536,6 @@ export const DeviceInfoApiAxiosParamCreator = function (configuration?: Configur export const DeviceInfoApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = DeviceInfoApiAxiosParamCreator(configuration) return { - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - async createDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.createDeviceInfo(upsertDeviceInfoDto, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - async updateDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.updateDeviceInfo(upsertDeviceInfoDto, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto @@ -5658,26 +5556,6 @@ export const DeviceInfoApiFp = function(configuration?: Configuration) { export const DeviceInfoApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = DeviceInfoApiFp(configuration) return { - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - createDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: any): AxiosPromise { - return localVarFp.createDeviceInfo(upsertDeviceInfoDto, options).then((request) => request(axios, basePath)); - }, - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - updateDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: any): AxiosPromise { - return localVarFp.updateDeviceInfo(upsertDeviceInfoDto, options).then((request) => request(axios, basePath)); - }, /** * * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto @@ -5697,30 +5575,6 @@ export const DeviceInfoApiFactory = function (configuration?: Configuration, bas * @extends {BaseAPI} */ export class DeviceInfoApi extends BaseAPI { - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - * @memberof DeviceInfoApi - */ - public createDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: AxiosRequestConfig) { - return DeviceInfoApiFp(this.configuration).createDeviceInfo(upsertDeviceInfoDto, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - * @memberof DeviceInfoApi - */ - public updateDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: AxiosRequestConfig) { - return DeviceInfoApiFp(this.configuration).updateDeviceInfo(upsertDeviceInfoDto, options).then((request) => request(this.axios, this.basePath)); - } - /** * * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto From 55d883925f1d2499a9eeb71aeb95ca74dc1ac127 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 26 Jan 2023 21:52:13 -0500 Subject: [PATCH 06/16] chore(server): rename database connection variables (#1437) --- .../infra/src/db/config/database.config.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/server/libs/infra/src/db/config/database.config.ts b/server/libs/infra/src/db/config/database.config.ts index b98f16d71d..84765d0afb 100644 --- a/server/libs/infra/src/db/config/database.config.ts +++ b/server/libs/infra/src/db/config/database.config.ts @@ -1,26 +1,25 @@ import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions'; import { DataSource } from 'typeorm'; -const baseDatabaseConfig: PostgresConnectionOptions = { +const url = process.env.DB_URL; +const urlOrParts = url + ? { url } + : { + host: process.env.DB_HOSTNAME || 'immich_postgres', + port: parseInt(process.env.DB_PORT || '5432'), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE_NAME, + }; + +export const databaseConfig: PostgresConnectionOptions = { type: 'postgres', entities: [__dirname + '/../**/*.entity.{js,ts}'], synchronize: false, migrations: [__dirname + '/../migrations/*.{js,ts}'], migrationsRun: true, connectTimeoutMS: 10000, // 10 seconds + ...urlOrParts, }; -const envBasedDatabaseConfig = { - host: process.env.DB_HOSTNAME || 'immich_postgres', - port: parseInt(process.env.DB_PORT || '5432'), - username: process.env.DB_USERNAME, - password: process.env.DB_PASSWORD, - database: process.env.DB_DATABASE_NAME, -}; - -const url = process.env.DB_URL; -const additionalSSLDatabaseConfig = url ? { url } : envBasedDatabaseConfig; - -export const databaseConfig: PostgresConnectionOptions = { ...baseDatabaseConfig, ...additionalSSLDatabaseConfig }; - export const dataSource = new DataSource(databaseConfig); From 6ea91b2dde68b2998c65aa444fc65064030c5849 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 26 Jan 2023 21:52:27 -0500 Subject: [PATCH 07/16] feat: columns on small screens (#1433) --- docs/src/pages/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index d10e536798..85d4674517 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -15,7 +15,7 @@ function HomepageHeader() {

ON MOBILE DEVICE

-
+
Date: Thu, 26 Jan 2023 22:50:22 -0600 Subject: [PATCH 08/16] feat(web/server): Add options to rerun job on all assets (#1422) --- mobile/openapi/doc/JobCommandDto.md | 1 + mobile/openapi/lib/model/job_command_dto.dart | 14 +- mobile/openapi/test/job_command_dto_test.dart | 5 + .../src/api-v1/asset/asset-repository.ts | 18 +++ .../src/api-v1/asset/asset.service.spec.ts | 2 + .../src/api-v1/job/dto/job-command.dto.ts | 6 +- .../immich/src/api-v1/job/job.controller.ts | 8 +- .../apps/immich/src/api-v1/job/job.service.ts | 43 ++++-- .../schedule-tasks/schedule-tasks.service.ts | 90 +------------ .../apps/immich/src/utils/file-name.util.ts | 5 + .../metadata-extraction.processor.ts | 4 +- .../processors/video-transcode.processor.ts | 15 +-- server/immich-openapi-specs.json | 6 +- web/src/api/open-api/api.ts | 6 + web/src/app.css | 4 + .../admin-page/jobs/job-tile.svelte | 122 +++++++++++------- .../admin-page/jobs/jobs-panel.svelte | 70 +++++++--- 17 files changed, 234 insertions(+), 185 deletions(-) create mode 100644 server/apps/immich/src/utils/file-name.util.ts diff --git a/mobile/openapi/doc/JobCommandDto.md b/mobile/openapi/doc/JobCommandDto.md index 4e87fde8e8..68cbee51a8 100644 --- a/mobile/openapi/doc/JobCommandDto.md +++ b/mobile/openapi/doc/JobCommandDto.md @@ -9,6 +9,7 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **command** | [**JobCommand**](JobCommand.md) | | +**includeAllAssets** | **bool** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart index e3e5d41da8..adf9fc3344 100644 --- a/mobile/openapi/lib/model/job_command_dto.dart +++ b/mobile/openapi/lib/model/job_command_dto.dart @@ -14,25 +14,31 @@ class JobCommandDto { /// Returns a new [JobCommandDto] instance. JobCommandDto({ required this.command, + required this.includeAllAssets, }); JobCommand command; + bool includeAllAssets; + @override bool operator ==(Object other) => identical(this, other) || other is JobCommandDto && - other.command == command; + other.command == command && + other.includeAllAssets == includeAllAssets; @override int get hashCode => // ignore: unnecessary_parenthesis - (command.hashCode); + (command.hashCode) + + (includeAllAssets.hashCode); @override - String toString() => 'JobCommandDto[command=$command]'; + String toString() => 'JobCommandDto[command=$command, includeAllAssets=$includeAllAssets]'; Map toJson() { final json = {}; json[r'command'] = this.command; + json[r'includeAllAssets'] = this.includeAllAssets; return json; } @@ -56,6 +62,7 @@ class JobCommandDto { return JobCommandDto( command: JobCommand.fromJson(json[r'command'])!, + includeAllAssets: mapValueOfType(json, r'includeAllAssets')!, ); } return null; @@ -106,6 +113,7 @@ class JobCommandDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'command', + 'includeAllAssets', }; } diff --git a/mobile/openapi/test/job_command_dto_test.dart b/mobile/openapi/test/job_command_dto_test.dart index fc31170277..fe847827ac 100644 --- a/mobile/openapi/test/job_command_dto_test.dart +++ b/mobile/openapi/test/job_command_dto_test.dart @@ -21,6 +21,11 @@ void main() { // TODO }); + // bool includeAllAssets + test('to test the property `includeAllAssets`', () async { + // TODO + }); + }); diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index af02ff6ab0..891c0c708f 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -29,6 +29,8 @@ export interface IAssetRepository { livePhotoAssetEntity?: AssetEntity, ): Promise; update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise; + getAll(): Promise; + getAllVideos(): Promise; getAllByUserId(userId: string, dto: AssetSearchDto): Promise; getAllByDeviceId(userId: string, deviceId: string): Promise; getById(assetId: string): Promise; @@ -61,6 +63,22 @@ export class AssetRepository implements IAssetRepository { @Inject(ITagRepository) private _tagRepository: ITagRepository, ) {} + async getAllVideos(): Promise { + return await this.assetRepository.find({ + where: { type: AssetType.VIDEO }, + }); + } + + async getAll(): Promise { + return await this.assetRepository.find({ + where: { isVisible: true }, + relations: { + exifInfo: true, + smartInfo: true, + }, + }); + } + async getAssetWithNoSmartInfo(): Promise { return await this.assetRepository .createQueryBuilder('asset') diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 44f84e3556..7dfb31cbec 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -123,6 +123,8 @@ describe('AssetService', () => { assetRepositoryMock = { create: jest.fn(), update: jest.fn(), + getAll: jest.fn(), + getAllVideos: jest.fn(), getAllByUserId: jest.fn(), getAllByDeviceId: jest.fn(), getAssetCountByTimeBucket: jest.fn(), diff --git a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts index f63f0fa517..5984404184 100644 --- a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts +++ b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsIn, IsNotEmpty } from 'class-validator'; +import { IsBoolean, IsIn, IsNotEmpty, IsOptional } from 'class-validator'; export class JobCommandDto { @IsNotEmpty() @@ -9,4 +9,8 @@ export class JobCommandDto { enumName: 'JobCommand', }) command!: string; + + @IsOptional() + @IsBoolean() + includeAllAssets!: boolean; } diff --git a/server/apps/immich/src/api-v1/job/job.controller.ts b/server/apps/immich/src/api-v1/job/job.controller.ts index 5dcb3e7a0c..a6228ddcc1 100644 --- a/server/apps/immich/src/api-v1/job/job.controller.ts +++ b/server/apps/immich/src/api-v1/job/job.controller.ts @@ -21,12 +21,12 @@ export class JobController { @Put('/:jobId') async sendJobCommand( @Param(ValidationPipe) params: GetJobDto, - @Body(ValidationPipe) body: JobCommandDto, + @Body(ValidationPipe) dto: JobCommandDto, ): Promise { - if (body.command === 'start') { - return await this.jobService.start(params.jobId); + if (dto.command === 'start') { + return await this.jobService.start(params.jobId, dto.includeAllAssets); } - if (body.command === 'stop') { + if (dto.command === 'stop') { return await this.jobService.stop(params.jobId); } return 0; diff --git a/server/apps/immich/src/api-v1/job/job.service.ts b/server/apps/immich/src/api-v1/job/job.service.ts index ea45f7aca8..ca31d3562e 100644 --- a/server/apps/immich/src/api-v1/job/job.service.ts +++ b/server/apps/immich/src/api-v1/job/job.service.ts @@ -5,7 +5,7 @@ import { IAssetRepository } from '../asset/asset-repository'; import { AssetType } from '@app/infra'; import { JobId } from './dto/get-job.dto'; import { MACHINE_LEARNING_ENABLED } from '@app/common'; - +import { getFileNameWithoutExtension } from '../../utils/file-name.util'; const jobIds = Object.values(JobId) as JobId[]; @Injectable() @@ -19,8 +19,8 @@ export class JobService { } } - start(jobId: JobId): Promise { - return this.run(this.asQueueName(jobId)); + start(jobId: JobId, includeAllAssets: boolean): Promise { + return this.run(this.asQueueName(jobId), includeAllAssets); } async stop(jobId: JobId): Promise { @@ -36,7 +36,7 @@ export class JobService { return response; } - private async run(name: QueueName): Promise { + private async run(name: QueueName, includeAllAssets: boolean): Promise { const isActive = await this.jobRepository.isActive(name); if (isActive) { throw new BadRequestException(`Job is already running`); @@ -44,7 +44,9 @@ export class JobService { switch (name) { case QueueName.VIDEO_CONVERSION: { - const assets = await this._assetRepository.getAssetWithNoEncodedVideo(); + const assets = includeAllAssets + ? await this._assetRepository.getAllVideos() + : await this._assetRepository.getAssetWithNoEncodedVideo(); for (const asset of assets) { await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } }); } @@ -61,7 +63,10 @@ export class JobService { throw new BadRequestException('Machine learning is not enabled.'); } - const assets = await this._assetRepository.getAssetWithNoSmartInfo(); + const assets = includeAllAssets + ? await this._assetRepository.getAll() + : await this._assetRepository.getAssetWithNoSmartInfo(); + for (const asset of assets) { await this.jobRepository.add({ name: JobName.IMAGE_TAGGING, data: { asset } }); await this.jobRepository.add({ name: JobName.OBJECT_DETECTION, data: { asset } }); @@ -70,19 +75,37 @@ export class JobService { } case QueueName.METADATA_EXTRACTION: { - const assets = await this._assetRepository.getAssetWithNoEXIF(); + const assets = includeAllAssets + ? await this._assetRepository.getAll() + : await this._assetRepository.getAssetWithNoEXIF(); + for (const asset of assets) { if (asset.type === AssetType.VIDEO) { - await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } }); + await this.jobRepository.add({ + name: JobName.EXTRACT_VIDEO_METADATA, + data: { + asset, + fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath), + }, + }); } else { - await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } }); + await this.jobRepository.add({ + name: JobName.EXIF_EXTRACTION, + data: { + asset, + fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath), + }, + }); } } return assets.length; } case QueueName.THUMBNAIL_GENERATION: { - const assets = await this._assetRepository.getAssetWithNoThumbnail(); + const assets = includeAllAssets + ? await this._assetRepository.getAll() + : await this._assetRepository.getAssetWithNoThumbnail(); + for (const asset of assets) { await this.jobRepository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } }); } diff --git a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts index e77163bf02..55b7ce6864 100644 --- a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts +++ b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts @@ -1,9 +1,8 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Not, Repository } from 'typeorm'; -import { AssetEntity, AssetType, ExifEntity, UserEntity } from '@app/infra'; -import { ConfigService } from '@nestjs/config'; +import { UserEntity } from '@app/infra'; import { userUtils } from '@app/common'; import { IJobRepository, JobName } from '@app/domain'; @@ -13,93 +12,8 @@ export class ScheduleTasksService { @InjectRepository(UserEntity) private userRepository: Repository, - @InjectRepository(AssetEntity) - private assetRepository: Repository, - - @InjectRepository(ExifEntity) - private exifRepository: Repository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - - private configService: ConfigService, ) {} - - @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) - async webpConversion() { - const assets = await this.assetRepository.find({ - where: { - webpPath: '', - }, - }); - - if (assets.length == 0) { - Logger.log('All assets has webp file - aborting task', 'CronjobWebpGenerator'); - return; - } - - for (const asset of assets) { - await this.jobRepository.add({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } }); - } - } - - @Cron(CronExpression.EVERY_DAY_AT_1AM) - async videoConversion() { - const assets = await this.assetRepository.find({ - where: { - type: AssetType.VIDEO, - mimeType: 'video/quicktime', - encodedVideoPath: '', - }, - order: { - createdAt: 'DESC', - }, - }); - - for (const asset of assets) { - await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } }); - } - } - - @Cron(CronExpression.EVERY_DAY_AT_2AM) - async reverseGeocoding() { - const isGeocodingEnabled = this.configService.get('DISABLE_REVERSE_GEOCODING') !== 'true'; - - if (isGeocodingEnabled) { - const exifInfo = await this.exifRepository.find({ - where: { - city: IsNull(), - longitude: Not(IsNull()), - latitude: Not(IsNull()), - }, - }); - - for (const exif of exifInfo) { - await this.jobRepository.add({ - name: JobName.REVERSE_GEOCODING, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - data: { exifId: exif.id, latitude: exif.latitude!, longitude: exif.longitude! }, - }); - } - } - } - - @Cron(CronExpression.EVERY_DAY_AT_3AM) - async extractExif() { - const exifAssets = await this.assetRepository - .createQueryBuilder('asset') - .leftJoinAndSelect('asset.exifInfo', 'ei') - .where('ei."assetId" IS NULL') - .getMany(); - - for (const asset of exifAssets) { - if (asset.type === AssetType.VIDEO) { - await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } }); - } else { - await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } }); - } - } - } - @Cron(CronExpression.EVERY_DAY_AT_11PM) async deleteUserAndRelatedAssets() { const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } }); diff --git a/server/apps/immich/src/utils/file-name.util.ts b/server/apps/immich/src/utils/file-name.util.ts new file mode 100644 index 0000000000..b575ae5f0b --- /dev/null +++ b/server/apps/immich/src/utils/file-name.util.ts @@ -0,0 +1,5 @@ +import { basename, extname } from 'node:path'; + +export function getFileNameWithoutExtension(path: string): string { + return basename(path, extname(path)); +} diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 7e6e9dadd7..36f9ca3cb6 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -216,7 +216,7 @@ export class MetadataExtractionProcessor { } } - await this.exifRepository.save(newExif); + await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); } catch (error: any) { this.logger.error(`Error extracting EXIF ${error}`, error?.stack); } @@ -327,7 +327,7 @@ export class MetadataExtractionProcessor { } } - await this.exifRepository.save(newExif); + await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt }); } catch (err) { // do nothing diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index 52557e62fe..34ea3e297a 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/server/apps/microservices/src/processors/video-transcode.processor.ts @@ -11,6 +11,7 @@ import { Repository } from 'typeorm'; @Processor(QueueName.VIDEO_CONVERSION) export class VideoTranscodeProcessor { + readonly logger = new Logger(VideoTranscodeProcessor.name); constructor( @InjectRepository(AssetEntity) private assetRepository: Repository, @@ -20,7 +21,6 @@ export class VideoTranscodeProcessor { @Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 }) async videoConversion(job: Job) { const { asset } = job.data; - const basePath = APP_UPLOAD_LOCATION; const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`; @@ -30,17 +30,14 @@ export class VideoTranscodeProcessor { const savedEncodedPath = `${encodedVideoPath}/${asset.id}.mp4`; - if (!asset.encodedVideoPath) { - // Put the processing into its own async function to prevent the job exist right away - await this.runVideoEncode(asset, savedEncodedPath); - } + await this.runVideoEncode(asset, savedEncodedPath); } async runFFProbePipeline(asset: AssetEntity): Promise { return new Promise((resolve, reject) => { ffmpeg.ffprobe(asset.originalPath, (err, data) => { if (err || !data) { - Logger.error(`Cannot probe video ${err}`, 'mp4Conversion'); + this.logger.error(`Cannot probe video ${err}`, 'runFFProbePipeline'); reject(err); } @@ -88,14 +85,14 @@ export class VideoTranscodeProcessor { ]) .output(savedEncodedPath) .on('start', () => { - Logger.log('Start Converting Video', 'mp4Conversion'); + this.logger.log('Start Converting Video'); }) .on('error', (error) => { - Logger.error(`Cannot Convert Video ${error}`, 'mp4Conversion'); + this.logger.error(`Cannot Convert Video ${error}`); reject(); }) .on('end', async () => { - Logger.log(`Converting Success ${asset.id}`, 'mp4Conversion'); + this.logger.log(`Converting Success ${asset.id}`); await this.assetRepository.update({ id: asset.id }, { encodedVideoPath: savedEncodedPath }); resolve(); }) diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index eedc18f5c2..1641c9bf8b 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4538,10 +4538,14 @@ "properties": { "command": { "$ref": "#/components/schemas/JobCommand" + }, + "includeAllAssets": { + "type": "boolean" } }, "required": [ - "command" + "command", + "includeAllAssets" ] } } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index f7333f306d..a128f4f51e 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1203,6 +1203,12 @@ export interface JobCommandDto { * @memberof JobCommandDto */ 'command': JobCommand; + /** + * + * @type {boolean} + * @memberof JobCommandDto + */ + 'includeAllAssets': boolean; } /** * diff --git a/web/src/app.css b/web/src/app.css index a683e10174..9324af9ff7 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -101,4 +101,8 @@ input:focus-visible { display: none; scrollbar-width: none; } + + .job-play-button { + @apply h-full flex flex-col place-items-center place-content-center px-8 text-gray-600 transition-all hover:bg-immich-primary hover:text-white dark:text-gray-200 dark:hover:bg-immich-dark-primary text-sm dark:hover:text-black w-[120px] gap-2; + } } diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index 134ad611fe..9ea6839d15 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -1,76 +1,102 @@ -
-
-

- {title.toUpperCase()} -

-

{subtitle}

-

- -

- - - - - - - - - - - - - - - - -
StatusActiveWaiting
- {#if jobCounts} - {jobCounts.active > 0 || jobCounts.waiting > 0 ? 'Active' : 'Idle'} - {:else} - - {/if} - +
+
+
+
+ {title.toUpperCase()} +
+ + {#if subtitle.length > 0} +
{subtitle}
+ {/if} +
+ +
+
+

Active

+

{#if jobCounts.active !== undefined} {jobCounts.active} {:else} {/if} -

+

+ + +
+

{#if jobCounts.waiting !== undefined} {jobCounts.waiting} {:else} {/if} -

+

+

Waiting

+
+
+
-
- + {/if} + + {#if !isRunning} + {#if showOptions} + + {:else} - {buttonTitle} + {/if} - + {/if}
diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 901a11a0ee..815cfe6fe9 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -18,20 +18,28 @@ onMount(async () => { await load(); - timer = setInterval(async () => await load(), 5_000); + timer = setInterval(async () => await load(), 1_000); }); onDestroy(() => { clearInterval(timer); }); - const run = async (jobId: JobId, jobName: string, emptyMessage: string) => { + const run = async ( + jobId: JobId, + jobName: string, + emptyMessage: string, + includeAllAssets: boolean + ) => { try { - const { data } = await api.jobApi.sendJobCommand(jobId, { command: JobCommand.Start }); + const { data } = await api.jobApi.sendJobCommand(jobId, { + command: JobCommand.Start, + includeAllAssets + }); if (data) { notificationController.show({ - message: `Started ${jobName}`, + message: includeAllAssets ? `Started ${jobName} for all assets` : `Started ${jobName}`, type: NotificationType.Info }); } else { @@ -43,53 +51,77 @@ }; -
+
{#if jobs} - run(JobId.ThumbnailGeneration, 'thumbnail generation', 'No missing thumbnails found')} + subtitle={'Regenerate JPEG and WebP thumbnails'} + on:click={(e) => { + const { includeAllAssets } = e.detail; + + run( + JobId.ThumbnailGeneration, + 'thumbnail generation', + 'No missing thumbnails found', + includeAllAssets + ); + }} jobCounts={jobs[JobId.ThumbnailGeneration]} /> run(JobId.MetadataExtraction, 'extract EXIF', 'No missing EXIF found')} + title={'EXTRACT METADATA'} + subtitle={'Extract metadata information i.e. GPS, resolution...etc'} + on:click={(e) => { + const { includeAllAssets } = e.detail; + run(JobId.MetadataExtraction, 'extract EXIF', 'No missing EXIF found', includeAllAssets); + }} jobCounts={jobs[JobId.MetadataExtraction]} /> - run(JobId.MachineLearning, 'object detection', 'No missing object detection found')} + on:click={(e) => { + const { includeAllAssets } = e.detail; + + run( + JobId.MachineLearning, + 'object detection', + 'No missing object detection found', + includeAllAssets + ); + }} jobCounts={jobs[JobId.MachineLearning]} > - Note that some assets may not have any objects detected, this is normal. + Note that some assets may not have any objects detected + subtitle={'Transcode videos not in the desired format'} + on:click={(e) => { + const { includeAllAssets } = e.detail; run( JobId.VideoConversion, 'video conversion', - 'No videos without an encoded version found' - )} + 'No videos without an encoded version found', + includeAllAssets + ); + }} jobCounts={jobs[JobId.VideoConversion]} /> run( JobId.StorageTemplateMigration, 'storage template migration', - 'All files have been migrated to the new storage template' + 'All files have been migrated to the new storage template', + false )} jobCounts={jobs[JobId.StorageTemplateMigration]} > From d377cf0d0272d26870b5fa15fc446efb03197e3c Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Fri, 27 Jan 2023 00:16:28 -0500 Subject: [PATCH 09/16] feat(mobile): Add to album from asset detail view (#1413) * add to album from asset detail view * layout and design * added shared albums * fixed remote, asset update, and hit test * made static size * fixed create album * suppress shared expansion tile if there are no shared albums * updates album * padding on tile --- .../modules/album/ui/add_to_album_list.dart | 129 ++++++++++++++++++ .../album/ui/album_thumbnail_listtile.dart | 115 ++++++++++++++++ .../album/views/create_album_page.dart | 10 +- .../asset_viewer/ui/top_control_app_bar.dart | 14 ++ .../asset_viewer/views/gallery_viewer.dart | 18 +++ mobile/lib/routing/router.gr.dart | 35 +++-- 6 files changed, 309 insertions(+), 12 deletions(-) create mode 100644 mobile/lib/modules/album/ui/add_to_album_list.dart create mode 100644 mobile/lib/modules/album/ui/album_thumbnail_listtile.dart diff --git a/mobile/lib/modules/album/ui/add_to_album_list.dart b/mobile/lib/modules/album/ui/add_to_album_list.dart new file mode 100644 index 0000000000..30cb064022 --- /dev/null +++ b/mobile/lib/modules/album/ui/add_to_album_list.dart @@ -0,0 +1,129 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; +import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; +import 'package:immich_mobile/modules/album/services/album.service.dart'; +import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/drag_sheet.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:openapi/api.dart'; + +class AddToAlbumList extends HookConsumerWidget { + + /// The asset to add to an album + final Asset asset; + + const AddToAlbumList({ + Key? key, + required this.asset, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(albumProvider); + final albumService = ref.watch(albumServiceProvider); + final sharedAlbums = ref.watch(sharedAlbumProvider); + + useEffect( + () { + // Fetch album updates, e.g., cover image + ref.read(albumProvider.notifier).getAllAlbums(); + ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + + return null; + }, + [], + ); + + void addToAlbum(AlbumResponseDto album) async { + final result = await albumService.addAdditionalAssetToAlbum( + [asset], + album.id, + ); + + if (result != null) { + if (result.alreadyInAlbum.isNotEmpty) { + ImmichToast.show( + context: context, + msg: 'Already in ${album.albumName}', + ); + } else { + ImmichToast.show( + context: context, + msg: 'Added to ${album.albumName}', + ); + } + } + + ref.read(albumProvider.notifier).getAllAlbums(); + ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + + Navigator.pop(context); + } + + return Card( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15), + topRight: Radius.circular(15), + ), + ), + child: ListView( + padding: const EdgeInsets.all(18.0), + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Align( + alignment: Alignment.center, + child: CustomDraggingHandle(), + ), + const SizedBox(height: 12), + Text('Add to album', + style: Theme.of(context).textTheme.headline1, + ), + TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('New album'), + onPressed: () { + ref.watch(assetSelectionProvider.notifier).removeAll(); + ref.watch(assetSelectionProvider.notifier).addNewAssets([asset]); + AutoRouter.of(context).push( + CreateAlbumRoute( + isSharedAlbum: false, + initialAssets: [asset], + ), + ); + }, + ), + ], + ), + if (sharedAlbums.isNotEmpty) + ExpansionTile( + title: const Text('Shared'), + tilePadding: const EdgeInsets.symmetric(horizontal: 10.0), + leading: const Icon(Icons.group), + children: sharedAlbums.map((album) => + AlbumThumbnailListTile( + album: album, + onTap: () => addToAlbum(album), + ), + ).toList(), + ), + const SizedBox(height: 12), + ... albums.map((album) => + AlbumThumbnailListTile( + album: album, + onTap: () => addToAlbum(album), + ), + ).toList(), + ], + ), + ); + } +} diff --git a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart new file mode 100644 index 0000000000..2366924a85 --- /dev/null +++ b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart @@ -0,0 +1,115 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:openapi/api.dart'; + +class AlbumThumbnailListTile extends StatelessWidget { + const AlbumThumbnailListTile({ + Key? key, + required this.album, + this.onTap, + }) : super(key: key); + + final AlbumResponseDto album; + final void Function()? onTap; + + @override + Widget build(BuildContext context) { + var box = Hive.box(userInfoBox); + var cardSize = 68.0; + var isDarkMode = Theme.of(context).brightness == Brightness.dark; + + buildEmptyThumbnail() { + return Container( + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.grey[200], + ), + child: SizedBox( + height: cardSize, + width: cardSize, + child: const Center( + child: Icon(Icons.no_photography), + ), + ), + ); + } + + buildAlbumThumbnail() { + return CachedNetworkImage( + width: cardSize, + height: cardSize, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 200), + imageUrl: getAlbumThumbnailUrl( + album, + type: ThumbnailFormat.JPEG, + ), + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG), + ); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap ?? () { + AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id)); + }, + child: Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: album.albumThumbnailAssetId == null + ? buildEmptyThumbnail() + : buildAlbumThumbnail(), + ), + Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 8.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + album.albumName, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + album.assetCount == 1 + ? 'album_thumbnail_card_item' + : 'album_thumbnail_card_items', + style: const TextStyle( + fontSize: 12, + ), + ).tr(args: ['${album.assetCount}']), + if (album.shared) + const Text( + 'album_thumbnail_card_shared', + style: TextStyle( + fontSize: 12, + ), + ).tr() + ], + ) + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/album/views/create_album_page.dart b/mobile/lib/modules/album/views/create_album_page.dart index 18d3d978a8..fa3db46968 100644 --- a/mobile/lib/modules/album/views/create_album_page.dart +++ b/mobile/lib/modules/album/views/create_album_page.dart @@ -11,12 +11,18 @@ import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart'; import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; // ignore: must_be_immutable class CreateAlbumPage extends HookConsumerWidget { - bool isSharedAlbum; + final bool isSharedAlbum; + final List? initialAssets; - CreateAlbumPage({Key? key, required this.isSharedAlbum}) : super(key: key); + const CreateAlbumPage({ + Key? key, + required this.isSharedAlbum, + this.initialAssets, + }) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 0d45719dfe..a5c80dde89 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -11,6 +11,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget { required this.onDownloadPressed, required this.onSharePressed, required this.onDeletePressed, + required this.onAddToAlbumPressed, required this.onToggleMotionVideo, required this.isPlayingMotionVideo, }) : super(key: key); @@ -20,6 +21,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget { final VoidCallback? onDownloadPressed; final VoidCallback onToggleMotionVideo; final VoidCallback onDeletePressed; + final VoidCallback onAddToAlbumPressed; final Function onSharePressed; final bool isPlayingMotionVideo; @@ -80,6 +82,18 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget { color: Colors.grey[200], ), ), + if (asset.isRemote) + IconButton( + iconSize: iconSize, + splashRadius: iconSize, + onPressed: () { + onAddToAlbumPressed(); + }, + icon: Icon( + Icons.add, + color: Colors.grey[200], + ), + ), IconButton( iconSize: iconSize, splashRadius: iconSize, diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 000d85c699..22375da341 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/modules/album/ui/add_to_album_list.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; @@ -105,6 +106,22 @@ class GalleryViewerPage extends HookConsumerWidget { ); } + void addToAlbum(Asset addToAlbumAsset) { + showModalBottomSheet( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + ), + barrierColor: Colors.transparent, + backgroundColor: Colors.transparent, + context: context, + builder: (BuildContext _) { + return AddToAlbumList( + asset: addToAlbumAsset, + ); + }, + ); + } + return Scaffold( backgroundColor: Colors.black, appBar: TopControlAppBar( @@ -130,6 +147,7 @@ class GalleryViewerPage extends HookConsumerWidget { isPlayingMotionVideo.value = !isPlayingMotionVideo.value; }), onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])), + onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]), ), body: SafeArea( child: PageView.builder( diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index d2100398f7..897b532225 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -60,7 +60,8 @@ class _$AppRouter extends RootStackRouter { isZoomedFunction: args.isZoomedFunction, isZoomedListener: args.isZoomedListener, loadPreview: args.loadPreview, - loadOriginal: args.loadOriginal)); + loadOriginal: args.loadOriginal, + showExifSheet: args.showExifSheet)); }, VideoViewerRoute.name: (routeData) { final args = routeData.argsAs(); @@ -87,7 +88,9 @@ class _$AppRouter extends RootStackRouter { return MaterialPageX( routeData: routeData, child: CreateAlbumPage( - key: args.key, isSharedAlbum: args.isSharedAlbum)); + key: args.key, + isSharedAlbum: args.isSharedAlbum, + initialAssets: args.initialAssets)); }, AssetSelectionRoute.name: (routeData) { return CustomPage( @@ -307,7 +310,8 @@ class ImageViewerRoute extends PageRouteInfo { required void Function() isZoomedFunction, required ValueNotifier isZoomedListener, required bool loadPreview, - required bool loadOriginal}) + required bool loadOriginal, + void Function()? showExifSheet}) : super(ImageViewerRoute.name, path: '/image-viewer-page', args: ImageViewerRouteArgs( @@ -318,7 +322,8 @@ class ImageViewerRoute extends PageRouteInfo { isZoomedFunction: isZoomedFunction, isZoomedListener: isZoomedListener, loadPreview: loadPreview, - loadOriginal: loadOriginal)); + loadOriginal: loadOriginal, + showExifSheet: showExifSheet)); static const String name = 'ImageViewerRoute'; } @@ -332,7 +337,8 @@ class ImageViewerRouteArgs { required this.isZoomedFunction, required this.isZoomedListener, required this.loadPreview, - required this.loadOriginal}); + required this.loadOriginal, + this.showExifSheet}); final Key? key; @@ -350,9 +356,11 @@ class ImageViewerRouteArgs { final bool loadOriginal; + final void Function()? showExifSheet; + @override String toString() { - return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal}'; + return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal, showExifSheet: $showExifSheet}'; } } @@ -432,24 +440,31 @@ class SearchResultRouteArgs { /// generated route for /// [CreateAlbumPage] class CreateAlbumRoute extends PageRouteInfo { - CreateAlbumRoute({Key? key, required bool isSharedAlbum}) + CreateAlbumRoute( + {Key? key, required bool isSharedAlbum, List? initialAssets}) : super(CreateAlbumRoute.name, path: '/create-album-page', - args: CreateAlbumRouteArgs(key: key, isSharedAlbum: isSharedAlbum)); + args: CreateAlbumRouteArgs( + key: key, + isSharedAlbum: isSharedAlbum, + initialAssets: initialAssets)); static const String name = 'CreateAlbumRoute'; } class CreateAlbumRouteArgs { - const CreateAlbumRouteArgs({this.key, required this.isSharedAlbum}); + const CreateAlbumRouteArgs( + {this.key, required this.isSharedAlbum, this.initialAssets}); final Key? key; final bool isSharedAlbum; + final List? initialAssets; + @override String toString() { - return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum}'; + return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum, initialAssets: $initialAssets}'; } } From de0e2184403afc5b74985ea3972026c9d15a9570 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 27 Jan 2023 09:32:26 -0500 Subject: [PATCH 10/16] feat(web): add Favorites page (#1397) * Duplicate photos page and rename to favorites * Implement basic functionality to page * Sort imports * Add missing sharing code * Remove unused import * Fix formatting * Use GalleryViewer and new api endpoint * Merge useFavorites into page * Run prettier * Move favorites in side-bar * Remove favorites when unfavorited * Fix close shared link model * Add favorite count to side-bar * Add add to favorites option * Fix formatting * Add favorite icon to image thumbnails * Change var to let --- .../asset-viewer/asset-viewer.svelte | 21 +-- .../shared-components/immich-thumbnail.svelte | 25 ++-- .../side-bar/side-bar.svelte | 56 ++++++- web/src/lib/constants.ts | 1 + web/src/lib/stores/assets.store.ts | 20 ++- web/src/routes/favorites/+page.server.ts | 21 +++ web/src/routes/favorites/+page.svelte | 140 ++++++++++++++++++ .../favorites/[assetId]/+page.server.ts | 14 ++ .../routes/favorites/[assetId]/+page.svelte | 0 web/src/routes/photos/+page.svelte | 61 +++++--- 10 files changed, 312 insertions(+), 47 deletions(-) create mode 100644 web/src/routes/favorites/+page.server.ts create mode 100644 web/src/routes/favorites/+page.svelte create mode 100644 web/src/routes/favorites/[assetId]/+page.server.ts create mode 100644 web/src/routes/favorites/[assetId]/+page.svelte diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index b15e6c53db..36819c747f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,26 +1,26 @@ + +
+ +
+ +
+ + + + {#if isMultiSelectionMode} + + +

+ Selected {selectedAssets.size} +

+
+ + + + +
+ {/if} + + + {#if isShowCreateSharedLinkModal} + + {/if} + + +
+
+
+
+

Favorites

+
+
+ +
+
+
+ + + {#if favorites.length === 0} +
+ Empty shared album + +

+ Add favorites to quickly find your best pictures and videos +

+
+ {/if} + + +
+
+
diff --git a/web/src/routes/favorites/[assetId]/+page.server.ts b/web/src/routes/favorites/[assetId]/+page.server.ts new file mode 100644 index 0000000000..a215862c7b --- /dev/null +++ b/web/src/routes/favorites/[assetId]/+page.server.ts @@ -0,0 +1,14 @@ +import { redirect } from '@sveltejs/kit'; +export const prerender = false; + +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const { user } = await parent(); + + if (!user) { + throw redirect(302, '/auth/login'); + } else { + throw redirect(302, '/favorites'); + } +}; diff --git a/web/src/routes/favorites/[assetId]/+page.svelte b/web/src/routes/favorites/[assetId]/+page.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/src/routes/photos/+page.svelte b/web/src/routes/photos/+page.svelte index 43208f38ea..306f21f46c 100644 --- a/web/src/routes/photos/+page.svelte +++ b/web/src/routes/photos/+page.svelte @@ -1,33 +1,33 @@