diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index ffccb1286a0bb..94cbff6ebe393 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -112,7 +112,8 @@ The `immich-server` container will need access to the gallery. Modify your docke ``` :::tip -The `ro` flag at the end only gives read-only access to the volumes. This will disallow the images from being deleted in the web UI. +The `ro` flag at the end only gives read-only access to the volumes. +This will disallow the images from being deleted in the web UI, or adding metadata to the library ([XMP sidecars](/docs/features/xmp-sidecars)). ::: :::info diff --git a/docs/docs/guides/external-library.md b/docs/docs/guides/external-library.md index 07d1047ea087c..b44949818c5eb 100644 --- a/docs/docs/guides/external-library.md +++ b/docs/docs/guides/external-library.md @@ -7,7 +7,7 @@ in a directory on the same machine. # Mount the directory into the containers. Edit `docker-compose.yml` to add one or more new mount points in the section `immich-server:` under `volumes:`. -If you want Immich to be able to delete the images in the external library, remove `:ro` from the end of the mount point. +If you want Immich to be able to delete the images in the external library or add metadata ([XMP sidecars](/docs/features/xmp-sidecars)), remove `:ro` from the end of the mount point. ```diff immich-server: diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 436613d4a8522..b45ea4137f25a 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - name: immich-e2e services: diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 15fe3de3bec37..1964dc6793642 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -236,6 +236,32 @@ describe('/users', () => { const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } }); }); + + it('should require a boolean for download include embedded videos', async () => { + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ download: { includeEmbeddedVideos: 1_234_567.89 } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['download.includeEmbeddedVideos must be a boolean value'])); + }); + + it('should update download include embedded videos', async () => { + const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(before).toMatchObject({ download: { includeEmbeddedVideos: false } }); + + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ download: { includeEmbeddedVideos: true } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ download: { includeEmbeddedVideos: true } }); + + const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } }); + }); }); describe('GET /users/:id', () => { diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 3ab8875a4dbac..c06b4900e699d 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -38,11 +38,17 @@ FROM python:3.11-slim-bookworm@sha256:1c0c54195c7c7b46e61a2f3b906e9b55a8165f2038 FROM prod-cpu AS prod-openvino -COPY scripts/configure-apt.sh ./ -RUN ./configure-apt.sh && \ - apt-get update && \ - apt-get install -t unstable --no-install-recommends -yqq intel-opencl-icd && \ - rm configure-apt.sh +RUN apt-get update && \ + apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-core_1.0.17193.4_amd64.deb && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-opencl_1.0.17193.4_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd-dbgsym_24.26.30049.6_amd64.ddeb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd_24.26.30049.6_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/libigdgmm12_22.3.20_amd64.deb && \ + dpkg -i *.deb && \ + rm *.deb && \ + apt-get remove wget -yqq && \ + rm -rf /var/lib/apt/lists/* FROM nvidia/cuda:12.3.2-cudnn9-runtime-ubuntu22.04@sha256:fa44193567d1908f7ca1f3abf8623ce9c63bc8cba7bcfdb32702eb04d326f7a8 AS prod-cuda diff --git a/mobile/.fvmrc b/mobile/.fvmrc index cf7449069c42b..971587f297946 100644 --- a/mobile/.fvmrc +++ b/mobile/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.22.3" -} \ No newline at end of file + "flutter": "3.24.0" +} diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index c959187bb5e56..aa43dab3fb008 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.22.3", + "dart.flutterSdkPath": ".fvm/versions/3.24.0", "search.exclude": { "**/.fvm": true }, diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 9222b38de0b90..edb41510f0156 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -24,7 +24,7 @@ + android:largeHeap="true" android:enableOnBackInvokedCallback="true"> + android:value="true" /> - - - - @@ -98,4 +94,4 @@ - + \ No newline at end of file diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/AppGlideModule.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/AppGlideModule.kt similarity index 96% rename from mobile/android/app/src/main/kotlin/com/example/mobile/AppGlideModule.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/AppGlideModule.kt index da43d1c2685d6..f969b9576f89c 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/AppGlideModule.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/AppGlideModule.kt @@ -1,7 +1,7 @@ -package app.alextran.immich - -import com.bumptech.glide.annotation.GlideModule -import com.bumptech.glide.module.AppGlideModule - -@GlideModule +package app.alextran.immich + +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.module.AppGlideModule + +@GlideModule class AppGlideModule : AppGlideModule() \ No newline at end of file diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt similarity index 100% rename from mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt similarity index 100% rename from mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/ContentObserverWorker.kt similarity index 100% rename from mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/ContentObserverWorker.kt diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/ImmichApp.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt similarity index 97% rename from mobile/android/app/src/main/kotlin/com/example/mobile/ImmichApp.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt index 86b82d2be986c..ff806870f9dfd 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/ImmichApp.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt @@ -1,19 +1,19 @@ -package app.alextran.immich - -import android.app.Application -import androidx.work.Configuration -import androidx.work.WorkManager - -class ImmichApp : Application() { - override fun onCreate() { - super.onCreate() - val config = Configuration.Builder().build() - WorkManager.initialize(this, config) - // always start BackupWorker after WorkManager init; this fixes the following bug: - // After the process is killed (by user or system), the first trigger (taking a new picture) is lost. - // Thus, the BackupWorker is not started. If the system kills the process after each initialization - // (because of low memory etc.), the backup is never performed. - // As a workaround, we also run a backup check when initializing the application - ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0) - } +package app.alextran.immich + +import android.app.Application +import androidx.work.Configuration +import androidx.work.WorkManager + +class ImmichApp : Application() { + override fun onCreate() { + super.onCreate() + val config = Configuration.Builder().build() + WorkManager.initialize(this, config) + // always start BackupWorker after WorkManager init; this fixes the following bug: + // After the process is killed (by user or system), the first trigger (taking a new picture) is lost. + // Thus, the BackupWorker is not started. If the system kills the process after each initialization + // (because of low memory etc.), the backup is never performed. + // As a workaround, we also run a backup check when initializing the application + ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0) + } } \ No newline at end of file diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt similarity index 96% rename from mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 5df36cb18fa48..4ffb490c77ea3 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -1,15 +1,15 @@ -package app.alextran.immich - -import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine -import android.os.Bundle -import android.content.Intent - -class MainActivity : FlutterActivity() { - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - flutterEngine.plugins.add(BackgroundServicePlugin()) - } - -} +package app.alextran.immich + +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import android.os.Bundle +import android.content.Intent + +class MainActivity : FlutterActivity() { + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.plugins.add(BackgroundServicePlugin()) + } + +} diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index 9b5e515a68f5a..87cc79281dd37 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -10,6 +10,18 @@ allprojects { rootProject.buildDir = '../build' subprojects { + // fix for verifyReleaseResources + // ============ + afterEvaluate { project -> + if (project.plugins.hasPlugin("com.android.application") || + project.plugins.hasPlugin("com.android.library")) { + project.android { + compileSdkVersion 34 + buildToolsVersion "34.0.0" + } + } + } + // ============ project.buildDir = "${rootProject.buildDir}/${project.name}" } @@ -23,4 +35,5 @@ tasks.register("clean", Delete) { tasks.named('wrapper') { distributionType = Wrapper.DistributionType.ALL -} \ No newline at end of file +} + diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index e3603eef4220a..3b361c4e1902f 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -202,7 +202,7 @@ SPEC CHECKSUMS: fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 - integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index a73b6417c6ad3..05cb061ca58b2 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -6,7 +6,7 @@ import path_provider_ios import photo_manager import permission_handler_apple -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 916c1ad3d3074..dc1df746cb964 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -39,7 +39,6 @@ import 'package:path_provider/path_provider.dart'; void main() async { ImmichWidgetsBinding(); - final db = await loadDb(); await initApp(); await migrateDatabaseIfNeeded(db); @@ -73,6 +72,7 @@ Future initApp() async { var log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { + debugPrint("FlutterError - Catch all: $details"); FlutterError.presentError(details); log.severe( 'FlutterError - Catch all', diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 8c2c70d93cc1a..cc62620dfb239 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -264,7 +264,7 @@ class GalleryViewerPage extends HookConsumerWidget { return PopScope( // Change immersive mode back to normal "edgeToEdge" mode - onPopInvoked: (_) => + onPopInvokedWithResult: (didPop, _) => SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge), child: Scaffold( backgroundColor: Colors.black, diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index e2a816bce11b2..7f6ee3e4e2860 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -74,7 +74,7 @@ class HeaderSettingsPage extends HookConsumerWidget { ], ), body: PopScope( - onPopInvoked: (_) => saveHeaders(headers.value), + onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value), child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0), itemCount: list.length, diff --git a/mobile/lib/pages/common/tab_controller.page.dart b/mobile/lib/pages/common/tab_controller.page.dart index a48e9e92be1ed..b619e003d2c3a 100644 --- a/mobile/lib/pages/common/tab_controller.page.dart +++ b/mobile/lib/pages/common/tab_controller.page.dart @@ -177,7 +177,7 @@ class TabControllerPage extends HookConsumerWidget { final tabsRouter = AutoTabsRouter.of(context); return PopScope( canPop: tabsRouter.activeIndex == 0, - onPopInvoked: (didPop) => + onPopInvokedWithResult: (didPop, _) => !didPop ? tabsRouter.setActiveIndex(0) : null, child: LayoutBuilder( builder: (context, constraints) { diff --git a/mobile/lib/pages/common/video_viewer.page.dart b/mobile/lib/pages/common/video_viewer.page.dart index 527411ec89634..573f7277f2e4c 100644 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ b/mobile/lib/pages/common/video_viewer.page.dart @@ -123,7 +123,7 @@ class VideoViewerPage extends HookConsumerWidget { final size = MediaQuery.sizeOf(context); return PopScope( - onPopInvoked: (pop) { + onPopInvokedWithResult: (didPop, _) { ref.read(videoPlaybackValueProvider.notifier).value = VideoPlaybackValue.uninitialized(); }, diff --git a/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart b/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart index 94a01a57c5b30..4490da7aedac1 100644 --- a/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart +++ b/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart @@ -59,6 +59,8 @@ class DraggableScrollbar extends StatefulWidget { final Function(bool scrolling) scrollStateListener; + final double viewPortHeight; + DraggableScrollbar.semicircle({ super.key, Key? scrollThumbKey, @@ -67,6 +69,7 @@ class DraggableScrollbar extends StatefulWidget { required this.controller, required this.itemPositionsListener, required this.scrollStateListener, + required this.viewPortHeight, this.heightScrollThumb = 48.0, this.backgroundColor = Colors.white, this.padding, @@ -251,7 +254,7 @@ class DraggableScrollbarState extends State } double get barMaxScrollExtent => - (context.size?.height ?? 0) - + widget.viewPortHeight - widget.heightScrollThumb - (widget.heightOffset ?? 0); @@ -316,37 +319,39 @@ class DraggableScrollbarState extends State } setState(() { - int firstItemIndex = - widget.itemPositionsListener.itemPositions.value.first.index; + try { + int firstItemIndex = + widget.itemPositionsListener.itemPositions.value.first.index; - if (notification is ScrollUpdateNotification) { - _barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent; + if (notification is ScrollUpdateNotification) { + _barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent; - if (_barOffset < barMinScrollExtent) { - _barOffset = barMinScrollExtent; - } - if (_barOffset > barMaxScrollExtent) { - _barOffset = barMaxScrollExtent; - } - } - - if (notification is ScrollUpdateNotification || - notification is OverscrollNotification) { - if (_thumbAnimationController.status != AnimationStatus.forward) { - _thumbAnimationController.forward(); + if (_barOffset < barMinScrollExtent) { + _barOffset = barMinScrollExtent; + } + if (_barOffset > barMaxScrollExtent) { + _barOffset = barMaxScrollExtent; + } } - if (itemPos < maxItemCount) { - _currentItem = itemPos; - } + if (notification is ScrollUpdateNotification || + notification is OverscrollNotification) { + if (_thumbAnimationController.status != AnimationStatus.forward) { + _thumbAnimationController.forward(); + } - _fadeoutTimer?.cancel(); - _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () { - _thumbAnimationController.reverse(); - _labelAnimationController.reverse(); - _fadeoutTimer = null; - }); - } + if (itemPos < maxItemCount) { + _currentItem = itemPos; + } + + _fadeoutTimer?.cancel(); + _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () { + _thumbAnimationController.reverse(); + _labelAnimationController.reverse(); + _fadeoutTimer = null; + }); + } + } catch (_) {} }); } diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index ea65031a0cd0c..8ae74ba120f59 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -262,8 +262,9 @@ class ImmichAssetGridViewState extends ConsumerState { shrinkWrap: widget.shrinkWrap, ); - final child = useDragScrolling + final child = (useDragScrolling && ModalRoute.of(context) != null) ? DraggableScrollbar.semicircle( + viewPortHeight: context.height, scrollStateListener: dragScrolling, itemPositionsListener: _itemPositionsListener, controller: _itemScrollController, @@ -281,6 +282,7 @@ class ImmichAssetGridViewState extends ConsumerState { child: listWidget, ) : listWidget; + return widget.onRefresh == null ? child : appBarOffset() @@ -528,7 +530,7 @@ class ImmichAssetGridViewState extends ConsumerState { Widget build(BuildContext context) { return PopScope( canPop: !(widget.selectionActive && _selectedAssets.isNotEmpty), - onPopInvoked: (didPop) => !didPop ? _deselectAll() : null, + onPopInvokedWithResult: (didPop, _) => !didPop ? _deselectAll() : null, child: Stack( children: [ AssetDragRegion( diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 455a19fcdb967..8e2465fc9ca3d 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -58,7 +58,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { isLabelVisible: serverInfoState.isVersionMismatch || ((user?.isAdmin ?? false) && serverInfoState.isNewReleaseAvailable), - offset: const Offset(2, 2), + offset: const Offset(-2, -12), child: user == null ? const Icon( Icons.face_outlined, @@ -132,7 +132,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { backgroundColor: Colors.transparent, alignment: Alignment.bottomRight, isLabelVisible: indicatorIcon != null, - offset: const Offset(2, 2), + offset: const Offset(-2, -12), child: Icon( Icons.backup_rounded, size: widgetSize, diff --git a/mobile/openapi/lib/model/download_response.dart b/mobile/openapi/lib/model/download_response.dart index 8973e17ebe474..25c5159a8b655 100644 --- a/mobile/openapi/lib/model/download_response.dart +++ b/mobile/openapi/lib/model/download_response.dart @@ -14,25 +14,31 @@ class DownloadResponse { /// Returns a new [DownloadResponse] instance. DownloadResponse({ required this.archiveSize, + this.includeEmbeddedVideos = false, }); int archiveSize; + bool includeEmbeddedVideos; + @override bool operator ==(Object other) => identical(this, other) || other is DownloadResponse && - other.archiveSize == archiveSize; + other.archiveSize == archiveSize && + other.includeEmbeddedVideos == includeEmbeddedVideos; @override int get hashCode => // ignore: unnecessary_parenthesis - (archiveSize.hashCode); + (archiveSize.hashCode) + + (includeEmbeddedVideos.hashCode); @override - String toString() => 'DownloadResponse[archiveSize=$archiveSize]'; + String toString() => 'DownloadResponse[archiveSize=$archiveSize, includeEmbeddedVideos=$includeEmbeddedVideos]'; Map toJson() { final json = {}; json[r'archiveSize'] = this.archiveSize; + json[r'includeEmbeddedVideos'] = this.includeEmbeddedVideos; return json; } @@ -45,6 +51,7 @@ class DownloadResponse { return DownloadResponse( archiveSize: mapValueOfType(json, r'archiveSize')!, + includeEmbeddedVideos: mapValueOfType(json, r'includeEmbeddedVideos')!, ); } return null; @@ -93,6 +100,7 @@ class DownloadResponse { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'archiveSize', + 'includeEmbeddedVideos', }; } diff --git a/mobile/openapi/lib/model/download_update.dart b/mobile/openapi/lib/model/download_update.dart index 1629706415de3..2c3839a6878dc 100644 --- a/mobile/openapi/lib/model/download_update.dart +++ b/mobile/openapi/lib/model/download_update.dart @@ -14,6 +14,7 @@ class DownloadUpdate { /// Returns a new [DownloadUpdate] instance. DownloadUpdate({ this.archiveSize, + this.includeEmbeddedVideos, }); /// Minimum value: 1 @@ -25,17 +26,27 @@ class DownloadUpdate { /// int? archiveSize; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? includeEmbeddedVideos; + @override bool operator ==(Object other) => identical(this, other) || other is DownloadUpdate && - other.archiveSize == archiveSize; + other.archiveSize == archiveSize && + other.includeEmbeddedVideos == includeEmbeddedVideos; @override int get hashCode => // ignore: unnecessary_parenthesis - (archiveSize == null ? 0 : archiveSize!.hashCode); + (archiveSize == null ? 0 : archiveSize!.hashCode) + + (includeEmbeddedVideos == null ? 0 : includeEmbeddedVideos!.hashCode); @override - String toString() => 'DownloadUpdate[archiveSize=$archiveSize]'; + String toString() => 'DownloadUpdate[archiveSize=$archiveSize, includeEmbeddedVideos=$includeEmbeddedVideos]'; Map toJson() { final json = {}; @@ -44,6 +55,11 @@ class DownloadUpdate { } else { // json[r'archiveSize'] = null; } + if (this.includeEmbeddedVideos != null) { + json[r'includeEmbeddedVideos'] = this.includeEmbeddedVideos; + } else { + // json[r'includeEmbeddedVideos'] = null; + } return json; } @@ -56,6 +72,7 @@ class DownloadUpdate { return DownloadUpdate( archiveSize: mapValueOfType(json, r'archiveSize'), + includeEmbeddedVideos: mapValueOfType(json, r'includeEmbeddedVideos'), ); } return null; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5c62b95227688..14b487ce4dd48 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -5,23 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "5aaf60d96c4cd00fe7f21594b5ad6a1b699c80a27420f8a837f4d68473ef09e3" + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 url: "https://pub.dev" source: hosted - version: "68.0.0" + version: "72.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.1.0" + version: "0.3.2" analyzer: dependency: "direct overridden" description: name: analyzer - sha256: "21f1d3720fd1c70316399d5e2bccaebb415c434592d778cce8acb967b8578808" + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.7.0" analyzer_plugin: dependency: "direct overridden" description: @@ -540,10 +540,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "55b9b229307a10974b26296ff29f2e132256ba4bd74266939118eaefa941cb00" + sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3 url: "https://pub.dev" source: hosted - version: "16.3.3" + version: "17.2.1+2" flutter_local_notifications_linux: dependency: transitive description: @@ -901,18 +901,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -941,10 +941,10 @@ packages: dependency: transitive description: name: macros - sha256: "12e8a9842b5a7390de7a781ec63d793527582398d16ea26c60fed58833c9ae79" + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" url: "https://pub.dev" source: hosted - version: "0.1.0-main.0" + version: "0.1.2-main.4" maplibre_gl: dependency: "direct main" description: @@ -981,10 +981,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: "direct overridden" description: @@ -1212,10 +1212,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: @@ -1537,10 +1537,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" thumbhash: dependency: "direct main" description: @@ -1737,10 +1737,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.4" wakelock_plus: dependency: "direct main" description: @@ -1847,4 +1847,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.22.3" + flutter: ">=3.24.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2551acce48e8c..51a31a24e3aa6 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -6,7 +6,7 @@ version: 1.112.1+154 environment: sdk: '>=3.3.0 <4.0.0' - flutter: 3.22.3 + flutter: 3.24.0 dependencies: flutter: @@ -50,7 +50,7 @@ dependencies: device_info_plus: ^9.1.1 connectivity_plus: ^5.0.2 wakelock_plus: ^1.1.4 - flutter_local_notifications: ^16.3.2 + flutter_local_notifications: ^17.2.1+2 timezone: ^0.9.2 octo_image: ^2.0.0 thumbhash: 0.1.0+1 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index aa0d9fa2bb186..63d22aa4f9dc6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8497,10 +8497,15 @@ "properties": { "archiveSize": { "type": "integer" + }, + "includeEmbeddedVideos": { + "default": false, + "type": "boolean" } }, "required": [ - "archiveSize" + "archiveSize", + "includeEmbeddedVideos" ], "type": "object" }, @@ -8527,6 +8532,9 @@ "archiveSize": { "minimum": 1, "type": "integer" + }, + "includeEmbeddedVideos": { + "type": "boolean" } }, "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d270f09e508bb..077e802b8c580 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -86,6 +86,7 @@ export type AvatarResponse = { }; export type DownloadResponse = { archiveSize: number; + includeEmbeddedVideos: boolean; }; export type EmailNotificationsResponse = { albumInvite: boolean; @@ -115,6 +116,7 @@ export type AvatarUpdate = { }; export type DownloadUpdate = { archiveSize?: number; + includeEmbeddedVideos?: boolean; }; export type EmailNotificationsUpdate = { albumInvite?: boolean; diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 541f7dc65985d..1a8a05fd4d77a 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -5,6 +5,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@ne import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; +import _ from 'lodash'; import { ClsModule } from 'nestjs-cls'; import { OpenTelemetryModule } from 'nestjs-otel'; import { commands } from 'src/commands'; @@ -13,6 +14,7 @@ import { controllers } from 'src/controllers'; import { databaseConfig } from 'src/database.config'; import { entities } from 'src/entities'; import { IEventRepository } from 'src/interfaces/event.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthGuard } from 'src/middleware/auth.guard'; import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; @@ -54,15 +56,25 @@ export class ApiModule implements OnModuleInit, OnModuleDestroy { constructor( private moduleRef: ModuleRef, @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, ) {} async onModuleInit() { - setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrapEvent', 'api'); + const items = setupEventHandlers(this.moduleRef); + + await this.eventRepository.emit('onBootstrap', 'api'); + + this.logger.setContext('EventLoader'); + const eventMap = _.groupBy(items, 'event'); + for (const [event, handlers] of Object.entries(eventMap)) { + for (const { priority, label } of handlers) { + this.logger.verbose(`Added ${event} {${label}${priority ? '' : ', ' + priority}} event`); + } + } } async onModuleDestroy() { - await this.eventRepository.emit('onShutdownEvent'); + await this.eventRepository.emit('onShutdown'); } } @@ -78,11 +90,11 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { async onModuleInit() { setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrapEvent', 'microservices'); + await this.eventRepository.emit('onBootstrap', 'microservices'); } async onModuleDestroy() { - await this.eventRepository.emit('onShutdownEvent'); + await this.eventRepository.emit('onShutdown'); } } diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 1c632e549a342..2316e114e885e 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -4,7 +4,7 @@ import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; -import { ServerEvent } from 'src/interfaces/event.interface'; +import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface'; import { Metadata } from 'src/middleware/auth.guard'; import { setUnion } from 'src/utils/set'; @@ -136,11 +136,12 @@ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GEN export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) => OnEvent(event, { suppressErrors: false, ...options }); -export type HandlerOptions = { +export type EmitConfig = { + event: EmitEvent; /** lower value has higher priority, defaults to 0 */ - priority: number; + priority?: number; }; -export const EventHandlerOptions = (options: HandlerOptions) => SetMetadata(Metadata.EVENT_HANDLER_OPTIONS, options); +export const OnEmit = (config: EmitConfig) => SetMetadata(Metadata.ON_EMIT_CONFIG, config); type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleMetadata = { diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index c3b2c051af0d2..7ccf6cd78bbb3 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -33,12 +33,15 @@ class EmailNotificationsUpdate { albumUpdate?: boolean; } -class DownloadUpdate { +class DownloadUpdate implements Partial { @Optional() @IsInt() @IsPositive() @ApiProperty({ type: 'integer' }) archiveSize?: number; + + @ValidateBoolean({ optional: true }) + includeEmbeddedVideos?: boolean; } class PurchaseUpdate { @@ -104,6 +107,8 @@ class EmailNotificationsResponse { class DownloadResponse { @ApiProperty({ type: 'integer' }) archiveSize!: number; + + includeEmbeddedVideos: boolean = false; } class PurchaseResponse { diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 2dcb570935c4e..eadcdeec57eb0 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -35,6 +35,7 @@ export interface UserPreferences { }; download: { archiveSize: number; + includeEmbeddedVideos: boolean; }; purchase: { showSupportBadge: boolean; @@ -65,6 +66,7 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences }, download: { archiveSize: HumanReadableSize.GiB * 4, + includeEmbeddedVideos: false, }, purchase: { showSupportBadge: true, diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 828531fdf3d91..613a6423a4534 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -4,41 +4,27 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d export const IEventRepository = 'IEventRepository'; -export type SystemConfigUpdateEvent = { newConfig: SystemConfig; oldConfig: SystemConfig }; -export type AlbumUpdateEvent = { - id: string; - /** user id */ - updatedBy: string; -}; -export type AlbumInviteEvent = { id: string; userId: string }; -export type UserSignupEvent = { notify: boolean; id: string; tempPassword?: string }; - -type MaybePromise = Promise | T; -type Handler = (data: T) => MaybePromise; - -const noop = () => {}; -const dummyHandlers = { +type EmitEventMap = { // app events - onBootstrapEvent: noop as Handler<'api' | 'microservices'>, - onShutdownEvent: noop as () => MaybePromise, + onBootstrap: ['api' | 'microservices']; + onShutdown: []; // config events - onConfigUpdateEvent: noop as Handler, - onConfigValidateEvent: noop as Handler, + onConfigUpdate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + onConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events - onAlbumUpdateEvent: noop as Handler, - onAlbumInviteEvent: noop as Handler, + onAlbumUpdate: [{ id: string; updatedBy: string }]; + onAlbumInvite: [{ id: string; userId: string }]; // user events - onUserSignupEvent: noop as Handler, + onUserSignup: [{ notify: boolean; id: string; tempPassword?: string }]; }; -export type EventHandlers = typeof dummyHandlers; -export type EmitEvent = keyof EventHandlers; -export type EmitEventHandler = (...args: Parameters) => MaybePromise; -export const events = Object.keys(dummyHandlers) as EmitEvent[]; -export type OnEvents = Partial; +export type EmitEvent = keyof EmitEventMap; +export type EmitHandler = (...args: ArgsOf) => Promise | void; +export type ArgOf = EmitEventMap[T][0]; +export type ArgsOf = EmitEventMap[T]; export enum ClientEvent { UPLOAD_SUCCESS = 'on_upload_success', @@ -81,8 +67,8 @@ export interface ServerEventMap { } export interface IEventRepository { - on(event: T, handler: EmitEventHandler): void; - emit(event: T, ...args: Parameters>): Promise; + on(event: T, handler: EmitHandler): void; + emit(event: T, ...args: ArgsOf): Promise; /** * Send to connected clients for a specific user diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index c4aa928dbda63..beab484950d48 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -20,7 +20,7 @@ export enum Metadata { ADMIN_ROUTE = 'admin_route', SHARED_ROUTE = 'shared_route', API_KEY_SECURITY = 'api_key', - EVENT_HANDLER_OPTIONS = 'event_handler_options', + ON_EMIT_CONFIG = 'on_emit_config', } type AdminRoute = { admin?: true }; diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 0bb973b29394a..668eac48d9de9 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -9,9 +9,10 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { + ArgsOf, ClientEventMap, EmitEvent, - EmitEventHandler, + EmitHandler, IEventRepository, ServerEvent, ServerEventMap, @@ -20,6 +21,8 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService } from 'src/services/auth.service'; import { Instrumentation } from 'src/utils/instrumentation'; +type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler[] }>; + @Instrumentation() @WebSocketGateway({ cors: true, @@ -28,7 +31,7 @@ import { Instrumentation } from 'src/utils/instrumentation'; }) @Injectable() export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, IEventRepository { - private emitHandlers: Partial[]>> = {}; + private emitHandlers: EmitHandlers = {}; @WebSocketServer() private server?: Server; @@ -78,12 +81,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect await client.leave(client.nsp.name); } - on(event: T, handler: EmitEventHandler): void { - const handlers: EmitEventHandler[] = this.emitHandlers[event] || []; - this.emitHandlers[event] = [...handlers, handler]; + on(event: T, handler: EmitHandler): void { + if (!this.emitHandlers[event]) { + this.emitHandlers[event] = []; + } + + this.emitHandlers[event].push(handler); } - async emit(event: T, ...args: Parameters>): Promise { + async emit(event: T, ...args: ArgsOf): Promise { const handlers = this.emitHandlers[event] || []; for (const handler of handlers) { await handler(...args); diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 41f8930733b01..6db39328df53e 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -380,7 +380,7 @@ describe(AlbumService.name, () => { userId: authStub.user2.user.id, albumId: albumStub.sharedWithAdmin.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInviteEvent', { + expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInvite', { id: albumStub.sharedWithAdmin.id, userId: userStub.user2.id, }); @@ -568,7 +568,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdateEvent', { + expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', { id: 'album-123', updatedBy: authStub.admin.user.id, }); @@ -612,7 +612,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdateEvent', { + expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', { id: 'album-123', updatedBy: authStub.user1.user.id, }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index f8108ad0651dc..71594d20b6178 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -187,7 +187,7 @@ export class AlbumService { albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId, }); - await this.eventRepository.emit('onAlbumUpdateEvent', { id, updatedBy: auth.user.id }); + await this.eventRepository.emit('onAlbumUpdate', { id, updatedBy: auth.user.id }); } return results; @@ -235,7 +235,7 @@ export class AlbumService { } await this.albumUserRepository.create({ userId: userId, albumId: id, role }); - await this.eventRepository.emit('onAlbumInviteEvent', { id, userId }); + await this.eventRepository.emit('onAlbumInvite', { id, userId }); } return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets); diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index a21b1d7d6778b..c63428560e03c 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -45,7 +45,7 @@ describe(DatabaseService.name, () => { it('should throw an error if PostgreSQL version is below minimum supported version', async () => { databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); + await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); }); @@ -65,7 +65,7 @@ describe(DatabaseService.name, () => { availableVersion: minVersionInRange, }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); @@ -79,7 +79,7 @@ describe(DatabaseService.name, () => { databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); const message = `The ${extensionName} extension is not available in this Postgres instance. If using a container image, ensure the image has the extension installed.`; - await expect(sut.onBootstrapEvent()).rejects.toThrow(message); + await expect(sut.onBootstrap()).rejects.toThrow(message); expect(databaseMock.createExtension).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled(); @@ -91,7 +91,7 @@ describe(DatabaseService.name, () => { availableVersion: versionBelowRange, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow( + await expect(sut.onBootstrap()).rejects.toThrow( `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, ); @@ -101,7 +101,7 @@ describe(DatabaseService.name, () => { it(`should throw an error if ${extension} extension version is a nightly`, async () => { databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); - await expect(sut.onBootstrapEvent()).rejects.toThrow( + await expect(sut.onBootstrap()).rejects.toThrow( `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, ); @@ -117,7 +117,7 @@ describe(DatabaseService.name, () => { }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); @@ -132,7 +132,7 @@ describe(DatabaseService.name, () => { installedVersion: minVersionInRange, }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); @@ -145,7 +145,7 @@ describe(DatabaseService.name, () => { installedVersion: null, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow(); + await expect(sut.onBootstrap()).rejects.toThrow(); expect(databaseMock.createExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); @@ -159,7 +159,7 @@ describe(DatabaseService.name, () => { installedVersion: minVersionInRange, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow(); + await expect(sut.onBootstrap()).rejects.toThrow(); expect(databaseMock.createExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); @@ -173,7 +173,7 @@ describe(DatabaseService.name, () => { installedVersion: updateInRange, }); - await expect(sut.onBootstrapEvent()).rejects.toThrow( + await expect(sut.onBootstrap()).rejects.toThrow( `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, ); @@ -189,7 +189,7 @@ describe(DatabaseService.name, () => { }); databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to update extension'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension'); expect(loggerMock.warn.mock.calls[0][0]).toContain( `The ${extensionName} extension can be updated to ${updateInRange}.`, @@ -206,7 +206,7 @@ describe(DatabaseService.name, () => { }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(loggerMock.warn).toHaveBeenCalledTimes(1); expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); @@ -218,7 +218,7 @@ describe(DatabaseService.name, () => { it(`should reindex ${extension} indices if needed`, async () => { databaseMock.shouldReindex.mockResolvedValue(true); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); expect(databaseMock.reindex).toHaveBeenCalledTimes(2); @@ -229,7 +229,7 @@ describe(DatabaseService.name, () => { it(`should not reindex ${extension} indices if not needed`, async () => { databaseMock.shouldReindex.mockResolvedValue(false); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); expect(databaseMock.reindex).toHaveBeenCalledTimes(0); @@ -240,7 +240,7 @@ describe(DatabaseService.name, () => { it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { process.env.DB_SKIP_MIGRATIONS = 'true'; - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); @@ -255,7 +255,7 @@ describe(DatabaseService.name, () => { databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); expect(loggerMock.fatal).toHaveBeenCalledTimes(1); expect(loggerMock.fatal.mock.calls[0][0]).toContain( @@ -274,7 +274,7 @@ describe(DatabaseService.name, () => { databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); expect(loggerMock.fatal).toHaveBeenCalledTimes(1); expect(loggerMock.fatal.mock.calls[0][0]).toContain( diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index a2f43c58bac6f..b6d61c578d79c 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import semver from 'semver'; import { getVectorExtension } from 'src/database.config'; -import { EventHandlerOptions } from 'src/decorators'; +import { OnEmit } from 'src/decorators'; import { DatabaseExtension, DatabaseLock, @@ -10,7 +10,6 @@ import { VectorExtension, VectorIndex, } from 'src/interfaces/database.interface'; -import { OnEvents } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; type CreateFailedArgs = { name: string; extension: string; otherName: string }; @@ -61,7 +60,7 @@ const messages = { }; @Injectable() -export class DatabaseService implements OnEvents { +export class DatabaseService { constructor( @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @@ -69,8 +68,8 @@ export class DatabaseService implements OnEvents { this.logger.setContext(DatabaseService.name); } - @EventHandlerOptions({ priority: -200 }) - async onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap', priority: -200 }) + async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); const postgresRange = this.databaseRepository.getPostgresVersionRange(); diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 2d3c11a6f15da..14fa7bab48f48 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -226,5 +226,31 @@ describe(DownloadService.name, () => { ], }); }); + + it('should skip the video portion of an android live photo by default', async () => { + const assetIds = [assetStub.livePhotoStillAsset.id]; + const assets = [ + assetStub.livePhotoStillAsset, + { ...assetStub.livePhotoMotionAsset, originalPath: 'upload/encoded-video/uuid-MP.mp4' }, + ]; + + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); + assetMock.getByIds.mockImplementation( + (ids) => + Promise.resolve( + ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), + ) as Promise, + ); + + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ + totalSize: 25_000, + archives: [ + { + assetIds: [assetStub.livePhotoStillAsset.id], + size: 25_000, + }, + ], + }); + }); }); }); diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 157142d906b87..1ff9e51576ba0 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { parse } from 'node:path'; import { AccessCore } from 'src/cores/access.core'; +import { StorageCore } from 'src/cores/storage.core'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; @@ -12,6 +13,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ImmichReadStream, IStorageRepository } from 'src/interfaces/storage.interface'; import { HumanReadableSize } from 'src/utils/bytes'; import { usePagination } from 'src/utils/pagination'; +import { getPreferences } from 'src/utils/preferences'; @Injectable() export class DownloadService { @@ -32,12 +34,22 @@ export class DownloadService { const archives: DownloadArchiveInfo[] = []; let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; + const preferences = getPreferences(auth.user); + const assetPagination = await this.getDownloadAssets(auth, dto); for await (const assets of assetPagination) { // motion part of live photos - const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); + const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); if (motionIds.length > 0) { - assets.push(...(await this.assetRepository.getByIds(motionIds, { exifInfo: true }))); + const motionAssets = await this.assetRepository.getByIds(motionIds, { exifInfo: true }); + for (const motionAsset of motionAssets) { + if ( + !StorageCore.isAndroidMotionPath(motionAsset.originalPath) || + preferences.download.includeEmbeddedVideos + ) { + assets.push(motionAsset); + } + } } for (const asset of assets) { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 7f81fd44aa82f..8a74ec918996c 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -73,7 +73,7 @@ describe(LibraryService.name, () => { it('should init cron job and subscribe to config changes', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(systemMock.get).toHaveBeenCalled(); expect(jobMock.addCronJob).toHaveBeenCalled(); @@ -105,7 +105,7 @@ describe(LibraryService.name, () => { ), ); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(storageMock.watch.mock.calls).toEqual( expect.arrayContaining([ @@ -118,7 +118,7 @@ describe(LibraryService.name, () => { it('should not initialize watcher when watching is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(storageMock.watch).not.toHaveBeenCalled(); }); @@ -127,7 +127,7 @@ describe(LibraryService.name, () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); databaseMock.tryLock.mockResolvedValue(false); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); expect(storageMock.watch).not.toHaveBeenCalled(); }); @@ -136,7 +136,7 @@ describe(LibraryService.name, () => { describe('onConfigValidateEvent', () => { it('should allow a valid cron expression', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { library: { scan: { cronExpression: '0 0 * * *' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -145,7 +145,7 @@ describe(LibraryService.name, () => { it('should fail for an invalid cron expression', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { library: { scan: { cronExpression: 'foo' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -730,7 +730,7 @@ describe(LibraryService.name, () => { const mockClose = vitest.fn(); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); expect(mockClose).toHaveBeenCalled(); @@ -861,7 +861,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); await sut.create({ ownerId: authStub.admin.user.id, importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, @@ -917,7 +917,7 @@ describe(LibraryService.name, () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); }); it('should update library', async () => { @@ -933,7 +933,7 @@ describe(LibraryService.name, () => { beforeEach(async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); }); it('should not watch library', async () => { @@ -949,7 +949,7 @@ describe(LibraryService.name, () => { beforeEach(async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(); }); it('should watch library', async () => { @@ -1107,8 +1107,8 @@ describe(LibraryService.name, () => { const mockClose = vitest.fn(); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); - await sut.onBootstrapEvent(); - await sut.onShutdownEvent(); + await sut.onBootstrap(); + await sut.onShutdown(); expect(mockClose).toHaveBeenCalledTimes(2); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index f0d7fe8cd44ee..1bee2d32c3a41 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -6,6 +6,7 @@ import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { CreateLibraryDto, LibraryResponseDto, @@ -22,7 +23,7 @@ import { AssetType } from 'src/enum'; import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, @@ -45,7 +46,7 @@ import { validateCronExpression } from 'src/validation'; const LIBRARY_SCAN_BATCH_SIZE = 5000; @Injectable() -export class LibraryService implements OnEvents { +export class LibraryService { private configCore: SystemConfigCore; private watchLibraries = false; private watchLock = false; @@ -65,7 +66,8 @@ export class LibraryService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap() { const config = await this.configCore.getConfig({ withCache: false }); const { watch, scan } = config.library; @@ -102,7 +104,7 @@ export class LibraryService implements OnEvents { }); } - onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { + onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { const { scan } = newConfig.library; if (!validateCronExpression(scan.cronExpression)) { throw new Error(`Invalid cron expression ${scan.cronExpression}`); @@ -187,7 +189,8 @@ export class LibraryService implements OnEvents { } } - async onShutdownEvent() { + @OnEmit({ event: 'onShutdown' }) + async onShutdown() { await this.unwatchAll(); } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 522e1320fd64b..05f6f9f658d93 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -95,7 +95,7 @@ describe(MetadataService.name, () => { }); afterEach(async () => { - await sut.onShutdownEvent(); + await sut.onShutdown(); }); it('should be defined', () => { @@ -104,7 +104,7 @@ describe(MetadataService.name, () => { describe('onBootstrapEvent', () => { it('should pause and resume queue during init', async () => { - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(jobMock.pause).toHaveBeenCalledTimes(1); expect(mapMock.init).toHaveBeenCalledTimes(1); @@ -114,7 +114,7 @@ describe(MetadataService.name, () => { it('should return if reverse geocoding is disabled', async () => { systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(jobMock.pause).not.toHaveBeenCalled(); expect(mapMock.init).not.toHaveBeenCalled(); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 041b35c02c31d..f1d367fb7b9f4 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,6 +8,7 @@ import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { AssetType } from 'src/enum'; @@ -15,7 +16,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { ClientEvent, IEventRepository, OnEvents } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, @@ -86,7 +87,7 @@ const validate = (value: T): NonNullable | null => { }; @Injectable() -export class MetadataService implements OnEvents { +export class MetadataService { private storageCore: StorageCore; private configCore: SystemConfigCore; @@ -120,7 +121,8 @@ export class MetadataService implements OnEvents { ); } - async onBootstrapEvent(app: 'api' | 'microservices') { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(app: ArgOf<'onBootstrap'>) { if (app !== 'microservices') { return; } @@ -128,7 +130,8 @@ export class MetadataService implements OnEvents { await this.init(config); } - async onConfigUpdateEvent({ newConfig }: { newConfig: SystemConfig }) { + @OnEmit({ event: 'onConfigUpdate' }) + async onConfigUpdate({ newConfig }: ArgOf<'onConfigUpdate'>) { await this.init(newConfig); } @@ -150,7 +153,8 @@ export class MetadataService implements OnEvents { } } - async onShutdownEvent() { + @OnEmit({ event: 'onShutdown' }) + async onShutdown() { await this.repository.teardown(); } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index fe1f4edc07bfe..46ca4118d1954 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { OnEvents } from 'src/interfaces/event.interface'; +import { OnEmit } from 'src/decorators'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; @@ -19,7 +20,7 @@ import { VersionService } from 'src/services/version.service'; import { otelShutdown } from 'src/utils/instrumentation'; @Injectable() -export class MicroservicesService implements OnEvents { +export class MicroservicesService { constructor( private auditService: AuditService, private assetService: AssetService, @@ -38,7 +39,8 @@ export class MicroservicesService implements OnEvents { private versionService: VersionService, ) {} - async onBootstrapEvent(app: 'api' | 'microservices') { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(app: ArgOf<'onBootstrap'>) { if (app !== 'microservices') { return; } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index f10c79c579571..74d2a12127dbd 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -90,7 +90,7 @@ describe(NotificationService.name, () => { const newConfig = configs.smtpEnabled; notificationMock.verifySmtp.mockResolvedValue(true); - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); @@ -99,7 +99,7 @@ describe(NotificationService.name, () => { const newConfig = configs.smtpTransport; notificationMock.verifySmtp.mockResolvedValue(true); - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); @@ -107,7 +107,7 @@ describe(NotificationService.name, () => { const oldConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpEnabled }; - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); }); @@ -115,19 +115,19 @@ describe(NotificationService.name, () => { const oldConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpDisabled }; - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); }); }); describe('onUserSignupEvent', () => { it('skips when notify is false', async () => { - await sut.onUserSignupEvent({ id: '', notify: false }); + await sut.onUserSignup({ id: '', notify: false }); expect(jobMock.queue).not.toHaveBeenCalled(); }); it('should queue notify signup event if notify is true', async () => { - await sut.onUserSignupEvent({ id: '', notify: true }); + await sut.onUserSignup({ id: '', notify: true }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_SIGNUP, data: { id: '', tempPassword: undefined }, @@ -137,7 +137,7 @@ describe(NotificationService.name, () => { describe('onAlbumUpdateEvent', () => { it('should queue notify album update event', async () => { - await sut.onAlbumUpdateEvent({ id: '', updatedBy: '42' }); + await sut.onAlbumUpdate({ id: '', updatedBy: '42' }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id: '', senderId: '42' }, @@ -147,7 +147,7 @@ describe(NotificationService.name, () => { describe('onAlbumInviteEvent', () => { it('should queue notify album invite event', async () => { - await sut.onAlbumInviteEvent({ id: '', userId: '42' }); + await sut.onAlbumInvite({ id: '', userId: '42' }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id: '', recipientId: '42' }, diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index c5f9a4f9f71c8..80abc4ca983d8 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -2,17 +2,12 @@ import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; import { isEqual } from 'lodash'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { - AlbumInviteEvent, - AlbumUpdateEvent, - OnEvents, - SystemConfigUpdateEvent, - UserSignupEvent, -} from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IEmailJob, IJobRepository, @@ -30,7 +25,7 @@ import { getFilenameExtension } from 'src/utils/file'; import { getPreferences } from 'src/utils/preferences'; @Injectable() -export class NotificationService implements OnEvents { +export class NotificationService { private configCore: SystemConfigCore; constructor( @@ -46,7 +41,8 @@ export class NotificationService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } - async onConfigValidateEvent({ oldConfig, newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate', priority: -100 }) + async onConfigValidate({ oldConfig, newConfig }: ArgOf<'onConfigValidate'>) { try { if ( newConfig.notifications.smtp.enabled && @@ -60,17 +56,20 @@ export class NotificationService implements OnEvents { } } - async onUserSignupEvent({ notify, id, tempPassword }: UserSignupEvent) { + @OnEmit({ event: 'onUserSignup' }) + async onUserSignup({ notify, id, tempPassword }: ArgOf<'onUserSignup'>) { if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); } } - async onAlbumUpdateEvent({ id, updatedBy }: AlbumUpdateEvent) { + @OnEmit({ event: 'onAlbumUpdate' }) + async onAlbumUpdate({ id, updatedBy }: ArgOf<'onAlbumUpdate'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); } - async onAlbumInviteEvent({ id, userId }: AlbumInviteEvent) { + @OnEmit({ event: 'onAlbumInvite' }) + async onAlbumInvite({ id, userId }: ArgOf<'onAlbumInvite'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 67e19eda78826..faf4d981644a3 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -3,6 +3,7 @@ import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; import { serverVersion } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -16,7 +17,6 @@ import { } from 'src/dtos/server.dto'; import { SystemMetadataKey } from 'src/enum'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { OnEvents } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -27,7 +27,7 @@ import { mimeTypes } from 'src/utils/mime-types'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class ServerService implements OnEvents { +export class ServerService { private configCore: SystemConfigCore; constructor( @@ -42,7 +42,8 @@ export class ServerService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent(): Promise { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index f18dc91ff100f..278e06d287db7 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -49,7 +49,7 @@ describe(SmartInfoService.name, () => { describe('onConfigValidateEvent', () => { it('should allow a valid model', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-16__openai' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -58,7 +58,7 @@ describe(SmartInfoService.name, () => { it('should allow including organization', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { machineLearning: { clip: { modelName: 'immich-app/ViT-B-16__openai' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -67,7 +67,7 @@ describe(SmartInfoService.name, () => { it('should fail for an unsupported model', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { machineLearning: { clip: { modelName: 'test-model' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -77,7 +77,7 @@ describe(SmartInfoService.name, () => { describe('onBootstrapEvent', () => { it('should return if not microservices', async () => { - await sut.onBootstrapEvent('api'); + await sut.onBootstrap('api'); expect(systemMock.get).not.toHaveBeenCalled(); expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); @@ -92,7 +92,7 @@ describe(SmartInfoService.name, () => { it('should return if machine learning is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); @@ -107,7 +107,7 @@ describe(SmartInfoService.name, () => { it('should return if model and DB dimension size are equal', async () => { searchMock.getDimensionSize.mockResolvedValue(512); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -123,7 +123,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(768); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -138,7 +138,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(768); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap('microservices'); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -154,7 +154,7 @@ describe(SmartInfoService.name, () => { it('should return if machine learning is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig, oldConfig: systemConfigStub.machineLearningDisabled as SystemConfig, }); @@ -172,7 +172,7 @@ describe(SmartInfoService.name, () => { it('should return if model and DB dimension size are equal', async () => { searchMock.getDimensionSize.mockResolvedValue(512); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true }, } as SystemConfig, @@ -194,7 +194,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(512); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-L-14-quickgelu__dfn2b', enabled: true }, enabled: true }, } as SystemConfig, @@ -215,7 +215,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(512); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true }, } as SystemConfig, @@ -237,7 +237,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(512); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); - await sut.onConfigUpdateEvent({ + await sut.onConfigUpdate({ newConfig: { machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true }, } as SystemConfig, diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 1957f3885c750..883f320abf50c 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,9 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, @@ -21,7 +22,7 @@ import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class SmartInfoService implements OnEvents { +export class SmartInfoService { private configCore: SystemConfigCore; constructor( @@ -37,7 +38,8 @@ export class SmartInfoService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent(app: 'api' | 'microservices') { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(app: ArgOf<'onBootstrap'>) { if (app !== 'microservices') { return; } @@ -46,7 +48,8 @@ export class SmartInfoService implements OnEvents { await this.init(config); } - onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate' }) + onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { try { getCLIPModelInfo(newConfig.machineLearning.clip.modelName); } catch { @@ -56,7 +59,8 @@ export class SmartInfoService implements OnEvents { } } - async onConfigUpdateEvent({ oldConfig, newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigUpdate' }) + async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'onConfigUpdate'>) { await this.init(newConfig, oldConfig); } diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 7a9b9952e0439..c1e0410a3d89c 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -76,10 +76,10 @@ describe(StorageTemplateService.name, () => { SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); }); - describe('onConfigValidateEvent', () => { + describe('onConfigValidate', () => { it('should allow valid templates', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { storageTemplate: { template: @@ -93,7 +93,7 @@ describe(StorageTemplateService.name, () => { it('should fail for an invalid template', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { storageTemplate: { template: '{{foo}}', diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 599f5e10a5186..0ee5bdd3b56de 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -15,6 +15,7 @@ import { } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEmit } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/entities/move.entity'; import { AssetType } from 'src/enum'; @@ -22,7 +23,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; @@ -46,7 +47,7 @@ interface RenderMetadata { } @Injectable() -export class StorageTemplateService implements OnEvents { +export class StorageTemplateService { private configCore: SystemConfigCore; private storageCore: StorageCore; private _template: { @@ -88,7 +89,8 @@ export class StorageTemplateService implements OnEvents { ); } - onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate' }) + onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { try { const { compiled } = this.compile(newConfig.storageTemplate.template); this.render(compiled, { diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index 5ce6d92d2698e..d9b4c8eefb3f3 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -20,9 +20,9 @@ describe(StorageService.name, () => { expect(sut).toBeDefined(); }); - describe('onBootstrapEvent', () => { + describe('onBootstrap', () => { it('should create the library folder on initialization', () => { - sut.onBootstrapEvent(); + sut.onBootstrap(); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); }); }); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 8222d7c46dd66..1535d53d95e23 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { OnEvents } from 'src/interfaces/event.interface'; +import { OnEmit } from 'src/decorators'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @Injectable() -export class StorageService implements OnEvents { +export class StorageService { constructor( @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @@ -14,7 +14,8 @@ export class StorageService implements OnEvents { this.logger.setContext(StorageService.name); } - onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap' }) + onBootstrap() { const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); this.storageRepository.mkdirSync(libraryBase); } diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 5aa800a224e7e..b4e6f903b1a03 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -13,20 +13,14 @@ import { supportedYearTokens, } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { EventHandlerOptions, OnServerEvent } from 'src/decorators'; +import { OnEmit, OnServerEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; -import { - ClientEvent, - IEventRepository, - OnEvents, - ServerEvent, - SystemConfigUpdateEvent, -} from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @Injectable() -export class SystemConfigService implements OnEvents { +export class SystemConfigService { private core: SystemConfigCore; constructor( @@ -39,8 +33,8 @@ export class SystemConfigService implements OnEvents { this.core.config$.subscribe((config) => this.setLogLevel(config)); } - @EventHandlerOptions({ priority: -100 }) - async onBootstrapEvent() { + @OnEmit({ event: 'onBootstrap', priority: -100 }) + async onBootstrap() { const config = await this.core.getConfig({ withCache: false }); this.core.config$.next(config); } @@ -54,7 +48,8 @@ export class SystemConfigService implements OnEvents { return mapConfig(defaults); } - onConfigValidateEvent({ newConfig, oldConfig }: SystemConfigUpdateEvent) { + @OnEmit({ event: 'onConfigValidate' }) + onConfigValidate({ newConfig, oldConfig }: ArgOf<'onConfigValidate'>) { if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); } @@ -68,7 +63,7 @@ export class SystemConfigService implements OnEvents { const oldConfig = await this.core.getConfig({ withCache: false }); try { - await this.eventRepository.emit('onConfigValidateEvent', { newConfig: dto, oldConfig }); + await this.eventRepository.emit('onConfigValidate', { newConfig: dto, oldConfig }); } catch (error) { this.logger.warn(`Unable to save system config due to a validation error: ${error}`); throw new BadRequestException(error instanceof Error ? error.message : error); @@ -79,7 +74,7 @@ export class SystemConfigService implements OnEvents { // TODO probably move web socket emits to a separate service this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); - await this.eventRepository.emit('onConfigUpdateEvent', { newConfig, oldConfig }); + await this.eventRepository.emit('onConfigUpdate', { newConfig, oldConfig }); return mapConfig(newConfig); } diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 76ae3dd23a838..95eeed0475b7f 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -45,7 +45,7 @@ export class UserAdminService { const { notify, ...rest } = dto; const user = await this.userCore.createUser(rest); - await this.eventRepository.emit('onUserSignupEvent', { + await this.eventRepository.emit('onUserSignup', { notify: !!notify, id: user.id, tempPassword: user.shouldChangePassword ? rest.password : undefined, diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 42e2b50ab5a02..2f04a510146cc 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -3,11 +3,11 @@ import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; import { isDev, serverVersion } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnServerEvent } from 'src/decorators'; +import { OnEmit, OnServerEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { SystemMetadataKey } from 'src/enum'; -import { ClientEvent, IEventRepository, OnEvents, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; +import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -23,7 +23,7 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re }; @Injectable() -export class VersionService implements OnEvents { +export class VersionService { private configCore: SystemConfigCore; constructor( @@ -37,7 +37,8 @@ export class VersionService implements OnEvents { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async onBootstrapEvent(): Promise { + @OnEmit({ event: 'onBootstrap' }) + async onBootstrap(): Promise { await this.handleVersionCheck(); } diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts index 1bee4c6558a45..2dd7e7fd5d208 100644 --- a/server/src/utils/events.ts +++ b/server/src/utils/events.ts @@ -1,33 +1,57 @@ import { ModuleRef, Reflector } from '@nestjs/core'; import _ from 'lodash'; -import { HandlerOptions } from 'src/decorators'; -import { EmitEvent, EmitEventHandler, IEventRepository, OnEvents, events } from 'src/interfaces/event.interface'; +import { EmitConfig } from 'src/decorators'; +import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface'; import { Metadata } from 'src/middleware/auth.guard'; import { services } from 'src/services'; +type Item = { + event: T; + handler: EmitHandler; + priority: number; + label: string; +}; + export const setupEventHandlers = (moduleRef: ModuleRef) => { const reflector = moduleRef.get(Reflector, { strict: false }); const repository = moduleRef.get(IEventRepository); - const handlers: Array<{ event: EmitEvent; handler: EmitEventHandler; priority: number }> = []; + const items: Item[] = []; // discovery for (const Service of services) { - const instance = moduleRef.get(Service); - for (const event of events) { - const handler = instance[event] as EmitEventHandler; + const instance = moduleRef.get(Service); + const ctx = Object.getPrototypeOf(instance); + for (const property of Object.getOwnPropertyNames(ctx)) { + const descriptor = Object.getOwnPropertyDescriptor(ctx, property); + if (!descriptor || descriptor.get || descriptor.set) { + continue; + } + + const handler = instance[property]; if (typeof handler !== 'function') { continue; } - const options = reflector.get(Metadata.EVENT_HANDLER_OPTIONS, handler); - const priority = options?.priority || 0; + const options = reflector.get(Metadata.ON_EMIT_CONFIG, handler); + if (!options) { + continue; + } - handlers.push({ event, handler: handler.bind(instance), priority }); + items.push({ + event: options.event, + priority: options.priority || 0, + handler: handler.bind(instance), + label: `${Service.name}.${handler.name}`, + }); } } + const handlers = _.orderBy(items, ['priority'], ['asc']); + // register by priority - for (const { event, handler } of _.orderBy(handlers, ['priority'], ['asc'])) { - repository.on(event, handler); + for (const { event, handler } of handlers) { + repository.on(event as EmitEvent, handler); } + + return handlers; }; diff --git a/web/src/lib/components/user-settings-page/download-settings.svelte b/web/src/lib/components/user-settings-page/download-settings.svelte index f103f348fc201..f5b94ebee8f2b 100644 --- a/web/src/lib/components/user-settings-page/download-settings.svelte +++ b/web/src/lib/components/user-settings-page/download-settings.svelte @@ -14,13 +14,21 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; let archiveSize = convertFromBytes($preferences?.download?.archiveSize || 4, ByteUnit.GiB); + let includeEmbeddedVideos = $preferences?.download?.includeEmbeddedVideos || false; const handleSave = async () => { try { - const dto = { download: { archiveSize: Math.floor(convertToBytes(archiveSize, ByteUnit.GiB)) } }; - const newPreferences = await updateMyPreferences({ userPreferencesUpdateDto: dto }); + const newPreferences = await updateMyPreferences({ + userPreferencesUpdateDto: { + download: { + archiveSize: Math.floor(convertToBytes(archiveSize, ByteUnit.GiB)), + includeEmbeddedVideos, + }, + }, + }); $preferences = newPreferences; notificationController.show({ message: $t('saved_settings'), type: NotificationType.Info }); @@ -34,14 +42,17 @@
-
- -
+ +
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 5b2d9d393a2e7..2b97cb6e24fd8 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -368,7 +368,7 @@ "appears_in": "Appears in", "archive": "Archive", "archive_or_unarchive_photo": "Archive or unarchive photo", - "archive_size": "Archive Size", + "archive_size": "Archive size", "archive_size_description": "Configure the archive size for downloads (in GiB)", "archived_count": "{count, plural, other {Archived #}}", "are_these_the_same_person": "Are these the same person?", @@ -512,6 +512,8 @@ "do_not_show_again": "Do not show this message again", "done": "Done", "download": "Download", + "download_include_embedded_motion_videos": "Embedded videos", + "download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file", "download_settings": "Download", "download_settings_description": "Manage settings related to asset download", "downloading": "Downloading", diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index a23c369009c08..74a695770e848 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -172,13 +172,19 @@ export const downloadFile = async (asset: AssetResponseDto) => { }, ]; + const isAndroidMotionVideo = (asset: AssetResponseDto) => { + return asset.originalPath.includes('encoded-video'); + }; + if (asset.livePhotoVideoId) { const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() }); - assets.push({ - filename: motionAsset.originalFileName, - id: asset.livePhotoVideoId, - size: motionAsset.exifInfo?.fileSizeInByte || 0, - }); + if (!isAndroidMotionVideo(motionAsset) || get(preferences).download.includeEmbeddedVideos) { + assets.push({ + filename: motionAsset.originalFileName, + id: asset.livePhotoVideoId, + size: motionAsset.exifInfo?.fileSizeInByte || 0, + }); + } } for (const { filename, id, size } of assets) {