Merge branch 'main' into main

This commit is contained in:
Lena Tauchner 2024-08-15 23:18:23 +02:00 committed by GitHub
commit 086bbfcf56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 515 additions and 329 deletions

View File

@ -112,7 +112,8 @@ The `immich-server` container will need access to the gallery. Modify your docke
``` ```
:::tip :::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 :::info

View File

@ -7,7 +7,7 @@ in a directory on the same machine.
# Mount the directory into the containers. # 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:`. 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 ```diff
immich-server: immich-server:

View File

@ -1,5 +1,3 @@
version: '3.8'
name: immich-e2e name: immich-e2e
services: services:

View File

@ -236,6 +236,32 @@ describe('/users', () => {
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } }); 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', () => { describe('GET /users/:id', () => {

View File

@ -38,11 +38,17 @@ FROM python:3.11-slim-bookworm@sha256:1c0c54195c7c7b46e61a2f3b906e9b55a8165f2038
FROM prod-cpu AS prod-openvino FROM prod-cpu AS prod-openvino
COPY scripts/configure-apt.sh ./ RUN apt-get update && \
RUN ./configure-apt.sh && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
apt-get update && \ wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-core_1.0.17193.4_amd64.deb && \
apt-get install -t unstable --no-install-recommends -yqq intel-opencl-icd && \ wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-opencl_1.0.17193.4_amd64.deb && \
rm configure-apt.sh 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 FROM nvidia/cuda:12.3.2-cudnn9-runtime-ubuntu22.04@sha256:fa44193567d1908f7ca1f3abf8623ce9c63bc8cba7bcfdb32702eb04d326f7a8 AS prod-cuda

View File

@ -1,3 +1,3 @@
{ {
"flutter": "3.22.3" "flutter": "3.24.0"
} }

View File

