put(Isar db) async {
@@ -541,8 +550,22 @@ class Asset {
"isArchived": $isArchived,
"isTrashed": $isTrashed,
"isOffline": $isOffline,
+ "visibility": "$visibility",
}""";
}
+
+ static getVisibility(AssetResponseDtoVisibilityEnum visibility) {
+ switch (visibility) {
+ case AssetResponseDtoVisibilityEnum.timeline:
+ return AssetVisibilityEnum.timeline;
+ case AssetResponseDtoVisibilityEnum.archive:
+ return AssetVisibilityEnum.archive;
+ case AssetResponseDtoVisibilityEnum.hidden:
+ return AssetVisibilityEnum.hidden;
+ case AssetResponseDtoVisibilityEnum.locked:
+ return AssetVisibilityEnum.locked;
+ }
+ }
}
enum AssetType {
diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart
index 07eee4825e..b558690813 100644
--- a/mobile/lib/entities/asset.entity.g.dart
+++ b/mobile/lib/entities/asset.entity.g.dart
@@ -118,8 +118,14 @@ const AssetSchema = CollectionSchema(
name: r'updatedAt',
type: IsarType.dateTime,
),
- r'width': PropertySchema(
+ r'visibility': PropertySchema(
id: 20,
+ name: r'visibility',
+ type: IsarType.byte,
+ enumMap: _AssetvisibilityEnumValueMap,
+ ),
+ r'width': PropertySchema(
+ id: 21,
name: r'width',
type: IsarType.int,
)
@@ -256,7 +262,8 @@ void _assetSerialize(
writer.writeString(offsets[17], object.thumbhash);
writer.writeByte(offsets[18], object.type.index);
writer.writeDateTime(offsets[19], object.updatedAt);
- writer.writeInt(offsets[20], object.width);
+ writer.writeByte(offsets[20], object.visibility.index);
+ writer.writeInt(offsets[21], object.width);
}
Asset _assetDeserialize(
@@ -288,7 +295,10 @@ Asset _assetDeserialize(
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ??
AssetType.other,
updatedAt: reader.readDateTime(offsets[19]),
- width: reader.readIntOrNull(offsets[20]),
+ visibility:
+ _AssetvisibilityValueEnumMap[reader.readByteOrNull(offsets[20])] ??
+ AssetVisibilityEnum.timeline,
+ width: reader.readIntOrNull(offsets[21]),
);
return object;
}
@@ -342,6 +352,9 @@ P _assetDeserializeProp(
case 19:
return (reader.readDateTime(offset)) as P;
case 20:
+ return (_AssetvisibilityValueEnumMap[reader.readByteOrNull(offset)] ??
+ AssetVisibilityEnum.timeline) as P;
+ case 21:
return (reader.readIntOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -360,6 +373,18 @@ const _AssettypeValueEnumMap = {
2: AssetType.video,
3: AssetType.audio,
};
+const _AssetvisibilityEnumValueMap = {
+ 'timeline': 0,
+ 'hidden': 1,
+ 'archive': 2,
+ 'locked': 3,
+};
+const _AssetvisibilityValueEnumMap = {
+ 0: AssetVisibilityEnum.timeline,
+ 1: AssetVisibilityEnum.hidden,
+ 2: AssetVisibilityEnum.archive,
+ 3: AssetVisibilityEnum.locked,
+};
Id _assetGetId(Asset object) {
return object.id;
@@ -2477,6 +2502,59 @@ extension AssetQueryFilter on QueryBuilder {
});
}
+ QueryBuilder visibilityEqualTo(
+ AssetVisibilityEnum value) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'visibility',
+ value: value,
+ ));
+ });
+ }
+
+ QueryBuilder visibilityGreaterThan(
+ AssetVisibilityEnum value, {
+ bool include = false,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ include: include,
+ property: r'visibility',
+ value: value,
+ ));
+ });
+ }
+
+ QueryBuilder visibilityLessThan(
+ AssetVisibilityEnum value, {
+ bool include = false,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.lessThan(
+ include: include,
+ property: r'visibility',
+ value: value,
+ ));
+ });
+ }
+
+ QueryBuilder visibilityBetween(
+ AssetVisibilityEnum lower,
+ AssetVisibilityEnum upper, {
+ bool includeLower = true,
+ bool includeUpper = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.between(
+ property: r'visibility',
+ lower: lower,
+ includeLower: includeLower,
+ upper: upper,
+ includeUpper: includeUpper,
+ ));
+ });
+ }
+
QueryBuilder widthIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
@@ -2791,6 +2869,18 @@ extension AssetQuerySortBy on QueryBuilder {
});
}
+ QueryBuilder sortByVisibility() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'visibility', Sort.asc);
+ });
+ }
+
+ QueryBuilder sortByVisibilityDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'visibility', Sort.desc);
+ });
+ }
+
QueryBuilder sortByWidth() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'width', Sort.asc);
@@ -3057,6 +3147,18 @@ extension AssetQuerySortThenBy on QueryBuilder {
});
}
+ QueryBuilder thenByVisibility() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'visibility', Sort.asc);
+ });
+ }
+
+ QueryBuilder thenByVisibilityDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'visibility', Sort.desc);
+ });
+ }
+
QueryBuilder thenByWidth() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'width', Sort.asc);
@@ -3201,6 +3303,12 @@ extension AssetQueryWhereDistinct on QueryBuilder {
});
}
+ QueryBuilder distinctByVisibility() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addDistinctBy(r'visibility');
+ });
+ }
+
QueryBuilder distinctByWidth() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'width');
@@ -3335,6 +3443,13 @@ extension AssetQueryProperty on QueryBuilder {
});
}
+ QueryBuilder
+ visibilityProperty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addPropertyName(r'visibility');
+ });
+ }
+
QueryBuilder widthProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'width');
diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart
index fe3320c9bb..a17e607d83 100644
--- a/mobile/lib/interfaces/asset_api.interface.dart
+++ b/mobile/lib/interfaces/asset_api.interface.dart
@@ -1,3 +1,4 @@
+import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAssetApiRepository {
@@ -15,4 +16,9 @@ abstract interface class IAssetApiRepository {
// Future delete(String id);
Future> search({List personIds = const []});
+
+ Future updateVisibility(
+ List list,
+ AssetVisibilityEnum visibility,
+ );
}
diff --git a/mobile/lib/interfaces/auth_api.interface.dart b/mobile/lib/interfaces/auth_api.interface.dart
index 0a4b235ff3..bb9a8b5a2c 100644
--- a/mobile/lib/interfaces/auth_api.interface.dart
+++ b/mobile/lib/interfaces/auth_api.interface.dart
@@ -6,4 +6,9 @@ abstract interface class IAuthApiRepository {
Future logout();
Future changePassword(String newPassword);
+
+ Future unlockPinCode(String pinCode);
+ Future lockPinCode();
+
+ Future setupPinCode(String pinCode);
}
diff --git a/mobile/lib/interfaces/biometric.interface.dart b/mobile/lib/interfaces/biometric.interface.dart
new file mode 100644
index 0000000000..e410c8e26e
--- /dev/null
+++ b/mobile/lib/interfaces/biometric.interface.dart
@@ -0,0 +1,6 @@
+import 'package:immich_mobile/models/auth/biometric_status.model.dart';
+
+abstract interface class IBiometricRepository {
+ Future getStatus();
+ Future authenticate(String? message);
+}
diff --git a/mobile/lib/interfaces/secure_storage.interface.dart b/mobile/lib/interfaces/secure_storage.interface.dart
new file mode 100644
index 0000000000..81230e0abd
--- /dev/null
+++ b/mobile/lib/interfaces/secure_storage.interface.dart
@@ -0,0 +1,5 @@
+abstract interface class ISecureStorageRepository {
+ Future read(String key);
+ Future write(String key, String value);
+ Future delete(String key);
+}
diff --git a/mobile/lib/interfaces/timeline.interface.dart b/mobile/lib/interfaces/timeline.interface.dart
index bc486a785f..3a4cce3cb6 100644
--- a/mobile/lib/interfaces/timeline.interface.dart
+++ b/mobile/lib/interfaces/timeline.interface.dart
@@ -31,4 +31,9 @@ abstract class ITimelineRepository {
);
Stream watchAssetSelectionTimeline(String userId);
+
+ Stream watchLockedTimeline(
+ String userId,
+ GroupAssetsBy groupAssetsBy,
+ );
}
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index c39d5e3a66..3c7c1fbe4d 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/routing/router.dart';
-import 'package:immich_mobile/routing/tab_navigation_observer.dart';
+import 'package:immich_mobile/routing/app_navigation_observer.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart';
@@ -219,7 +219,7 @@ class ImmichAppState extends ConsumerState
),
routeInformationParser: router.defaultRouteParser(),
routerDelegate: router.delegate(
- navigatorObservers: () => [TabNavigationObserver(ref: ref)],
+ navigatorObservers: () => [AppNavigationObserver(ref: ref)],
),
),
),
diff --git a/mobile/lib/models/auth/biometric_status.model.dart b/mobile/lib/models/auth/biometric_status.model.dart
new file mode 100644
index 0000000000..3057f06e9c
--- /dev/null
+++ b/mobile/lib/models/auth/biometric_status.model.dart
@@ -0,0 +1,38 @@
+import 'package:collection/collection.dart';
+import 'package:local_auth/local_auth.dart';
+
+class BiometricStatus {
+ final List availableBiometrics;
+ final bool canAuthenticate;
+
+ const BiometricStatus({
+ required this.availableBiometrics,
+ required this.canAuthenticate,
+ });
+
+ @override
+ String toString() =>
+ 'BiometricStatus(availableBiometrics: $availableBiometrics, canAuthenticate: $canAuthenticate)';
+
+ BiometricStatus copyWith({
+ List? availableBiometrics,
+ bool? canAuthenticate,
+ }) {
+ return BiometricStatus(
+ availableBiometrics: availableBiometrics ?? this.availableBiometrics,
+ canAuthenticate: canAuthenticate ?? this.canAuthenticate,
+ );
+ }
+
+ @override
+ bool operator ==(covariant BiometricStatus other) {
+ if (identical(this, other)) return true;
+ final listEquals = const DeepCollectionEquality().equals;
+
+ return listEquals(other.availableBiometrics, availableBiometrics) &&
+ other.canAuthenticate == canAuthenticate;
+ }
+
+ @override
+ int get hashCode => availableBiometrics.hashCode ^ canAuthenticate.hashCode;
+}
diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart
index 1dc336d204..50126ed1a8 100644
--- a/mobile/lib/pages/library/library.page.dart
+++ b/mobile/lib/pages/library/library.page.dart
@@ -140,6 +140,19 @@ class QuickAccessButtons extends ConsumerWidget {
),
onTap: () => context.pushRoute(FolderRoute()),
),
+ ListTile(
+ leading: const Icon(
+ Icons.lock_outline_rounded,
+ size: 26,
+ ),
+ title: Text(
+ 'locked_folder'.tr(),
+ style: context.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ onTap: () => context.pushRoute(const LockedRoute()),
+ ),
ListTile(
leading: const Icon(
Icons.group_outlined,
diff --git a/mobile/lib/pages/library/locked/locked.page.dart b/mobile/lib/pages/library/locked/locked.page.dart
new file mode 100644
index 0000000000..eef12a7107
--- /dev/null
+++ b/mobile/lib/pages/library/locked/locked.page.dart
@@ -0,0 +1,95 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/providers/auth.provider.dart';
+import 'package:immich_mobile/providers/multiselect.provider.dart';
+import 'package:immich_mobile/providers/timeline.provider.dart';
+import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
+
+@RoutePage()
+class LockedPage extends HookConsumerWidget {
+ const LockedPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final appLifeCycle = useAppLifecycleState();
+ final showOverlay = useState(false);
+ final authProviderNotifier = ref.read(authProvider.notifier);
+ // lock the page when it is destroyed
+ useEffect(
+ () {
+ return () {
+ authProviderNotifier.lockPinCode();
+ };
+ },
+ [],
+ );
+
+ useEffect(
+ () {
+ if (context.mounted) {
+ if (appLifeCycle == AppLifecycleState.resumed) {
+ showOverlay.value = false;
+ } else {
+ showOverlay.value = true;
+ }
+ }
+
+ return null;
+ },
+ [appLifeCycle],
+ );
+
+ return Scaffold(
+ appBar: ref.watch(multiselectProvider) ? null : const LockPageAppBar(),
+ body: showOverlay.value
+ ? const SizedBox()
+ : MultiselectGrid(
+ renderListProvider: lockedTimelineProvider,
+ topWidget: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Center(
+ child: Text(
+ 'no_locked_photos_message'.tr(),
+ style: context.textTheme.labelLarge,
+ ),
+ ),
+ ),
+ editEnabled: false,
+ favoriteEnabled: false,
+ unfavorite: false,
+ archiveEnabled: false,
+ stackEnabled: false,
+ unarchive: false,
+ ),
+ );
+ }
+}
+
+class LockPageAppBar extends ConsumerWidget implements PreferredSizeWidget {
+ const LockPageAppBar({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return AppBar(
+ leading: IconButton(
+ onPressed: () {
+ ref.read(authProvider.notifier).lockPinCode();
+ context.maybePop();
+ },
+ icon: const Icon(Icons.arrow_back_ios_rounded),
+ ),
+ centerTitle: true,
+ automaticallyImplyLeading: false,
+ title: const Text(
+ 'locked_folder',
+ ).tr(),
+ );
+ }
+
+ @override
+ Size get preferredSize => const Size.fromHeight(kToolbarHeight);
+}
diff --git a/mobile/lib/pages/library/locked/pin_auth.page.dart b/mobile/lib/pages/library/locked/pin_auth.page.dart
new file mode 100644
index 0000000000..cca0e3b7ac
--- /dev/null
+++ b/mobile/lib/pages/library/locked/pin_auth.page.dart
@@ -0,0 +1,127 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/providers/local_auth.provider.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/widgets/forms/pin_registration_form.dart';
+import 'package:immich_mobile/widgets/forms/pin_verification_form.dart';
+
+@RoutePage()
+class PinAuthPage extends HookConsumerWidget {
+ final bool createPinCode;
+
+ const PinAuthPage({super.key, this.createPinCode = false});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final localAuthState = ref.watch(localAuthProvider);
+ final showPinRegistrationForm = useState(createPinCode);
+
+ Future registerBiometric(String pinCode) async {
+ final isRegistered =
+ await ref.read(localAuthProvider.notifier).registerBiometric(
+ context,
+ pinCode,
+ );
+
+ if (isRegistered) {
+ context.showSnackBar(
+ SnackBar(
+ content: Text(
+ 'biometric_auth_enabled'.tr(),
+ style: context.textTheme.labelLarge,
+ ),
+ duration: const Duration(seconds: 3),
+ backgroundColor: context.colorScheme.primaryContainer,
+ ),
+ );
+
+ context.replaceRoute(const LockedRoute());
+ }
+ }
+
+ enableBiometricAuth() {
+ showDialog(
+ context: context,
+ builder: (buildContext) {
+ return SimpleDialog(
+ children: [
+ Container(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ PinVerificationForm(
+ description: 'enable_biometric_auth_description'.tr(),
+ onSuccess: (pinCode) {
+ Navigator.pop(buildContext);
+ registerBiometric(pinCode);
+ },
+ autoFocus: true,
+ icon: Icons.fingerprint_rounded,
+ successIcon: Icons.fingerprint_rounded,
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ },
+ );
+ }
+
+ return Scaffold(
+ appBar: AppBar(
+ title: Text('locked_folder'.tr()),
+ ),
+ body: ListView(
+ shrinkWrap: true,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(top: 36.0),
+ child: showPinRegistrationForm.value
+ ? Center(
+ child: PinRegistrationForm(
+ onDone: () => showPinRegistrationForm.value = false,
+ ),
+ )
+ : Column(
+ children: [
+ Center(
+ child: PinVerificationForm(
+ autoFocus: true,
+ onSuccess: (_) =>
+ context.replaceRoute(const LockedRoute()),
+ ),
+ ),
+ const SizedBox(height: 24),
+ if (localAuthState.canAuthenticate) ...[
+ Padding(
+ padding: const EdgeInsets.only(right: 16.0),
+ child: TextButton.icon(
+ icon: const Icon(
+ Icons.fingerprint,
+ size: 28,
+ ),
+ onPressed: enableBiometricAuth,
+ label: Text(
+ 'use_biometric'.tr(),
+ style: context.textTheme.labelLarge?.copyWith(
+ color: context.primaryColor,
+ fontSize: 18,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart
index a35ab10bf3..5b77da90f3 100644
--- a/mobile/lib/providers/asset.provider.dart
+++ b/mobile/lib/providers/asset.provider.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
@@ -170,6 +171,13 @@ class AssetNotifier extends StateNotifier {
status ??= !assets.every((a) => a.isArchived);
return _assetService.changeArchiveStatus(assets, status);
}
+
+ Future setLockedView(
+ List selection,
+ AssetVisibilityEnum visibility,
+ ) {
+ return _assetService.setVisibility(selection, visibility);
+ }
}
final assetDetailProvider =
diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart
index 297b3a99fe..8c783395cd 100644
--- a/mobile/lib/providers/auth.provider.dart
+++ b/mobile/lib/providers/auth.provider.dart
@@ -188,4 +188,16 @@ class AuthNotifier extends StateNotifier {
Future setOpenApiServiceEndpoint() {
return _authService.setOpenApiServiceEndpoint();
}
+
+ Future unlockPinCode(String pinCode) {
+ return _authService.unlockPinCode(pinCode);
+ }
+
+ Future lockPinCode() {
+ return _authService.lockPinCode();
+ }
+
+ Future setupPinCode(String pinCode) {
+ return _authService.setupPinCode(pinCode);
+ }
}
diff --git a/mobile/lib/providers/local_auth.provider.dart b/mobile/lib/providers/local_auth.provider.dart
new file mode 100644
index 0000000000..6f7ca5eb71
--- /dev/null
+++ b/mobile/lib/providers/local_auth.provider.dart
@@ -0,0 +1,97 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/constants.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/models/auth/biometric_status.model.dart';
+import 'package:immich_mobile/services/local_auth.service.dart';
+import 'package:immich_mobile/services/secure_storage.service.dart';
+import 'package:logging/logging.dart';
+
+final localAuthProvider =
+ StateNotifierProvider((ref) {
+ return LocalAuthNotifier(
+ ref.watch(localAuthServiceProvider),
+ ref.watch(secureStorageServiceProvider),
+ );
+});
+
+class LocalAuthNotifier extends StateNotifier {
+ final LocalAuthService _localAuthService;
+ final SecureStorageService _secureStorageService;
+
+ final _log = Logger("LocalAuthNotifier");
+
+ LocalAuthNotifier(this._localAuthService, this._secureStorageService)
+ : super(
+ const BiometricStatus(
+ availableBiometrics: [],
+ canAuthenticate: false,
+ ),
+ ) {
+ _localAuthService.getStatus().then((value) {
+ state = state.copyWith(
+ canAuthenticate: value.canAuthenticate,
+ availableBiometrics: value.availableBiometrics,
+ );
+ });
+ }
+
+ Future registerBiometric(BuildContext context, String pinCode) async {
+ final isAuthenticated =
+ await authenticate(context, 'Authenticate to enable biometrics');
+
+ if (!isAuthenticated) {
+ return false;
+ }
+
+ await _secureStorageService.write(kSecuredPinCode, pinCode);
+
+ return true;
+ }
+
+ Future authenticate(BuildContext context, String? message) async {
+ String errorMessage = "";
+
+ try {
+ return await _localAuthService.authenticate(message);
+ } on PlatformException catch (error) {
+ switch (error.code) {
+ case "NotEnrolled":
+ _log.warning("User is not enrolled in biometrics");
+ errorMessage = "biometric_no_options".tr();
+ break;
+ case "NotAvailable":
+ _log.warning("Biometric authentication is not available");
+ errorMessage = "biometric_not_available".tr();
+ break;
+ case "LockedOut":
+ _log.warning("User is locked out of biometric authentication");
+ errorMessage = "biometric_locked_out".tr();
+ break;
+ default:
+ _log.warning("Failed to authenticate with unknown reason");
+ errorMessage = 'failed_to_authenticate'.tr();
+ }
+ } catch (error) {
+ _log.warning("Error during authentication: $error");
+ errorMessage = 'failed_to_authenticate'.tr();
+ } finally {
+ if (errorMessage.isNotEmpty) {
+ context.showSnackBar(
+ SnackBar(
+ content: Text(
+ errorMessage,
+ style: context.textTheme.labelLarge,
+ ),
+ duration: const Duration(seconds: 3),
+ backgroundColor: context.colorScheme.errorContainer,
+ ),
+ );
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart
new file mode 100644
index 0000000000..a5b903e312
--- /dev/null
+++ b/mobile/lib/providers/routes.provider.dart
@@ -0,0 +1,3 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+final inLockedViewProvider = StateProvider((ref) => false);
diff --git a/mobile/lib/providers/secure_storage.provider.dart b/mobile/lib/providers/secure_storage.provider.dart
new file mode 100644
index 0000000000..0194e527e9
--- /dev/null
+++ b/mobile/lib/providers/secure_storage.provider.dart
@@ -0,0 +1,10 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+final secureStorageProvider =
+ StateNotifierProvider((ref) {
+ return SecureStorageProvider();
+});
+
+class SecureStorageProvider extends StateNotifier {
+ SecureStorageProvider() : super(null);
+}
diff --git a/mobile/lib/providers/timeline.provider.dart b/mobile/lib/providers/timeline.provider.dart
index f857d8aa6c..b2c763cdfa 100644
--- a/mobile/lib/providers/timeline.provider.dart
+++ b/mobile/lib/providers/timeline.provider.dart
@@ -73,3 +73,8 @@ final assetsTimelineProvider =
null,
);
});
+
+final lockedTimelineProvider = StreamProvider((ref) {
+ final timelineService = ref.watch(timelineServiceProvider);
+ return timelineService.watchLockedTimelineProvider();
+});
diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart
index f4fcd8a6dd..45442c2d61 100644
--- a/mobile/lib/repositories/asset_api.repository.dart
+++ b/mobile/lib/repositories/asset_api.repository.dart
@@ -1,4 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/asset_api.interface.dart';
import 'package:immich_mobile/providers/api.provider.dart';
@@ -48,4 +49,27 @@ class AssetApiRepository extends ApiRepository implements IAssetApiRepository {
}
return result;
}
+
+ @override
+ Future updateVisibility(
+ List ids,
+ AssetVisibilityEnum visibility,
+ ) async {
+ return _api.updateAssets(
+ AssetBulkUpdateDto(ids: ids, visibility: _mapVisibility(visibility)),
+ );
+ }
+
+ _mapVisibility(AssetVisibilityEnum visibility) {
+ switch (visibility) {
+ case AssetVisibilityEnum.timeline:
+ return AssetVisibility.timeline;
+ case AssetVisibilityEnum.hidden:
+ return AssetVisibility.hidden;
+ case AssetVisibilityEnum.locked:
+ return AssetVisibility.locked;
+ case AssetVisibilityEnum.archive:
+ return AssetVisibility.archive;
+ }
+ }
}
diff --git a/mobile/lib/repositories/auth_api.repository.dart b/mobile/lib/repositories/auth_api.repository.dart
index f3a1d52de3..4015ffd7bc 100644
--- a/mobile/lib/repositories/auth_api.repository.dart
+++ b/mobile/lib/repositories/auth_api.repository.dart
@@ -55,4 +55,26 @@ class AuthApiRepository extends ApiRepository implements IAuthApiRepository {
userId: dto.userId,
);
}
+
+ @override
+ Future unlockPinCode(String pinCode) async {
+ try {
+ await _apiService.authenticationApi
+ .unlockAuthSession(SessionUnlockDto(pinCode: pinCode));
+ return true;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ @override
+ Future setupPinCode(String pinCode) {
+ return _apiService.authenticationApi
+ .setupPinCode(PinCodeSetupDto(pinCode: pinCode));
+ }
+
+ @override
+ Future lockPinCode() {
+ return _apiService.authenticationApi.lockAuthSession();
+ }
}
diff --git a/mobile/lib/repositories/biometric.repository.dart b/mobile/lib/repositories/biometric.repository.dart
new file mode 100644
index 0000000000..588fa44797
--- /dev/null
+++ b/mobile/lib/repositories/biometric.repository.dart
@@ -0,0 +1,35 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/interfaces/biometric.interface.dart';
+import 'package:immich_mobile/models/auth/biometric_status.model.dart';
+import 'package:local_auth/local_auth.dart';
+
+final biometricRepositoryProvider =
+ Provider((ref) => BiometricRepository(LocalAuthentication()));
+
+class BiometricRepository implements IBiometricRepository {
+ final LocalAuthentication _localAuth;
+
+ BiometricRepository(this._localAuth);
+
+ @override
+ Future getStatus() async {
+ final bool canAuthenticateWithBiometrics =
+ await _localAuth.canCheckBiometrics;
+ final bool canAuthenticate =
+ canAuthenticateWithBiometrics || await _localAuth.isDeviceSupported();
+ final availableBiometric = await _localAuth.getAvailableBiometrics();
+
+ return BiometricStatus(
+ canAuthenticate: canAuthenticate,
+ availableBiometrics: availableBiometric,
+ );
+ }
+
+ @override
+ Future authenticate(String? message) async {
+ return _localAuth.authenticate(
+ localizedReason: message ?? 'please_auth_to_access'.tr(),
+ );
+ }
+}
diff --git a/mobile/lib/repositories/secure_storage.repository.dart b/mobile/lib/repositories/secure_storage.repository.dart
new file mode 100644
index 0000000000..fc641bcc91
--- /dev/null
+++ b/mobile/lib/repositories/secure_storage.repository.dart
@@ -0,0 +1,27 @@
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/interfaces/secure_storage.interface.dart';
+
+final secureStorageRepositoryProvider =
+ Provider((ref) => SecureStorageRepository(const FlutterSecureStorage()));
+
+class SecureStorageRepository implements ISecureStorageRepository {
+ final FlutterSecureStorage _secureStorage;
+
+ SecureStorageRepository(this._secureStorage);
+
+ @override
+ Future read(String key) {
+ return _secureStorage.read(key: key);
+ }
+
+ @override
+ Future write(String key, String value) {
+ return _secureStorage.write(key: key, value: value);
+ }
+
+ @override
+ Future delete(String key) {
+ return _secureStorage.delete(key: key);
+ }
+}
diff --git a/mobile/lib/repositories/timeline.repository.dart b/mobile/lib/repositories/timeline.repository.dart
index 319ce3e5b4..f48b749767 100644
--- a/mobile/lib/repositories/timeline.repository.dart
+++ b/mobile/lib/repositories/timeline.repository.dart
@@ -45,8 +45,8 @@ class TimelineRepository extends DatabaseRepository
.where()
.ownerIdEqualToAnyChecksum(fastHash(userId))
.filter()
- .isArchivedEqualTo(true)
.isTrashedEqualTo(false)
+ .visibilityEqualTo(AssetVisibilityEnum.archive)
.sortByFileCreatedAtDesc();
return _watchRenderList(query, GroupAssetsBy.none);
@@ -59,6 +59,8 @@ class TimelineRepository extends DatabaseRepository
.ownerIdEqualToAnyChecksum(fastHash(userId))
.filter()
.isFavoriteEqualTo(true)
+ .not()
+ .visibilityEqualTo(AssetVisibilityEnum.locked)
.isTrashedEqualTo(false)
.sortByFileCreatedAtDesc();
@@ -94,8 +96,8 @@ class TimelineRepository extends DatabaseRepository
Stream watchAllVideosTimeline() {
final query = db.assets
.filter()
- .isArchivedEqualTo(false)
.isTrashedEqualTo(false)
+ .visibilityEqualTo(AssetVisibilityEnum.timeline)
.typeEqualTo(AssetType.video)
.sortByFileCreatedAtDesc();
@@ -111,9 +113,9 @@ class TimelineRepository extends DatabaseRepository
.where()
.ownerIdEqualToAnyChecksum(fastHash(userId))
.filter()
- .isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackPrimaryAssetIdIsNull()
+ .visibilityEqualTo(AssetVisibilityEnum.timeline)
.sortByFileCreatedAtDesc();
return _watchRenderList(query, groupAssetByOption);
@@ -129,8 +131,8 @@ class TimelineRepository extends DatabaseRepository
.where()
.anyOf(isarUserIds, (qb, id) => qb.ownerIdEqualToAnyChecksum(id))
.filter()
- .isArchivedEqualTo(false)
.isTrashedEqualTo(false)
+ .visibilityEqualTo(AssetVisibilityEnum.timeline)
.stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc();
return _watchRenderList(query, groupAssetByOption);
@@ -151,6 +153,7 @@ class TimelineRepository extends DatabaseRepository
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(fastHash(userId))
+ .visibilityEqualTo(AssetVisibilityEnum.timeline)
.isTrashedEqualTo(false)
.stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc();
@@ -158,6 +161,22 @@ class TimelineRepository extends DatabaseRepository
return _watchRenderList(query, GroupAssetsBy.none);
}
+ @override
+ Stream watchLockedTimeline(
+ String userId,
+ GroupAssetsBy getGroupByOption,
+ ) {
+ final query = db.assets
+ .where()
+ .ownerIdEqualToAnyChecksum(fastHash(userId))
+ .filter()
+ .visibilityEqualTo(AssetVisibilityEnum.locked)
+ .isTrashedEqualTo(false)
+ .sortByFileCreatedAtDesc();
+
+ return _watchRenderList(query, getGroupByOption);
+ }
+
Stream _watchRenderList(
QueryBuilder query,
GroupAssetsBy groupAssetsBy,
diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart
new file mode 100644
index 0000000000..44662c0b8b
--- /dev/null
+++ b/mobile/lib/routing/app_navigation_observer.dart
@@ -0,0 +1,52 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/providers/routes.provider.dart';
+import 'package:immich_mobile/routing/router.dart';
+
+class AppNavigationObserver extends AutoRouterObserver {
+ /// Riverpod Instance
+ final WidgetRef ref;
+
+ AppNavigationObserver({
+ required this.ref,
+ });
+
+ @override
+ Future didChangeTabRoute(
+ TabPageRoute route,
+ TabPageRoute previousRoute,
+ ) async {
+ Future(
+ () => ref.read(inLockedViewProvider.notifier).state = false,
+ );
+ }
+
+ @override
+ void didPush(Route route, Route? previousRoute) {
+ _handleLockedViewState(route, previousRoute);
+ }
+
+ _handleLockedViewState(Route route, Route? previousRoute) {
+ final isInLockedView = ref.read(inLockedViewProvider);
+ final isFromLockedViewToDetailView =
+ route.settings.name == GalleryViewerRoute.name &&
+ previousRoute?.settings.name == LockedRoute.name;
+
+ final isFromDetailViewToInfoPanelView = route.settings.name == null &&
+ previousRoute?.settings.name == GalleryViewerRoute.name &&
+ isInLockedView;
+
+ if (route.settings.name == LockedRoute.name ||
+ isFromLockedViewToDetailView ||
+ isFromDetailViewToInfoPanelView) {
+ Future(
+ () => ref.read(inLockedViewProvider.notifier).state = true,
+ );
+ } else {
+ Future(
+ () => ref.read(inLockedViewProvider.notifier).state = false,
+ );
+ }
+ }
+}
diff --git a/mobile/lib/routing/locked_guard.dart b/mobile/lib/routing/locked_guard.dart
new file mode 100644
index 0000000000..d731c7942c
--- /dev/null
+++ b/mobile/lib/routing/locked_guard.dart
@@ -0,0 +1,89 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/services.dart';
+import 'package:immich_mobile/constants/constants.dart';
+import 'package:immich_mobile/routing/router.dart';
+
+import 'package:immich_mobile/services/api.service.dart';
+import 'package:immich_mobile/services/local_auth.service.dart';
+import 'package:immich_mobile/services/secure_storage.service.dart';
+import 'package:local_auth/error_codes.dart' as auth_error;
+import 'package:logging/logging.dart';
+// ignore: import_rule_openapi
+import 'package:openapi/api.dart';
+
+class LockedGuard extends AutoRouteGuard {
+ final ApiService _apiService;
+ final SecureStorageService _secureStorageService;
+ final LocalAuthService _localAuth;
+ final _log = Logger("AuthGuard");
+
+ LockedGuard(
+ this._apiService,
+ this._secureStorageService,
+ this._localAuth,
+ );
+
+ @override
+ void onNavigation(NavigationResolver resolver, StackRouter router) async {
+ final authStatus = await _apiService.authenticationApi.getAuthStatus();
+
+ if (authStatus == null) {
+ resolver.next(false);
+ return;
+ }
+
+ /// Check if a pincode has been created but this user. Show the form to create if not exist
+ if (!authStatus.pinCode) {
+ router.push(PinAuthRoute(createPinCode: true));
+ }
+
+ if (authStatus.isElevated) {
+ resolver.next(true);
+ return;
+ }
+
+ /// Check if the user has the pincode saved in secure storage, meaning
+ /// the user has enabled the biometric authentication
+ final securePinCode = await _secureStorageService.read(kSecuredPinCode);
+ if (securePinCode == null) {
+ router.push(PinAuthRoute());
+ return;
+ }
+
+ try {
+ final bool isAuth = await _localAuth.authenticate();
+
+ if (!isAuth) {
+ resolver.next(false);
+ return;
+ }
+
+ await _apiService.authenticationApi.unlockAuthSession(
+ SessionUnlockDto(pinCode: securePinCode),
+ );
+
+ resolver.next(true);
+ } on PlatformException catch (error) {
+ switch (error.code) {
+ case auth_error.notAvailable:
+ _log.severe("notAvailable: $error");
+ break;
+ case auth_error.notEnrolled:
+ _log.severe("not enrolled");
+ break;
+ default:
+ _log.severe("error");
+ break;
+ }
+
+ resolver.next(false);
+ } on ApiException {
+ // PIN code has changed, need to re-enter to access
+ await _secureStorageService.delete(kSecuredPinCode);
+ router.push(PinAuthRoute());
+ } catch (error) {
+ _log.severe("Failed to access locked page", error);
+ resolver.next(false);
+ }
+ }
+}
diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart
index fcfe7e59bd..317ce7cc54 100644
--- a/mobile/lib/routing/router.dart
+++ b/mobile/lib/routing/router.dart
@@ -39,6 +39,8 @@ import 'package:immich_mobile/pages/library/favorite.page.dart';
import 'package:immich_mobile/pages/library/folder/folder.page.dart';
import 'package:immich_mobile/pages/library/library.page.dart';
import 'package:immich_mobile/pages/library/local_albums.page.dart';
+import 'package:immich_mobile/pages/library/locked/locked.page.dart';
+import 'package:immich_mobile/pages/library/locked/pin_auth.page.dart';
import 'package:immich_mobile/pages/library/partner/partner.page.dart';
import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart';
import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
@@ -67,24 +69,41 @@ import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/routing/backup_permission_guard.dart';
import 'package:immich_mobile/routing/custom_transition_builders.dart';
import 'package:immich_mobile/routing/duplicate_guard.dart';
+import 'package:immich_mobile/routing/locked_guard.dart';
import 'package:immich_mobile/services/api.service.dart';
+import 'package:immich_mobile/services/local_auth.service.dart';
+import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
part 'router.gr.dart';
+final appRouterProvider = Provider(
+ (ref) => AppRouter(
+ ref.watch(apiServiceProvider),
+ ref.watch(galleryPermissionNotifier.notifier),
+ ref.watch(secureStorageServiceProvider),
+ ref.watch(localAuthServiceProvider),
+ ),
+);
+
@AutoRouterConfig(replaceInRouteName: 'Page,Route')
class AppRouter extends RootStackRouter {
late final AuthGuard _authGuard;
late final DuplicateGuard _duplicateGuard;
late final BackupPermissionGuard _backupPermissionGuard;
+ late final LockedGuard _lockedGuard;
AppRouter(
ApiService apiService,
GalleryPermissionNotifier galleryPermissionNotifier,
+ SecureStorageService secureStorageService,
+ LocalAuthService localAuthService,
) {
_authGuard = AuthGuard(apiService);
_duplicateGuard = DuplicateGuard();
+ _lockedGuard =
+ LockedGuard(apiService, secureStorageService, localAuthService);
_backupPermissionGuard = BackupPermissionGuard(galleryPermissionNotifier);
}
@@ -289,12 +308,13 @@ class AppRouter extends RootStackRouter {
page: ShareIntentRoute.page,
guards: [_authGuard, _duplicateGuard],
),
+ AutoRoute(
+ page: LockedRoute.page,
+ guards: [_authGuard, _lockedGuard, _duplicateGuard],
+ ),
+ AutoRoute(
+ page: PinAuthRoute.page,
+ guards: [_authGuard, _duplicateGuard],
+ ),
];
}
-
-final appRouterProvider = Provider(
- (ref) => AppRouter(
- ref.watch(apiServiceProvider),
- ref.watch(galleryPermissionNotifier.notifier),
- ),
-);
diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart
index 01ab3fa13c..da488779e6 100644
--- a/mobile/lib/routing/router.gr.dart
+++ b/mobile/lib/routing/router.gr.dart
@@ -956,6 +956,25 @@ class LocalAlbumsRoute extends PageRouteInfo {
);
}
+/// generated route for
+/// [LockedPage]
+class LockedRoute extends PageRouteInfo {
+ const LockedRoute({List? children})
+ : super(
+ LockedRoute.name,
+ initialChildren: children,
+ );
+
+ static const String name = 'LockedRoute';
+
+ static PageInfo page = PageInfo(
+ name,
+ builder: (data) {
+ return const LockedPage();
+ },
+ );
+}
+
/// generated route for
/// [LoginPage]
class LoginRoute extends PageRouteInfo {
@@ -1359,6 +1378,53 @@ class PhotosRoute extends PageRouteInfo {
);
}
+/// generated route for
+/// [PinAuthPage]
+class PinAuthRoute extends PageRouteInfo {
+ PinAuthRoute({
+ Key? key,
+ bool createPinCode = false,
+ List? children,
+ }) : super(
+ PinAuthRoute.name,
+ args: PinAuthRouteArgs(
+ key: key,
+ createPinCode: createPinCode,
+ ),
+ initialChildren: children,
+ );
+
+ static const String name = 'PinAuthRoute';
+
+ static PageInfo page = PageInfo(
+ name,
+ builder: (data) {
+ final args =
+ data.argsAs(orElse: () => const PinAuthRouteArgs());
+ return PinAuthPage(
+ key: args.key,
+ createPinCode: args.createPinCode,
+ );
+ },
+ );
+}
+
+class PinAuthRouteArgs {
+ const PinAuthRouteArgs({
+ this.key,
+ this.createPinCode = false,
+ });
+
+ final Key? key;
+
+ final bool createPinCode;
+
+ @override
+ String toString() {
+ return 'PinAuthRouteArgs{key: $key, createPinCode: $createPinCode}';
+ }
+}
+
/// generated route for
/// [PlacesCollectionPage]
class PlacesCollectionRoute extends PageRouteInfo {
diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart
deleted file mode 100644
index d95820885e..0000000000
--- a/mobile/lib/routing/tab_navigation_observer.dart
+++ /dev/null
@@ -1,35 +0,0 @@
-import 'package:auto_route/auto_route.dart';
-import 'package:flutter/foundation.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/providers/asset.provider.dart';
-import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
-import 'package:immich_mobile/providers/memory.provider.dart';
-import 'package:immich_mobile/providers/server_info.provider.dart';
-
-class TabNavigationObserver extends AutoRouterObserver {
- /// Riverpod Instance
- final WidgetRef ref;
-
- TabNavigationObserver({
- required this.ref,
- });
-
- @override
- Future didChangeTabRoute(
- TabPageRoute route,
- TabPageRoute previousRoute,
- ) async {
- if (route.name == 'HomeRoute') {
- ref.invalidate(memoryFutureProvider);
- Future(() => ref.read(assetProvider.notifier).getAllAsset());
-
- // Update user info
- try {
- ref.read(userServiceProvider).refreshMyUser();
- ref.read(serverInfoProvider.notifier).getServerVersion();
- } catch (e) {
- debugPrint("Error refreshing user info $e");
- }
- }
- }
-}
diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart
index 8a24e72fbe..a52d6e6368 100644
--- a/mobile/lib/services/asset.service.dart
+++ b/mobile/lib/services/asset.service.dart
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/interfaces/exif.interface.dart';
import 'package:immich_mobile/domain/interfaces/user.interface.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
@@ -239,6 +240,9 @@ class AssetService {
for (var element in assets) {
element.isArchived = isArchived;
+ element.visibility = isArchived
+ ? AssetVisibilityEnum.archive
+ : AssetVisibilityEnum.timeline;
}
await _syncService.upsertAssetsWithExif(assets);
@@ -458,6 +462,7 @@ class AssetService {
bool shouldDeletePermanently = false,
}) async {
final candidates = assets.where((a) => a.isRemote);
+
if (candidates.isEmpty) {
return;
}
@@ -475,6 +480,7 @@ class AssetService {
.where((asset) => asset.storage == AssetState.merged)
.map((asset) {
asset.remoteId = null;
+ asset.visibility = AssetVisibilityEnum.timeline;
return asset;
})
: assets.where((asset) => asset.isRemote).map((asset) {
@@ -529,4 +535,21 @@ class AssetService {
final me = _userService.getMyUser();
return _assetRepository.getMotionAssets(me.id);
}
+
+ Future setVisibility(
+ List assets,
+ AssetVisibilityEnum visibility,
+ ) async {
+ await _assetApiRepository.updateVisibility(
+ assets.map((asset) => asset.remoteId!).toList(),
+ visibility,
+ );
+
+ final updatedAssets = assets.map((asset) {
+ asset.visibility = visibility;
+ return asset;
+ }).toList();
+
+ await _assetRepository.updateAll(updatedAssets);
+ }
}
diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart
index ec053c078b..41709b714c 100644
--- a/mobile/lib/services/auth.service.dart
+++ b/mobile/lib/services/auth.service.dart
@@ -201,4 +201,16 @@ class AuthService {
return null;
}
+
+ Future unlockPinCode(String pinCode) {
+ return _authApiRepository.unlockPinCode(pinCode);
+ }
+
+ Future lockPinCode() {
+ return _authApiRepository.lockPinCode();
+ }
+
+ Future setupPinCode(String pinCode) {
+ return _authApiRepository.setupPinCode(pinCode);
+ }
}
diff --git a/mobile/lib/services/local_auth.service.dart b/mobile/lib/services/local_auth.service.dart
new file mode 100644
index 0000000000..f797e9065a
--- /dev/null
+++ b/mobile/lib/services/local_auth.service.dart
@@ -0,0 +1,26 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/interfaces/biometric.interface.dart';
+import 'package:immich_mobile/models/auth/biometric_status.model.dart';
+import 'package:immich_mobile/repositories/biometric.repository.dart';
+
+final localAuthServiceProvider = Provider(
+ (ref) => LocalAuthService(
+ ref.watch(biometricRepositoryProvider),
+ ),
+);
+
+class LocalAuthService {
+ // final _log = Logger("LocalAuthService");
+
+ final IBiometricRepository _biometricRepository;
+
+ LocalAuthService(this._biometricRepository);
+
+ Future getStatus() {
+ return _biometricRepository.getStatus();
+ }
+
+ Future authenticate([String? message]) async {
+ return _biometricRepository.authenticate(message);
+ }
+}
diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart
index efd38f1140..d6c44278c7 100644
--- a/mobile/lib/services/memory.service.dart
+++ b/mobile/lib/services/memory.service.dart
@@ -1,10 +1,10 @@
-import 'package:easy_localization/easy_localization.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
+import 'package:immich_mobile/utils/translation.dart';
import 'package:logging/logging.dart';
final memoryServiceProvider = StateProvider((ref) {
@@ -40,10 +40,7 @@ class MemoryService {
.getAllByRemoteId(memory.assets.map((e) => e.id));
final yearsAgo = now.year - memory.data.year;
if (dbAssets.isNotEmpty) {
- final String title = yearsAgo <= 1
- ? 'memories_year_ago'.tr()
- : 'memories_years_ago'
- .tr(namedArgs: {'years': yearsAgo.toString()});
+ final String title = t('years_ago', {'years': yearsAgo.toString()});
memories.add(
Memory(
title: title,
diff --git a/mobile/lib/services/secure_storage.service.dart b/mobile/lib/services/secure_storage.service.dart
new file mode 100644
index 0000000000..77803f29c3
--- /dev/null
+++ b/mobile/lib/services/secure_storage.service.dart
@@ -0,0 +1,29 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/interfaces/secure_storage.interface.dart';
+import 'package:immich_mobile/repositories/secure_storage.repository.dart';
+
+final secureStorageServiceProvider = Provider(
+ (ref) => SecureStorageService(
+ ref.watch(secureStorageRepositoryProvider),
+ ),
+);
+
+class SecureStorageService {
+ // final _log = Logger("LocalAuthService");
+
+ final ISecureStorageRepository _secureStorageRepository;
+
+ SecureStorageService(this._secureStorageRepository);
+
+ Future write(String key, String value) async {
+ await _secureStorageRepository.write(key, value);
+ }
+
+ Future delete(String key) async {
+ await _secureStorageRepository.delete(key);
+ }
+
+ Future read(String key) async {
+ return _secureStorageRepository.read(key);
+ }
+}
diff --git a/mobile/lib/services/timeline.service.dart b/mobile/lib/services/timeline.service.dart
index 4e91d27a7c..7ecad43ca7 100644
--- a/mobile/lib/services/timeline.service.dart
+++ b/mobile/lib/services/timeline.service.dart
@@ -105,4 +105,13 @@ class TimelineService {
return GroupAssetsBy
.values[_appSettingsService.getSetting(AppSettingsEnum.groupAssetsBy)];
}
+
+ Stream watchLockedTimelineProvider() async* {
+ final user = _userService.getMyUser();
+
+ yield* _timelineRepository.watchLockedTimeline(
+ user.id,
+ _getGroupByOption(),
+ );
+ }
}
diff --git a/mobile/lib/theme/theme_data.dart b/mobile/lib/theme/theme_data.dart
index 2a593ffb38..a351b09093 100644
--- a/mobile/lib/theme/theme_data.dart
+++ b/mobile/lib/theme/theme_data.dart
@@ -42,7 +42,7 @@ ThemeData getThemeData({
titleTextStyle: TextStyle(
color: colorScheme.primary,
fontFamily: _getFontFamilyFromLocale(locale),
- fontWeight: FontWeight.bold,
+ fontWeight: FontWeight.w600,
fontSize: 18,
),
backgroundColor:
@@ -54,28 +54,28 @@ ThemeData getThemeData({
),
textTheme: const TextTheme(
displayLarge: TextStyle(
- fontSize: 26,
- fontWeight: FontWeight.bold,
+ fontSize: 18,
+ fontWeight: FontWeight.w600,
),
displayMedium: TextStyle(
fontSize: 14,
- fontWeight: FontWeight.bold,
+ fontWeight: FontWeight.w600,
),
displaySmall: TextStyle(
fontSize: 12,
- fontWeight: FontWeight.bold,
+ fontWeight: FontWeight.w600,
),
titleSmall: TextStyle(
fontSize: 16.0,
- fontWeight: FontWeight.bold,
+ fontWeight: FontWeight.w600,
),
titleMedium: TextStyle(
fontSize: 18.0,
- fontWeight: FontWeight.bold,
+ fontWeight: FontWeight.w600,
),
titleLarge: TextStyle(
fontSize: 26.0,
- fontWeight: FontWeight.bold,
+ fontWeight: FontWeight.w600,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart
index 6a09f79ce2..4519c6d803 100644
--- a/mobile/lib/utils/migration.dart
+++ b/mobile/lib/utils/migration.dart
@@ -20,7 +20,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
-const int targetVersion = 10;
+const int targetVersion = 11;
Future migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, targetVersion);
diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart
index d054749b1e..1ffe05c781 100644
--- a/mobile/lib/utils/openapi_patching.dart
+++ b/mobile/lib/utils/openapi_patching.dart
@@ -32,6 +32,11 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'visibility', AssetVisibility.timeline);
}
break;
+ case 'AssetResponseDto':
+ if (value is Map) {
+ addDefault(value, 'visibility', 'timeline');
+ }
+ break;
case 'UserAdminResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart
index c63d819153..1ae583bedd 100644
--- a/mobile/lib/utils/selection_handlers.dart
+++ b/mobile/lib/utils/selection_handlers.dart
@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asset_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -157,3 +158,29 @@ Future handleEditLocation(
ref.read(assetServiceProvider).changeLocation(selection.toList(), location);
}
+
+Future handleSetAssetsVisibility(
+ WidgetRef ref,
+ BuildContext context,
+ AssetVisibilityEnum visibility,
+ List selection, {
+ ToastGravity toastGravity = ToastGravity.BOTTOM,
+}) async {
+ if (selection.isNotEmpty) {
+ await ref
+ .watch(assetProvider.notifier)
+ .setLockedView(selection, visibility);
+
+ final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
+ final toastMessage = visibility == AssetVisibilityEnum.locked
+ ? 'Added ${selection.length} $assetOrAssets to locked folder'
+ : 'Removed ${selection.length} $assetOrAssets from locked folder';
+ if (context.mounted) {
+ ImmichToast.show(
+ context: context,
+ msg: toastMessage,
+ gravity: ToastGravity.BOTTOM,
+ );
+ }
+ }
+}
diff --git a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart
index 7a049fa7fd..892e7e5b8a 100644
--- a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart
+++ b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart
@@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
+import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart';
import 'package:immich_mobile/models/asset_selection_state.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
@@ -37,6 +38,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
final void Function()? onEditTime;
final void Function()? onEditLocation;
final void Function()? onRemoveFromAlbum;
+ final void Function()? onToggleLocked;
final bool enabled;
final bool unfavorite;
@@ -58,6 +60,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
this.onEditTime,
this.onEditLocation,
this.onRemoveFromAlbum,
+ this.onToggleLocked,
this.selectionAssetState = const AssetSelectionState(),
this.enabled = true,
this.unarchive = false,
@@ -77,6 +80,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
ref.watch(albumProvider).where((a) => a.shared).toList();
const bottomPadding = 0.20;
final scrollController = useDraggableScrollController();
+ final isInLockedView = ref.watch(inLockedViewProvider);
void minimize() {
scrollController.animateTo(
@@ -133,11 +137,12 @@ class ControlBottomAppBar extends HookConsumerWidget {
label: "share".tr(),
onPressed: enabled ? () => onShare(true) : null,
),
- ControlBoxButton(
- iconData: Icons.link_rounded,
- label: "control_bottom_app_bar_share_link".tr(),
- onPressed: enabled ? () => onShare(false) : null,
- ),
+ if (!isInLockedView)
+ ControlBoxButton(
+ iconData: Icons.link_rounded,
+ label: "share_link".tr(),
+ onPressed: enabled ? () => onShare(false) : null,
+ ),
if (hasRemote && onArchive != null)
ControlBoxButton(
iconData:
@@ -153,7 +158,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
label: (unfavorite ? "unfavorite" : "favorite").tr(),
onPressed: enabled ? onFavorite : null,
),
- if (hasLocal && hasRemote && onDelete != null)
+ if (hasLocal && hasRemote && onDelete != null && !isInLockedView)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 90),
child: ControlBoxButton(
@@ -166,7 +171,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
enabled ? () => showForceDeleteDialog(onDelete!) : null,
),
),
- if (hasRemote && onDeleteServer != null)
+ if (hasRemote && onDeleteServer != null && !isInLockedView)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 85),
child: ControlBoxButton(
@@ -189,9 +194,23 @@ class ControlBottomAppBar extends HookConsumerWidget {
: null,
),
),
- if (hasLocal && onDeleteLocal != null)
+ if (isInLockedView)
ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 85),
+ constraints: const BoxConstraints(maxWidth: 110),
+ child: ControlBoxButton(
+ iconData: Icons.delete_forever,
+ label: "delete_dialog_title".tr(),
+ onPressed: enabled
+ ? () => showForceDeleteDialog(
+ onDeleteServer!,
+ alertMsg: "delete_dialog_alert_remote",
+ )
+ : null,
+ ),
+ ),
+ if (hasLocal && onDeleteLocal != null && !isInLockedView)
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 95),
child: ControlBoxButton(
iconData: Icons.no_cell_outlined,
label: "control_bottom_app_bar_delete_from_local".tr(),
@@ -231,6 +250,19 @@ class ControlBottomAppBar extends HookConsumerWidget {
onPressed: enabled ? onEditLocation : null,
),
),
+ if (hasRemote)
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 100),
+ child: ControlBoxButton(
+ iconData: isInLockedView
+ ? Icons.lock_open_rounded
+ : Icons.lock_outline_rounded,
+ label: isInLockedView
+ ? "remove_from_locked_folder".tr()
+ : "move_to_locked_folder".tr(),
+ onPressed: enabled ? onToggleLocked : null,
+ ),
+ ),
if (!selectionAssetState.hasLocal &&
selectionAssetState.selectedCount > 1 &&
onStack != null)
@@ -269,20 +301,40 @@ class ControlBottomAppBar extends HookConsumerWidget {
];
}
+ getInitialSize() {
+ if (isInLockedView) {
+ return 0.20;
+ }
+ if (hasRemote) {
+ return 0.35;
+ }
+ return bottomPadding;
+ }
+
+ getMaxChildSize() {
+ if (isInLockedView) {
+ return 0.20;
+ }
+ if (hasRemote) {
+ return 0.65;
+ }
+ return bottomPadding;
+ }
+
return DraggableScrollableSheet(
controller: scrollController,
- initialChildSize: hasRemote ? 0.35 : bottomPadding,
+ initialChildSize: getInitialSize(),
minChildSize: bottomPadding,
- maxChildSize: hasRemote ? 0.65 : bottomPadding,
+ maxChildSize: getMaxChildSize(),
snap: true,
builder: (
BuildContext context,
ScrollController scrollController,
) {
return Card(
- color: context.colorScheme.surfaceContainerLow,
- surfaceTintColor: Colors.transparent,
- elevation: 18.0,
+ color: context.colorScheme.surfaceContainerHigh,
+ surfaceTintColor: context.colorScheme.surfaceContainerHigh,
+ elevation: 6.0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
@@ -300,27 +352,27 @@ class ControlBottomAppBar extends HookConsumerWidget {
const CustomDraggingHandle(),
const SizedBox(height: 12),
SizedBox(
- height: 100,
+ height: 120,
child: ListView(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
children: renderActionButtons(),
),
),
- if (hasRemote)
+ if (hasRemote && !isInLockedView) ...[
const Divider(
indent: 16,
endIndent: 16,
thickness: 1,
),
- if (hasRemote)
_AddToAlbumTitleRow(
onCreateNewAlbum: enabled ? onCreateNewAlbum : null,
),
+ ],
],
),
),
- if (hasRemote)
+ if (hasRemote && !isInLockedView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: AddToAlbumSliverList(
@@ -352,12 +404,9 @@ class _AddToAlbumTitleRow extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- const Text(
+ Text(
"add_to_album",
- style: TextStyle(
- fontSize: 14,
- fontWeight: FontWeight.bold,
- ),
+ style: context.textTheme.titleSmall,
).tr(),
TextButton.icon(
onPressed: onCreateNewAlbum,
diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart
index ceaee581d2..8cc725ab77 100644
--- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart
+++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
@@ -15,6 +16,7 @@ import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
+import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/album.service.dart';
@@ -395,6 +397,32 @@ class MultiselectGrid extends HookConsumerWidget {
}
}
+ void onToggleLockedVisibility() async {
+ processing.value = true;
+ try {
+ final remoteAssets = ownedRemoteSelection(
+ localErrorMessage: 'home_page_locked_error_local'.tr(),
+ ownerErrorMessage: 'home_page_locked_error_partner'.tr(),
+ );
+ if (remoteAssets.isNotEmpty) {
+ final isInLockedView = ref.read(inLockedViewProvider);
+ final visibility = isInLockedView
+ ? AssetVisibilityEnum.timeline
+ : AssetVisibilityEnum.locked;
+
+ await handleSetAssetsVisibility(
+ ref,
+ context,
+ visibility,
+ remoteAssets.toList(),
+ );
+ }
+ } finally {
+ processing.value = false;
+ selectionEnabledHook.value = false;
+ }
+ }
+
Future Function() wrapLongRunningFun(
Future Function() fun, {
bool showOverlay = true,
@@ -460,6 +488,7 @@ class MultiselectGrid extends HookConsumerWidget {
onEditLocation: editEnabled ? onEditLocation : null,
unfavorite: unfavorite,
unarchive: unarchive,
+ onToggleLocked: onToggleLockedVisibility,
onRemoveFromAlbum: onRemoveFromAlbum != null
? wrapLongRunningFun(
() => onRemoveFromAlbum!(selection.value),
diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
index 8bfcdc12ca..1ff8596c43 100644
--- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
+++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
@@ -15,6 +15,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
+import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -46,6 +47,7 @@ class BottomGalleryBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final isInLockedView = ref.watch(inLockedViewProvider);
final asset = ref.watch(currentAssetProvider);
if (asset == null) {
return const SizedBox();
@@ -277,7 +279,7 @@ class BottomGalleryBar extends ConsumerWidget {
tooltip: 'share'.tr(),
): (_) => shareAsset(),
},
- if (asset.isImage)
+ if (asset.isImage && !isInLockedView)
{
BottomNavigationBarItem(
icon: const Icon(Icons.tune_outlined),
@@ -285,7 +287,7 @@ class BottomGalleryBar extends ConsumerWidget {
tooltip: 'edit'.tr(),
): (_) => handleEdit(),
},
- if (isOwner)
+ if (isOwner && !isInLockedView)
{
asset.isArchived
? BottomNavigationBarItem(
@@ -299,7 +301,7 @@ class BottomGalleryBar extends ConsumerWidget {
tooltip: 'archive'.tr(),
): (_) => handleArchive(),
},
- if (isOwner && asset.stackCount > 0)
+ if (isOwner && asset.stackCount > 0 && !isInLockedView)
{
BottomNavigationBarItem(
icon: const Icon(Icons.burst_mode_outlined),
diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart
index 937d1adf32..64cb1c619f 100644
--- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart
+++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart
@@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
+import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
@@ -39,6 +40,7 @@ class TopControlAppBar extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final isInLockedView = ref.watch(inLockedViewProvider);
const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset;
final album = ref.watch(currentAlbumProvider);
@@ -178,15 +180,22 @@ class TopControlAppBar extends HookConsumerWidget {
shape: const Border(),
actions: [
if (asset.isRemote && isOwner) buildFavoriteButton(a),
- if (isOwner && !isInHomePage && !(isInTrash ?? false))
+ if (isOwner &&
+ !isInHomePage &&
+ !(isInTrash ?? false) &&
+ !isInLockedView)
buildLocateButton(),
if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
- if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
+ if (asset.isRemote &&
+ (isOwner || isPartner) &&
+ !asset.isTrashed &&
+ !isInLockedView)
buildAddToAlbumButton(),
if (asset.isTrashed) buildRestoreButton(),
- if (album != null && album.shared) buildActivitiesButton(),
+ if (album != null && album.shared && !isInLockedView)
+ buildActivitiesButton(),
buildMoreInfoButton(),
],
);
diff --git a/mobile/lib/widgets/common/drag_sheet.dart b/mobile/lib/widgets/common/drag_sheet.dart
index 45addd0c2e..923050bcc6 100644
--- a/mobile/lib/widgets/common/drag_sheet.dart
+++ b/mobile/lib/widgets/common/drag_sheet.dart
@@ -35,7 +35,9 @@ class ControlBoxButton extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialButton(
padding: const EdgeInsets.all(10),
- shape: const CircleBorder(),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.all(Radius.circular(20)),
+ ),
onPressed: onPressed,
onLongPress: onLongPressed,
minWidth: 75.0,
@@ -47,8 +49,8 @@ class ControlBoxButton extends StatelessWidget {
const SizedBox(height: 8),
Text(
label,
- style: const TextStyle(fontSize: 12.0),
- maxLines: 2,
+ style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400),
+ maxLines: 3,
textAlign: TextAlign.center,
),
],
diff --git a/mobile/lib/widgets/common/immich_toast.dart b/mobile/lib/widgets/common/immich_toast.dart
index 7f3207032b..945568a74c 100644
--- a/mobile/lib/widgets/common/immich_toast.dart
+++ b/mobile/lib/widgets/common/immich_toast.dart
@@ -40,7 +40,7 @@ class ImmichToast {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(5.0),
+ borderRadius: const BorderRadius.all(Radius.circular(16.0)),
color: context.colorScheme.surfaceContainer,
border: Border.all(
color: context.colorScheme.outline.withValues(alpha: .5),
@@ -59,14 +59,23 @@ class ImmichToast {
msg,
style: TextStyle(
color: getColor(toastType, context),
- fontWeight: FontWeight.bold,
- fontSize: 15,
+ fontWeight: FontWeight.w600,
+ fontSize: 14,
),
),
),
],
),
),
+ positionedToastBuilder: (context, child, gravity) {
+ return Positioned(
+ top: gravity == ToastGravity.TOP ? 150 : null,
+ bottom: gravity == ToastGravity.BOTTOM ? 150 : null,
+ left: MediaQuery.of(context).size.width / 2 - 150,
+ right: MediaQuery.of(context).size.width / 2 - 150,
+ child: child,
+ );
+ },
gravity: gravity,
toastDuration: Duration(seconds: durationInSecond),
);
diff --git a/mobile/lib/widgets/forms/pin_input.dart b/mobile/lib/widgets/forms/pin_input.dart
new file mode 100644
index 0000000000..1588a65c60
--- /dev/null
+++ b/mobile/lib/widgets/forms/pin_input.dart
@@ -0,0 +1,124 @@
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:pinput/pinput.dart';
+
+class PinInput extends StatelessWidget {
+ final Function(String)? onCompleted;
+ final Function(String)? onChanged;
+ final int? length;
+ final bool? obscureText;
+ final bool? autoFocus;
+ final bool? hasError;
+ final String? label;
+ final TextEditingController? controller;
+
+ const PinInput({
+ super.key,
+ this.onCompleted,
+ this.onChanged,
+ this.length,
+ this.obscureText,
+ this.autoFocus,
+ this.hasError,
+ this.label,
+ this.controller,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ getPinSize() {
+ final minimumPadding = 18.0;
+ final gapWidth = 3.0;
+ final screenWidth = context.width;
+ final pinWidth =
+ (screenWidth - (minimumPadding * 2) - (gapWidth * 5)) / (length ?? 6);
+
+ if (pinWidth > 60) {
+ return const Size(60, 64);
+ }
+
+ final pinHeight = pinWidth / (60 / 64);
+ return Size(pinWidth, pinHeight);
+ }
+
+ final defaultPinTheme = PinTheme(
+ width: getPinSize().width,
+ height: getPinSize().height,
+ textStyle: TextStyle(
+ fontSize: 24,
+ color: context.colorScheme.onSurface,
+ fontFamily: 'Overpass Mono',
+ ),
+ decoration: BoxDecoration(
+ borderRadius: const BorderRadius.all(Radius.circular(19)),
+ border: Border.all(color: context.colorScheme.surfaceBright),
+ color: context.colorScheme.surfaceContainerHigh,
+ ),
+ );
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (label != null) ...[
+ Text(
+ label!,
+ style: context.textTheme.displayLarge
+ ?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
+ ),
+ const SizedBox(height: 4),
+ ],
+ Pinput(
+ controller: controller,
+ forceErrorState: hasError ?? false,
+ autofocus: autoFocus ?? false,
+ obscureText: obscureText ?? false,
+ obscuringWidget: Icon(
+ Icons.vpn_key_rounded,
+ color: context.primaryColor,
+ size: 20,
+ ),
+ separatorBuilder: (index) => const SizedBox(
+ height: 64,
+ width: 3,
+ ),
+ cursor: Column(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ Container(
+ margin: const EdgeInsets.only(bottom: 9),
+ width: 18,
+ height: 2,
+ color: context.primaryColor,
+ ),
+ ],
+ ),
+ defaultPinTheme: defaultPinTheme,
+ focusedPinTheme: defaultPinTheme.copyWith(
+ decoration: BoxDecoration(
+ borderRadius: const BorderRadius.all(Radius.circular(19)),
+ border: Border.all(
+ color: context.primaryColor.withValues(alpha: 0.5),
+ width: 2,
+ ),
+ color: context.colorScheme.surfaceContainerHigh,
+ ),
+ ),
+ errorPinTheme: defaultPinTheme.copyWith(
+ decoration: BoxDecoration(
+ color: context.colorScheme.error.withAlpha(15),
+ borderRadius: const BorderRadius.all(Radius.circular(19)),
+ border: Border.all(
+ color: context.colorScheme.error.withAlpha(100),
+ width: 2,
+ ),
+ ),
+ ),
+ pinputAutovalidateMode: PinputAutovalidateMode.onSubmit,
+ length: length ?? 6,
+ onChanged: onChanged,
+ onCompleted: onCompleted,
+ ),
+ ],
+ );
+ }
+}
diff --git a/mobile/lib/widgets/forms/pin_registration_form.dart b/mobile/lib/widgets/forms/pin_registration_form.dart
new file mode 100644
index 0000000000..c3cfd3a864
--- /dev/null
+++ b/mobile/lib/widgets/forms/pin_registration_form.dart
@@ -0,0 +1,128 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/providers/auth.provider.dart';
+import 'package:immich_mobile/widgets/forms/pin_input.dart';
+
+class PinRegistrationForm extends HookConsumerWidget {
+ final Function() onDone;
+
+ const PinRegistrationForm({
+ super.key,
+ required this.onDone,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final hasError = useState(false);
+ final newPinCodeController = useTextEditingController();
+ final confirmPinCodeController = useTextEditingController();
+
+ bool validatePinCode() {
+ if (confirmPinCodeController.text.length != 6) {
+ return false;
+ }
+
+ if (newPinCodeController.text != confirmPinCodeController.text) {
+ return false;
+ }
+
+ return true;
+ }
+
+ createNewPinCode() async {
+ final isValid = validatePinCode();
+ if (!isValid) {
+ hasError.value = true;
+ return;
+ }
+
+ try {
+ await ref.read(authProvider.notifier).setupPinCode(
+ newPinCodeController.text,
+ );
+
+ onDone();
+ } catch (error) {
+ hasError.value = true;
+ context.showSnackBar(
+ SnackBar(content: Text(error.toString())),
+ );
+ }
+ }
+
+ return Form(
+ child: Column(
+ children: [
+ Icon(
+ Icons.pin_outlined,
+ size: 64,
+ color: context.primaryColor,
+ ),
+ const SizedBox(height: 32),
+ SizedBox(
+ width: context.width * 0.7,
+ child: Text(
+ 'setup_pin_code'.tr(),
+ style: context.textTheme.labelLarge!.copyWith(
+ fontSize: 24,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ SizedBox(
+ width: context.width * 0.8,
+ child: Text(
+ 'new_pin_code_subtitle'.tr(),
+ style: context.textTheme.bodyLarge!.copyWith(
+ fontSize: 16,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ const SizedBox(height: 32),
+ PinInput(
+ controller: newPinCodeController,
+ label: 'new_pin_code'.tr(),
+ length: 6,
+ autoFocus: true,
+ hasError: hasError.value,
+ onChanged: (input) {
+ if (input.length < 6) {
+ hasError.value = false;
+ }
+ },
+ ),
+ const SizedBox(height: 32),
+ PinInput(
+ controller: confirmPinCodeController,
+ label: 'confirm_new_pin_code'.tr(),
+ length: 6,
+ hasError: hasError.value,
+ onChanged: (input) {
+ if (input.length < 6) {
+ hasError.value = false;
+ }
+ },
+ ),
+ const SizedBox(height: 48),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24.0),
+ child: Row(
+ children: [
+ Expanded(
+ child: ElevatedButton(
+ onPressed: createNewPinCode,
+ child: Text('create'.tr()),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/widgets/forms/pin_verification_form.dart b/mobile/lib/widgets/forms/pin_verification_form.dart
new file mode 100644
index 0000000000..f4ebf4272f
--- /dev/null
+++ b/mobile/lib/widgets/forms/pin_verification_form.dart
@@ -0,0 +1,94 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/providers/auth.provider.dart';
+import 'package:immich_mobile/widgets/forms/pin_input.dart';
+
+class PinVerificationForm extends HookConsumerWidget {
+ final Function(String) onSuccess;
+ final VoidCallback? onError;
+ final bool? autoFocus;
+ final String? description;
+ final IconData? icon;
+ final IconData? successIcon;
+
+ const PinVerificationForm({
+ super.key,
+ required this.onSuccess,
+ this.onError,
+ this.autoFocus,
+ this.description,
+ this.icon,
+ this.successIcon,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final hasError = useState(false);
+ final isVerified = useState(false);
+
+ verifyPin(String pinCode) async {
+ final isUnlocked =
+ await ref.read(authProvider.notifier).unlockPinCode(pinCode);
+
+ if (isUnlocked) {
+ isVerified.value = true;
+
+ await Future.delayed(const Duration(seconds: 1));
+ onSuccess(pinCode);
+ } else {
+ hasError.value = true;
+ onError?.call();
+ }
+ }
+
+ return Form(
+ child: Column(
+ children: [
+ AnimatedSwitcher(
+ duration: const Duration(milliseconds: 200),
+ child: isVerified.value
+ ? Icon(
+ successIcon ?? Icons.lock_open_rounded,
+ size: 64,
+ color: Colors.green[300],
+ )
+ : Icon(
+ icon ?? Icons.lock_outline_rounded,
+ size: 64,
+ color: hasError.value
+ ? context.colorScheme.error
+ : context.primaryColor,
+ ),
+ ),
+ const SizedBox(height: 36),
+ SizedBox(
+ width: context.width * 0.7,
+ child: Text(
+ description ?? 'enter_your_pin_code_subtitle'.tr(),
+ style: context.textTheme.labelLarge!.copyWith(
+ fontSize: 18,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ const SizedBox(height: 18),
+ PinInput(
+ obscureText: true,
+ autoFocus: autoFocus,
+ hasError: hasError.value,
+ length: 6,
+ onChanged: (pinCode) {
+ if (pinCode.length < 6) {
+ hasError.value = false;
+ }
+ },
+ onCompleted: verifyPin,
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index 7e490edd25..3df4e4e8a9 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -621,6 +621,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
+ flutter_secure_storage:
+ dependency: "direct main"
+ description:
+ name: flutter_secure_storage
+ sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.2.4"
+ flutter_secure_storage_linux:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_linux
+ sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.3"
+ flutter_secure_storage_macos:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_macos
+ sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.3"
+ flutter_secure_storage_platform_interface:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_platform_interface
+ sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.2"
+ flutter_secure_storage_web:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_web
+ sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ flutter_secure_storage_windows:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_windows
+ sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.2"
flutter_svg:
dependency: "direct main"
description:
@@ -976,6 +1024,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
+ local_auth:
+ dependency: "direct main"
+ description:
+ name: local_auth
+ sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.0"
+ local_auth_android:
+ dependency: transitive
+ description:
+ name: local_auth_android
+ sha256: "63ad7ca6396290626dc0cb34725a939e4cfe965d80d36112f08d49cf13a8136e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.49"
+ local_auth_darwin:
+ dependency: transitive
+ description:
+ name: local_auth_darwin
+ sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.3"
+ local_auth_platform_interface:
+ dependency: transitive
+ description:
+ name: local_auth_platform_interface
+ sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.10"
+ local_auth_windows:
+ dependency: transitive
+ description:
+ name: local_auth_windows
+ sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.11"
logging:
dependency: "direct main"
description:
@@ -1264,6 +1352,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
+ pinput:
+ dependency: "direct main"
+ description:
+ name: pinput
+ sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.0.1"
platform:
dependency: transitive
description:
@@ -1741,6 +1837,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.2"
+ universal_platform:
+ dependency: transitive
+ description:
+ name: universal_platform
+ sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
url_launcher:
dependency: "direct main"
description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 08e9661d58..37c9ef7498 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -64,6 +64,9 @@ dependencies:
uuid: ^4.5.1
wakelock_plus: ^1.2.10
worker_manager: ^7.2.3
+ local_auth: ^2.3.0
+ pinput: ^5.0.1
+ flutter_secure_storage: ^9.2.4
native_video_player:
git:
From 0d773af6c343d423226a27e9414cef9c5862a169 Mon Sep 17 00:00:00 2001
From: Mert <101130780+mertalev@users.noreply.github.com>
Date: Tue, 20 May 2025 09:36:43 -0400
Subject: [PATCH 013/170] feat: vectorchord (#18042)
* wip
auto-detect available extensions
auto-recovery, fix reindexing check
use original image for ml
* set probes
* update image for sql checker
update images for gha
* cascade
* fix new instance
* accurate dummy vector
* simplify dummy
* preexisiting pg docs
* handle different db name
* maybe fix sql generation
* revert refreshfaces sql change
* redundant switch
* outdated message
* update docker compose files
* Update docs/docs/administration/postgres-standalone.md
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
* tighten range
* avoid always printing "vector reindexing complete"
* remove nesting
* use new images
* add vchord to unit tests
* debug e2e image
* mention 1.107.2 in startup error
* support new vchord versions
---------
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
---
.github/workflows/test.yml | 2 +-
docker/docker-compose.dev.yml | 20 +-
docker/docker-compose.prod.yml | 20 +-
docker/docker-compose.yml | 22 +-
.../administration/postgres-standalone.md | 77 ++++-
docs/docs/features/searching.md | 2 +-
docs/docs/install/environment-variables.md | 24 +-
e2e/docker-compose.yml | 4 +-
server/src/constants.ts | 17 +-
server/src/decorators.ts | 2 +-
server/src/dtos/env.dto.ts | 4 +-
server/src/enum.ts | 1 +
.../migrations/1700713871511-UsePgVectors.ts | 6 +-
.../1700713994428-AddCLIPEmbeddingIndex.ts | 5 +-
.../1700714033632-AddFaceEmbeddingIndex.ts | 5 +-
.../1718486162779-AddFaceSearchRelation.ts | 7 +-
server/src/queries/database.repository.sql | 8 -
server/src/queries/person.repository.sql | 15 +
server/src/queries/search.repository.sql | 24 +-
.../repositories/config.repository.spec.ts | 2 +-
server/src/repositories/config.repository.ts | 20 +-
.../src/repositories/database.repository.ts | 306 +++++++++++++-----
server/src/repositories/person.repository.ts | 1 +
server/src/repositories/search.repository.ts | 185 ++++-------
.../1744910873969-InitialMigration.ts | 7 +-
server/src/services/database.service.spec.ts | 72 +----
server/src/services/database.service.ts | 53 ++-
server/src/services/person.service.ts | 3 +
.../src/services/smart-info.service.spec.ts | 54 ++--
server/src/services/smart-info.service.ts | 8 +-
server/src/types.ts | 4 +-
server/src/utils/database.ts | 18 +-
server/test/medium.factory.ts | 2 +-
server/test/medium/globalSetup.ts | 8 +-
.../repositories/database.repository.mock.ts | 8 +-
35 files changed, 572 insertions(+), 444 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 91f4ffce4f..6c1cb8e07e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -643,7 +643,7 @@ jobs:
contents: read
services:
postgres:
- image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
+ image: ghcr.io/immich-app/postgres:14
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml
index a428934022..1da06ef2ff 100644
--- a/docker/docker-compose.dev.yml
+++ b/docker/docker-compose.dev.yml
@@ -122,7 +122,7 @@ services:
database:
container_name: immich_postgres
- image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0
env_file:
- .env
environment:
@@ -134,24 +134,6 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
- healthcheck:
- test: >-
- pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
- Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
- --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
- echo "checksum failure count is $$Chksum";
- [ "$$Chksum" = '0' ] || exit 1
- interval: 5m
- start_interval: 30s
- start_period: 5m
- command: >-
- postgres
- -c shared_preload_libraries=vectors.so
- -c 'search_path="$$user", public, vectors'
- -c logging_collector=on
- -c max_wal_size=2GB
- -c shared_buffers=512MB
- -c wal_compression=on
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
# immich-prometheus:
diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml
index bfcb5455aa..e17a034ddb 100644
--- a/docker/docker-compose.prod.yml
+++ b/docker/docker-compose.prod.yml
@@ -63,7 +63,7 @@ services:
database:
container_name: immich_postgres
- image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0
env_file:
- .env
environment:
@@ -75,24 +75,6 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
- healthcheck:
- test: >-
- pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
- Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
- --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
- echo "checksum failure count is $$Chksum";
- [ "$$Chksum" = '0' ] || exit 1
- interval: 5m
- start_interval: 30s
- start_period: 5m
- command: >-
- postgres
- -c shared_preload_libraries=vectors.so
- -c 'search_path="$$user", public, vectors'
- -c logging_collector=on
- -c max_wal_size=2GB
- -c shared_buffers=512MB
- -c wal_compression=on
restart: always
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 4387f5fd0c..f2b1a20321 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -56,7 +56,7 @@ services:
database:
container_name: immich_postgres
- image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
@@ -65,24 +65,8 @@ services:
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
- healthcheck:
- test: >-
- pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
- Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
- --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
- echo "checksum failure count is $$Chksum";
- [ "$$Chksum" = '0' ] || exit 1
- interval: 5m
- start_interval: 30s
- start_period: 5m
- command: >-
- postgres
- -c shared_preload_libraries=vectors.so
- -c 'search_path="$$user", public, vectors'
- -c logging_collector=on
- -c max_wal_size=2GB
- -c shared_buffers=512MB
- -c wal_compression=on
+ # change ssd below to hdd if you are using a hard disk drive or other slow storage
+ command: postgres -c config_file=/etc/postgresql/postgresql.ssd.conf
restart: always
volumes:
diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md
index 2ca23e195f..44c2c8e4c6 100644
--- a/docs/docs/administration/postgres-standalone.md
+++ b/docs/docs/administration/postgres-standalone.md
@@ -10,12 +10,12 @@ Running with a pre-existing Postgres server can unlock powerful administrative f
## Prerequisites
-You must install pgvecto.rs into your instance of Postgres using their [instructions][vectors-install]. After installation, add `shared_preload_libraries = 'vectors.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vectors.so'`.
+You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`.
:::note
-Immich is known to work with Postgres versions 14, 15, and 16. Earlier versions are unsupported. Postgres 17 is nominally compatible, but pgvecto.rs does not have prebuilt images or packages for it as of writing.
+Immich is known to work with Postgres versions 14, 15, 16 and 17. Earlier versions are unsupported.
-Make sure the installed version of pgvecto.rs is compatible with your version of Immich. The current accepted range for pgvecto.rs is `>= 0.2.0, < 0.4.0`.
+Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 1.0.0`.
:::
## Specifying the connection URL
@@ -53,16 +53,75 @@ CREATE DATABASE ;
\c
BEGIN;
ALTER DATABASE OWNER TO ;
-CREATE EXTENSION vectors;
+CREATE EXTENSION vchord CASCADE;
CREATE EXTENSION earthdistance CASCADE;
-ALTER DATABASE SET search_path TO "$user", public, vectors;
-ALTER SCHEMA vectors OWNER TO ;
COMMIT;
```
-### Updating pgvecto.rs
+### Updating VectorChord
-When installing a new version of pgvecto.rs, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vectors UPDATE;`.
+When installing a new version of VectorChord, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vchord UPDATE;`.
+
+## Migrating to VectorChord
+
+VectorChord is the successor extension to pgvecto.rs, allowing for higher performance, lower memory usage and higher quality results for smart search and facial recognition.
+
+### Migrating from pgvecto.rs
+
+Support for pgvecto.rs will be dropped in a later release, hence we recommend all users currently using pgvecto.rs to migrate to VectorChord at their convenience. There are two primary approaches to do so.
+
+The easiest option is to have both extensions installed during the migration:
+
+1. Ensure you still have pgvecto.rs installed
+2. [Install VectorChord][vchord-install]
+3. Add `shared_preload_libraries= 'vchord.so, vectors.so'` to your `postgresql.conf`, making sure to include _both_ `vchord.so` and `vectors.so`. You may include other libraries here as well if needed
+4. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;` using psql or your choice of database client
+5. Start Immich and wait for the logs `Reindexed face_index` and `Reindexed clip_index` to be output
+6. Remove the `vectors.so` entry from the `shared_preload_libraries` setting
+7. Uninstall pgvecto.rs (e.g. `apt-get purge vectors-pg14` on Debian-based environments, replacing `pg14` as appropriate)
+
+If it is not possible to have both VectorChord and pgvector.s installed at the same time, you can perform the migration with more manual steps:
+
+1. While pgvecto.rs is still installed, run the following SQL command using psql or your choice of database client. Take note of the number outputted by this command as you will need it later
+
+```sql
+SELECT atttypmod as dimsize
+ FROM pg_attribute f
+ JOIN pg_class c ON c.oid = f.attrelid
+ WHERE c.relkind = 'r'::char
+ AND f.attnum > 0
+ AND c.relname = 'smart_search'::text
+ AND f.attname = 'embedding'::text;
+```
+
+2. Remove references to pgvecto.rs using the below SQL commands
+
+```sql
+DROP INDEX IF EXISTS clip_index;
+DROP INDEX IF EXISTS face_index;
+ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE real[];
+ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE real[];
+```
+
+3. [Install VectorChord][vchord-install]
+4. Change the columns back to the appropriate vector types, replacing `` with the number from step 1
+
+```sql
+CREATE EXTENSION IF NOT EXISTS vchord CASCADE;
+ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector();
+ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512);
+```
+
+5. Start Immich and let it create new indices using VectorChord
+
+### Migrating from pgvector
+
+1. Ensure you have at least 0.7.0 of pgvector installed. If it is below that, please upgrade it and run the SQL command `ALTER EXTENSION vector UPDATE;` using psql or your choice of database client
+2. Follow the Prerequisites to install VectorChord
+3. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;`
+4. Start Immich and let it create new indices using VectorChord
+
+Note that VectorChord itself uses pgvector types, so you should not uninstall pgvector after following these steps.
### Common errors
@@ -70,4 +129,4 @@ When installing a new version of pgvecto.rs, you will need to manually update th
If you get the error `driverError: error: permission denied for view pg_vector_index_stat`, you can fix this by connecting to the Immich database and running `GRANT SELECT ON TABLE pg_vector_index_stat TO ;`.
-[vectors-install]: https://docs.vectorchord.ai/getting-started/installation.html
+[vchord-install]: https://docs.vectorchord.ai/vectorchord/getting-started/installation.html
diff --git a/docs/docs/features/searching.md b/docs/docs/features/searching.md
index f6bfac6e7a..d7ebd1a468 100644
--- a/docs/docs/features/searching.md
+++ b/docs/docs/features/searching.md
@@ -5,7 +5,7 @@ import TabItem from '@theme/TabItem';
Immich uses Postgres as its search database for both metadata and contextual CLIP search.
-Contextual CLIP search is powered by the [pgvecto.rs](https://github.com/tensorchord/pgvecto.rs) extension, utilizing machine learning models like [CLIP](https://openai.com/research/clip) to provide relevant search results. This allows for freeform searches without requiring specific keywords in the image or video metadata.
+Contextual CLIP search is powered by the [VectorChord](https://github.com/tensorchord/VectorChord) extension, utilizing machine learning models like [CLIP](https://openai.com/research/clip) to provide relevant search results. This allows for freeform searches without requiring specific keywords in the image or video metadata.
## Advanced Search Filters
diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md
index c853a873ab..d3ca49a0a4 100644
--- a/docs/docs/install/environment-variables.md
+++ b/docs/docs/install/environment-variables.md
@@ -72,21 +72,21 @@ Information on the current workers can be found [here](/docs/administration/jobs
## Database
-| Variable | Description | Default | Containers |
-| :---------------------------------- | :----------------------------------------------------------------------- | :----------: | :----------------------------- |
-| `DB_URL` | Database URL | | server |
-| `DB_HOSTNAME` | Database host | `database` | server |
-| `DB_PORT` | Database port | `5432` | server |
-| `DB_USERNAME` | Database user | `postgres` | server, database\*1 |
-| `DB_PASSWORD` | Database password | `postgres` | server, database\*1 |
-| `DB_DATABASE_NAME` | Database name | `immich` | server, database\*1 |
-| `DB_SSL_MODE` | Database SSL mode | | server |
-| `DB_VECTOR_EXTENSION`\*2 | Database vector extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server |
-| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
+| Variable | Description | Default | Containers |
+| :---------------------------------- | :--------------------------------------------------------------------------- | :--------: | :----------------------------- |
+| `DB_URL` | Database URL | | server |
+| `DB_HOSTNAME` | Database host | `database` | server |
+| `DB_PORT` | Database port | `5432` | server |
+| `DB_USERNAME` | Database user | `postgres` | server, database\*1 |
+| `DB_PASSWORD` | Database password | `postgres` | server, database\*1 |
+| `DB_DATABASE_NAME` | Database name | `immich` | server, database\*1 |
+| `DB_SSL_MODE` | Database SSL mode | | server |
+| `DB_VECTOR_EXTENSION`\*2 | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
+| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
-\*2: This setting cannot be changed after the server has successfully started up.
+\*2: If not provided, the appropriate extension to use is auto-detected at startup by introspecting the database. When multiple extensions are installed, the order of preference is VectorChord, pgvecto.rs, pgvector.
:::info
diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml
index 48c17c828b..a8cb21aaf7 100644
--- a/e2e/docker-compose.yml
+++ b/e2e/docker-compose.yml
@@ -37,8 +37,8 @@ services:
image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa
database:
- image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
- command: -c fsync=off -c shared_preload_libraries=vectors.so
+ image: ghcr.io/immich-app/postgres:14
+ command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
diff --git a/server/src/constants.ts b/server/src/constants.ts
index 6c0319fcee..8268360d9f 100644
--- a/server/src/constants.ts
+++ b/server/src/constants.ts
@@ -1,9 +1,10 @@
import { Duration } from 'luxon';
import { readFileSync } from 'node:fs';
import { SemVer } from 'semver';
-import { DatabaseExtension, ExifOrientation } from 'src/enum';
+import { DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
+export const VECTORCHORD_VERSION_RANGE = '>=0.3 <1';
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
@@ -20,8 +21,22 @@ export const EXTENSION_NAMES: Record = {
earthdistance: 'earthdistance',
vector: 'pgvector',
vectors: 'pgvecto.rs',
+ vchord: 'VectorChord',
} as const;
+export const VECTOR_EXTENSIONS = [
+ DatabaseExtension.VECTORCHORD,
+ DatabaseExtension.VECTORS,
+ DatabaseExtension.VECTOR,
+] as const;
+
+export const VECTOR_INDEX_TABLES = {
+ [VectorIndex.CLIP]: 'smart_search',
+ [VectorIndex.FACE]: 'face_search',
+} as const;
+
+export const VECTORCHORD_LIST_SLACK_FACTOR = 1.2;
+
export const SALT_ROUNDS = 10;
export const IWorker = 'IWorker';
diff --git a/server/src/decorators.ts b/server/src/decorators.ts
index 1af9342e0b..6b34ffcafe 100644
--- a/server/src/decorators.ts
+++ b/server/src/decorators.ts
@@ -116,7 +116,7 @@ export const DummyValue = {
DATE: new Date(),
TIME_BUCKET: '2024-01-01T00:00:00.000Z',
BOOLEAN: true,
- VECTOR: '[1, 2, 3]',
+ VECTOR: JSON.stringify(Array.from({ length: 512 }, () => 0)),
};
export const GENERATE_SQL_KEY = 'generate-sql-key';
diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts
index 7f0df8abb9..99fd1d2149 100644
--- a/server/src/dtos/env.dto.ts
+++ b/server/src/dtos/env.dto.ts
@@ -154,9 +154,9 @@ export class EnvDto {
@Optional()
DB_USERNAME?: string;
- @IsEnum(['pgvector', 'pgvecto.rs'])
+ @IsEnum(['pgvector', 'pgvecto.rs', 'vectorchord'])
@Optional()
- DB_VECTOR_EXTENSION?: 'pgvector' | 'pgvecto.rs';
+ DB_VECTOR_EXTENSION?: 'pgvector' | 'pgvecto.rs' | 'vectorchord';
@IsString()
@Optional()
diff --git a/server/src/enum.ts b/server/src/enum.ts
index e49f1636a0..c9cf34383e 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -414,6 +414,7 @@ export enum DatabaseExtension {
EARTH_DISTANCE = 'earthdistance',
VECTOR = 'vector',
VECTORS = 'vectors',
+ VECTORCHORD = 'vchord',
}
export enum BootstrapEventPriority {
diff --git a/server/src/migrations/1700713871511-UsePgVectors.ts b/server/src/migrations/1700713871511-UsePgVectors.ts
index e67c7275a7..4511e1001b 100644
--- a/server/src/migrations/1700713871511-UsePgVectors.ts
+++ b/server/src/migrations/1700713871511-UsePgVectors.ts
@@ -1,15 +1,13 @@
-import { ConfigRepository } from 'src/repositories/config.repository';
+import { getVectorExtension } from 'src/repositories/database.repository';
import { getCLIPModelInfo } from 'src/utils/misc';
import { MigrationInterface, QueryRunner } from 'typeorm';
-const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
-
export class UsePgVectors1700713871511 implements MigrationInterface {
name = 'UsePgVectors1700713871511';
public async up(queryRunner: QueryRunner): Promise {
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
- await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS ${vectorExtension}`);
+ await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS ${await getVectorExtension(queryRunner)}`);
const faceDimQuery = await queryRunner.query(`
SELECT CARDINALITY(embedding::real[]) as dimsize
FROM asset_faces
diff --git a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts
index b5d47bb8cd..43809d6364 100644
--- a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts
+++ b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts
@@ -1,13 +1,12 @@
-import { ConfigRepository } from 'src/repositories/config.repository';
+import { getVectorExtension } from 'src/repositories/database.repository';
import { vectorIndexQuery } from 'src/utils/database';
import { MigrationInterface, QueryRunner } from 'typeorm';
-const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
-
export class AddCLIPEmbeddingIndex1700713994428 implements MigrationInterface {
name = 'AddCLIPEmbeddingIndex1700713994428';
public async up(queryRunner: QueryRunner): Promise {
+ const vectorExtension = await getVectorExtension(queryRunner);
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' }));
diff --git a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts
index 2b41788fe4..5ee91afbcc 100644
--- a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts
+++ b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts
@@ -1,13 +1,12 @@
-import { ConfigRepository } from 'src/repositories/config.repository';
+import { getVectorExtension } from 'src/repositories/database.repository';
import { vectorIndexQuery } from 'src/utils/database';
import { MigrationInterface, QueryRunner } from 'typeorm';
-const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
-
export class AddFaceEmbeddingIndex1700714033632 implements MigrationInterface {
name = 'AddFaceEmbeddingIndex1700714033632';
public async up(queryRunner: QueryRunner): Promise {
+ const vectorExtension = await getVectorExtension(queryRunner);
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'asset_faces', indexName: 'face_index' }));
diff --git a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts
index 64849708d2..68e1618775 100644
--- a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts
+++ b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts
@@ -1,12 +1,11 @@
import { DatabaseExtension } from 'src/enum';
-import { ConfigRepository } from 'src/repositories/config.repository';
+import { getVectorExtension } from 'src/repositories/database.repository';
import { vectorIndexQuery } from 'src/utils/database';
import { MigrationInterface, QueryRunner } from 'typeorm';
-const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
-
export class AddFaceSearchRelation1718486162779 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise {
+ const vectorExtension = await getVectorExtension(queryRunner);
if (vectorExtension === DatabaseExtension.VECTORS) {
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
}
@@ -48,11 +47,11 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface {
await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512)`);
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' }));
-
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'face_search', indexName: 'face_index' }));
}
public async down(queryRunner: QueryRunner): Promise {
+ const vectorExtension = await getVectorExtension(queryRunner);
if (vectorExtension === DatabaseExtension.VECTORS) {
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
}
diff --git a/server/src/queries/database.repository.sql b/server/src/queries/database.repository.sql
index 8c87a7470f..9dc60ac43f 100644
--- a/server/src/queries/database.repository.sql
+++ b/server/src/queries/database.repository.sql
@@ -11,11 +11,3 @@ WHERE
-- DatabaseRepository.getPostgresVersion
SHOW server_version
-
--- DatabaseRepository.shouldReindex
-SELECT
- idx_status
-FROM
- pg_vector_index_stat
-WHERE
- indexname = $1
diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql
index fefc25ee6a..48854f4872 100644
--- a/server/src/queries/person.repository.sql
+++ b/server/src/queries/person.repository.sql
@@ -204,6 +204,21 @@ where
"person"."ownerId" = $3
and "asset_faces"."deletedAt" is null
+-- PersonRepository.refreshFaces
+with
+ "added_embeddings" as (
+ insert into
+ "face_search" ("faceId", "embedding")
+ values
+ ($1, $2)
+ )
+select
+from
+ (
+ select
+ 1
+ ) as "dummy"
+
-- PersonRepository.getFacesByIds
select
"asset_faces".*,
diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql
index c18fe02418..c100089179 100644
--- a/server/src/queries/search.repository.sql
+++ b/server/src/queries/search.repository.sql
@@ -64,6 +64,9 @@ limit
$15
-- SearchRepository.searchSmart
+begin
+set
+ local vchordrq.probes = 1
select
"assets".*
from
@@ -83,8 +86,12 @@ limit
$7
offset
$8
+commit
-- SearchRepository.searchDuplicates
+begin
+set
+ local vchordrq.probes = 1
with
"cte" as (
select
@@ -102,18 +109,22 @@ with
and "assets"."id" != $5::uuid
and "assets"."stackId" is null
order by
- smart_search.embedding <=> $6
+ "distance"
limit
- $7
+ $6
)
select
*
from
"cte"
where
- "cte"."distance" <= $8
+ "cte"."distance" <= $7
+commit
-- SearchRepository.searchFaces
+begin
+set
+ local vchordrq.probes = 1
with
"cte" as (
select
@@ -129,16 +140,17 @@ with
"assets"."ownerId" = any ($2::uuid[])
and "assets"."deletedAt" is null
order by
- face_search.embedding <=> $3
+ "distance"
limit
- $4
+ $3
)
select
*
from
"cte"
where
- "cte"."distance" <= $5
+ "cte"."distance" <= $4
+commit
-- SearchRepository.searchPlaces
select
diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts
index 143892fdd0..238b48bcef 100644
--- a/server/src/repositories/config.repository.spec.ts
+++ b/server/src/repositories/config.repository.spec.ts
@@ -89,7 +89,7 @@ describe('getEnv', () => {
password: 'postgres',
},
skipMigrations: false,
- vectorExtension: 'vectors',
+ vectorExtension: undefined,
});
});
diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts
index 9b3e406437..9a0a24f70f 100644
--- a/server/src/repositories/config.repository.ts
+++ b/server/src/repositories/config.repository.ts
@@ -58,7 +58,7 @@ export interface EnvData {
database: {
config: DatabaseConnectionParams;
skipMigrations: boolean;
- vectorExtension: VectorExtension;
+ vectorExtension?: VectorExtension;
};
licensePublicKey: {
@@ -196,6 +196,22 @@ const getEnv = (): EnvData => {
ssl: dto.DB_SSL_MODE || undefined,
};
+ let vectorExtension: VectorExtension | undefined;
+ switch (dto.DB_VECTOR_EXTENSION) {
+ case 'pgvector': {
+ vectorExtension = DatabaseExtension.VECTOR;
+ break;
+ }
+ case 'pgvecto.rs': {
+ vectorExtension = DatabaseExtension.VECTORS;
+ break;
+ }
+ case 'vectorchord': {
+ vectorExtension = DatabaseExtension.VECTORCHORD;
+ break;
+ }
+ }
+
return {
host: dto.IMMICH_HOST,
port: dto.IMMICH_PORT || 2283,
@@ -251,7 +267,7 @@ const getEnv = (): EnvData => {
database: {
config: databaseConnection,
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
- vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS,
+ vectorExtension,
},
licensePublicKey: isProd ? productionKeys : stagingKeys,
diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts
index addf6bcff0..67bb1b6ca2 100644
--- a/server/src/repositories/database.repository.ts
+++ b/server/src/repositories/database.repository.ts
@@ -5,7 +5,16 @@ import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import semver from 'semver';
-import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
+import {
+ EXTENSION_NAMES,
+ POSTGRES_VERSION_RANGE,
+ VECTOR_EXTENSIONS,
+ VECTOR_INDEX_TABLES,
+ VECTOR_VERSION_RANGE,
+ VECTORCHORD_LIST_SLACK_FACTOR,
+ VECTORCHORD_VERSION_RANGE,
+ VECTORS_VERSION_RANGE,
+} from 'src/constants';
import { DB } from 'src/db';
import { GenerateSql } from 'src/decorators';
import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum';
@@ -14,11 +23,42 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
import { vectorIndexQuery } from 'src/utils/database';
import { isValidInteger } from 'src/validation';
-import { DataSource } from 'typeorm';
+import { DataSource, QueryRunner } from 'typeorm';
+
+export let cachedVectorExtension: VectorExtension | undefined;
+export async function getVectorExtension(runner: Kysely | QueryRunner): Promise {
+ if (cachedVectorExtension) {
+ return cachedVectorExtension;
+ }
+
+ cachedVectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
+ if (cachedVectorExtension) {
+ return cachedVectorExtension;
+ }
+
+ let availableExtensions: { name: VectorExtension }[];
+ const query = `SELECT name FROM pg_available_extensions WHERE name IN (${VECTOR_EXTENSIONS.map((ext) => `'${ext}'`).join(', ')})`;
+ if (runner instanceof Kysely) {
+ const { rows } = await sql.raw<{ name: VectorExtension }>(query).execute(runner);
+ availableExtensions = rows;
+ } else {
+ availableExtensions = (await runner.query(query)) as { name: VectorExtension }[];
+ }
+ const extensionNames = new Set(availableExtensions.map((row) => row.name));
+ cachedVectorExtension = VECTOR_EXTENSIONS.find((ext) => extensionNames.has(ext));
+ if (!cachedVectorExtension) {
+ throw new Error(`No vector extension found. Available extensions: ${VECTOR_EXTENSIONS.join(', ')}`);
+ }
+ return cachedVectorExtension;
+}
+
+export const probes: Record = {
+ [VectorIndex.CLIP]: 1,
+ [VectorIndex.FACE]: 1,
+};
@Injectable()
export class DatabaseRepository {
- private vectorExtension: VectorExtension;
private readonly asyncLock = new AsyncLock();
constructor(
@@ -26,7 +66,6 @@ export class DatabaseRepository {
private logger: LoggingRepository,
private configRepository: ConfigRepository,
) {
- this.vectorExtension = configRepository.getEnv().database.vectorExtension;
this.logger.setContext(DatabaseRepository.name);
}
@@ -34,6 +73,10 @@ export class DatabaseRepository {
await this.db.destroy();
}
+ getVectorExtension(): Promise {
+ return getVectorExtension(this.db);
+ }
+
@GenerateSql({ params: [DatabaseExtension.VECTORS] })
async getExtensionVersion(extension: DatabaseExtension): Promise {
const { rows } = await sql`
@@ -45,7 +88,20 @@ export class DatabaseRepository {
}
getExtensionVersionRange(extension: VectorExtension): string {
- return extension === DatabaseExtension.VECTORS ? VECTORS_VERSION_RANGE : VECTOR_VERSION_RANGE;
+ switch (extension) {
+ case DatabaseExtension.VECTORCHORD: {
+ return VECTORCHORD_VERSION_RANGE;
+ }
+ case DatabaseExtension.VECTORS: {
+ return VECTORS_VERSION_RANGE;
+ }
+ case DatabaseExtension.VECTOR: {
+ return VECTOR_VERSION_RANGE;
+ }
+ default: {
+ throw new Error(`Unsupported vector extension: '${extension}'`);
+ }
+ }
}
@GenerateSql()
@@ -59,7 +115,14 @@ export class DatabaseRepository {
}
async createExtension(extension: DatabaseExtension): Promise {
- await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(extension)}`.execute(this.db);
+ await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(extension)} CASCADE`.execute(this.db);
+ if (extension === DatabaseExtension.VECTORCHORD) {
+ const dbName = sql.table(await this.getDatabaseName());
+ await sql`ALTER DATABASE ${dbName} SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536'`.execute(this.db);
+ await sql`SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536'`.execute(this.db);
+ await sql`ALTER DATABASE ${dbName} SET vchordrq.probes = 1`.execute(this.db);
+ await sql`SET vchordrq.probes = 1`.execute(this.db);
+ }
}
async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise {
@@ -78,120 +141,201 @@ export class DatabaseRepository {
await this.db.transaction().execute(async (tx) => {
await this.setSearchPath(tx);
- if (isVectors && installedVersion === '0.1.1') {
- await this.setExtVersion(tx, DatabaseExtension.VECTORS, '0.1.11');
- }
-
- const isSchemaUpgrade = semver.satisfies(installedVersion, '0.1.1 || 0.1.11');
- if (isSchemaUpgrade && isVectors) {
- await this.updateVectorsSchema(tx);
- }
-
await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(tx);
const diff = semver.diff(installedVersion, targetVersion);
- if (isVectors && diff && ['minor', 'major'].includes(diff)) {
+ if (isVectors && (diff === 'major' || diff === 'minor')) {
await sql`SELECT pgvectors_upgrade()`.execute(tx);
restartRequired = true;
- } else {
- await this.reindex(VectorIndex.CLIP);
- await this.reindex(VectorIndex.FACE);
+ } else if (diff) {
+ await Promise.all([this.reindexVectors(VectorIndex.CLIP), this.reindexVectors(VectorIndex.FACE)]);
}
});
return { restartRequired };
}
- async reindex(index: VectorIndex): Promise {
- try {
- await sql`REINDEX INDEX ${sql.raw(index)}`.execute(this.db);
- } catch (error) {
- if (this.vectorExtension !== DatabaseExtension.VECTORS) {
- throw error;
- }
- this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`);
+ async prewarm(index: VectorIndex): Promise {
+ const vectorExtension = await getVectorExtension(this.db);
+ if (vectorExtension !== DatabaseExtension.VECTORCHORD) {
+ return;
+ }
+ this.logger.debug(`Prewarming ${index}`);
+ await sql`SELECT vchordrq_prewarm(${index})`.execute(this.db);
+ }
- const table = await this.getIndexTable(index);
- const dimSize = await this.getDimSize(table);
- await this.db.transaction().execute(async (tx) => {
- await this.setSearchPath(tx);
- await sql`DROP INDEX IF EXISTS ${sql.raw(index)}`.execute(tx);
- await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx);
- await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE vector(${sql.raw(String(dimSize))})`.execute(
- tx,
- );
- await sql.raw(vectorIndexQuery({ vectorExtension: this.vectorExtension, table, indexName: index })).execute(tx);
- });
+ async reindexVectorsIfNeeded(names: VectorIndex[]): Promise {
+ const { rows } = await sql<{
+ indexdef: string;
+ indexname: string;
+ }>`SELECT indexdef, indexname FROM pg_indexes WHERE indexname = ANY(ARRAY[${sql.join(names)}])`.execute(this.db);
+
+ const vectorExtension = await getVectorExtension(this.db);
+
+ const promises = [];
+ for (const indexName of names) {
+ const row = rows.find((index) => index.indexname === indexName);
+ const table = VECTOR_INDEX_TABLES[indexName];
+ if (!row) {
+ promises.push(this.reindexVectors(indexName));
+ continue;
+ }
+
+ switch (vectorExtension) {
+ case DatabaseExtension.VECTOR: {
+ if (!row.indexdef.toLowerCase().includes('using hnsw')) {
+ promises.push(this.reindexVectors(indexName));
+ }
+ break;
+ }
+ case DatabaseExtension.VECTORS: {
+ if (!row.indexdef.toLowerCase().includes('using vectors')) {
+ promises.push(this.reindexVectors(indexName));
+ }
+ break;
+ }
+ case DatabaseExtension.VECTORCHORD: {
+ const matches = row.indexdef.match(/(?<=lists = \[)\d+/g);
+ const lists = matches && matches.length > 0 ? Number(matches[0]) : 1;
+ promises.push(
+ this.db
+ .selectFrom(this.db.dynamic.table(table).as('t'))
+ .select((eb) => eb.fn.countAll().as('count'))
+ .executeTakeFirstOrThrow()
+ .then(({ count }) => {
+ const targetLists = this.targetListCount(count);
+ this.logger.log(`targetLists=${targetLists}, current=${lists} for ${indexName} of ${count} rows`);
+ if (
+ !row.indexdef.toLowerCase().includes('using vchordrq') ||
+ // slack factor is to avoid frequent reindexing if the count is borderline
+ (lists !== targetLists && lists !== this.targetListCount(count * VECTORCHORD_LIST_SLACK_FACTOR))
+ ) {
+ probes[indexName] = this.targetProbeCount(targetLists);
+ return this.reindexVectors(indexName, { lists: targetLists });
+ } else {
+ probes[indexName] = this.targetProbeCount(lists);
+ }
+ }),
+ );
+ break;
+ }
+ }
+ }
+
+ if (promises.length > 0) {
+ await Promise.all(promises);
}
}
- @GenerateSql({ params: [VectorIndex.CLIP] })
- async shouldReindex(name: VectorIndex): Promise {
- if (this.vectorExtension !== DatabaseExtension.VECTORS) {
- return false;
+ private async reindexVectors(indexName: VectorIndex, { lists }: { lists?: number } = {}): Promise {
+ this.logger.log(`Reindexing ${indexName}`);
+ const table = VECTOR_INDEX_TABLES[indexName];
+ const vectorExtension = await getVectorExtension(this.db);
+ const { rows } = await sql<{
+ columnName: string;
+ }>`SELECT column_name as "columnName" FROM information_schema.columns WHERE table_name = ${table}`.execute(this.db);
+ if (rows.length === 0) {
+ this.logger.warn(
+ `Table ${table} does not exist, skipping reindexing. This is only normal if this is a new Immich instance.`,
+ );
+ return;
}
-
- try {
- const { rows } = await sql<{
- idx_status: string;
- }>`SELECT idx_status FROM pg_vector_index_stat WHERE indexname = ${name}`.execute(this.db);
- return rows[0]?.idx_status === 'UPGRADE';
- } catch (error) {
- const message: string = (error as any).message;
- if (message.includes('index is not existing')) {
- return true;
- } else if (message.includes('relation "pg_vector_index_stat" does not exist')) {
- return false;
+ const dimSize = await this.getDimensionSize(table);
+ await this.db.transaction().execute(async (tx) => {
+ await sql`DROP INDEX IF EXISTS ${sql.raw(indexName)}`.execute(tx);
+ if (!rows.some((row) => row.columnName === 'embedding')) {
+ this.logger.warn(`Column 'embedding' does not exist in table '${table}', truncating and adding column.`);
+ await sql`TRUNCATE TABLE ${sql.raw(table)}`.execute(tx);
+ await sql`ALTER TABLE ${sql.raw(table)} ADD COLUMN embedding real[] NOT NULL`.execute(tx);
}
- throw error;
- }
+ await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx);
+ const schema = vectorExtension === DatabaseExtension.VECTORS ? 'vectors.' : '';
+ await sql`
+ ALTER TABLE ${sql.raw(table)}
+ ALTER COLUMN embedding
+ SET DATA TYPE ${sql.raw(schema)}vector(${sql.raw(String(dimSize))})`.execute(tx);
+ await sql.raw(vectorIndexQuery({ vectorExtension, table, indexName, lists })).execute(tx);
+ });
+ this.logger.log(`Reindexed ${indexName}`);
}
private async setSearchPath(tx: Transaction): Promise {
await sql`SET search_path TO "$user", public, vectors`.execute(tx);
}
- private async setExtVersion(tx: Transaction, extName: DatabaseExtension, version: string): Promise {
- await sql`UPDATE pg_catalog.pg_extension SET extversion = ${version} WHERE extname = ${extName}`.execute(tx);
+ private async getDatabaseName(): Promise {
+ const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(this.db);
+ return rows[0].db;
}
- private async getIndexTable(index: VectorIndex): Promise {
- const { rows } = await sql<{
- relname: string | null;
- }>`SELECT relname FROM pg_stat_all_indexes WHERE indexrelname = ${index}`.execute(this.db);
- const table = rows[0]?.relname;
- if (!table) {
- throw new Error(`Could not find table for index ${index}`);
- }
- return table;
- }
-
- private async updateVectorsSchema(tx: Transaction): Promise {
- const extension = DatabaseExtension.VECTORS;
- await sql`CREATE SCHEMA IF NOT EXISTS ${extension}`.execute(tx);
- await sql`UPDATE pg_catalog.pg_extension SET extrelocatable = true WHERE extname = ${extension}`.execute(tx);
- await sql`ALTER EXTENSION vectors SET SCHEMA vectors`.execute(tx);
- await sql`UPDATE pg_catalog.pg_extension SET extrelocatable = false WHERE extname = ${extension}`.execute(tx);
- }
-
- private async getDimSize(table: string, column = 'embedding'): Promise {
+ async getDimensionSize(table: string, column = 'embedding'): Promise {
const { rows } = await sql<{ dimsize: number }>`
SELECT atttypmod as dimsize
FROM pg_attribute f
JOIN pg_class c ON c.oid = f.attrelid
WHERE c.relkind = 'r'::char
AND f.attnum > 0
- AND c.relname = ${table}
- AND f.attname = '${column}'
+ AND c.relname = ${table}::text
+ AND f.attname = ${column}::text
`.execute(this.db);
const dimSize = rows[0]?.dimsize;
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
- throw new Error(`Could not retrieve dimension size`);
+ this.logger.warn(`Could not retrieve dimension size of column '${column}' in table '${table}', assuming 512`);
+ return 512;
}
return dimSize;
}
+ async setDimensionSize(dimSize: number): Promise {
+ if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
+ throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
+ }
+
+ // this is done in two transactions to handle concurrent writes
+ await this.db.transaction().execute(async (trx) => {
+ await sql`delete from ${sql.table('smart_search')}`.execute(trx);
+ await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
+ await sql`alter table ${sql.table('smart_search')} add constraint dim_size_constraint check (array_length(embedding::real[], 1) = ${sql.lit(dimSize)})`.execute(
+ trx,
+ );
+ });
+
+ const vectorExtension = await this.getVectorExtension();
+ await this.db.transaction().execute(async (trx) => {
+ await sql`drop index if exists clip_index`.execute(trx);
+ await trx.schema
+ .alterTable('smart_search')
+ .alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`)))
+ .execute();
+ await sql
+ .raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: VectorIndex.CLIP }))
+ .execute(trx);
+ await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
+ });
+ probes[VectorIndex.CLIP] = 1;
+
+ await sql`vacuum analyze ${sql.table('smart_search')}`.execute(this.db);
+ }
+
+ async deleteAllSearchEmbeddings(): Promise {
+ await sql`truncate ${sql.table('smart_search')}`.execute(this.db);
+ }
+
+ private targetListCount(count: number) {
+ if (count < 128_000) {
+ return 1;
+ } else if (count < 2_048_000) {
+ return 1 << (32 - Math.clz32(count / 1000));
+ } else {
+ return 1 << (33 - Math.clz32(Math.sqrt(count)));
+ }
+ }
+
+ private targetProbeCount(lists: number) {
+ return Math.ceil(lists / 8);
+ }
+
async runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise {
const { database } = this.configRepository.getEnv();
diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts
index ad18d7ed67..70a9980201 100644
--- a/server/src/repositories/person.repository.ts
+++ b/server/src/repositories/person.repository.ts
@@ -398,6 +398,7 @@ export class PersonRepository {
return results.map(({ id }) => id);
}
+ @GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
async refreshFaces(
facesToAdd: (Insertable & { assetId: string })[],
faceIdsToRemove: string[],
diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts
index 4e6b6e0fcf..a7b7027b7b 100644
--- a/server/src/repositories/search.repository.ts
+++ b/server/src/repositories/search.repository.ts
@@ -5,9 +5,9 @@ import { randomUUID } from 'node:crypto';
import { DB, Exif } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto';
-import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
-import { ConfigRepository } from 'src/repositories/config.repository';
-import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database';
+import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum';
+import { probes } from 'src/repositories/database.repository';
+import { anyUuid, asUuid, searchAssetBuilder } from 'src/utils/database';
import { paginationHelper } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation';
@@ -168,10 +168,7 @@ export interface GetCameraMakesOptions {
@Injectable()
export class SearchRepository {
- constructor(
- @InjectKysely() private db: Kysely,
- private configRepository: ConfigRepository,
- ) {}
+ constructor(@InjectKysely() private db: Kysely) {}
@GenerateSql({
params: [
@@ -236,19 +233,21 @@ export class SearchRepository {
},
],
})
- async searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions) {
+ searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions) {
if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) {
throw new Error(`Invalid value for 'size': ${pagination.size}`);
}
- const items = await searchAssetBuilder(this.db, options)
- .innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
- .orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
- .limit(pagination.size + 1)
- .offset((pagination.page - 1) * pagination.size)
- .execute();
-
- return paginationHelper(items, pagination.size);
+ return this.db.transaction().execute(async (trx) => {
+ await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.CLIP])}`.execute(trx);
+ const items = await searchAssetBuilder(trx, options)
+ .innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
+ .orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
+ .limit(pagination.size + 1)
+ .offset((pagination.page - 1) * pagination.size)
+ .execute();
+ return paginationHelper(items, pagination.size);
+ });
}
@GenerateSql({
@@ -263,29 +262,32 @@ export class SearchRepository {
],
})
searchDuplicates({ assetId, embedding, maxDistance, type, userIds }: AssetDuplicateSearch) {
- return this.db
- .with('cte', (qb) =>
- qb
- .selectFrom('assets')
- .select([
- 'assets.id as assetId',
- 'assets.duplicateId',
- sql`smart_search.embedding <=> ${embedding}`.as('distance'),
- ])
- .innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
- .where('assets.ownerId', '=', anyUuid(userIds))
- .where('assets.deletedAt', 'is', null)
- .where('assets.visibility', '!=', AssetVisibility.HIDDEN)
- .where('assets.type', '=', type)
- .where('assets.id', '!=', asUuid(assetId))
- .where('assets.stackId', 'is', null)
- .orderBy(sql`smart_search.embedding <=> ${embedding}`)
- .limit(64),
- )
- .selectFrom('cte')
- .selectAll()
- .where('cte.distance', '<=', maxDistance as number)
- .execute();
+ return this.db.transaction().execute(async (trx) => {
+ await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.CLIP])}`.execute(trx);
+ return await trx
+ .with('cte', (qb) =>
+ qb
+ .selectFrom('assets')
+ .select([
+ 'assets.id as assetId',
+ 'assets.duplicateId',
+ sql`smart_search.embedding <=> ${embedding}`.as('distance'),
+ ])
+ .innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
+ .where('assets.ownerId', '=', anyUuid(userIds))
+ .where('assets.deletedAt', 'is', null)
+ .where('assets.visibility', '!=', AssetVisibility.HIDDEN)
+ .where('assets.type', '=', type)
+ .where('assets.id', '!=', asUuid(assetId))
+ .where('assets.stackId', 'is', null)
+ .orderBy('distance')
+ .limit(64),
+ )
+ .selectFrom('cte')
+ .selectAll()
+ .where('cte.distance', '<=', maxDistance as number)
+ .execute();
+ });
}
@GenerateSql({
@@ -303,31 +305,36 @@ export class SearchRepository {
throw new Error(`Invalid value for 'numResults': ${numResults}`);
}
- return this.db
- .with('cte', (qb) =>
- qb
- .selectFrom('asset_faces')
- .select([
- 'asset_faces.id',
- 'asset_faces.personId',
- sql`face_search.embedding <=> ${embedding}`.as('distance'),
- ])
- .innerJoin('assets', 'assets.id', 'asset_faces.assetId')
- .innerJoin('face_search', 'face_search.faceId', 'asset_faces.id')
- .leftJoin('person', 'person.id', 'asset_faces.personId')
- .where('assets.ownerId', '=', anyUuid(userIds))
- .where('assets.deletedAt', 'is', null)
- .$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null))
- .$if(!!minBirthDate, (qb) =>
- qb.where((eb) => eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)])),
- )
- .orderBy(sql`face_search.embedding <=> ${embedding}`)
- .limit(numResults),
- )
- .selectFrom('cte')
- .selectAll()
- .where('cte.distance', '<=', maxDistance)
- .execute();
+ return this.db.transaction().execute(async (trx) => {
+ await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.FACE])}`.execute(trx);
+ return await trx
+ .with('cte', (qb) =>
+ qb
+ .selectFrom('asset_faces')
+ .select([
+ 'asset_faces.id',
+ 'asset_faces.personId',
+ sql`face_search.embedding <=> ${embedding}`.as('distance'),
+ ])
+ .innerJoin('assets', 'assets.id', 'asset_faces.assetId')
+ .innerJoin('face_search', 'face_search.faceId', 'asset_faces.id')
+ .leftJoin('person', 'person.id', 'asset_faces.personId')
+ .where('assets.ownerId', '=', anyUuid(userIds))
+ .where('assets.deletedAt', 'is', null)
+ .$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null))
+ .$if(!!minBirthDate, (qb) =>
+ qb.where((eb) =>
+ eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
+ ),
+ )
+ .orderBy('distance')
+ .limit(numResults),
+ )
+ .selectFrom('cte')
+ .selectAll()
+ .where('cte.distance', '<=', maxDistance)
+ .execute();
+ });
}
@GenerateSql({ params: [DummyValue.STRING] })
@@ -416,56 +423,6 @@ export class SearchRepository {
.execute();
}
- async getDimensionSize(): Promise {
- const { rows } = await sql<{ dimsize: number }>`
- select atttypmod as dimsize
- from pg_attribute f
- join pg_class c ON c.oid = f.attrelid
- where c.relkind = 'r'::char
- and f.attnum > 0
- and c.relname = 'smart_search'
- and f.attname = 'embedding'
- `.execute(this.db);
-
- const dimSize = rows[0]['dimsize'];
- if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
- throw new Error(`Could not retrieve CLIP dimension size`);
- }
- return dimSize;
- }
-
- async setDimensionSize(dimSize: number): Promise {
- if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
- throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
- }
-
- // this is done in two transactions to handle concurrent writes
- await this.db.transaction().execute(async (trx) => {
- await sql`delete from ${sql.table('smart_search')}`.execute(trx);
- await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
- await sql`alter table ${sql.table('smart_search')} add constraint dim_size_constraint check (array_length(embedding::real[], 1) = ${sql.lit(dimSize)})`.execute(
- trx,
- );
- });
-
- const vectorExtension = this.configRepository.getEnv().database.vectorExtension;
- await this.db.transaction().execute(async (trx) => {
- await sql`drop index if exists clip_index`.execute(trx);
- await trx.schema
- .alterTable('smart_search')
- .alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`)))
- .execute();
- await sql.raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })).execute(trx);
- await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
- });
-
- await sql`vacuum analyze ${sql.table('smart_search')}`.execute(this.db);
- }
-
- async deleteAllSearchEmbeddings(): Promise {
- await sql`truncate ${sql.table('smart_search')}`.execute(this.db);
- }
-
async getCountries(userIds: string[]): Promise {
const res = await this.getExifField('country', userIds).execute();
return res.map((row) => row.country!);
diff --git a/server/src/schema/migrations/1744910873969-InitialMigration.ts b/server/src/schema/migrations/1744910873969-InitialMigration.ts
index ce4a37ae3b..63625a69ad 100644
--- a/server/src/schema/migrations/1744910873969-InitialMigration.ts
+++ b/server/src/schema/migrations/1744910873969-InitialMigration.ts
@@ -1,10 +1,9 @@
import { Kysely, sql } from 'kysely';
import { DatabaseExtension } from 'src/enum';
-import { ConfigRepository } from 'src/repositories/config.repository';
+import { getVectorExtension } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { vectorIndexQuery } from 'src/utils/database';
-const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
const lastMigrationSql = sql<{ name: string }>`SELECT "name" FROM "migrations" ORDER BY "timestamp" DESC LIMIT 1;`;
const tableExists = sql<{ result: string | null }>`select to_regclass('migrations') as "result"`;
const logger = LoggingRepository.create();
@@ -25,12 +24,14 @@ export async function up(db: Kysely): Promise {
return;
}
+ const vectorExtension = await getVectorExtension(db);
+
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`.execute(db);
await sql`CREATE EXTENSION IF NOT EXISTS "unaccent";`.execute(db);
await sql`CREATE EXTENSION IF NOT EXISTS "cube";`.execute(db);
await sql`CREATE EXTENSION IF NOT EXISTS "earthdistance";`.execute(db);
await sql`CREATE EXTENSION IF NOT EXISTS "pg_trgm";`.execute(db);
- await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(vectorExtension)}`.execute(db);
+ await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(vectorExtension)} CASCADE`.execute(db);
await sql`CREATE OR REPLACE FUNCTION immich_uuid_v7(p_timestamp timestamp with time zone default clock_timestamp())
RETURNS uuid
VOLATILE LANGUAGE SQL
diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts
index e0ab4a624d..1c89fa313c 100644
--- a/server/src/services/database.service.spec.ts
+++ b/server/src/services/database.service.spec.ts
@@ -1,5 +1,5 @@
import { EXTENSION_NAMES } from 'src/constants';
-import { DatabaseExtension } from 'src/enum';
+import { DatabaseExtension, VectorIndex } from 'src/enum';
import { DatabaseService } from 'src/services/database.service';
import { VectorExtension } from 'src/types';
import { mockEnvData } from 'test/repositories/config.repository.mock';
@@ -47,8 +47,10 @@ describe(DatabaseService.name, () => {
describe.each(>[
{ extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] },
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
+ { extension: DatabaseExtension.VECTORCHORD, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORCHORD] },
])('should work with $extensionName', ({ extension, extensionName }) => {
beforeEach(() => {
+ mocks.database.getVectorExtension.mockResolvedValue(extension);
mocks.config.getEnv.mockReturnValue(
mockEnvData({
database: {
@@ -240,41 +242,32 @@ describe(DatabaseService.name, () => {
});
it(`should reindex ${extension} indices if needed`, async () => {
- mocks.database.shouldReindex.mockResolvedValue(true);
-
await expect(sut.onBootstrap()).resolves.toBeUndefined();
- expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2);
- expect(mocks.database.reindex).toHaveBeenCalledTimes(2);
+ expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledExactlyOnceWith([
+ VectorIndex.CLIP,
+ VectorIndex.FACE,
+ ]);
+ expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledTimes(1);
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal).not.toHaveBeenCalled();
});
it(`should throw an error if reindexing fails`, async () => {
- mocks.database.shouldReindex.mockResolvedValue(true);
- mocks.database.reindex.mockRejectedValue(new Error('Error reindexing'));
+ mocks.database.reindexVectorsIfNeeded.mockRejectedValue(new Error('Error reindexing'));
await expect(sut.onBootstrap()).rejects.toBeDefined();
- expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(1);
- expect(mocks.database.reindex).toHaveBeenCalledTimes(1);
+ expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledExactlyOnceWith([
+ VectorIndex.CLIP,
+ VectorIndex.FACE,
+ ]);
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
expect(mocks.logger.fatal).not.toHaveBeenCalled();
expect(mocks.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not run vector reindexing checks.'),
);
});
-
- it(`should not reindex ${extension} indices if not needed`, async () => {
- mocks.database.shouldReindex.mockResolvedValue(false);
-
- await expect(sut.onBootstrap()).resolves.toBeUndefined();
-
- expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2);
- expect(mocks.database.reindex).toHaveBeenCalledTimes(0);
- expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
- expect(mocks.logger.fatal).not.toHaveBeenCalled();
- });
});
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
@@ -300,23 +293,7 @@ describe(DatabaseService.name, () => {
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
});
- it(`should throw error if pgvector extension could not be created`, async () => {
- mocks.config.getEnv.mockReturnValue(
- mockEnvData({
- database: {
- config: {
- connectionType: 'parts',
- host: 'database',
- port: 5432,
- username: 'postgres',
- password: 'postgres',
- database: 'immich',
- },
- skipMigrations: true,
- vectorExtension: DatabaseExtension.VECTOR,
- },
- }),
- );
+ it(`should throw error if extension could not be created`, async () => {
mocks.database.getExtensionVersion.mockResolvedValue({
installedVersion: null,
availableVersion: minVersionInRange,
@@ -328,26 +305,7 @@ describe(DatabaseService.name, () => {
expect(mocks.logger.fatal).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal.mock.calls[0][0]).toContain(
- `Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`,
- );
- expect(mocks.database.createExtension).toHaveBeenCalledTimes(1);
- expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
- expect(mocks.database.runMigrations).not.toHaveBeenCalled();
- });
-
- it(`should throw error if pgvecto.rs extension could not be created`, async () => {
- mocks.database.getExtensionVersion.mockResolvedValue({
- installedVersion: null,
- availableVersion: minVersionInRange,
- });
- mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false });
- mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension'));
-
- await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
-
- expect(mocks.logger.fatal).toHaveBeenCalledTimes(1);
- expect(mocks.logger.fatal.mock.calls[0][0]).toContain(
- `Alternatively, if your Postgres instance has pgvector, you may use this instead`,
+ `Alternatively, if your Postgres instance has any of vector, vectors, vchord, you may use one of them instead by setting the environment variable 'DB_VECTOR_EXTENSION='`,
);
expect(mocks.database.createExtension).toHaveBeenCalledTimes(1);
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts
index d71dc25104..ec7be195ba 100644
--- a/server/src/services/database.service.ts
+++ b/server/src/services/database.service.ts
@@ -6,7 +6,7 @@ import { BootstrapEventPriority, DatabaseExtension, DatabaseLock, VectorIndex }
import { BaseService } from 'src/services/base.service';
import { VectorExtension } from 'src/types';
-type CreateFailedArgs = { name: string; extension: string; otherName: string };
+type CreateFailedArgs = { name: string; extension: string; otherExtensions: string[] };
type UpdateFailedArgs = { name: string; extension: string; availableVersion: string };
type RestartRequiredArgs = { name: string; availableVersion: string };
type NightlyVersionArgs = { name: string; extension: string; version: string };
@@ -25,18 +25,15 @@ const messages = {
outOfRange: ({ name, version, range }: OutOfRangeArgs) =>
`The ${name} extension version is ${version}, but Immich only supports ${range}.
Please change ${name} to a compatible version in the Postgres instance.`,
- createFailed: ({ name, extension, otherName }: CreateFailedArgs) =>
+ createFailed: ({ name, extension, otherExtensions }: CreateFailedArgs) =>
`Failed to activate ${name} extension.
Please ensure the Postgres instance has ${name} installed.
If the Postgres instance already has ${name} installed, Immich may not have the necessary permissions to activate it.
- In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension}' manually as a superuser.
+ In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension} CASCADE' manually as a superuser.
See https://immich.app/docs/guides/database-queries for how to query the database.
- Alternatively, if your Postgres instance has ${otherName}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherName}'.
- Note that switching between the two extensions after a successful startup is not supported.
- The exception is if your version of Immich prior to upgrading was 1.90.2 or earlier.
- In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup.`,
+ Alternatively, if your Postgres instance has any of ${otherExtensions.join(', ')}, you may use one of them instead by setting the environment variable 'DB_VECTOR_EXTENSION='.`,
updateFailed: ({ name, extension, availableVersion }: UpdateFailedArgs) =>
`The ${name} extension can be updated to ${availableVersion}.
Immich attempted to update the extension, but failed to do so.
@@ -67,8 +64,7 @@ export class DatabaseService extends BaseService {
}
await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => {
- const envData = this.configRepository.getEnv();
- const extension = envData.database.vectorExtension;
+ const extension = await this.databaseRepository.getVectorExtension();
const name = EXTENSION_NAMES[extension];
const extensionRange = this.databaseRepository.getExtensionVersionRange(extension);
@@ -97,12 +93,23 @@ export class DatabaseService extends BaseService {
throw new Error(messages.invalidDowngrade({ name, extension, availableVersion, installedVersion }));
}
- await this.checkReindexing();
+ try {
+ await this.databaseRepository.reindexVectorsIfNeeded([VectorIndex.CLIP, VectorIndex.FACE]);
+ } catch (error) {
+ this.logger.warn(
+ 'Could not run vector reindexing checks. If the extension was updated, please restart the Postgres instance. If you are upgrading directly from a version below 1.107.2, please upgrade to 1.107.2 first.',
+ );
+ throw error;
+ }
const { database } = this.configRepository.getEnv();
if (!database.skipMigrations) {
await this.databaseRepository.runMigrations();
}
+ await Promise.all([
+ this.databaseRepository.prewarm(VectorIndex.CLIP),
+ this.databaseRepository.prewarm(VectorIndex.FACE),
+ ]);
});
}
@@ -110,10 +117,13 @@ export class DatabaseService extends BaseService {
try {
await this.databaseRepository.createExtension(extension);
} catch (error) {
- const otherExtension =
- extension === DatabaseExtension.VECTORS ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS;
+ const otherExtensions = [
+ DatabaseExtension.VECTOR,
+ DatabaseExtension.VECTORS,
+ DatabaseExtension.VECTORCHORD,
+ ].filter((ext) => ext !== extension);
const name = EXTENSION_NAMES[extension];
- this.logger.fatal(messages.createFailed({ name, extension, otherName: EXTENSION_NAMES[otherExtension] }));
+ this.logger.fatal(messages.createFailed({ name, extension, otherExtensions }));
throw error;
}
}
@@ -130,21 +140,4 @@ export class DatabaseService extends BaseService {
throw error;
}
}
-
- private async checkReindexing() {
- try {
- if (await this.databaseRepository.shouldReindex(VectorIndex.CLIP)) {
- await this.databaseRepository.reindex(VectorIndex.CLIP);
- }
-
- if (await this.databaseRepository.shouldReindex(VectorIndex.FACE)) {
- await this.databaseRepository.reindex(VectorIndex.FACE);
- }
- } catch (error) {
- this.logger.warn(
- 'Could not run vector reindexing checks. If the extension was updated, please restart the Postgres instance.',
- );
- throw error;
- }
- }
}
diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts
index 23ba562ba6..cd484c230b 100644
--- a/server/src/services/person.service.ts
+++ b/server/src/services/person.service.ts
@@ -33,6 +33,7 @@ import {
QueueName,
SourceType,
SystemMetadataKey,
+ VectorIndex,
} from 'src/enum';
import { BoundingBox } from 'src/repositories/machine-learning.repository';
import { UpdateFacesData } from 'src/repositories/person.repository';
@@ -418,6 +419,8 @@ export class PersonService extends BaseService {
return JobStatus.SKIPPED;
}
+ await this.databaseRepository.prewarm(VectorIndex.FACE);
+
const lastRun = new Date().toISOString();
const facePagination = this.personRepository.getAllFaces(
force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING },
diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts
index 9cc97a8f0d..a6529fa623 100644
--- a/server/src/services/smart-info.service.spec.ts
+++ b/server/src/services/smart-info.service.spec.ts
@@ -54,28 +54,28 @@ describe(SmartInfoService.name, () => {
it('should return if machine learning is disabled', async () => {
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig });
- expect(mocks.search.getDimensionSize).not.toHaveBeenCalled();
- expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
- expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
+ expect(mocks.database.getDimensionSize).not.toHaveBeenCalled();
+ expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
+ expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
});
it('should return if model and DB dimension size are equal', async () => {
- mocks.search.getDimensionSize.mockResolvedValue(512);
+ mocks.database.getDimensionSize.mockResolvedValue(512);
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig });
- expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
- expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
- expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
+ expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
+ expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
+ expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
});
it('should update DB dimension size if model and DB have different values', async () => {
- mocks.search.getDimensionSize.mockResolvedValue(768);
+ mocks.database.getDimensionSize.mockResolvedValue(768);
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig });
- expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
- expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512);
+ expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
+ expect(mocks.database.setDimensionSize).toHaveBeenCalledWith(512);
});
});
@@ -89,13 +89,13 @@ describe(SmartInfoService.name, () => {
});
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
- expect(mocks.search.getDimensionSize).not.toHaveBeenCalled();
- expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
- expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
+ expect(mocks.database.getDimensionSize).not.toHaveBeenCalled();
+ expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
+ expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
});
it('should return if model and DB dimension size are equal', async () => {
- mocks.search.getDimensionSize.mockResolvedValue(512);
+ mocks.database.getDimensionSize.mockResolvedValue(512);
await sut.onConfigUpdate({
newConfig: {
@@ -106,13 +106,13 @@ describe(SmartInfoService.name, () => {
} as SystemConfig,
});
- expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
- expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
- expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
+ expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
+ expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
+ expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
});
it('should update DB dimension size if model and DB have different values', async () => {
- mocks.search.getDimensionSize.mockResolvedValue(512);
+ mocks.database.getDimensionSize.mockResolvedValue(512);
await sut.onConfigUpdate({
newConfig: {
@@ -123,12 +123,12 @@ describe(SmartInfoService.name, () => {
} as SystemConfig,
});
- expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
- expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(768);
+ expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
+ expect(mocks.database.setDimensionSize).toHaveBeenCalledWith(768);
});
it('should clear embeddings if old and new models are different', async () => {
- mocks.search.getDimensionSize.mockResolvedValue(512);
+ mocks.database.getDimensionSize.mockResolvedValue(512);
await sut.onConfigUpdate({
newConfig: {
@@ -139,9 +139,9 @@ describe(SmartInfoService.name, () => {
} as SystemConfig,
});
- expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled();
- expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
- expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
+ expect(mocks.database.deleteAllSearchEmbeddings).toHaveBeenCalled();
+ expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
+ expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
});
});
@@ -151,7 +151,7 @@ describe(SmartInfoService.name, () => {
await sut.handleQueueEncodeClip({});
- expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
+ expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
});
it('should queue the assets without clip embeddings', async () => {
@@ -163,7 +163,7 @@ describe(SmartInfoService.name, () => {
{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } },
]);
expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(false);
- expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
+ expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
});
it('should queue all the assets', async () => {
@@ -175,7 +175,7 @@ describe(SmartInfoService.name, () => {
{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } },
]);
expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(true);
- expect(mocks.search.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512);
+ expect(mocks.database.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512);
});
});
diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts
index f3702c2010..705e8ed2e5 100644
--- a/server/src/services/smart-info.service.ts
+++ b/server/src/services/smart-info.service.ts
@@ -38,7 +38,7 @@ export class SmartInfoService extends BaseService {
await this.databaseRepository.withLock(DatabaseLock.CLIPDimSize, async () => {
const { dimSize } = getCLIPModelInfo(newConfig.machineLearning.clip.modelName);
- const dbDimSize = await this.searchRepository.getDimensionSize();
+ const dbDimSize = await this.databaseRepository.getDimensionSize('smart_search');
this.logger.verbose(`Current database CLIP dimension size is ${dbDimSize}`);
const modelChange =
@@ -53,10 +53,10 @@ export class SmartInfoService extends BaseService {
`Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`,
);
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
- await this.searchRepository.setDimensionSize(dimSize);
+ await this.databaseRepository.setDimensionSize(dimSize);
this.logger.log(`Successfully updated database CLIP dimension size from ${dbDimSize} to ${dimSize}.`);
} else {
- await this.searchRepository.deleteAllSearchEmbeddings();
+ await this.databaseRepository.deleteAllSearchEmbeddings();
}
// TODO: A job to reindex all assets should be scheduled, though user
@@ -74,7 +74,7 @@ export class SmartInfoService extends BaseService {
if (force) {
const { dimSize } = getCLIPModelInfo(machineLearning.clip.modelName);
// in addition to deleting embeddings, update the dimension size in case it failed earlier
- await this.searchRepository.setDimensionSize(dimSize);
+ await this.databaseRepository.setDimensionSize(dimSize);
}
let queue: JobItem[] = [];
diff --git a/server/src/types.ts b/server/src/types.ts
index 52a5266e42..9479a39eea 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -1,7 +1,7 @@
import { SystemConfig } from 'src/config';
+import { VECTOR_EXTENSIONS } from 'src/constants';
import {
AssetType,
- DatabaseExtension,
DatabaseSslMode,
ExifOrientation,
ImageFormat,
@@ -363,7 +363,7 @@ export type JobItem =
// Version check
| { name: JobName.VERSION_CHECK; data: IBaseJob };
-export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS;
+export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];
export type DatabaseConnectionURL = {
connectionType: 'url';
diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts
index e0e7af49a4..40bf7503db 100644
--- a/server/src/utils/database.ts
+++ b/server/src/utils/database.ts
@@ -384,14 +384,28 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild
.$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null));
}
-type VectorIndexOptions = { vectorExtension: VectorExtension; table: string; indexName: string };
+export type ReindexVectorIndexOptions = { indexName: string; lists?: number };
-export function vectorIndexQuery({ vectorExtension, table, indexName }: VectorIndexOptions): string {
+type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions;
+
+export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: VectorIndexQueryOptions): string {
switch (vectorExtension) {
+ case DatabaseExtension.VECTORCHORD: {
+ return `
+ CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING vchordrq (embedding vector_cosine_ops) WITH (options = $$
+ residual_quantization = false
+ [build.internal]
+ lists = [${lists ?? 1}]
+ spherical_centroids = true
+ build_threads = 4
+ sampling_factor = 1024
+ $$)`;
+ }
case DatabaseExtension.VECTORS: {
return `
CREATE INDEX IF NOT EXISTS ${indexName} ON ${table}
USING vectors (embedding vector_cos_ops) WITH (options = $$
+ optimizing.optimizing_threads = 4
[indexing.hnsw]
m = 16
ef_construction = 300
diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts
index 6f4f46c075..8b730c2b41 100644
--- a/server/test/medium.factory.ts
+++ b/server/test/medium.factory.ts
@@ -170,7 +170,7 @@ export const getRepository = (key: K, db: Kys
}
case 'search': {
- return new SearchRepository(db, new ConfigRepository());
+ return new SearchRepository(db);
}
case 'session': {
diff --git a/server/test/medium/globalSetup.ts b/server/test/medium/globalSetup.ts
index 4398da5c0a..323d2c4a53 100644
--- a/server/test/medium/globalSetup.ts
+++ b/server/test/medium/globalSetup.ts
@@ -7,7 +7,7 @@ import { getKyselyConfig } from 'src/utils/database';
import { GenericContainer, Wait } from 'testcontainers';
const globalSetup = async () => {
- const postgresContainer = await new GenericContainer('tensorchord/pgvecto-rs:pg14-v0.2.0')
+ const postgresContainer = await new GenericContainer('ghcr.io/immich-app/postgres:14')
.withExposedPorts(5432)
.withEnvironment({
POSTGRES_PASSWORD: 'postgres',
@@ -17,9 +17,7 @@ const globalSetup = async () => {
.withCommand([
'postgres',
'-c',
- 'shared_preload_libraries=vectors.so',
- '-c',
- 'search_path="$$user", public, vectors',
+ 'shared_preload_libraries=vchord.so',
'-c',
'max_wal_size=2GB',
'-c',
@@ -30,6 +28,8 @@ const globalSetup = async () => {
'full_page_writes=off',
'-c',
'synchronous_commit=off',
+ '-c',
+ 'config_file=/var/lib/postgresql/data/postgresql.conf',
])
.withWaitStrategy(Wait.forAll([Wait.forLogMessage('database system is ready to accept connections', 2)]))
.start();
diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts
index eeedf682de..171e04fe33 100644
--- a/server/test/repositories/database.repository.mock.ts
+++ b/server/test/repositories/database.repository.mock.ts
@@ -6,13 +6,17 @@ export const newDatabaseRepositoryMock = (): Mocked=14.0.0'),
createExtension: vitest.fn().mockResolvedValue(void 0),
updateVectorExtension: vitest.fn(),
- reindex: vitest.fn(),
- shouldReindex: vitest.fn(),
+ reindexVectorsIfNeeded: vitest.fn(),
+ getDimensionSize: vitest.fn(),
+ setDimensionSize: vitest.fn(),
+ deleteAllSearchEmbeddings: vitest.fn(),
+ prewarm: vitest.fn(),
runMigrations: vitest.fn(),
withLock: vitest.fn().mockImplementation((_, function_: () => Promise) => function_()),
tryLock: vitest.fn(),
From a6a4dfcfd3875fd28e852e05e2ee9941607e2f3d Mon Sep 17 00:00:00 2001
From: Mert <101130780+mertalev@users.noreply.github.com>
Date: Tue, 20 May 2025 09:44:39 -0400
Subject: [PATCH 014/170] fix(server): queueing for duplicate detection
(#18380)
* fix queueing
* update tests
---
server/src/queries/asset.job.repository.sql | 31 +++----------------
.../src/repositories/asset-job.repository.ts | 24 +++++++-------
server/src/services/duplicate.service.spec.ts | 22 +------------
server/src/services/duplicate.service.ts | 11 ++-----
4 files changed, 18 insertions(+), 70 deletions(-)
diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql
index 577635a912..2301408ffe 100644
--- a/server/src/queries/asset.job.repository.sql
+++ b/server/src/queries/asset.job.repository.sql
@@ -8,30 +8,14 @@ select
"duplicateId",
"stackId",
"visibility",
- "smart_search"."embedding",
- (
- select
- coalesce(json_agg(agg), '[]')
- from
- (
- select
- "asset_files"."id",
- "asset_files"."path",
- "asset_files"."type"
- from
- "asset_files"
- where
- "asset_files"."assetId" = "assets"."id"
- and "asset_files"."type" = $1
- ) as agg
- ) as "files"
+ "smart_search"."embedding"
from
"assets"
left join "smart_search" on "assets"."id" = "smart_search"."assetId"
where
- "assets"."id" = $2::uuid
+ "assets"."id" = $1::uuid
limit
- $3
+ $2
-- AssetJobRepository.getForSidecarWriteJob
select
@@ -199,18 +183,11 @@ select
"assets"."id"
from
"assets"
+ inner join "smart_search" on "assets"."id" = "smart_search"."assetId"
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
where
"assets"."visibility" != $1
and "assets"."deletedAt" is null
- and "job_status"."previewAt" is not null
- and not exists (
- select
- from
- "smart_search"
- where
- "assetId" = "assets"."id"
- )
and "job_status"."duplicatesDetectedAt" is null
-- AssetJobRepository.streamForEncodeClip
diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts
index 132bef6988..b9ce52962c 100644
--- a/server/src/repositories/asset-job.repository.ts
+++ b/server/src/repositories/asset-job.repository.ts
@@ -28,16 +28,7 @@ export class AssetJobRepository {
.selectFrom('assets')
.where('assets.id', '=', asUuid(id))
.leftJoin('smart_search', 'assets.id', 'smart_search.assetId')
- .select((eb) => [
- 'id',
- 'type',
- 'ownerId',
- 'duplicateId',
- 'stackId',
- 'visibility',
- 'smart_search.embedding',
- withFiles(eb, AssetFileType.PREVIEW),
- ])
+ .select(['id', 'type', 'ownerId', 'duplicateId', 'stackId', 'visibility', 'smart_search.embedding'])
.limit(1)
.executeTakeFirst();
}
@@ -146,10 +137,17 @@ export class AssetJobRepository {
@GenerateSql({ params: [], stream: true })
streamForSearchDuplicates(force?: boolean) {
- return this.assetsWithPreviews()
- .where((eb) => eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))))
- .$if(!force, (qb) => qb.where('job_status.duplicatesDetectedAt', 'is', null))
+ return this.db
+ .selectFrom('assets')
.select(['assets.id'])
+ .where('assets.visibility', '!=', AssetVisibility.HIDDEN)
+ .where('assets.deletedAt', 'is', null)
+ .innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
+ .$if(!force, (qb) =>
+ qb
+ .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
+ .where('job_status.duplicatesDetectedAt', 'is', null),
+ )
.stream();
}
diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts
index 3f08e36a21..d23144babe 100644
--- a/server/src/services/duplicate.service.spec.ts
+++ b/server/src/services/duplicate.service.spec.ts
@@ -1,4 +1,4 @@
-import { AssetFileType, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
+import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -11,17 +11,6 @@ vitest.useFakeTimers();
const hasEmbedding = {
id: 'asset-1',
ownerId: 'user-id',
- files: [
- {
- assetId: 'asset-1',
- createdAt: new Date(),
- id: 'file-1',
- path: 'preview.jpg',
- type: AssetFileType.PREVIEW,
- updatedAt: new Date(),
- updateId: 'update-1',
- },
- ],
stackId: null,
type: AssetType.IMAGE,
duplicateId: null,
@@ -218,15 +207,6 @@ describe(SearchService.name, () => {
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`);
});
- it('should fail if asset is missing preview image', async () => {
- mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, files: [] });
-
- const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id });
-
- expect(result).toBe(JobStatus.FAILED);
- expect(mocks.logger.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`);
- });
-
it('should fail if asset is missing embedding', async () => {
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, embedding: null });
diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts
index b5e4f573f2..617f5c5d0d 100644
--- a/server/src/services/duplicate.service.ts
+++ b/server/src/services/duplicate.service.ts
@@ -4,11 +4,10 @@ import { OnJob } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
-import { AssetFileType, AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum';
+import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum';
import { AssetDuplicateResult } from 'src/repositories/search.repository';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
-import { getAssetFile } from 'src/utils/asset.util';
import { isDuplicateDetectionEnabled } from 'src/utils/misc';
@Injectable()
@@ -65,17 +64,11 @@ export class DuplicateService extends BaseService {
return JobStatus.SKIPPED;
}
- if (asset.visibility == AssetVisibility.HIDDEN) {
+ if (asset.visibility === AssetVisibility.HIDDEN) {
this.logger.debug(`Asset ${id} is not visible, skipping`);
return JobStatus.SKIPPED;
}
- const previewFile = getAssetFile(asset.files || [], AssetFileType.PREVIEW);
- if (!previewFile) {
- this.logger.warn(`Asset ${id} is missing preview image`);
- return JobStatus.FAILED;
- }
-
if (!asset.embedding) {
this.logger.debug(`Asset ${id} is missing embedding`);
return JobStatus.FAILED;
From 495a9598790e4be9750e7d6c5fc1b3e8acebef55 Mon Sep 17 00:00:00 2001
From: Daimolean <92239625+wuzihao051119@users.noreply.github.com>
Date: Tue, 20 May 2025 22:00:20 +0800
Subject: [PATCH 015/170] fix(web): slide show in blurred background (#18384)
Co-authored-by: Alex
---
web/src/lib/components/asset-viewer/photo-viewer.svelte | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte
index 564cef5308..b00be3c2f3 100644
--- a/web/src/lib/components/asset-viewer/photo-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte
@@ -216,7 +216,7 @@
{/if}
From 62f24a79f46b1d39d509b2ea15b1fda0258e8e3b Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 20 May 2025 15:14:47 +0100
Subject: [PATCH 016/170] chore(deps): update prom/prometheus docker digest to
78ed1f9 (#18381)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
docker/docker-compose.prod.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml
index e17a034ddb..efe4271209 100644
--- a/docker/docker-compose.prod.yml
+++ b/docker/docker-compose.prod.yml
@@ -82,7 +82,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
- image: prom/prometheus@sha256:e2b8aa62b64855956e3ec1e18b4f9387fb6203174a4471936f4662f437f04405
+ image: prom/prometheus@sha256:78ed1f9050eb9eaf766af6e580230b1c4965728650e332cd1ee918c0c4699775
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
From 965498d19b1cf5dbceb356cc6924af66b3d0da8e Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 20 May 2025 15:14:59 +0100
Subject: [PATCH 017/170] chore(deps): update node.js to v22.15.1 (#18388)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/.nvmrc | 2 +-
cli/.nvmrc | 2 +-
cli/package.json | 2 +-
docs/.nvmrc | 2 +-
docs/package.json | 2 +-
e2e/.nvmrc | 2 +-
e2e/package.json | 2 +-
open-api/typescript-sdk/.nvmrc | 2 +-
open-api/typescript-sdk/package.json | 2 +-
server/.nvmrc | 2 +-
server/package.json | 2 +-
web/.nvmrc | 2 +-
web/package.json | 2 +-
13 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/.github/.nvmrc b/.github/.nvmrc
index b8ffd70759..8320a6d299 100644
--- a/.github/.nvmrc
+++ b/.github/.nvmrc
@@ -1 +1 @@
-22.15.0
+22.15.1
diff --git a/cli/.nvmrc b/cli/.nvmrc
index b8ffd70759..8320a6d299 100644
--- a/cli/.nvmrc
+++ b/cli/.nvmrc
@@ -1 +1 @@
-22.15.0
+22.15.1
diff --git a/cli/package.json b/cli/package.json
index 74a97ccaec..320f815499 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
- "node": "22.15.0"
+ "node": "22.15.1"
}
}
diff --git a/docs/.nvmrc b/docs/.nvmrc
index b8ffd70759..8320a6d299 100644
--- a/docs/.nvmrc
+++ b/docs/.nvmrc
@@ -1 +1 @@
-22.15.0
+22.15.1
diff --git a/docs/package.json b/docs/package.json
index b20303c4ab..05ca51d6f4 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -57,6 +57,6 @@
"node": ">=20"
},
"volta": {
- "node": "22.15.0"
+ "node": "22.15.1"
}
}
diff --git a/e2e/.nvmrc b/e2e/.nvmrc
index b8ffd70759..8320a6d299 100644
--- a/e2e/.nvmrc
+++ b/e2e/.nvmrc
@@ -1 +1 @@
-22.15.0
+22.15.1
diff --git a/e2e/package.json b/e2e/package.json
index b792d1aaf6..deb0492302 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -52,6 +52,6 @@
"vitest": "^3.0.0"
},
"volta": {
- "node": "22.15.0"
+ "node": "22.15.1"
}
}
diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc
index b8ffd70759..8320a6d299 100644
--- a/open-api/typescript-sdk/.nvmrc
+++ b/open-api/typescript-sdk/.nvmrc
@@ -1 +1 @@
-22.15.0
+22.15.1
diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json
index 3daaa27f78..90b8b6a0b3 100644
--- a/open-api/typescript-sdk/package.json
+++ b/open-api/typescript-sdk/package.json
@@ -28,6 +28,6 @@
"directory": "open-api/typescript-sdk"
},
"volta": {
- "node": "22.15.0"
+ "node": "22.15.1"
}
}
diff --git a/server/.nvmrc b/server/.nvmrc
index b8ffd70759..8320a6d299 100644
--- a/server/.nvmrc
+++ b/server/.nvmrc
@@ -1 +1 @@
-22.15.0
+22.15.1
diff --git a/server/package.json b/server/package.json
index a9336059ee..a61e9443db 100644
--- a/server/package.json
+++ b/server/package.json
@@ -154,7 +154,7 @@
"vitest": "^3.0.0"
},
"volta": {
- "node": "22.15.0"
+ "node": "22.15.1"
},
"overrides": {
"sharp": "^0.34.0"
diff --git a/web/.nvmrc b/web/.nvmrc
index b8ffd70759..8320a6d299 100644
--- a/web/.nvmrc
+++ b/web/.nvmrc
@@ -1 +1 @@
-22.15.0
+22.15.1
diff --git a/web/package.json b/web/package.json
index 99df56b7f0..e61c3919ee 100644
--- a/web/package.json
+++ b/web/package.json
@@ -100,6 +100,6 @@
"vitest": "^3.0.0"
},
"volta": {
- "node": "22.15.0"
+ "node": "22.15.1"
}
}
From d1e6682df04fab9879fccaf439824732e6586607 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 20 May 2025 15:15:10 +0100
Subject: [PATCH 018/170] chore(deps): update dependency @types/node to
^22.15.18 (#18387)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
cli/package-lock.json | 10 +++++-----
cli/package.json | 2 +-
e2e/package-lock.json | 12 ++++++------
e2e/package.json | 2 +-
open-api/typescript-sdk/package-lock.json | 8 ++++----
open-api/typescript-sdk/package.json | 2 +-
server/package-lock.json | 8 ++++----
server/package.json | 2 +-
8 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/cli/package-lock.json b/cli/package-lock.json
index bc4a710b46..8680ae54bd 100644
--- a/cli/package-lock.json
+++ b/cli/package-lock.json
@@ -27,7 +27,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
- "@types/node": "^22.15.16",
+ "@types/node": "^22.15.18",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -61,7 +61,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
- "@types/node": "^22.15.16",
+ "@types/node": "^22.15.18",
"typescript": "^5.3.3"
}
},
@@ -1372,9 +1372,9 @@
}
},
"node_modules/@types/node": {
- "version": "22.15.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
- "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
+ "version": "22.15.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz",
+ "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/cli/package.json b/cli/package.json
index 320f815499..40c19c91b1 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -21,7 +21,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
- "@types/node": "^22.15.16",
+ "@types/node": "^22.15.18",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
index eb0de90a39..cbce017e08 100644
--- a/e2e/package-lock.json
+++ b/e2e/package-lock.json
@@ -15,7 +15,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
- "@types/node": "^22.15.16",
+ "@types/node": "^22.15.18",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
@@ -66,7 +66,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
- "@types/node": "^22.15.16",
+ "@types/node": "^22.15.18",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -100,7 +100,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
- "@types/node": "^22.15.16",
+ "@types/node": "^22.15.18",
"typescript": "^5.3.3"
}
},
@@ -1593,9 +1593,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.15.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
- "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
+ "version": "22.15.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz",
+ "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/e2e/package.json b/e2e/package.json
index deb0492302..fc0196fb99 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
- "@types/node": "^22.15.16",
+ "@types/node": "^22.15.18",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json
index 9abec7f0a8..542f67d62e 100644
--- a/open-api/typescript-sdk/package-lock.json
+++ b/open-api/typescript-sdk/package-lock.json
@@ -12,7 +12,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
- "@types/node": "^22.15.16",
+ "@types/node": "^22.15.18",
"typescript": "^5.3.3"
}
},
@@ -23,9 +23,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.15.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
- "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
+ "version": "22.15.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz",
+ "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json
index 90b8b6a0b3..a5d4a1592b 100644
--- a/open-api/typescript-sdk/package.json
+++ b/open-api/typescript-sdk/package.json
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
- "@types/node": "^22.15.16",
+ "@types/node": "^22.15.18",
"typescript": "^5.3.3"
},
"repository": {
diff --git a/server/package-lock.json b/server/package-lock.json
index 3f00bb575c..bf70dd5f0a 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -92,7 +92,7 @@
"@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
- "@types/node": "^22.15.16",
+ "@types/node": "^22.15.18",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
@@ -5439,9 +5439,9 @@
}
},
"node_modules/@types/node": {
- "version": "22.15.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
- "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
+ "version": "22.15.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz",
+ "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
diff --git a/server/package.json b/server/package.json
index a61e9443db..b588aa153a 100644
--- a/server/package.json
+++ b/server/package.json
@@ -117,7 +117,7 @@
"@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
- "@types/node": "^22.15.16",
+ "@types/node": "^22.15.18",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
From 4e2fc9f0175765d5065ade1a60d405222739cf56 Mon Sep 17 00:00:00 2001
From: Alex
Date: Tue, 20 May 2025 09:39:05 -0500
Subject: [PATCH 019/170] chore: remove PIN code from secure storage on logged
out (#18393)
---
mobile/lib/providers/auth.provider.dart | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart
index 8c783395cd..5207858f99 100644
--- a/mobile/lib/providers/auth.provider.dart
+++ b/mobile/lib/providers/auth.provider.dart
@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
@@ -11,6 +12,7 @@ import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
+import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -20,6 +22,7 @@ final authProvider = StateNotifierProvider((ref) {
ref.watch(authServiceProvider),
ref.watch(apiServiceProvider),
ref.watch(userServiceProvider),
+ ref.watch(secureStorageServiceProvider),
);
});
@@ -27,12 +30,17 @@ class AuthNotifier extends StateNotifier {
final AuthService _authService;
final ApiService _apiService;
final UserService _userService;
+ final SecureStorageService _secureStorageService;
final _log = Logger("AuthenticationNotifier");
static const Duration _timeoutDuration = Duration(seconds: 7);
- AuthNotifier(this._authService, this._apiService, this._userService)
- : super(
+ AuthNotifier(
+ this._authService,
+ this._apiService,
+ this._userService,
+ this._secureStorageService,
+ ) : super(
AuthState(
deviceId: "",
userId: "",
@@ -67,6 +75,7 @@ class AuthNotifier extends StateNotifier {
Future logout() async {
try {
+ await _secureStorageService.delete(kSecuredPinCode);
await _authService.logout();
} finally {
await _cleanUp();
From e7b60a927878caf7014a07c43b350232f6bb3910 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 20 May 2025 16:12:17 +0100
Subject: [PATCH 020/170] chore(deps): update github-actions (#18246)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/actions/image-build/action.yml | 2 +-
.github/workflows/cli.yml | 2 +-
.github/workflows/docs-deploy.yml | 6 +++---
.github/workflows/docs-destroy.yml | 2 +-
.github/workflows/multi-runner-build.yml | 2 +-
.github/workflows/test.yml | 2 +-
6 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/.github/actions/image-build/action.yml b/.github/actions/image-build/action.yml
index ee23bd8ba8..a4168dcd5a 100644
--- a/.github/actions/image-build/action.yml
+++ b/.github/actions/image-build/action.yml
@@ -84,7 +84,7 @@ runs:
- name: Build and push image
id: build
- uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
+ uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index 4e0bf12fdc..74f5970139 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -96,7 +96,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
- uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
+ uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
with:
file: cli/Dockerfile
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index 73c5d5945a..c04adbafc6 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -150,7 +150,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
- uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5
+ uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8
with:
tg_version: '0.58.12'
tofu_version: '1.7.1'
@@ -165,7 +165,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
- uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5
+ uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8
with:
tg_version: '0.58.12'
tofu_version: '1.7.1'
@@ -199,7 +199,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
- uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5
+ uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8
with:
tg_version: '0.58.12'
tofu_version: '1.7.1'
diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml
index 778cba77e1..cd095b117f 100644
--- a/.github/workflows/docs-destroy.yml
+++ b/.github/workflows/docs-destroy.yml
@@ -25,7 +25,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
- uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5
+ uses: gruntwork-io/terragrunt-action@aee21a7df999be8b471c2a8564c6cd853cb674e1 # v2.1.8
with:
tg_version: '0.58.12'
tofu_version: '1.7.1'
diff --git a/.github/workflows/multi-runner-build.yml b/.github/workflows/multi-runner-build.yml
index 17eceb7e8f..f6d7c12355 100644
--- a/.github/workflows/multi-runner-build.yml
+++ b/.github/workflows/multi-runner-build.yml
@@ -115,7 +115,7 @@ jobs:
packages: write
steps:
- name: Download digests
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: ${{ runner.temp }}/digests
pattern: ${{ needs.matrix.outputs.key }}-*
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6c1cb8e07e..e6aecdb403 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -643,7 +643,7 @@ jobs:
contents: read
services:
postgres:
- image: ghcr.io/immich-app/postgres:14
+ image: ghcr.io/immich-app/postgres:14@sha256:14bec5d02e8704081eafd566029204a4eb6bb75f3056cfb34e81c5ab1657a490
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
From 895e0eacfebf41ea6f0ab0bacfbbcec6775ca992 Mon Sep 17 00:00:00 2001
From: Alex
Date: Tue, 20 May 2025 10:37:10 -0500
Subject: [PATCH 021/170] refactor: slide-show settings (#18394)
---
.../asset-viewer/slideshow-bar.svelte | 68 +++++++++++++++----
1 file changed, 54 insertions(+), 14 deletions(-)
diff --git a/web/src/lib/components/asset-viewer/slideshow-bar.svelte b/web/src/lib/components/asset-viewer/slideshow-bar.svelte
index 327227f0b4..949f069caf 100644
--- a/web/src/lib/components/asset-viewer/slideshow-bar.svelte
+++ b/web/src/lib/components/asset-viewer/slideshow-bar.svelte
@@ -1,10 +1,10 @@
-
+
- (progressBarStatus === ProgressBarStatus.Paused ? progressBar?.play() : progressBar?.pause())}
- title={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
+ aria-label={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
+ class="text-white"
/>
-
-
-
+
+ (showSettings = !showSettings)}
- title={$t('slideshow_settings')}
+ onclick={onSettingToggled}
+ aria-label={$t('slideshow_settings')}
+ class="text-white"
/>
{#if !isFullScreen}
-
{/if}
From bdf19ce3316cca170ab6d85855b140202ffb62a4 Mon Sep 17 00:00:00 2001
From: Alex
Date: Tue, 20 May 2025 10:53:34 -0500
Subject: [PATCH 022/170] fix: TimelineAsset visibility (#18395)
* fix: TimelineAsset visibility
* fix enum values
---
mobile/lib/entities/asset.entity.dart | 10 +--
mobile/lib/utils/openapi_patching.dart | 1 -
.../openapi/lib/model/asset_response_dto.dart | 84 +------------------
open-api/immich-openapi-specs.json | 12 ++-
open-api/typescript-sdk/src/fetch-client.ts | 8 +-
server/src/dtos/asset-response.dto.ts | 1 +
.../actions/set-visibility-action.svelte | 4 +-
.../asset-viewer/asset-viewer-nav-bar.svelte | 4 +-
.../actions/set-visibility-action.svelte | 6 +-
web/src/lib/utils/timeline-util.ts | 5 +-
web/src/routes/auth/pin-prompt/+page.svelte | 2 +-
web/src/test-data/factories/asset-factory.ts | 10 +--
12 files changed, 27 insertions(+), 120 deletions(-)
diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart
index 9119d96a63..d8d2bd23c3 100644
--- a/mobile/lib/entities/asset.entity.dart
+++ b/mobile/lib/entities/asset.entity.dart
@@ -554,15 +554,15 @@ class Asset {
}""";
}
- static getVisibility(AssetResponseDtoVisibilityEnum visibility) {
+ static getVisibility(AssetVisibility visibility) {
switch (visibility) {
- case AssetResponseDtoVisibilityEnum.timeline:
+ case AssetVisibility.timeline:
return AssetVisibilityEnum.timeline;
- case AssetResponseDtoVisibilityEnum.archive:
+ case AssetVisibility.archive:
return AssetVisibilityEnum.archive;
- case AssetResponseDtoVisibilityEnum.hidden:
+ case AssetVisibility.hidden:
return AssetVisibilityEnum.hidden;
- case AssetResponseDtoVisibilityEnum.locked:
+ case AssetVisibility.locked:
return AssetVisibilityEnum.locked;
}
}
diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart
index 1ffe05c781..7c7d9bab88 100644
--- a/mobile/lib/utils/openapi_patching.dart
+++ b/mobile/lib/utils/openapi_patching.dart
@@ -29,7 +29,6 @@ dynamic upgradeDto(dynamic value, String targetType) {
case 'UserResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
- addDefault(value, 'visibility', AssetVisibility.timeline);
}
break;
case 'AssetResponseDto':
diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart
index 74af8bd1eb..3d85b779cc 100644
--- a/mobile/openapi/lib/model/asset_response_dto.dart
+++ b/mobile/openapi/lib/model/asset_response_dto.dart
@@ -133,7 +133,7 @@ class AssetResponseDto {
DateTime updatedAt;
- AssetResponseDtoVisibilityEnum visibility;
+ AssetVisibility visibility;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
@@ -318,7 +318,7 @@ class AssetResponseDto {
type: AssetTypeEnum.fromJson(json[r'type'])!,
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
- visibility: AssetResponseDtoVisibilityEnum.fromJson(json[r'visibility'])!,
+ visibility: AssetVisibility.fromJson(json[r'visibility'])!,
);
}
return null;
@@ -389,83 +389,3 @@ class AssetResponseDto {
};
}
-
-class AssetResponseDtoVisibilityEnum {
- /// Instantiate a new enum with the provided [value].
- const AssetResponseDtoVisibilityEnum._(this.value);
-
- /// The underlying value of this enum member.
- final String value;
-
- @override
- String toString() => value;
-
- String toJson() => value;
-
- static const archive = AssetResponseDtoVisibilityEnum._(r'archive');
- static const timeline = AssetResponseDtoVisibilityEnum._(r'timeline');
- static const hidden = AssetResponseDtoVisibilityEnum._(r'hidden');
- static const locked = AssetResponseDtoVisibilityEnum._(r'locked');
-
- /// List of all possible values in this [enum][AssetResponseDtoVisibilityEnum].
- static const values = [
- archive,
- timeline,
- hidden,
- locked,
- ];
-
- static AssetResponseDtoVisibilityEnum? fromJson(dynamic value) => AssetResponseDtoVisibilityEnumTypeTransformer().decode(value);
-
- static List listFromJson(dynamic json, {bool growable = false,}) {
- final result = [];
- if (json is List && json.isNotEmpty) {
- for (final row in json) {
- final value = AssetResponseDtoVisibilityEnum.fromJson(row);
- if (value != null) {
- result.add(value);
- }
- }
- }
- return result.toList(growable: growable);
- }
-}
-
-/// Transformation class that can [encode] an instance of [AssetResponseDtoVisibilityEnum] to String,
-/// and [decode] dynamic data back to [AssetResponseDtoVisibilityEnum].
-class AssetResponseDtoVisibilityEnumTypeTransformer {
- factory AssetResponseDtoVisibilityEnumTypeTransformer() => _instance ??= const AssetResponseDtoVisibilityEnumTypeTransformer._();
-
- const AssetResponseDtoVisibilityEnumTypeTransformer._();
-
- String encode(AssetResponseDtoVisibilityEnum data) => data.value;
-
- /// Decodes a [dynamic value][data] to a AssetResponseDtoVisibilityEnum.
- ///
- /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
- /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
- /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
- ///
- /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
- /// and users are still using an old app with the old code.
- AssetResponseDtoVisibilityEnum? decode(dynamic data, {bool allowNull = true}) {
- if (data != null) {
- switch (data) {
- case r'archive': return AssetResponseDtoVisibilityEnum.archive;
- case r'timeline': return AssetResponseDtoVisibilityEnum.timeline;
- case r'hidden': return AssetResponseDtoVisibilityEnum.hidden;
- case r'locked': return AssetResponseDtoVisibilityEnum.locked;
- default:
- if (!allowNull) {
- throw ArgumentError('Unknown enum value to decode: $data');
- }
- }
- }
- return null;
- }
-
- /// Singleton [AssetResponseDtoVisibilityEnumTypeTransformer] instance.
- static AssetResponseDtoVisibilityEnumTypeTransformer? _instance;
-}
-
-
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 8d21c3ef90..2a8555f82c 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -9289,13 +9289,11 @@
"type": "string"
},
"visibility": {
- "enum": [
- "archive",
- "timeline",
- "hidden",
- "locked"
- ],
- "type": "string"
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/AssetVisibility"
+ }
+ ]
}
},
"required": [
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index 5358cdfec9..c27c9bc194 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -329,7 +329,7 @@ export type AssetResponseDto = {
"type": AssetTypeEnum;
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
updatedAt: string;
- visibility: Visibility;
+ visibility: AssetVisibility;
};
export type AlbumResponseDto = {
albumName: string;
@@ -3675,12 +3675,6 @@ export enum AssetTypeEnum {
Audio = "AUDIO",
Other = "OTHER"
}
-export enum Visibility {
- Archive = "archive",
- Timeline = "timeline",
- Hidden = "hidden",
- Locked = "locked"
-}
export enum AssetOrder {
Asc = "asc",
Desc = "desc"
diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts
index 4c1f2571e8..9bbfb450b2 100644
--- a/server/src/dtos/asset-response.dto.ts
+++ b/server/src/dtos/asset-response.dto.ts
@@ -44,6 +44,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
isArchived!: boolean;
isTrashed!: boolean;
isOffline!: boolean;
+ @ApiProperty({ enum: AssetVisibility, enumName: 'AssetVisibility' })
visibility!: AssetVisibility;
exifInfo?: ExifResponseDto;
tags?: TagResponseDto[];
diff --git a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte
index 91db84b172..dff470f456 100644
--- a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte
@@ -6,7 +6,7 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { handleError } from '$lib/utils/handle-error';
import { AssetVisibility, updateAssets } from '@immich/sdk';
- import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
+ import { mdiLockOpenVariantOutline, mdiLockOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action';
@@ -57,5 +57,5 @@
toggleLockedVisibility()}
text={isLocked ? $t('move_off_locked_folder') : $t('add_to_locked_folder')}
- icon={isLocked ? mdiFolderMoveOutline : mdiEyeOffOutline}
+ icon={isLocked ? mdiLockOpenVariantOutline : mdiLockOutline}
/>
diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
index 70600e6208..19705f05b6 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
@@ -29,7 +29,7 @@
import {
AssetJobName,
AssetTypeEnum,
- Visibility,
+ AssetVisibility,
type AlbumResponseDto,
type AssetResponseDto,
type PersonResponseDto,
@@ -94,7 +94,7 @@
const sharedLink = getSharedLink();
let isOwner = $derived($user && asset.ownerId === $user?.id);
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
- let isLocked = $derived(asset.visibility === Visibility.Locked);
+ let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
// $: showEditorButton =
// isOwner &&
diff --git a/web/src/lib/components/photos-page/actions/set-visibility-action.svelte b/web/src/lib/components/photos-page/actions/set-visibility-action.svelte
index c11ba114ce..407a92fadc 100644
--- a/web/src/lib/components/photos-page/actions/set-visibility-action.svelte
+++ b/web/src/lib/components/photos-page/actions/set-visibility-action.svelte
@@ -7,7 +7,7 @@
import { handleError } from '$lib/utils/handle-error';
import { AssetVisibility, updateAssets } from '@immich/sdk';
import { Button } from '@immich/ui';
- import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
+ import { mdiLockOpenVariantOutline, mdiLockOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -56,11 +56,11 @@
{:else}