@ -1,5 +1,5 @@
{ {
"dart.flutterSdkPath": ".fvm/versions/3.22.3", "dart.flutterSdkPath": ".fvm/versions/3.24.0",
"search.exclude": { "search.exclude": {
"**/.fvm": true "**/.fvm": true
}, },

View File

@ -24,7 +24,7 @@
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true" <application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"
android:largeHeap="true"> android:largeHeap="true" android:enableOnBackInvokedCallback="true">
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"
@ -35,7 +35,7 @@
<meta-data <meta-data
android:name="io.flutter.embedding.android.EnableImpeller" android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" /> android:value="true" />
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_icon" android:name="com.google.firebase.messaging.default_notification_icon"
@ -84,10 +84,6 @@
</application> </application>
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View File

@ -10,6 +10,18 @@ allprojects {
rootProject.buildDir = '../build' rootProject.buildDir = '../build'
subprojects { 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}" project.buildDir = "${rootProject.buildDir}/${project.name}"
} }
@ -24,3 +36,4 @@ tasks.register("clean", Delete) {
tasks.named('wrapper') { tasks.named('wrapper') {
distributionType = Wrapper.DistributionType.ALL distributionType = Wrapper.DistributionType.ALL
} }

View File

@ -202,7 +202,7 @@ SPEC CHECKSUMS:
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450 geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9

View File

@ -6,7 +6,7 @@ import path_provider_ios
import photo_manager import photo_manager
import permission_handler_apple import permission_handler_apple
@UIApplicationMain @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
override func application( override func application(

View File

@ -39,7 +39,6 @@ import 'package:path_provider/path_provider.dart';
void main() async { void main() async {
ImmichWidgetsBinding(); ImmichWidgetsBinding();
final db = await loadDb(); final db = await loadDb();
await initApp(); await initApp();
await migrateDatabaseIfNeeded(db); await migrateDatabaseIfNeeded(db);
@ -73,6 +72,7 @@ Future<void> initApp() async {
var log = Logger("ImmichErrorLogger"); var log = Logger("ImmichErrorLogger");
FlutterError.onError = (details) { FlutterError.onError = (details) {
debugPrint("FlutterError - Catch all: $details");
FlutterError.presentError(details); FlutterError.presentError(details);
log.severe( log.severe(
'FlutterError - Catch all', 'FlutterError - Catch all',

View File

@ -264,7 +264,7 @@ class GalleryViewerPage extends HookConsumerWidget {
return PopScope( return PopScope(
// Change immersive mode back to normal "edgeToEdge" mode // Change immersive mode back to normal "edgeToEdge" mode
onPopInvoked: (_) => onPopInvokedWithResult: (didPop, _) =>
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge), SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge),
child: Scaffold( child: Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,

View File

@ -74,7 +74,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
], ],
), ),
body: PopScope( body: PopScope(
onPopInvoked: (_) => saveHeaders(headers.value), onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value),
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0), padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
itemCount: list.length, itemCount: list.length,

View File

@ -177,7 +177,7 @@ class TabControllerPage extends HookConsumerWidget {
final tabsRouter = AutoTabsRouter.of(context); final tabsRouter = AutoTabsRouter.of(context);
return PopScope( return PopScope(
canPop: tabsRouter.activeIndex == 0, canPop: tabsRouter.activeIndex == 0,
onPopInvoked: (didPop) => onPopInvokedWithResult: (didPop, _) =>
!didPop ? tabsRouter.setActiveIndex(0) : null, !didPop ? tabsRouter.setActiveIndex(0) : null,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {

View File

@ -123,7 +123,7 @@ class VideoViewerPage extends HookConsumerWidget {
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
return PopScope( return PopScope(
onPopInvoked: (pop) { onPopInvokedWithResult: (didPop, _) {
ref.read(videoPlaybackValueProvider.notifier).value = ref.read(videoPlaybackValueProvider.notifier).value =
VideoPlaybackValue.uninitialized(); VideoPlaybackValue.uninitialized();
}, },

View File

@ -59,6 +59,8 @@ class DraggableScrollbar extends StatefulWidget {
final Function(bool scrolling) scrollStateListener; final Function(bool scrolling) scrollStateListener;
final double viewPortHeight;
DraggableScrollbar.semicircle({ DraggableScrollbar.semicircle({
super.key, super.key,
Key? scrollThumbKey, Key? scrollThumbKey,
@ -67,6 +69,7 @@ class DraggableScrollbar extends StatefulWidget {
required this.controller, required this.controller,
required this.itemPositionsListener, required this.itemPositionsListener,
required this.scrollStateListener, required this.scrollStateListener,
required this.viewPortHeight,
this.heightScrollThumb = 48.0, this.heightScrollThumb = 48.0,
this.backgroundColor = Colors.white, this.backgroundColor = Colors.white,
this.padding, this.padding,
@ -251,7 +254,7 @@ class DraggableScrollbarState extends State<DraggableScrollbar>
} }
double get barMaxScrollExtent => double get barMaxScrollExtent =>
(context.size?.height ?? 0) - widget.viewPortHeight -
widget.heightScrollThumb - widget.heightScrollThumb -
(widget.heightOffset ?? 0); (widget.heightOffset ?? 0);
@ -316,37 +319,39 @@ class DraggableScrollbarState extends State<DraggableScrollbar>
} }
setState(() { setState(() {
int firstItemIndex = try {
widget.itemPositionsListener.itemPositions.value.first.index; int firstItemIndex =
widget.itemPositionsListener.itemPositions.value.first.index;
if (notification is ScrollUpdateNotification) { if (notification is ScrollUpdateNotification) {
_barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent; _barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent;
if (_barOffset < barMinScrollExtent) { if (_barOffset < barMinScrollExtent) {
_barOffset = barMinScrollExtent; _barOffset = barMinScrollExtent;
} }
if (_barOffset > barMaxScrollExtent) { if (_barOffset > barMaxScrollExtent) {
_barOffset = barMaxScrollExtent; _barOffset = barMaxScrollExtent;
} }
}
if (notification is ScrollUpdateNotification ||
notification is OverscrollNotification) {
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
} }
if (itemPos < maxItemCount) { if (notification is ScrollUpdateNotification ||
_currentItem = itemPos; notification is OverscrollNotification) {
} if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
_fadeoutTimer?.cancel(); if (itemPos < maxItemCount) {
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () { _currentItem = itemPos;
_thumbAnimationController.reverse(); }
_labelAnimationController.reverse();
_fadeoutTimer = null; _fadeoutTimer?.cancel();
}); _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
} _thumbAnimationController.reverse();
_labelAnimationController.reverse();
_fadeoutTimer = null;
});
}
} catch (_) {}
}); });
} }

View File

@ -262,8 +262,9 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
shrinkWrap: widget.shrinkWrap, shrinkWrap: widget.shrinkWrap,
); );
final child = useDragScrolling final child = (useDragScrolling && ModalRoute.of(context) != null)
? DraggableScrollbar.semicircle( ? DraggableScrollbar.semicircle(
viewPortHeight: context.height,
scrollStateListener: dragScrolling, scrollStateListener: dragScrolling,
itemPositionsListener: _itemPositionsListener, itemPositionsListener: _itemPositionsListener,
controller: _itemScrollController, controller: _itemScrollController,
@ -281,6 +282,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
child: listWidget, child: listWidget,
) )
: listWidget; : listWidget;
return widget.onRefresh == null return widget.onRefresh == null
? child ? child
: appBarOffset() : appBarOffset()
@ -528,7 +530,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopScope( return PopScope(
canPop: !(widget.selectionActive && _selectedAssets.isNotEmpty), canPop: !(widget.selectionActive && _selectedAssets.isNotEmpty),
onPopInvoked: (didPop) => !didPop ? _deselectAll() : null, onPopInvokedWithResult: (didPop, _) => !didPop ? _deselectAll() : null,
child: Stack( child: Stack(
children: [ children: [
AssetDragRegion( AssetDragRegion(

View File

@ -58,7 +58,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
isLabelVisible: serverInfoState.isVersionMismatch || isLabelVisible: serverInfoState.isVersionMismatch ||
((user?.isAdmin ?? false) && ((user?.isAdmin ?? false) &&
serverInfoState.isNewReleaseAvailable), serverInfoState.isNewReleaseAvailable),
offset: const Offset(2, 2), offset: const Offset(-2, -12),
child: user == null child: user == null
? const Icon( ? const Icon(
Icons.face_outlined, Icons.face_outlined,
@ -132,7 +132,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,
isLabelVisible: indicatorIcon != null, isLabelVisible: indicatorIcon != null,
offset: const Offset(2, 2), offset: const Offset(-2, -12),
child: Icon( child: Icon(
Icons.backup_rounded, Icons.backup_rounded,
size: widgetSize, size: widgetSize,

View File

@ -14,25 +14,31 @@ class DownloadResponse {
/// Returns a new [DownloadResponse] instance. /// Returns a new [DownloadResponse] instance.
DownloadResponse({ DownloadResponse({
required this.archiveSize, required this.archiveSize,
this.includeEmbeddedVideos = false,
}); });
int archiveSize; int archiveSize;
bool includeEmbeddedVideos;
@override @override
bool operator ==(Object other) => identical(this, other) || other is DownloadResponse && bool operator ==(Object other) => identical(this, other) || other is DownloadResponse &&
other.archiveSize == archiveSize; other.archiveSize == archiveSize &&
other.includeEmbeddedVideos == includeEmbeddedVideos;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(archiveSize.hashCode); (archiveSize.hashCode) +
(includeEmbeddedVideos.hashCode);
@override @override
String toString() => 'DownloadResponse[archiveSize=$archiveSize]'; String toString() => 'DownloadResponse[archiveSize=$archiveSize, includeEmbeddedVideos=$includeEmbeddedVideos]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'archiveSize'] = this.archiveSize; json[r'archiveSize'] = this.archiveSize;
json[r'includeEmbeddedVideos'] = this.includeEmbeddedVideos;
return json; return json;
} }
@ -45,6 +51,7 @@ class DownloadResponse {
return DownloadResponse( return DownloadResponse(
archiveSize: mapValueOfType<int>(json, r'archiveSize')!, archiveSize: mapValueOfType<int>(json, r'archiveSize')!,
includeEmbeddedVideos: mapValueOfType<bool>(json, r'includeEmbeddedVideos')!,
); );
} }
return null; return null;
@ -93,6 +100,7 @@ class DownloadResponse {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'archiveSize', 'archiveSize',
'includeEmbeddedVideos',
}; };
} }

View File

@ -14,6 +14,7 @@ class DownloadUpdate {
/// Returns a new [DownloadUpdate] instance. /// Returns a new [DownloadUpdate] instance.
DownloadUpdate({ DownloadUpdate({
this.archiveSize, this.archiveSize,
this.includeEmbeddedVideos,
}); });
/// Minimum value: 1 /// Minimum value: 1
@ -25,17 +26,27 @@ class DownloadUpdate {
/// ///
int? archiveSize; 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 @override
bool operator ==(Object other) => identical(this, other) || other is DownloadUpdate && bool operator ==(Object other) => identical(this, other) || other is DownloadUpdate &&
other.archiveSize == archiveSize; other.archiveSize == archiveSize &&
other.includeEmbeddedVideos == includeEmbeddedVideos;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(archiveSize == null ? 0 : archiveSize!.hashCode); (archiveSize == null ? 0 : archiveSize!.hashCode) +
(includeEmbeddedVideos == null ? 0 : includeEmbeddedVideos!.hashCode);
@override @override
String toString() => 'DownloadUpdate[archiveSize=$archiveSize]'; String toString() => 'DownloadUpdate[archiveSize=$archiveSize, includeEmbeddedVideos=$includeEmbeddedVideos]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -44,6 +55,11 @@ class DownloadUpdate {
} else { } else {
// json[r'archiveSize'] = null; // json[r'archiveSize'] = null;
} }
if (this.includeEmbeddedVideos != null) {
json[r'includeEmbeddedVideos'] = this.includeEmbeddedVideos;
} else {
// json[r'includeEmbeddedVideos'] = null;
}
return json; return json;
} }
@ -56,6 +72,7 @@ class DownloadUpdate {
return DownloadUpdate( return DownloadUpdate(
archiveSize: mapValueOfType<int>(json, r'archiveSize'), archiveSize: mapValueOfType<int>(json, r'archiveSize'),
includeEmbeddedVideos: mapValueOfType<bool>(json, r'includeEmbeddedVideos'),
); );
} }
return null; return null;

View File

@ -5,23 +5,23 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: "5aaf60d96c4cd00fe7f21594b5ad6a1b699c80a27420f8a837f4d68473ef09e3" sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "68.0.0" version: "72.0.0"
_macros: _macros:
dependency: transitive dependency: transitive
description: dart description: dart
source: sdk source: sdk
version: "0.1.0" version: "0.3.2"
analyzer: analyzer:
dependency: "direct overridden" dependency: "direct overridden"
description: description:
name: analyzer name: analyzer
sha256: "21f1d3720fd1c70316399d5e2bccaebb415c434592d778cce8acb967b8578808" sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.0" version: "6.7.0"
analyzer_plugin: analyzer_plugin:
dependency: "direct overridden" dependency: "direct overridden"
description: description:
@ -540,10 +540,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_local_notifications name: flutter_local_notifications
sha256: "55b9b229307a10974b26296ff29f2e132256ba4bd74266939118eaefa941cb00" sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "16.3.3" version: "17.2.1+2"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
@ -901,18 +901,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.4" version: "10.0.5"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.5"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@ -941,10 +941,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: macros name: macros
sha256: "12e8a9842b5a7390de7a781ec63d793527582398d16ea26c60fed58833c9ae79" sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.0-main.0" version: "0.1.2-main.4"
maplibre_gl: maplibre_gl:
dependency: "direct main" dependency: "direct main"
description: description:
@ -981,10 +981,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.0" version: "0.11.1"
meta: meta:
dependency: "direct overridden" dependency: "direct overridden"
description: description:
@ -1212,10 +1212,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: platform name: platform
sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.4" version: "3.1.5"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1537,10 +1537,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" version: "0.7.2"
thumbhash: thumbhash:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1737,10 +1737,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.1" version: "14.2.4"
wakelock_plus: wakelock_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1847,4 +1847,4 @@ packages:
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.4.0 <4.0.0" dart: ">=3.4.0 <4.0.0"
flutter: ">=3.22.3" flutter: ">=3.24.0"

View File

@ -6,7 +6,7 @@ version: 1.112.1+154
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
flutter: 3.22.3 flutter: 3.24.0
dependencies: dependencies:
flutter: flutter:
@ -50,7 +50,7 @@ dependencies:
device_info_plus: ^9.1.1 device_info_plus: ^9.1.1
connectivity_plus: ^5.0.2 connectivity_plus: ^5.0.2
wakelock_plus: ^1.1.4 wakelock_plus: ^1.1.4
flutter_local_notifications: ^16.3.2 flutter_local_notifications: ^17.2.1+2
timezone: ^0.9.2 timezone: ^0.9.2
octo_image: ^2.0.0 octo_image: ^2.0.0
thumbhash: 0.1.0+1 thumbhash: 0.1.0+1

View File

@ -8497,10 +8497,15 @@
"properties": { "properties": {
"archiveSize": { "archiveSize": {
"type": "integer" "type": "integer"
},
"includeEmbeddedVideos": {
"default": false,
"type": "boolean"
} }
}, },
"required": [ "required": [
"archiveSize" "archiveSize",
"includeEmbeddedVideos"
], ],
"type": "object" "type": "object"
}, },
@ -8527,6 +8532,9 @@
"archiveSize": { "archiveSize": {
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
},
"includeEmbeddedVideos": {
"type": "boolean"
} }
}, },
"type": "object" "type": "object"

View File

@ -86,6 +86,7 @@ export type AvatarResponse = {
}; };
export type DownloadResponse = { export type DownloadResponse = {
archiveSize: number; archiveSize: number;
includeEmbeddedVideos: boolean;
}; };
export type EmailNotificationsResponse = { export type EmailNotificationsResponse = {
albumInvite: boolean; albumInvite: boolean;
@ -115,6 +116,7 @@ export type AvatarUpdate = {
}; };
export type DownloadUpdate = { export type DownloadUpdate = {
archiveSize?: number; archiveSize?: number;
includeEmbeddedVideos?: boolean;
}; };
export type EmailNotificationsUpdate = { export type EmailNotificationsUpdate = {
albumInvite?: boolean; albumInvite?: boolean;

View File

@ -5,6 +5,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@ne
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import _ from 'lodash';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { OpenTelemetryModule } from 'nestjs-otel'; import { OpenTelemetryModule } from 'nestjs-otel';
import { commands } from 'src/commands'; import { commands } from 'src/commands';
@ -13,6 +14,7 @@ import { controllers } from 'src/controllers';
import { databaseConfig } from 'src/database.config'; import { databaseConfig } from 'src/database.config';
import { entities } from 'src/entities'; import { entities } from 'src/entities';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthGuard } from 'src/middleware/auth.guard'; import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
@ -54,15 +56,25 @@ export class ApiModule implements OnModuleInit, OnModuleDestroy {
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {} ) {}
async onModuleInit() { async onModuleInit() {
setupEventHandlers(this.moduleRef); const items = setupEventHandlers(this.moduleRef);
await this.eventRepository.emit('onBootstrapEvent', 'api');
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() { async onModuleDestroy() {
await this.eventRepository.emit('onShutdownEvent'); await this.eventRepository.emit('onShutdown');
} }
} }
@ -78,11 +90,11 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { async onModuleInit() {
setupEventHandlers(this.moduleRef); setupEventHandlers(this.moduleRef);
await this.eventRepository.emit('onBootstrapEvent', 'microservices'); await this.eventRepository.emit('onBootstrap', 'microservices');
} }
async onModuleDestroy() { async onModuleDestroy() {
await this.eventRepository.emit('onShutdownEvent'); await this.eventRepository.emit('onShutdown');
} }
} }

View File

@ -4,7 +4,7 @@ import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces';
import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
import _ from 'lodash'; import _ from 'lodash';
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; 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 { Metadata } from 'src/middleware/auth.guard';
import { setUnion } from 'src/utils/set'; import { setUnion } from 'src/utils/set';
@ -136,11 +136,12 @@ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GEN
export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) => export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) =>
OnEvent(event, { suppressErrors: false, ...options }); OnEvent(event, { suppressErrors: false, ...options });
export type HandlerOptions = { export type EmitConfig = {
event: EmitEvent;
/** lower value has higher priority, defaults to 0 */ /** 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 LifecycleRelease = 'NEXT_RELEASE' | string;
type LifecycleMetadata = { type LifecycleMetadata = {

View File

@ -33,12 +33,15 @@ class EmailNotificationsUpdate {
albumUpdate?: boolean; albumUpdate?: boolean;
} }
class DownloadUpdate { class DownloadUpdate implements Partial<DownloadResponse> {
@Optional() @Optional()
@IsInt() @IsInt()
@IsPositive() @IsPositive()
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
archiveSize?: number; archiveSize?: number;
@ValidateBoolean({ optional: true })
includeEmbeddedVideos?: boolean;
} }
class PurchaseUpdate { class PurchaseUpdate {
@ -104,6 +107,8 @@ class EmailNotificationsResponse {
class DownloadResponse { class DownloadResponse {
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
archiveSize!: number; archiveSize!: number;
includeEmbeddedVideos: boolean = false;
} }
class PurchaseResponse { class PurchaseResponse {

View File

@ -35,6 +35,7 @@ export interface UserPreferences {
}; };
download: { download: {
archiveSize: number; archiveSize: number;
includeEmbeddedVideos: boolean;
}; };
purchase: { purchase: {
showSupportBadge: boolean; showSupportBadge: boolean;
@ -65,6 +66,7 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences
}, },
download: { download: {
archiveSize: HumanReadableSize.GiB * 4, archiveSize: HumanReadableSize.GiB * 4,
includeEmbeddedVideos: false,
}, },
purchase: { purchase: {
showSupportBadge: true, showSupportBadge: true,

View File

@ -4,41 +4,27 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d
export const IEventRepository = 'IEventRepository'; export const IEventRepository = 'IEventRepository';
export type SystemConfigUpdateEvent = { newConfig: SystemConfig; oldConfig: SystemConfig }; type EmitEventMap = {
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<T> = Promise<T> | T;
type Handler<T = undefined> = (data: T) => MaybePromise<void>;
const noop = () => {};
const dummyHandlers = {
// app events // app events
onBootstrapEvent: noop as Handler<'api' | 'microservices'>, onBootstrap: ['api' | 'microservices'];
onShutdownEvent: noop as () => MaybePromise<void>, onShutdown: [];
// config events // config events
onConfigUpdateEvent: noop as Handler<SystemConfigUpdateEvent>, onConfigUpdate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
onConfigValidateEvent: noop as Handler<SystemConfigUpdateEvent>, onConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
// album events // album events
onAlbumUpdateEvent: noop as Handler<AlbumUpdateEvent>, onAlbumUpdate: [{ id: string; updatedBy: string }];
onAlbumInviteEvent: noop as Handler<AlbumInviteEvent>, onAlbumInvite: [{ id: string; userId: string }];
// user events // user events
onUserSignupEvent: noop as Handler<UserSignupEvent>, onUserSignup: [{ notify: boolean; id: string; tempPassword?: string }];
}; };
export type EventHandlers = typeof dummyHandlers; export type EmitEvent = keyof EmitEventMap;
export type EmitEvent = keyof EventHandlers; export type EmitHandler<T extends EmitEvent> = (...args: ArgsOf<T>) => Promise<void> | void;
export type EmitEventHandler<T extends EmitEvent> = (...args: Parameters<EventHandlers[T]>) => MaybePromise<void>; export type ArgOf<T extends EmitEvent> = EmitEventMap[T][0];
export const events = Object.keys(dummyHandlers) as EmitEvent[]; export type ArgsOf<T extends EmitEvent> = EmitEventMap[T];
export type OnEvents = Partial<EventHandlers>;
export enum ClientEvent { export enum ClientEvent {
UPLOAD_SUCCESS = 'on_upload_success', UPLOAD_SUCCESS = 'on_upload_success',
@ -81,8 +67,8 @@ export interface ServerEventMap {
} }
export interface IEventRepository { export interface IEventRepository {
on<T extends EmitEvent>(event: T, handler: EmitEventHandler<T>): void; on<T extends keyof EmitEventMap>(event: T, handler: EmitHandler<T>): void;
emit<T extends EmitEvent>(event: T, ...args: Parameters<EmitEventHandler<T>>): Promise<void>; emit<T extends keyof EmitEventMap>(event: T, ...args: ArgsOf<T>): Promise<void>;
/** /**
* Send to connected clients for a specific user * Send to connected clients for a specific user

View File

@ -20,7 +20,7 @@ export enum Metadata {
ADMIN_ROUTE = 'admin_route', ADMIN_ROUTE = 'admin_route',
SHARED_ROUTE = 'shared_route', SHARED_ROUTE = 'shared_route',
API_KEY_SECURITY = 'api_key', API_KEY_SECURITY = 'api_key',
EVENT_HANDLER_OPTIONS = 'event_handler_options', ON_EMIT_CONFIG = 'on_emit_config',
} }
type AdminRoute = { admin?: true }; type AdminRoute = { admin?: true };

View File

@ -9,9 +9,10 @@ import {
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { import {
ArgsOf,
ClientEventMap, ClientEventMap,
EmitEvent, EmitEvent,
EmitEventHandler, EmitHandler,
IEventRepository, IEventRepository,
ServerEvent, ServerEvent,
ServerEventMap, ServerEventMap,
@ -20,6 +21,8 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { Instrumentation } from 'src/utils/instrumentation'; import { Instrumentation } from 'src/utils/instrumentation';
type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler<T>[] }>;
@Instrumentation() @Instrumentation()
@WebSocketGateway({ @WebSocketGateway({
cors: true, cors: true,
@ -28,7 +31,7 @@ import { Instrumentation } from 'src/utils/instrumentation';
}) })
@Injectable() @Injectable()
export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, IEventRepository { export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, IEventRepository {
private emitHandlers: Partial<Record<EmitEvent, EmitEventHandler<EmitEvent>[]>> = {}; private emitHandlers: EmitHandlers = {};
@WebSocketServer() @WebSocketServer()
private server?: Server; private server?: Server;
@ -78,12 +81,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
await client.leave(client.nsp.name); await client.leave(client.nsp.name);
} }
on<T extends EmitEvent>(event: T, handler: EmitEventHandler<T>): void { on<T extends EmitEvent>(event: T, handler: EmitHandler<T>): void {
const handlers: EmitEventHandler<EmitEvent>[] = this.emitHandlers[event] || []; if (!this.emitHandlers[event]) {
this.emitHandlers[event] = [...handlers, handler]; this.emitHandlers[event] = [];
}
this.emitHandlers[event].push(handler);
} }
async emit<T extends EmitEvent>(event: T, ...args: Parameters<EmitEventHandler<T>>): Promise<void> { async emit<T extends EmitEvent>(event: T, ...args: ArgsOf<T>): Promise<void> {
const handlers = this.emitHandlers[event] || []; const handlers = this.emitHandlers[event] || [];
for (const handler of handlers) { for (const handler of handlers) {
await handler(...args); await handler(...args);

View File

@ -380,7 +380,7 @@ describe(AlbumService.name, () => {
userId: authStub.user2.user.id, userId: authStub.user2.user.id,
albumId: albumStub.sharedWithAdmin.id, albumId: albumStub.sharedWithAdmin.id,
}); });
expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInviteEvent', { expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInvite', {
id: albumStub.sharedWithAdmin.id, id: albumStub.sharedWithAdmin.id,
userId: userStub.user2.id, userId: userStub.user2.id,
}); });
@ -568,7 +568,7 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); 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', id: 'album-123',
updatedBy: authStub.admin.user.id, updatedBy: authStub.admin.user.id,
}); });
@ -612,7 +612,7 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); 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', id: 'album-123',
updatedBy: authStub.user1.user.id, updatedBy: authStub.user1.user.id,
}); });

View File

@ -187,7 +187,7 @@ export class AlbumService {
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId, 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; return results;
@ -235,7 +235,7 @@ export class AlbumService {
} }
await this.albumUserRepository.create({ userId: userId, albumId: id, role }); 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); return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets);

View File

@ -45,7 +45,7 @@ describe(DatabaseService.name, () => {
it('should throw an error if PostgreSQL version is below minimum supported version', async () => { it('should throw an error if PostgreSQL version is below minimum supported version', async () => {
databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); 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); expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1);
}); });
@ -65,7 +65,7 @@ describe(DatabaseService.name, () => {
availableVersion: minVersionInRange, availableVersion: minVersionInRange,
}); });
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); expect(databaseMock.getPostgresVersion).toHaveBeenCalled();
expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); expect(databaseMock.createExtension).toHaveBeenCalledWith(extension);
@ -79,7 +79,7 @@ describe(DatabaseService.name, () => {
databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null });
const message = `The ${extensionName} extension is not available in this Postgres instance. const message = `The ${extensionName} extension is not available in this Postgres instance.
If using a container image, ensure the image has the extension installed.`; 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.createExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
@ -91,7 +91,7 @@ describe(DatabaseService.name, () => {
availableVersion: versionBelowRange, 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}`, `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 () => { it(`should throw an error if ${extension} extension version is a nightly`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); 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.`, `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 }); 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).toHaveBeenCalledWith(extension, updateInRange);
expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1);
@ -132,7 +132,7 @@ describe(DatabaseService.name, () => {
installedVersion: minVersionInRange, installedVersion: minVersionInRange,
}); });
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
@ -145,7 +145,7 @@ describe(DatabaseService.name, () => {
installedVersion: null, installedVersion: null,
}); });
await expect(sut.onBootstrapEvent()).rejects.toThrow(); await expect(sut.onBootstrap()).rejects.toThrow();
expect(databaseMock.createExtension).not.toHaveBeenCalled(); expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
@ -159,7 +159,7 @@ describe(DatabaseService.name, () => {
installedVersion: minVersionInRange, installedVersion: minVersionInRange,
}); });
await expect(sut.onBootstrapEvent()).rejects.toThrow(); await expect(sut.onBootstrap()).rejects.toThrow();
expect(databaseMock.createExtension).not.toHaveBeenCalled(); expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
@ -173,7 +173,7 @@ describe(DatabaseService.name, () => {
installedVersion: updateInRange, 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.`, `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')); 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( expect(loggerMock.warn.mock.calls[0][0]).toContain(
`The ${extensionName} extension can be updated to ${updateInRange}.`, `The ${extensionName} extension can be updated to ${updateInRange}.`,
@ -206,7 +206,7 @@ describe(DatabaseService.name, () => {
}); });
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); 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).toHaveBeenCalledTimes(1);
expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName);
@ -218,7 +218,7 @@ describe(DatabaseService.name, () => {
it(`should reindex ${extension} indices if needed`, async () => { it(`should reindex ${extension} indices if needed`, async () => {
databaseMock.shouldReindex.mockResolvedValue(true); databaseMock.shouldReindex.mockResolvedValue(true);
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
expect(databaseMock.reindex).toHaveBeenCalledTimes(2); expect(databaseMock.reindex).toHaveBeenCalledTimes(2);
@ -229,7 +229,7 @@ describe(DatabaseService.name, () => {
it(`should not reindex ${extension} indices if not needed`, async () => { it(`should not reindex ${extension} indices if not needed`, async () => {
databaseMock.shouldReindex.mockResolvedValue(false); databaseMock.shouldReindex.mockResolvedValue(false);
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
expect(databaseMock.reindex).toHaveBeenCalledTimes(0); expect(databaseMock.reindex).toHaveBeenCalledTimes(0);
@ -240,7 +240,7 @@ describe(DatabaseService.name, () => {
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
process.env.DB_SKIP_MIGRATIONS = 'true'; process.env.DB_SKIP_MIGRATIONS = 'true';
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.runMigrations).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled();
}); });
@ -255,7 +255,7 @@ describe(DatabaseService.name, () => {
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); 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).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal.mock.calls[0][0]).toContain( expect(loggerMock.fatal.mock.calls[0][0]).toContain(
@ -274,7 +274,7 @@ describe(DatabaseService.name, () => {
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); 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).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal.mock.calls[0][0]).toContain( expect(loggerMock.fatal.mock.calls[0][0]).toContain(

View File

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import semver from 'semver'; import semver from 'semver';
import { getVectorExtension } from 'src/database.config'; import { getVectorExtension } from 'src/database.config';
import { EventHandlerOptions } from 'src/decorators'; import { OnEmit } from 'src/decorators';
import { import {
DatabaseExtension, DatabaseExtension,
DatabaseLock, DatabaseLock,
@ -10,7 +10,6 @@ import {
VectorExtension, VectorExtension,
VectorIndex, VectorIndex,
} from 'src/interfaces/database.interface'; } from 'src/interfaces/database.interface';
import { OnEvents } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
type CreateFailedArgs = { name: string; extension: string; otherName: string }; type CreateFailedArgs = { name: string; extension: string; otherName: string };
@ -61,7 +60,7 @@ const messages = {
}; };
@Injectable() @Injectable()
export class DatabaseService implements OnEvents { export class DatabaseService {
constructor( constructor(
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@ -69,8 +68,8 @@ export class DatabaseService implements OnEvents {
this.logger.setContext(DatabaseService.name); this.logger.setContext(DatabaseService.name);
} }
@EventHandlerOptions({ priority: -200 }) @OnEmit({ event: 'onBootstrap', priority: -200 })
async onBootstrapEvent() { async onBootstrap() {
const version = await this.databaseRepository.getPostgresVersion(); const version = await this.databaseRepository.getPostgresVersion();
const current = semver.coerce(version); const current = semver.coerce(version);
const postgresRange = this.databaseRepository.getPostgresVersionRange(); const postgresRange = this.databaseRepository.getPostgresVersionRange();

View File

@ -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<AssetEntity[]>,
);
await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({
totalSize: 25_000,
archives: [
{
assetIds: [assetStub.livePhotoStillAsset.id],
size: 25_000,
},
],
});
});
}); });
}); });

View File

@ -1,6 +1,7 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { parse } from 'node:path'; import { parse } from 'node:path';
import { AccessCore } from 'src/cores/access.core'; import { AccessCore } from 'src/cores/access.core';
import { StorageCore } from 'src/cores/storage.core';
import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.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 { ImmichReadStream, IStorageRepository } from 'src/interfaces/storage.interface';
import { HumanReadableSize } from 'src/utils/bytes'; import { HumanReadableSize } from 'src/utils/bytes';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
import { getPreferences } from 'src/utils/preferences';
@Injectable() @Injectable()
export class DownloadService { export class DownloadService {
@ -32,12 +34,22 @@ export class DownloadService {
const archives: DownloadArchiveInfo[] = []; const archives: DownloadArchiveInfo[] = [];
let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; let archive: DownloadArchiveInfo = { size: 0, assetIds: [] };
const preferences = getPreferences(auth.user);
const assetPagination = await this.getDownloadAssets(auth, dto); const assetPagination = await this.getDownloadAssets(auth, dto);
for await (const assets of assetPagination) { for await (const assets of assetPagination) {
// motion part of live photos // motion part of live photos
const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter<string>((id): id is string => !!id); const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id);
if (motionIds.length > 0) { 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) { for (const asset of assets) {

View File

@ -73,7 +73,7 @@ describe(LibraryService.name, () => {
it('should init cron job and subscribe to config changes', async () => { it('should init cron job and subscribe to config changes', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); systemMock.get.mockResolvedValue(systemConfigStub.libraryScan);
await sut.onBootstrapEvent(); await sut.onBootstrap();
expect(systemMock.get).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
expect(jobMock.addCronJob).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(storageMock.watch.mock.calls).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@ -118,7 +118,7 @@ describe(LibraryService.name, () => {
it('should not initialize watcher when watching is disabled', async () => { it('should not initialize watcher when watching is disabled', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
await sut.onBootstrapEvent(); await sut.onBootstrap();
expect(storageMock.watch).not.toHaveBeenCalled(); expect(storageMock.watch).not.toHaveBeenCalled();
}); });
@ -127,7 +127,7 @@ describe(LibraryService.name, () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
databaseMock.tryLock.mockResolvedValue(false); databaseMock.tryLock.mockResolvedValue(false);
await sut.onBootstrapEvent(); await sut.onBootstrap();
expect(storageMock.watch).not.toHaveBeenCalled(); expect(storageMock.watch).not.toHaveBeenCalled();
}); });
@ -136,7 +136,7 @@ describe(LibraryService.name, () => {
describe('onConfigValidateEvent', () => { describe('onConfigValidateEvent', () => {
it('should allow a valid cron expression', () => { it('should allow a valid cron expression', () => {
expect(() => expect(() =>
sut.onConfigValidateEvent({ sut.onConfigValidate({
newConfig: { library: { scan: { cronExpression: '0 0 * * *' } } } as SystemConfig, newConfig: { library: { scan: { cronExpression: '0 0 * * *' } } } as SystemConfig,
oldConfig: {} as SystemConfig, oldConfig: {} as SystemConfig,
}), }),
@ -145,7 +145,7 @@ describe(LibraryService.name, () => {
it('should fail for an invalid cron expression', () => { it('should fail for an invalid cron expression', () => {
expect(() => expect(() =>
sut.onConfigValidateEvent({ sut.onConfigValidate({
newConfig: { library: { scan: { cronExpression: 'foo' } } } as SystemConfig, newConfig: { library: { scan: { cronExpression: 'foo' } } } as SystemConfig,
oldConfig: {} as SystemConfig, oldConfig: {} as SystemConfig,
}), }),
@ -730,7 +730,7 @@ describe(LibraryService.name, () => {
const mockClose = vitest.fn(); const mockClose = vitest.fn();
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
await sut.onBootstrapEvent(); await sut.onBootstrap();
await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); await sut.delete(libraryStub.externalLibraryWithImportPaths1.id);
expect(mockClose).toHaveBeenCalled(); expect(mockClose).toHaveBeenCalled();
@ -861,7 +861,7 @@ describe(LibraryService.name, () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([]); libraryMock.getAll.mockResolvedValue([]);
await sut.onBootstrapEvent(); await sut.onBootstrap();
await sut.create({ await sut.create({
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths,
@ -917,7 +917,7 @@ describe(LibraryService.name, () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.getAll.mockResolvedValue([]); libraryMock.getAll.mockResolvedValue([]);
await sut.onBootstrapEvent(); await sut.onBootstrap();
}); });
it('should update library', async () => { it('should update library', async () => {
@ -933,7 +933,7 @@ describe(LibraryService.name, () => {
beforeEach(async () => { beforeEach(async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
await sut.onBootstrapEvent(); await sut.onBootstrap();
}); });
it('should not watch library', async () => { it('should not watch library', async () => {
@ -949,7 +949,7 @@ describe(LibraryService.name, () => {
beforeEach(async () => { beforeEach(async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.getAll.mockResolvedValue([]); libraryMock.getAll.mockResolvedValue([]);
await sut.onBootstrapEvent(); await sut.onBootstrap();
}); });
it('should watch library', async () => { it('should watch library', async () => {
@ -1107,8 +1107,8 @@ describe(LibraryService.name, () => {
const mockClose = vitest.fn(); const mockClose = vitest.fn();
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
await sut.onBootstrapEvent(); await sut.onBootstrap();
await sut.onShutdownEvent(); await sut.onShutdown();
expect(mockClose).toHaveBeenCalledTimes(2); expect(mockClose).toHaveBeenCalledTimes(2);
}); });

View File

@ -6,6 +6,7 @@ import path, { basename, parse } from 'node:path';
import picomatch from 'picomatch'; import picomatch from 'picomatch';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { import {
CreateLibraryDto, CreateLibraryDto,
LibraryResponseDto, LibraryResponseDto,
@ -22,7 +23,7 @@ import { AssetType } from 'src/enum';
import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.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 { import {
IBaseJob, IBaseJob,
IEntityJob, IEntityJob,
@ -45,7 +46,7 @@ import { validateCronExpression } from 'src/validation';
const LIBRARY_SCAN_BATCH_SIZE = 5000; const LIBRARY_SCAN_BATCH_SIZE = 5000;
@Injectable() @Injectable()
export class LibraryService implements OnEvents { export class LibraryService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
private watchLibraries = false; private watchLibraries = false;
private watchLock = false; private watchLock = false;
@ -65,7 +66,8 @@ export class LibraryService implements OnEvents {
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
async onBootstrapEvent() { @OnEmit({ event: 'onBootstrap' })
async onBootstrap() {
const config = await this.configCore.getConfig({ withCache: false }); const config = await this.configCore.getConfig({ withCache: false });
const { watch, scan } = config.library; 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; const { scan } = newConfig.library;
if (!validateCronExpression(scan.cronExpression)) { if (!validateCronExpression(scan.cronExpression)) {
throw new Error(`Invalid cron expression ${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(); await this.unwatchAll();
} }

View File

@ -95,7 +95,7 @@ describe(MetadataService.name, () => {
}); });
afterEach(async () => { afterEach(async () => {
await sut.onShutdownEvent(); await sut.onShutdown();
}); });
it('should be defined', () => { it('should be defined', () => {
@ -104,7 +104,7 @@ describe(MetadataService.name, () => {
describe('onBootstrapEvent', () => { describe('onBootstrapEvent', () => {
it('should pause and resume queue during init', async () => { it('should pause and resume queue during init', async () => {
await sut.onBootstrapEvent('microservices'); await sut.onBootstrap('microservices');
expect(jobMock.pause).toHaveBeenCalledTimes(1); expect(jobMock.pause).toHaveBeenCalledTimes(1);
expect(mapMock.init).toHaveBeenCalledTimes(1); expect(mapMock.init).toHaveBeenCalledTimes(1);
@ -114,7 +114,7 @@ describe(MetadataService.name, () => {
it('should return if reverse geocoding is disabled', async () => { it('should return if reverse geocoding is disabled', async () => {
systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: false } });
await sut.onBootstrapEvent('microservices'); await sut.onBootstrap('microservices');
expect(jobMock.pause).not.toHaveBeenCalled(); expect(jobMock.pause).not.toHaveBeenCalled();
expect(mapMock.init).not.toHaveBeenCalled(); expect(mapMock.init).not.toHaveBeenCalled();

View File

@ -8,6 +8,7 @@ import path from 'node:path';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity'; import { ExifEntity } from 'src/entities/exif.entity';
import { AssetType } from 'src/enum'; 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 { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.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 { import {
IBaseJob, IBaseJob,
IEntityJob, IEntityJob,
@ -86,7 +87,7 @@ const validate = <T>(value: T): NonNullable<T> | null => {
}; };
@Injectable() @Injectable()
export class MetadataService implements OnEvents { export class MetadataService {
private storageCore: StorageCore; private storageCore: StorageCore;
private configCore: SystemConfigCore; 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') { if (app !== 'microservices') {
return; return;
} }
@ -128,7 +130,8 @@ export class MetadataService implements OnEvents {
await this.init(config); await this.init(config);
} }
async onConfigUpdateEvent({ newConfig }: { newConfig: SystemConfig }) { @OnEmit({ event: 'onConfigUpdate' })
async onConfigUpdate({ newConfig }: ArgOf<'onConfigUpdate'>) {
await this.init(newConfig); await this.init(newConfig);
} }
@ -150,7 +153,8 @@ export class MetadataService implements OnEvents {
} }
} }
async onShutdownEvent() { @OnEmit({ event: 'onShutdown' })
async onShutdown() {
await this.repository.teardown(); await this.repository.teardown();
} }

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; 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 { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface';
import { AssetService } from 'src/services/asset.service'; import { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.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'; import { otelShutdown } from 'src/utils/instrumentation';
@Injectable() @Injectable()
export class MicroservicesService implements OnEvents { export class MicroservicesService {
constructor( constructor(
private auditService: AuditService, private auditService: AuditService,
private assetService: AssetService, private assetService: AssetService,
@ -38,7 +39,8 @@ export class MicroservicesService implements OnEvents {
private versionService: VersionService, private versionService: VersionService,
) {} ) {}
async onBootstrapEvent(app: 'api' | 'microservices') { @OnEmit({ event: 'onBootstrap' })
async onBootstrap(app: ArgOf<'onBootstrap'>) {
if (app !== 'microservices') { if (app !== 'microservices') {
return; return;
} }

View File

@ -90,7 +90,7 @@ describe(NotificationService.name, () => {
const newConfig = configs.smtpEnabled; const newConfig = configs.smtpEnabled;
notificationMock.verifySmtp.mockResolvedValue(true); 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); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport);
}); });
@ -99,7 +99,7 @@ describe(NotificationService.name, () => {
const newConfig = configs.smtpTransport; const newConfig = configs.smtpTransport;
notificationMock.verifySmtp.mockResolvedValue(true); 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); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport);
}); });
@ -107,7 +107,7 @@ describe(NotificationService.name, () => {
const oldConfig = { ...configs.smtpEnabled }; const oldConfig = { ...configs.smtpEnabled };
const newConfig = { ...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(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled();
}); });
@ -115,19 +115,19 @@ describe(NotificationService.name, () => {
const oldConfig = { ...configs.smtpEnabled }; const oldConfig = { ...configs.smtpEnabled };
const newConfig = { ...configs.smtpDisabled }; 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(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled();
}); });
}); });
describe('onUserSignupEvent', () => { describe('onUserSignupEvent', () => {
it('skips when notify is false', async () => { it('skips when notify is false', async () => {
await sut.onUserSignupEvent({ id: '', notify: false }); await sut.onUserSignup({ id: '', notify: false });
expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
}); });
it('should queue notify signup event if notify is true', async () => { 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({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_SIGNUP, name: JobName.NOTIFY_SIGNUP,
data: { id: '', tempPassword: undefined }, data: { id: '', tempPassword: undefined },
@ -137,7 +137,7 @@ describe(NotificationService.name, () => {
describe('onAlbumUpdateEvent', () => { describe('onAlbumUpdateEvent', () => {
it('should queue notify album update event', async () => { it('should queue notify album update event', async () => {
await sut.onAlbumUpdateEvent({ id: '', updatedBy: '42' }); await sut.onAlbumUpdate({ id: '', updatedBy: '42' });
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_UPDATE, name: JobName.NOTIFY_ALBUM_UPDATE,
data: { id: '', senderId: '42' }, data: { id: '', senderId: '42' },
@ -147,7 +147,7 @@ describe(NotificationService.name, () => {
describe('onAlbumInviteEvent', () => { describe('onAlbumInviteEvent', () => {
it('should queue notify album invite event', async () => { it('should queue notify album invite event', async () => {
await sut.onAlbumInviteEvent({ id: '', userId: '42' }); await sut.onAlbumInvite({ id: '', userId: '42' });
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_INVITE, name: JobName.NOTIFY_ALBUM_INVITE,
data: { id: '', recipientId: '42' }, data: { id: '', recipientId: '42' },

View File

@ -2,17 +2,12 @@ import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { import { ArgOf } from 'src/interfaces/event.interface';
AlbumInviteEvent,
AlbumUpdateEvent,
OnEvents,
SystemConfigUpdateEvent,
UserSignupEvent,
} from 'src/interfaces/event.interface';
import { import {
IEmailJob, IEmailJob,
IJobRepository, IJobRepository,
@ -30,7 +25,7 @@ import { getFilenameExtension } from 'src/utils/file';
import { getPreferences } from 'src/utils/preferences'; import { getPreferences } from 'src/utils/preferences';
@Injectable() @Injectable()
export class NotificationService implements OnEvents { export class NotificationService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
constructor( constructor(
@ -46,7 +41,8 @@ export class NotificationService implements OnEvents {
this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
} }
async onConfigValidateEvent({ oldConfig, newConfig }: SystemConfigUpdateEvent) { @OnEmit({ event: 'onConfigValidate', priority: -100 })
async onConfigValidate({ oldConfig, newConfig }: ArgOf<'onConfigValidate'>) {
try { try {
if ( if (
newConfig.notifications.smtp.enabled && 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) { if (notify) {
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); 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 } }); 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 } }); await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } });
} }

View File

@ -3,6 +3,7 @@ import { getBuildMetadata, getServerLicensePublicKey } from 'src/config';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { import {
ServerAboutResponseDto, ServerAboutResponseDto,
@ -16,7 +17,6 @@ import {
} from 'src/dtos/server.dto'; } from 'src/dtos/server.dto';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { OnEvents } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.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'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc';
@Injectable() @Injectable()
export class ServerService implements OnEvents { export class ServerService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
constructor( constructor(
@ -42,7 +42,8 @@ export class ServerService implements OnEvents {
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
async onBootstrapEvent(): Promise<void> { @OnEmit({ event: 'onBootstrap' })
async onBootstrap(): Promise<void> {
const featureFlags = await this.getFeatures(); const featureFlags = await this.getFeatures();
if (featureFlags.configFile) { if (featureFlags.configFile) {
await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, {

View File

@ -49,7 +49,7 @@ describe(SmartInfoService.name, () => {
describe('onConfigValidateEvent', () => { describe('onConfigValidateEvent', () => {
it('should allow a valid model', () => { it('should allow a valid model', () => {
expect(() => expect(() =>
sut.onConfigValidateEvent({ sut.onConfigValidate({
newConfig: { machineLearning: { clip: { modelName: 'ViT-B-16__openai' } } } as SystemConfig, newConfig: { machineLearning: { clip: { modelName: 'ViT-B-16__openai' } } } as SystemConfig,
oldConfig: {} as SystemConfig, oldConfig: {} as SystemConfig,
}), }),
@ -58,7 +58,7 @@ describe(SmartInfoService.name, () => {
it('should allow including organization', () => { it('should allow including organization', () => {
expect(() => expect(() =>
sut.onConfigValidateEvent({ sut.onConfigValidate({
newConfig: { machineLearning: { clip: { modelName: 'immich-app/ViT-B-16__openai' } } } as SystemConfig, newConfig: { machineLearning: { clip: { modelName: 'immich-app/ViT-B-16__openai' } } } as SystemConfig,
oldConfig: {} as SystemConfig, oldConfig: {} as SystemConfig,
}), }),
@ -67,7 +67,7 @@ describe(SmartInfoService.name, () => {
it('should fail for an unsupported model', () => { it('should fail for an unsupported model', () => {
expect(() => expect(() =>
sut.onConfigValidateEvent({ sut.onConfigValidate({
newConfig: { machineLearning: { clip: { modelName: 'test-model' } } } as SystemConfig, newConfig: { machineLearning: { clip: { modelName: 'test-model' } } } as SystemConfig,
oldConfig: {} as SystemConfig, oldConfig: {} as SystemConfig,
}), }),
@ -77,7 +77,7 @@ describe(SmartInfoService.name, () => {
describe('onBootstrapEvent', () => { describe('onBootstrapEvent', () => {
it('should return if not microservices', async () => { it('should return if not microservices', async () => {
await sut.onBootstrapEvent('api'); await sut.onBootstrap('api');
expect(systemMock.get).not.toHaveBeenCalled(); expect(systemMock.get).not.toHaveBeenCalled();
expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); expect(searchMock.getDimensionSize).not.toHaveBeenCalled();
@ -92,7 +92,7 @@ describe(SmartInfoService.name, () => {
it('should return if machine learning is disabled', async () => { it('should return if machine learning is disabled', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
await sut.onBootstrapEvent('microservices'); await sut.onBootstrap('microservices');
expect(systemMock.get).toHaveBeenCalledTimes(1); expect(systemMock.get).toHaveBeenCalledTimes(1);
expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); expect(searchMock.getDimensionSize).not.toHaveBeenCalled();
@ -107,7 +107,7 @@ describe(SmartInfoService.name, () => {
it('should return if model and DB dimension size are equal', async () => { it('should return if model and DB dimension size are equal', async () => {
searchMock.getDimensionSize.mockResolvedValue(512); searchMock.getDimensionSize.mockResolvedValue(512);
await sut.onBootstrapEvent('microservices'); await sut.onBootstrap('microservices');
expect(systemMock.get).toHaveBeenCalledTimes(1); expect(systemMock.get).toHaveBeenCalledTimes(1);
expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
@ -123,7 +123,7 @@ describe(SmartInfoService.name, () => {
searchMock.getDimensionSize.mockResolvedValue(768); searchMock.getDimensionSize.mockResolvedValue(768);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.onBootstrapEvent('microservices'); await sut.onBootstrap('microservices');
expect(systemMock.get).toHaveBeenCalledTimes(1); expect(systemMock.get).toHaveBeenCalledTimes(1);
expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
@ -138,7 +138,7 @@ describe(SmartInfoService.name, () => {
searchMock.getDimensionSize.mockResolvedValue(768); searchMock.getDimensionSize.mockResolvedValue(768);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true });
await sut.onBootstrapEvent('microservices'); await sut.onBootstrap('microservices');
expect(systemMock.get).toHaveBeenCalledTimes(1); expect(systemMock.get).toHaveBeenCalledTimes(1);
expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
@ -154,7 +154,7 @@ describe(SmartInfoService.name, () => {
it('should return if machine learning is disabled', async () => { it('should return if machine learning is disabled', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
await sut.onConfigUpdateEvent({ await sut.onConfigUpdate({
newConfig: systemConfigStub.machineLearningDisabled as SystemConfig, newConfig: systemConfigStub.machineLearningDisabled as SystemConfig,
oldConfig: 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 () => { it('should return if model and DB dimension size are equal', async () => {
searchMock.getDimensionSize.mockResolvedValue(512); searchMock.getDimensionSize.mockResolvedValue(512);
await sut.onConfigUpdateEvent({ await sut.onConfigUpdate({
newConfig: { newConfig: {
machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true }, machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true },
} as SystemConfig, } as SystemConfig,
@ -194,7 +194,7 @@ describe(SmartInfoService.name, () => {
searchMock.getDimensionSize.mockResolvedValue(512); searchMock.getDimensionSize.mockResolvedValue(512);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.onConfigUpdateEvent({ await sut.onConfigUpdate({
newConfig: { newConfig: {
machineLearning: { clip: { modelName: 'ViT-L-14-quickgelu__dfn2b', enabled: true }, enabled: true }, machineLearning: { clip: { modelName: 'ViT-L-14-quickgelu__dfn2b', enabled: true }, enabled: true },
} as SystemConfig, } as SystemConfig,
@ -215,7 +215,7 @@ describe(SmartInfoService.name, () => {
searchMock.getDimensionSize.mockResolvedValue(512); searchMock.getDimensionSize.mockResolvedValue(512);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.onConfigUpdateEvent({ await sut.onConfigUpdate({
newConfig: { newConfig: {
machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true }, machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true },
} as SystemConfig, } as SystemConfig,
@ -237,7 +237,7 @@ describe(SmartInfoService.name, () => {
searchMock.getDimensionSize.mockResolvedValue(512); searchMock.getDimensionSize.mockResolvedValue(512);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true });
await sut.onConfigUpdateEvent({ await sut.onConfigUpdate({
newConfig: { newConfig: {
machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true }, machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true },
} as SystemConfig, } as SystemConfig,

View File

@ -1,9 +1,10 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.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 { import {
IBaseJob, IBaseJob,
IEntityJob, IEntityJob,
@ -21,7 +22,7 @@ import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
export class SmartInfoService implements OnEvents { export class SmartInfoService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
constructor( constructor(
@ -37,7 +38,8 @@ export class SmartInfoService implements OnEvents {
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
async onBootstrapEvent(app: 'api' | 'microservices') { @OnEmit({ event: 'onBootstrap' })
async onBootstrap(app: ArgOf<'onBootstrap'>) {
if (app !== 'microservices') { if (app !== 'microservices') {
return; return;
} }
@ -46,7 +48,8 @@ export class SmartInfoService implements OnEvents {
await this.init(config); await this.init(config);
} }
onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { @OnEmit({ event: 'onConfigValidate' })
onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) {
try { try {
getCLIPModelInfo(newConfig.machineLearning.clip.modelName); getCLIPModelInfo(newConfig.machineLearning.clip.modelName);
} catch { } 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); await this.init(newConfig, oldConfig);
} }

View File

@ -76,10 +76,10 @@ describe(StorageTemplateService.name, () => {
SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults);
}); });
describe('onConfigValidateEvent', () => { describe('onConfigValidate', () => {
it('should allow valid templates', () => { it('should allow valid templates', () => {
expect(() => expect(() =>
sut.onConfigValidateEvent({ sut.onConfigValidate({
newConfig: { newConfig: {
storageTemplate: { storageTemplate: {
template: template:
@ -93,7 +93,7 @@ describe(StorageTemplateService.name, () => {
it('should fail for an invalid template', () => { it('should fail for an invalid template', () => {
expect(() => expect(() =>
sut.onConfigValidateEvent({ sut.onConfigValidate({
newConfig: { newConfig: {
storageTemplate: { storageTemplate: {
template: '{{foo}}', template: '{{foo}}',

View File

@ -15,6 +15,7 @@ import {
} from 'src/constants'; } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/entities/move.entity'; import { AssetPathType } from 'src/entities/move.entity';
import { AssetType } from 'src/enum'; import { AssetType } from 'src/enum';
@ -22,7 +23,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.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 { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
@ -46,7 +47,7 @@ interface RenderMetadata {
} }
@Injectable() @Injectable()
export class StorageTemplateService implements OnEvents { export class StorageTemplateService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
private storageCore: StorageCore; private storageCore: StorageCore;
private _template: { private _template: {
@ -88,7 +89,8 @@ export class StorageTemplateService implements OnEvents {
); );
} }
onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { @OnEmit({ event: 'onConfigValidate' })
onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) {
try { try {
const { compiled } = this.compile(newConfig.storageTemplate.template); const { compiled } = this.compile(newConfig.storageTemplate.template);
this.render(compiled, { this.render(compiled, {

View File

@ -20,9 +20,9 @@ describe(StorageService.name, () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
describe('onBootstrapEvent', () => { describe('onBootstrap', () => {
it('should create the library folder on initialization', () => { it('should create the library folder on initialization', () => {
sut.onBootstrapEvent(); sut.onBootstrap();
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library');
}); });
}); });

View File

@ -1,12 +1,12 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { StorageCore, StorageFolder } from 'src/cores/storage.core'; 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 { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
@Injectable() @Injectable()
export class StorageService implements OnEvents { export class StorageService {
constructor( constructor(
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@ -14,7 +14,8 @@ export class StorageService implements OnEvents {
this.logger.setContext(StorageService.name); this.logger.setContext(StorageService.name);
} }
onBootstrapEvent() { @OnEmit({ event: 'onBootstrap' })
onBootstrap() {
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
this.storageRepository.mkdirSync(libraryBase); this.storageRepository.mkdirSync(libraryBase);
} }

View File

@ -13,20 +13,14 @@ import {
supportedYearTokens, supportedYearTokens,
} from 'src/constants'; } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core'; 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 { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto';
import { import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface';
ClientEvent,
IEventRepository,
OnEvents,
ServerEvent,
SystemConfigUpdateEvent,
} from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@Injectable() @Injectable()
export class SystemConfigService implements OnEvents { export class SystemConfigService {
private core: SystemConfigCore; private core: SystemConfigCore;
constructor( constructor(
@ -39,8 +33,8 @@ export class SystemConfigService implements OnEvents {
this.core.config$.subscribe((config) => this.setLogLevel(config)); this.core.config$.subscribe((config) => this.setLogLevel(config));
} }
@EventHandlerOptions({ priority: -100 }) @OnEmit({ event: 'onBootstrap', priority: -100 })
async onBootstrapEvent() { async onBootstrap() {
const config = await this.core.getConfig({ withCache: false }); const config = await this.core.getConfig({ withCache: false });
this.core.config$.next(config); this.core.config$.next(config);
} }
@ -54,7 +48,8 @@ export class SystemConfigService implements OnEvents {
return mapConfig(defaults); return mapConfig(defaults);
} }
onConfigValidateEvent({ newConfig, oldConfig }: SystemConfigUpdateEvent) { @OnEmit({ event: 'onConfigValidate' })
onConfigValidate({ newConfig, oldConfig }: ArgOf<'onConfigValidate'>) {
if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { 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.'); 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 }); const oldConfig = await this.core.getConfig({ withCache: false });
try { try {
await this.eventRepository.emit('onConfigValidateEvent', { newConfig: dto, oldConfig }); await this.eventRepository.emit('onConfigValidate', { newConfig: dto, oldConfig });
} catch (error) { } catch (error) {
this.logger.warn(`Unable to save system config due to a validation error: ${error}`); this.logger.warn(`Unable to save system config due to a validation error: ${error}`);
throw new BadRequestException(error instanceof Error ? error.message : 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 // TODO probably move web socket emits to a separate service
this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {});
this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null);
await this.eventRepository.emit('onConfigUpdateEvent', { newConfig, oldConfig }); await this.eventRepository.emit('onConfigUpdate', { newConfig, oldConfig });
return mapConfig(newConfig); return mapConfig(newConfig);
} }

View File

@ -45,7 +45,7 @@ export class UserAdminService {
const { notify, ...rest } = dto; const { notify, ...rest } = dto;
const user = await this.userCore.createUser(rest); const user = await this.userCore.createUser(rest);
await this.eventRepository.emit('onUserSignupEvent', { await this.eventRepository.emit('onUserSignup', {
notify: !!notify, notify: !!notify,
id: user.id, id: user.id,
tempPassword: user.shouldChangePassword ? rest.password : undefined, tempPassword: user.shouldChangePassword ? rest.password : undefined,

View File

@ -3,11 +3,11 @@ import { DateTime } from 'luxon';
import semver, { SemVer } from 'semver'; import semver, { SemVer } from 'semver';
import { isDev, serverVersion } from 'src/constants'; import { isDev, serverVersion } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core'; 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 { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity';
import { SystemMetadataKey } from 'src/enum'; 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 { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
@ -23,7 +23,7 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re
}; };
@Injectable() @Injectable()
export class VersionService implements OnEvents { export class VersionService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
constructor( constructor(
@ -37,7 +37,8 @@ export class VersionService implements OnEvents {
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
async onBootstrapEvent(): Promise<void> { @OnEmit({ event: 'onBootstrap' })
async onBootstrap(): Promise<void> {
await this.handleVersionCheck(); await this.handleVersionCheck();
} }

View File

@ -1,33 +1,57 @@
import { ModuleRef, Reflector } from '@nestjs/core'; import { ModuleRef, Reflector } from '@nestjs/core';
import _ from 'lodash'; import _ from 'lodash';
import { HandlerOptions } from 'src/decorators'; import { EmitConfig } from 'src/decorators';
import { EmitEvent, EmitEventHandler, IEventRepository, OnEvents, events } from 'src/interfaces/event.interface'; import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface';
import { Metadata } from 'src/middleware/auth.guard'; import { Metadata } from 'src/middleware/auth.guard';
import { services } from 'src/services'; import { services } from 'src/services';
type Item<T extends EmitEvent> = {
event: T;
handler: EmitHandler<T>;
priority: number;
label: string;
};
export const setupEventHandlers = (moduleRef: ModuleRef) => { export const setupEventHandlers = (moduleRef: ModuleRef) => {
const reflector = moduleRef.get(Reflector, { strict: false }); const reflector = moduleRef.get(Reflector, { strict: false });
const repository = moduleRef.get<IEventRepository>(IEventRepository); const repository = moduleRef.get<IEventRepository>(IEventRepository);
const handlers: Array<{ event: EmitEvent; handler: EmitEventHandler<EmitEvent>; priority: number }> = []; const items: Item<EmitEvent>[] = [];
// discovery // discovery
for (const Service of services) { for (const Service of services) {
const instance = moduleRef.get<OnEvents>(Service); const instance = moduleRef.get<any>(Service);
for (const event of events) { const ctx = Object.getPrototypeOf(instance);
const handler = instance[event] as EmitEventHandler<typeof event>; 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') { if (typeof handler !== 'function') {
continue; continue;
} }
const options = reflector.get<HandlerOptions>(Metadata.EVENT_HANDLER_OPTIONS, handler); const options = reflector.get<EmitConfig>(Metadata.ON_EMIT_CONFIG, handler);
const priority = options?.priority || 0; 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 // register by priority
for (const { event, handler } of _.orderBy(handlers, ['priority'], ['asc'])) { for (const { event, handler } of handlers) {
repository.on(event, handler); repository.on(event as EmitEvent, handler);
} }
return handlers;
}; };

View File

@ -14,13 +14,21 @@
SettingInputFieldType, SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte'; } from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; 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 archiveSize = convertFromBytes($preferences?.download?.archiveSize || 4, ByteUnit.GiB);
let includeEmbeddedVideos = $preferences?.download?.includeEmbeddedVideos || false;
const handleSave = async () => { const handleSave = async () => {
try { try {
const dto = { download: { archiveSize: Math.floor(convertToBytes(archiveSize, ByteUnit.GiB)) } }; const newPreferences = await updateMyPreferences({
const newPreferences = await updateMyPreferences({ userPreferencesUpdateDto: dto }); userPreferencesUpdateDto: {
download: {
archiveSize: Math.floor(convertToBytes(archiveSize, ByteUnit.GiB)),
includeEmbeddedVideos,
},
},
});
$preferences = newPreferences; $preferences = newPreferences;
notificationController.show({ message: $t('saved_settings'), type: NotificationType.Info }); notificationController.show({ message: $t('saved_settings'), type: NotificationType.Info });
@ -34,14 +42,17 @@
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4"> <SettingInputField
<SettingInputField inputType={SettingInputFieldType.NUMBER}
inputType={SettingInputFieldType.NUMBER} label={$t('archive_size')}
label={$t('archive_size')} desc={$t('archive_size_description')}
desc={$t('archive_size_description')} bind:value={archiveSize}
bind:value={archiveSize} />
/> <SettingSwitch
</div> title={$t('download_include_embedded_motion_videos')}
subtitle={$t('download_include_embedded_motion_videos_description')}
bind:checked={includeEmbeddedVideos}
></SettingSwitch>
<div class="flex justify-end"> <div class="flex justify-end">
<Button type="submit" size="sm" on:click={() => handleSave()}>{$t('save')}</Button> <Button type="submit" size="sm" on:click={() => handleSave()}>{$t('save')}</Button>
</div> </div>

View File

@ -368,7 +368,7 @@
"appears_in": "Appears in", "appears_in": "Appears in",
"archive": "Archive", "archive": "Archive",
"archive_or_unarchive_photo": "Archive or unarchive photo", "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)", "archive_size_description": "Configure the archive size for downloads (in GiB)",
"archived_count": "{count, plural, other {Archived #}}", "archived_count": "{count, plural, other {Archived #}}",
"are_these_the_same_person": "Are these the same person?", "are_these_the_same_person": "Are these the same person?",
@ -512,6 +512,8 @@
"do_not_show_again": "Do not show this message again", "do_not_show_again": "Do not show this message again",
"done": "Done", "done": "Done",
"download": "Download", "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": "Download",
"download_settings_description": "Manage settings related to asset download", "download_settings_description": "Manage settings related to asset download",
"downloading": "Downloading", "downloading": "Downloading",

View File

@ -172,13 +172,19 @@ export const downloadFile = async (asset: AssetResponseDto) => {
}, },
]; ];
const isAndroidMotionVideo = (asset: AssetResponseDto) => {
return asset.originalPath.includes('encoded-video');
};
if (asset.livePhotoVideoId) { if (asset.livePhotoVideoId) {
const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() }); const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() });
assets.push({ if (!isAndroidMotionVideo(motionAsset) || get(preferences).download.includeEmbeddedVideos) {
filename: motionAsset.originalFileName, assets.push({
id: asset.livePhotoVideoId, filename: motionAsset.originalFileName,
size: motionAsset.exifInfo?.fileSizeInByte || 0, id: asset.livePhotoVideoId,
}); size: motionAsset.exifInfo?.fileSizeInByte || 0,
});
}
} }
for (const { filename, id, size } of assets) { for (const { filename, id, size } of assets) {