From 564cda50880c97af12b0175d80167d1720ef8ce4 Mon Sep 17 00:00:00 2001 From: Pavel Miniutka Date: Wed, 22 Jul 2026 20:53:21 +0300 Subject: [PATCH 001/127] chore(mobile): Adds Belarusian language option in settings on mobile (#29939) chore(mobile): add missing Belarusian (be) language option in settings --- mobile/lib/constants/locales.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/constants/locales.dart b/mobile/lib/constants/locales.dart index 3082a1a0dd..ed87deab8a 100644 --- a/mobile/lib/constants/locales.dart +++ b/mobile/lib/constants/locales.dart @@ -6,6 +6,7 @@ const Map locales = { // Additional locales 'Arabic (ar)': Locale('ar'), 'Basque (eu)': Locale('eu'), + 'Belarusian (be)': Locale('be'), 'Bosnian (bl)': Locale('bn'), 'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'), 'Bulgarian (bg)': Locale('bg'), From 1f81eac8ab3989a5b46721c498d1731a871ee0ed Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:18:28 +0530 Subject: [PATCH 002/127] refactor: toast repository (#29386) refactor: feedback repository Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../infrastructure/toast.provider.dart | 4 +++ mobile/lib/repositories/toast.repository.dart | 26 +++++++++++++++++++ mobile/packages/ui/lib/src/snackbar.dart | 22 +++++++++++----- 3 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 mobile/lib/providers/infrastructure/toast.provider.dart create mode 100644 mobile/lib/repositories/toast.repository.dart diff --git a/mobile/lib/providers/infrastructure/toast.provider.dart b/mobile/lib/providers/infrastructure/toast.provider.dart new file mode 100644 index 0000000000..27d1cf9e6b --- /dev/null +++ b/mobile/lib/providers/infrastructure/toast.provider.dart @@ -0,0 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/repositories/toast.repository.dart'; + +final toastRepositoryProvider = Provider((ref) => const .new()); diff --git a/mobile/lib/repositories/toast.repository.dart b/mobile/lib/repositories/toast.repository.dart new file mode 100644 index 0000000000..0cca50fdec --- /dev/null +++ b/mobile/lib/repositories/toast.repository.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +import 'package:immich_ui/immich_ui.dart'; + +class ToastOption { + final Duration? timeout; + final FutureOr Function()? onUndo; + + const ToastOption({this.timeout, this.onUndo}); +} + +class ToastRepository { + const ToastRepository(); + + FutureOr success(String message, {ToastOption? toast}) { + snackbar.success(message, duration: toast?.timeout); + } + + FutureOr info(String message, {ToastOption? toast}) { + snackbar.info(message, duration: toast?.timeout); + } + + FutureOr error(String message, {ToastOption? toast}) { + snackbar.error(message, duration: toast?.timeout); + } +} diff --git a/mobile/packages/ui/lib/src/snackbar.dart b/mobile/packages/ui/lib/src/snackbar.dart index a44be8d513..1ede1124a8 100644 --- a/mobile/packages/ui/lib/src/snackbar.dart +++ b/mobile/packages/ui/lib/src/snackbar.dart @@ -6,18 +6,23 @@ final scaffoldMessengerKey = GlobalKey(); class SnackbarManager { const SnackbarManager(); - ScaffoldFeatureController? show(String message, SnackbarType type) { + ScaffoldFeatureController? show( + String message, + SnackbarType type, { + Duration? duration, + }) { final messenger = scaffoldMessengerKey.currentState; final context = scaffoldMessengerKey.currentContext; if (messenger == null || context == null) { return null; } + duration ??= const .new(seconds: 4); messenger.hideCurrentSnackBar(); - return messenger.showSnackBar(_build(context, message, type)); + return messenger.showSnackBar(_build(context, message, type, duration)); } - SnackBar _build(BuildContext context, String message, SnackbarType type) { + SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) { final theme = Theme.of(context); final colors = theme.extension() ?? ImmichColors.harmonized(theme.colorScheme); final (IconData icon, Color background, Color foreground) = switch (type) { @@ -29,7 +34,7 @@ class SnackbarManager { return SnackBar( behavior: .floating, backgroundColor: background, - duration: const .new(seconds: 4), + duration: duration, shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))), content: Row( children: [ @@ -48,11 +53,14 @@ class SnackbarManager { ); } - ScaffoldFeatureController? info(String message) => show(message, .info); + ScaffoldFeatureController? info(String message, {Duration? duration}) => + show(message, .info, duration: duration); - ScaffoldFeatureController? success(String message) => show(message, .success); + ScaffoldFeatureController? success(String message, {Duration? duration}) => + show(message, .success, duration: duration); - ScaffoldFeatureController? error(String message) => show(message, .error); + ScaffoldFeatureController? error(String message, {Duration? duration}) => + show(message, .error, duration: duration); } const snackbar = SnackbarManager(); From 9403e71d23202c5d538148c2336b9fc127b43ef5 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:21:06 +0530 Subject: [PATCH 003/127] refactor: add asset update method (#29384) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/constants/enums.dart | 2 -- .../repositories/remote_asset.repository.dart | 17 ++++++++++ .../repositories/asset_api.repository.dart | 32 ++++++++++++++----- mobile/lib/services/action.service.dart | 8 ++--- mobile/lib/utils/option.dart | 13 ++++++++ 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 72479416a8..d59c48c045 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -9,8 +9,6 @@ enum SortOrder { enum TextSearchType { context, filename, description, ocr } -enum AssetVisibilityEnum { timeline, hidden, archive, locked } - enum ActionSource { timeline, viewer } enum ShareAssetType { original, preview } diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index b2cecaca35..db89bfc1fc 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class RemoteAssetRepository extends DriftDatabaseRepository { @@ -292,4 +293,20 @@ class RemoteAssetRepository extends DriftDatabaseRepository { ..orderBy([(row) => OrderingTerm.asc(row.sequence)]); return query.map((row) => row.toDto()!).get(); } + + Future update( + List remoteIds, { + Option isFavorite = const .none(), + Option visibility = const .none(), + }) { + final companion = RemoteAssetEntityCompanion( + visibility: visibility.toDriftValue(), + isFavorite: isFavorite.toDriftValue(), + ); + return _db.batch((batch) { + for (final remoteId in remoteIds) { + batch.update(_db.remoteAssetEntity, companion, where: (e) => e.id.equals(remoteId)); + } + }); + } } diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 40233e90c4..f6ab726de6 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,12 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; -import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction; import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:openapi/api.dart'; +import 'package:openapi/api.dart' as api show AssetVisibility; +import 'package:openapi/api.dart' hide AssetVisibility; final assetApiRepositoryProvider = Provider( (ref) => AssetApiRepository( @@ -41,7 +43,7 @@ class AssetApiRepository extends ApiRepository { return response?.count ?? 0; } - Future updateVisibility(List ids, AssetVisibilityEnum visibility) async { + Future updateVisibility(List ids, AssetVisibility visibility) async { return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility)))); } @@ -77,11 +79,11 @@ class AssetApiRepository extends ApiRepository { return _api.downloadAssetWithHttpInfo(id, edited: edited); } - _mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) { - AssetVisibilityEnum.timeline => AssetVisibility.timeline, - AssetVisibilityEnum.hidden => AssetVisibility.hidden, - AssetVisibilityEnum.locked => AssetVisibility.locked, - AssetVisibilityEnum.archive => AssetVisibility.archive, + api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) { + AssetVisibility.timeline => api.AssetVisibility.timeline, + AssetVisibility.hidden => api.AssetVisibility.hidden, + AssetVisibility.locked => api.AssetVisibility.locked, + AssetVisibility.archive => api.AssetVisibility.archive, }; Future getAssetMIMEType(String assetId) async { @@ -106,6 +108,20 @@ class AssetApiRepository extends ApiRepository { Future removeEdits(String assetId) async { return _api.removeAssetEdits(assetId); } + + Future update( + List remoteIds, { + Option isFavorite = const .none(), + Option visibility = const .none(), + }) { + return _api.updateAssets( + AssetBulkUpdateDto( + ids: remoteIds, + isFavorite: isFavorite.toOptional(), + visibility: visibility.map(_mapVisibility).toOptional(), + ), + ); + } } extension on StackResponseDto { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 8e01777c5d..19782c8512 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -79,17 +79,17 @@ class ActionService { } Future archive(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive); + await _assetApiRepository.updateVisibility(remoteIds, .archive); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive); } Future unArchive(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline); + await _assetApiRepository.updateVisibility(remoteIds, .timeline); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline); } Future moveToLockFolder(List remoteIds, List localIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked); + await _assetApiRepository.updateVisibility(remoteIds, .locked); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked); // Ask user if they want to delete local copies @@ -99,7 +99,7 @@ class ActionService { } Future removeFromLockFolder(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline); + await _assetApiRepository.updateVisibility(remoteIds, .timeline); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline); } diff --git a/mobile/lib/utils/option.dart b/mobile/lib/utils/option.dart index d98dad1995..e88ae59c1f 100644 --- a/mobile/lib/utils/option.dart +++ b/mobile/lib/utils/option.dart @@ -1,3 +1,4 @@ +import 'package:drift/drift.dart'; import 'package:openapi/api.dart' show Optional; sealed class Option { @@ -21,6 +22,11 @@ sealed class Option { None() => null, }; + Option map(U Function(T value) f) => switch (this) { + Some(:final value) => Some(f(value)), + None() => None(), + }; + U fold(U Function(T value) onSome, U Function() onNone) => switch (this) { Some(:final value) => onSome(value), None() => onNone(), @@ -65,3 +71,10 @@ extension OptionToOptional on Option { Some(:final value) => Optional.present(value), }; } + +extension OptionToDriftValue on Option { + Value toDriftValue() => switch (this) { + Some(:final value) => Value(value), + None() => const Value.absent(), + }; +} From 36dfc985273558df9a990582d3ff865b6709feef Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Thu, 23 Jul 2026 03:02:17 +0600 Subject: [PATCH 004/127] fix(mobile): birthday picker date order follows locale (#29419) * fix(mobile): birthday picker date order follows locale * test(mobile): cover date order locales for the birthday picker --- .../person_edit_birthday_modal.widget.dart | 16 +++++ .../person_edit_birthday_modal_test.dart | 69 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 mobile/test/presentation/widgets/people/person_edit_birthday_modal_test.dart diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart index 7ed02af26b..c194bbc684 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart @@ -65,6 +65,7 @@ class _DriftPersonNameEditFormState extends ConsumerState? datePickerColumnOrder(String? pattern) { + if (pattern == null) { + return null; + } + final positions = { + DatePickerViewType.year: pattern.indexOf('y'), + DatePickerViewType.month: pattern.indexOf('M'), + DatePickerViewType.day: pattern.indexOf('d'), + }; + if (positions.values.any((position) => position < 0)) { + return null; + } + return positions.keys.toList()..sort((a, b) => positions[a]!.compareTo(positions[b]!)); +} diff --git a/mobile/test/presentation/widgets/people/person_edit_birthday_modal_test.dart b/mobile/test/presentation/widgets/people/person_edit_birthday_modal_test.dart new file mode 100644 index 0000000000..387b0ee903 --- /dev/null +++ b/mobile/test/presentation/widgets/people/person_edit_birthday_modal_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/presentation/widgets/people/person_edit_birthday_modal.widget.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; +import 'package:scroll_date_picker/scroll_date_picker.dart'; + +void main() { + group('datePickerColumnOrder', () { + test('month first (en_US)', () { + expect( + datePickerColumnOrder('M/d/y'), + orderedEquals([DatePickerViewType.month, DatePickerViewType.day, DatePickerViewType.year]), + ); + }); + + test('day first (pl)', () { + expect( + datePickerColumnOrder('dd.MM.y'), + orderedEquals([DatePickerViewType.day, DatePickerViewType.month, DatePickerViewType.year]), + ); + }); + + test('year first (ko)', () { + expect( + datePickerColumnOrder('y. M. d.'), + orderedEquals([DatePickerViewType.year, DatePickerViewType.month, DatePickerViewType.day]), + ); + }); + + test('null pattern falls back to package default', () { + expect(datePickerColumnOrder(null), isNull); + }); + + test('missing field falls back to package default', () { + expect(datePickerColumnOrder('M/y'), isNull); + }); + }); + + group('datePickerColumnOrder with real locale patterns', () { + setUpAll(() async { + await initializeDateFormatting(); + }); + + for (final (locales, order, name) in const [ + ( + ['en', 'en-US', 'en-PH'], + [DatePickerViewType.month, DatePickerViewType.day, DatePickerViewType.year], + 'month/day/year', + ), + ( + ['en-GB', 'fr', 'fr-FR', 'de', 'de-DE', 'pl'], + [DatePickerViewType.day, DatePickerViewType.month, DatePickerViewType.year], + 'day/month/year', + ), + ( + ['ja', 'ja-JP', 'zh', 'zh-CN', 'ko', 'ko-KR'], + [DatePickerViewType.year, DatePickerViewType.month, DatePickerViewType.day], + 'year/month/day', + ), + (['ky'], [DatePickerViewType.year, DatePickerViewType.day, DatePickerViewType.month], 'year/day/month'), + ]) { + for (final locale in locales) { + test('$locale uses $name', () { + expect(datePickerColumnOrder(DateFormat.yMd(locale).pattern), orderedEquals(order)); + }); + } + } + }); +} From f24a128319529fcd9f5d751cb71b70b13a5d8903 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 23 Jul 2026 00:33:23 +0200 Subject: [PATCH 005/127] feat(server): new search schemas and query builders (#28686) --- .../src/controllers/memory.controller.spec.ts | 6 +- server/src/database.ts | 30 + server/src/decorators.ts | 2 + server/src/dtos/search.dto.ts | 181 ++- server/src/enum.ts | 9 + server/src/queries/search.repository.sql | 1219 ++++++++++++++++- server/src/repositories/search.repository.ts | 68 +- server/src/utils/database.ts | 496 ++++++- server/src/validation.ts | 2 +- 9 files changed, 1980 insertions(+), 33 deletions(-) diff --git a/server/src/controllers/memory.controller.spec.ts b/server/src/controllers/memory.controller.spec.ts index 64d225f155..a3839793eb 100644 --- a/server/src/controllers/memory.controller.spec.ts +++ b/server/src/controllers/memory.controller.spec.ts @@ -106,7 +106,11 @@ describe(MemoryController.name, () => { it('should require at least one field', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }])); + expect(body).toEqual( + errorDto.validationError([ + { path: [], message: 'At least one of the following fields is required: isSaved, seenAt, memoryAt' }, + ]), + ); }); }); diff --git a/server/src/database.ts b/server/src/database.ts index 1770b9d720..100ba451e7 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -304,6 +304,36 @@ export const columns = { 'asset.height', 'asset.isEdited', ], + searchAsset: [ + 'asset.id', + 'asset.updateId', + 'asset.createdAt', + 'asset.updatedAt', + 'asset.deletedAt', + 'asset.status', + 'asset.checksum', + 'asset.checksumAlgorithm', + 'asset.duplicateId', + 'asset.duration', + 'asset.fileCreatedAt', + 'asset.fileModifiedAt', + 'asset.isExternal', + 'asset.isFavorite', + 'asset.isOffline', + 'asset.isEdited', + 'asset.visibility', + 'asset.libraryId', + 'asset.livePhotoVideoId', + 'asset.localDateTime', + 'asset.originalFileName', + 'asset.originalPath', + 'asset.ownerId', + 'asset.stackId', + 'asset.thumbhash', + 'asset.type', + 'asset.width', + 'asset.height', + ], workflowAssetV1: [ 'asset.id', 'asset.ownerId', diff --git a/server/src/decorators.ts b/server/src/decorators.ts index f89de14610..07398b0058 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -108,9 +108,11 @@ export function ChunkedSet(options?: { paramIndex?: number; chunkSize?: number } } const UUID = '00000000-0000-4000-a000-000000000000'; +const UUID_1 = '00000000-0000-4000-a000-000000000001'; export const DummyValue = { UUID, + UUID_1, UUID_SET: new Set([UUID]), PAGINATION: { take: 10, skip: 0 }, EMAIL: 'user@immich.app', diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index ec4d58dae3..7911f92f44 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -3,8 +3,15 @@ import { Place } from 'src/database'; import { HistoryBuilder } from 'src/decorators'; import { AlbumResponseSchema } from 'src/dtos/album.dto'; import { AssetResponseSchema } from 'src/dtos/asset-response.dto'; -import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum'; -import { isoDatetimeToDate, stringToBool } from 'src/validation'; +import { + AssetOrder, + AssetOrderSchema, + AssetTypeSchema, + AssetVisibilitySchema, + SearchOrderField, + SearchOrderFieldSchema, +} from 'src/enum'; +import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation'; import z from 'zod'; const BaseSearchSchema = z.object({ @@ -142,6 +149,176 @@ const SearchSuggestionRequestSchema = z }) .meta({ id: 'SearchSuggestionRequestDto' }); +const IdFilterSchema = nonEmptyPartial({ + eq: z.uuidv4(), + ne: z.uuidv4(), +}).meta({ id: 'IdFilter' }); + +const IdFilterNullableSchema = nonEmptyPartial({ + eq: z.uuidv4().nullable(), + ne: z.uuidv4().nullable(), +}).meta({ id: 'IdFilterNullable' }); + +const IdsFilterSchema = nonEmptyPartial({ + any: z.array(z.uuidv4()).min(1), + all: z.array(z.uuidv4()).min(1), + none: z.array(z.uuidv4()).min(1), +}).meta({ id: 'IdsFilter' }); + +const stringListShape = { + in: z.array(z.string()).min(1), + notIn: z.array(z.string()).min(1), +}; + +const StringFilterSchema = nonEmptyPartial({ + eq: z.string(), + ne: z.string(), + ...stringListShape, +}).meta({ id: 'StringFilter' }); + +const stringNullableShape = { + eq: z.string().nullable(), + ne: z.string().nullable(), + ...stringListShape, +}; + +const StringFilterNullableSchema = nonEmptyPartial(stringNullableShape).meta({ id: 'StringFilterNullable' }); + +const StringPatternFilterSchema = nonEmptyPartial({ + ...stringNullableShape, + like: z.string().min(1), + notLike: z.string().min(1), + startsWith: z.string().min(1), + endsWith: z.string().min(1), +}).meta({ id: 'StringPatternFilter' }); + +const numberRangeShape = { + lt: z.number(), + lte: z.number(), + gt: z.number(), + gte: z.number(), + in: z.array(z.number()).min(1), + notIn: z.array(z.number()).min(1), +}; + +const NumberFilterSchema = nonEmptyPartial({ + eq: z.number(), + ne: z.number(), + ...numberRangeShape, +}).meta({ id: 'NumberFilter' }); + +const NumberFilterNullableSchema = nonEmptyPartial({ + eq: z.number().nullable(), + ne: z.number().nullable(), + ...numberRangeShape, +}).meta({ id: 'NumberFilterNullable' }); + +const dateRangeShape = { + gt: isoDatetimeToDate, + gte: isoDatetimeToDate, + lt: isoDatetimeToDate, + lte: isoDatetimeToDate, +}; + +const DateFilterSchema = nonEmptyPartial({ + eq: isoDatetimeToDate, + ne: isoDatetimeToDate, + ...dateRangeShape, +}).meta({ id: 'DateFilter' }); + +const DateFilterNullableSchema = nonEmptyPartial({ + eq: isoDatetimeToDate.nullable(), + ne: isoDatetimeToDate.nullable(), + ...dateRangeShape, +}).meta({ id: 'DateFilterNullable' }); + +const BoolFilterSchema = z.object({ eq: z.boolean() }).meta({ id: 'BoolFilter' }); + +const enumFilterSchema = (values: z.ZodEnum, id: string) => + nonEmptyPartial({ + eq: values, + ne: values, + in: z.array(values).min(1), + notIn: z.array(values).min(1), + }).meta({ id }); + +const EnumFilterAssetTypeSchema = enumFilterSchema(AssetTypeSchema, 'EnumFilterAssetType'); +const EnumFilterAssetVisibilitySchema = enumFilterSchema(AssetVisibilitySchema, 'EnumFilterAssetVisibility'); + +const StringSimilarityFilterSchema = z + .object({ + matches: z.string().min(1), + }) + .meta({ id: 'StringSimilarityFilter' }); + +export const DEFAULT_SEARCH_ORDER = { + field: SearchOrderField.FileCreatedAt, + direction: AssetOrder.Desc, +}; + +export const SearchOrderSchema = z + .object({ + field: SearchOrderFieldSchema.default(DEFAULT_SEARCH_ORDER.field), + direction: AssetOrderSchema.default(DEFAULT_SEARCH_ORDER.direction), + }) + .meta({ id: 'SearchOrder' }); + +const SearchFilterBranchSchema = z + .object({ + id: IdFilterSchema, + libraryId: IdFilterNullableSchema, + type: EnumFilterAssetTypeSchema, + visibility: EnumFilterAssetVisibilitySchema, + isFavorite: BoolFilterSchema, + isMotion: BoolFilterSchema, + isOffline: BoolFilterSchema, + isEncoded: BoolFilterSchema, + hasAlbums: BoolFilterSchema, + hasPeople: BoolFilterSchema, + hasTags: BoolFilterSchema, + city: StringFilterNullableSchema, + state: StringFilterNullableSchema, + country: StringFilterNullableSchema, + make: StringFilterNullableSchema, + model: StringFilterNullableSchema, + lensModel: StringFilterNullableSchema, + description: StringPatternFilterSchema, + originalFileName: StringPatternFilterSchema, + originalPath: StringPatternFilterSchema, + ocr: StringSimilarityFilterSchema, + rating: NumberFilterNullableSchema, + fileSizeInBytes: NumberFilterSchema, + takenAt: DateFilterSchema, + createdAt: DateFilterSchema, + updatedAt: DateFilterSchema, + trashedAt: DateFilterNullableSchema, + personIds: IdsFilterSchema, + tagIds: IdsFilterSchema, + albumIds: IdsFilterSchema, + checksum: StringFilterSchema, + encodedVideoPath: StringFilterSchema, + }) + .partial() + .meta({ id: 'SearchFilterBranch' }); + +export const SearchFilterSchema = SearchFilterBranchSchema.extend({ + or: z.array(SearchFilterBranchSchema).min(1).optional(), +}).meta({ id: 'SearchFilter' }); + +export type IdFilter = z.infer; +export type IdFilterNullable = z.infer; +export type IdsFilter = z.infer; +export type StringFilter = z.infer; +export type StringFilterNullable = z.infer; +export type StringPatternFilter = z.infer; +export type NumberFilter = z.infer; +export type NumberFilterNullable = z.infer; +export type DateFilter = z.infer; +export type DateFilterNullable = z.infer; +export type SearchOrder = z.infer; +export type SearchFilter = z.infer; +export type SearchFilterBranch = z.infer; + export class RandomSearchDto extends createZodDto(RandomSearchSchema) {} export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {} export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {} diff --git a/server/src/enum.ts b/server/src/enum.ts index 0996abe6fc..0d29244e09 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1234,3 +1234,12 @@ export enum CalendarHeatmapType { Upload = 'Upload', Taken = 'Taken', } + +export enum SearchOrderField { + FileCreatedAt = 'fileCreatedAt', + LocalDateTime = 'localDateTime', + FileSizeInBytes = 'fileSizeInBytes', + Rating = 'rating', +} + +export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'SearchOrderField' }); diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 25b8566375..efd7236bb5 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -2,7 +2,34 @@ -- SearchRepository.searchMetadata select - "asset".* + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" @@ -13,7 +40,8 @@ where and "asset"."isFavorite" = $4 and "asset"."deletedAt" is null order by - "asset"."fileCreatedAt" desc + "asset"."fileCreatedAt" desc, + "asset"."id" desc limit $5 offset @@ -34,7 +62,34 @@ where -- SearchRepository.searchRandom select - "asset".* + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" @@ -51,7 +106,34 @@ limit -- SearchRepository.searchLargeAssets select - "asset".*, + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height", to_json("asset_exif") as "exifInfo" from "asset" @@ -73,7 +155,34 @@ begin set local vchordrq.probes = 1 select - "asset".* + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" @@ -85,7 +194,8 @@ where and "asset"."isFavorite" = $4 and "asset"."deletedAt" is null order by - smart_search.embedding <=> $5 + smart_search.embedding <=> $5, + "asset"."id" asc limit $6 offset @@ -203,7 +313,34 @@ with recursive ) ) select - "asset".*, + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height", to_jsonb("asset_exif") as "exifInfo" from "asset" @@ -276,3 +413,1071 @@ where and "deletedAt" is null and "lensModel" is not null and "lensModel" != $3 + +-- SearchRepository.searchMetadataV3 (baseline) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (empty) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + true +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $1 + +-- SearchRepository.searchMetadataV3 (or-exif-only) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and "asset_exif"."city" = $2 +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-eq-null) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and "asset_exif"."city" is null +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (string-pattern-like) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($2) || '%') +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-pattern-notLike) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($2) || '%') +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-pattern-startsWith) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($2) || '%') +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-similarity-ocr) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "ocr_search" + where + "ocr_search"."assetId" = "asset"."id" + and f_unaccent (ocr_search.text) %>> f_unaccent ($2) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-any) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + and "album_asset"."albumId" = any ($2::uuid[]) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-all) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + "asset_face"."assetId" + from + "asset_face" + where + "asset_face"."assetId" = "asset"."id" + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $2 + and "asset_face"."personId" = any ($3::uuid[]) + group by + "asset_face"."assetId" + having + count(distinct "asset_face"."personId") = $4 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $5 + +-- SearchRepository.searchMetadataV3 (ids-all-single) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + and "album_asset"."albumId" = any ($2::uuid[]) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-none) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and not exists ( + select + from + "tag_asset" + inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant" + where + "tag_asset"."assetId" = "asset"."id" + and "tag_closure"."id_ancestor" = any ($2::uuid[]) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-tags-all) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + "tag_asset"."assetId" + from + "tag_asset" + inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant" + where + "tag_asset"."assetId" = "asset"."id" + and "tag_closure"."id_ancestor" = any ($2::uuid[]) + group by + "tag_asset"."assetId" + having + count(distinct "tag_closure"."id_ancestor") = $3 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (has-albums-false) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and not exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (is-encoded) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $2 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (number-range) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset_exif"."fileSizeInByte" <= $2 + and "asset_exif"."fileSizeInByte" >= $3 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (date-eq) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and "asset"."fileCreatedAt" = $2 +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (date-range) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."fileCreatedAt" < $2 + and "asset"."fileCreatedAt" >= $3 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (order-fileSize-noExif) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true +order by + "asset_exif"."fileSizeInByte" desc nulls last, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (order-rating-withExif) +select + to_json("asset_exif") as "exifInfo", + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true +order by + "asset_exif"."rating" asc nulls last, + "asset"."id" asc +limit + $2 + +-- SearchRepository.searchMetadataV3 (or-branches) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."isFavorite" = $2 + or exists ( + select + from + "asset_face" + where + "asset_face"."assetId" = "asset"."id" + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $3 + and "asset_face"."personId" = any ($4::uuid[]) + ) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $5 + +-- SearchRepository.searchMetadataV3 (or-with-top-level) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."fileCreatedAt" < $2 + and "asset"."fileCreatedAt" >= $3 + and ( + "asset"."isFavorite" = $4 + or exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + and "album_asset"."albumId" = any ($5::uuid[]) + ) + ) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $6 + +-- SearchRepository.searchStatisticsV3 (baseline) +select + count(*) as "total" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true + +-- SearchRepository.searchStatisticsV3 (with-filter) +select + count(*) as "total" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset_exif"."fileSizeInByte" >= $2 + and "asset"."fileCreatedAt" < $3 + and "asset"."fileCreatedAt" >= $4 + ) + +-- SearchRepository.searchStatisticsV3 (with-or) +select + count(*) as "total" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."isFavorite" = $2 + or not exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + ) + ) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 7faa19f7cd..70ba09ef2c 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,12 +1,23 @@ import { Injectable } from '@nestjs/common'; import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; +import { MapAsset } from 'src/dtos/asset-response.dto'; +import { SearchFilter, SearchOrder } from 'src/dtos/search.dto'; import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum'; import { probes } from 'src/repositories/database.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; -import { anyUuid, searchAssetBuilder, withExifInner } from 'src/utils/database'; +import { + anyUuid, + searchAssetBuilder, + searchAssetBuilderLegacy, + searchMetadataV3Examples, + searchStatisticsV3Examples, + withExifInner, + withSearchOrder, +} from 'src/utils/database'; import { paginationHelper } from 'src/utils/pagination'; import z from 'zod'; @@ -122,6 +133,21 @@ export type AssetSearchOptions = Omit & export type AssetSearchBuilderOptions = Omit; +export interface AssetSearchBuilderV3Options { + filter?: SearchFilter; + /** Server-derived ownership scope. Never client-controlled. */ + userIds?: string[]; + withExif?: boolean; + withFaces?: boolean; + withPeople?: boolean; + withStacked?: boolean; + order?: SearchOrder; +} + +export interface AssetSearchPaginationV3Options { + size: number; +} + export type SmartSearchOptions = SearchDateOptions & SearchEmbeddingOptions & SearchExifOptions & @@ -196,9 +222,10 @@ export class SearchRepository { }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; - const items = await searchAssetBuilder(this.db, options) - .selectAll('asset') + const items = await searchAssetBuilderLegacy(this.db, options) + .select(columns.searchAsset) .orderBy('asset.fileCreatedAt', orderDirection) + .orderBy('asset.id', orderDirection) .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) .execute(); @@ -217,7 +244,7 @@ export class SearchRepository { ], }) searchStatistics(options: AssetSearchOptions) { - return searchAssetBuilder(this.db, options) + return searchAssetBuilderLegacy(this.db, options) .select((qb) => qb.fn.countAll().as('total')) .executeTakeFirstOrThrow(); } @@ -235,8 +262,8 @@ export class SearchRepository { ], }) async searchRandom(size: number, options: AssetSearchOptions) { - return searchAssetBuilder(this.db, options) - .selectAll('asset') + return searchAssetBuilderLegacy(this.db, options) + .select(columns.searchAsset) .orderBy(sql`random()`) .limit(size) .execute(); @@ -256,8 +283,8 @@ export class SearchRepository { }) searchLargeAssets(size: number, options: LargeAssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; - return searchAssetBuilder(this.db, options) - .selectAll('asset') + return searchAssetBuilderLegacy(this.db, options) + .select(columns.searchAsset) .$call(withExifInner) .where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0) .orderBy('asset_exif.fileSizeInByte', orderDirection) @@ -285,10 +312,11 @@ export class SearchRepository { 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) - .selectAll('asset') + const items = await searchAssetBuilderLegacy(trx, options) + .select(columns.searchAsset) .innerJoin('smart_search', 'asset.id', 'smart_search.assetId') .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) + .orderBy('asset.id', 'asc') .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) .execute(); @@ -417,7 +445,7 @@ export class SearchRepository { .selectFrom('asset') .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .innerJoin('cte', 'asset.id', 'cte.assetId') - .selectAll('asset') + .select(columns.searchAsset) .select((eb) => eb .fn('to_jsonb', [eb.table('asset_exif')]) @@ -490,6 +518,24 @@ export class SearchRepository { return res.map((row) => row.lensModel!); } + @GenerateSql(...searchMetadataV3Examples) + searchMetadataV3( + pagination: AssetSearchPaginationV3Options, + options: AssetSearchBuilderV3Options, + ): Promise { + return withSearchOrder(searchAssetBuilder(this.db, options), options.order) + .select(columns.searchAsset) + .limit(pagination.size) + .execute(); + } + + @GenerateSql(...searchStatisticsV3Examples) + searchStatisticsV3(options: AssetSearchBuilderV3Options) { + return searchAssetBuilder(this.db, options) + .select((qb) => qb.fn.countAll().as('total')) + .executeTakeFirstOrThrow(); + } + private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) { return this.db .selectFrom('asset_exif') diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index ffd5a603e4..0f4d8775b6 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -7,21 +7,42 @@ import { Kysely, KyselyConfig, NotNull, + OperandValueExpression, + ReferenceExpression, Selectable, SelectQueryBuilder, ShallowDehydrateObject, sql, + SqlBool, } from 'kysely'; import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { Notice, PostgresError } from 'postgres'; import { columns, lockableProperties, LockableProperty, Person } from 'src/database'; +import { DummyValue, GenerateSqlQueries } from 'src/decorators'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum'; -import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; +import { + DEFAULT_SEARCH_ORDER, + IdsFilter, + SearchFilterBranch, + SearchOrder, + StringFilter, + StringPatternFilter, +} from 'src/dtos/search.dto'; +import { + AssetFileType, + AssetOrder, + AssetOrderBy, + AssetVisibility, + DatabaseExtension, + ExifOrientation, + SearchOrderField, +} from 'src/enum'; +import { AssetSearchBuilderOptions, AssetSearchBuilderV3Options } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types'; +import { fromChecksum } from 'src/utils/request'; export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => { return { @@ -54,6 +75,8 @@ export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyCon }; }; +const uniqueIds = (ids: string[]) => [...new Set(ids)]; + export const asUuid = (id: string | Expression) => sql`${id}::uuid`; export const anyUuid = (ids: string[]) => sql`any(${`{${ids}}`}::uuid[])`; @@ -85,16 +108,15 @@ export function withDefaultVisibility(qb: SelectQueryBuilder) return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]); } +const selectExifInfo = (eb: AssetExpressionBuilder) => + eb.fn + .toJson(eb.table('asset_exif')) + .$castTo> | null>() + .as('exifInfo'); + // TODO come up with a better query that only selects the fields we need export function withExif(qb: SelectQueryBuilder) { - return qb - .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => - eb.fn - .toJson(eb.table('asset_exif')) - .$castTo> | null>() - .as('exifInfo'), - ); + return qb.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId').select(selectExifInfo); } export function withExifInner(qb: SelectQueryBuilder) { @@ -373,7 +395,7 @@ export function withEdits(eb: ExpressionBuilder): AliasedEditAction const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ -export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { +export function searchAssetBuilderLegacy(kysely: Kysely, options: AssetSearchBuilderOptions) { options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); return kysely @@ -493,6 +515,458 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null)); } +type AssetExpressionBuilder = ExpressionBuilder; + +const albumAssets = (eb: AssetExpressionBuilder) => + eb.selectFrom('album_asset').whereRef('album_asset.assetId', '=', 'asset.id'); + +const visibleFaces = (eb: AssetExpressionBuilder) => + eb + .selectFrom('asset_face') + .whereRef('asset_face.assetId', '=', 'asset.id') + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true); + +const tagAssets = (eb: AssetExpressionBuilder) => + eb.selectFrom('tag_asset').whereRef('tag_asset.assetId', '=', 'asset.id'); + +// shared any/all/none mechanics; `matchesAll` only receives deduplicated multi-id lists, +// so its `count(distinct id) = ids.length` check stays satisfiable +function idsPredicates( + eb: AssetExpressionBuilder, + { any, all, none }: IdsFilter = {}, + ops: { + matchesAny: (ids: string[]) => Expression; + matchesAll: (ids: string[]) => Expression; + }, +) { + const predicates: Expression[] = []; + if (any) { + predicates.push(ops.matchesAny(any)); + } + if (all) { + const ids = uniqueIds(all); + predicates.push(ids.length === 1 ? ops.matchesAny(ids) : ops.matchesAll(ids)); + } + if (none) { + predicates.push(eb.not(ops.matchesAny(none))); + } + return predicates; +} + +function albumIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { + const matching = (ids: string[]) => albumAssets(eb).where('album_asset.albumId', '=', anyUuid(ids)); + return idsPredicates(eb, filter, { + matchesAny: (ids) => eb.exists(matching(ids)), + matchesAll: (ids) => + eb.exists( + matching(ids) + .select('album_asset.assetId') + .groupBy('album_asset.assetId') + .having((eb) => eb.fn.count('album_asset.albumId').distinct(), '=', ids.length), + ), + }); +} + +function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { + const matching = (ids: string[]) => visibleFaces(eb).where('asset_face.personId', '=', anyUuid(ids)); + return idsPredicates(eb, filter, { + matchesAny: (ids) => eb.exists(matching(ids)), + matchesAll: (ids) => + eb.exists( + matching(ids) + .select('asset_face.assetId') + .groupBy('asset_face.assetId') + .having((eb) => eb.fn.count('asset_face.personId').distinct(), '=', ids.length), + ), + }); +} + +function tagIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { + const matching = (ids: string[]) => + tagAssets(eb) + .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') + .where('tag_closure.id_ancestor', '=', anyUuid(ids)); + return idsPredicates(eb, filter, { + matchesAny: (ids) => eb.exists(matching(ids)), + matchesAll: (ids) => + eb.exists( + matching(ids) + .select('tag_asset.assetId') + .groupBy('tag_asset.assetId') + .having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '=', ids.length), + ), + }); +} + +type ComparisonFilter = { + eq?: T | null; + ne?: T | null; + lt?: T; + lte?: T; + gt?: T; + gte?: T; + in?: T[]; + notIn?: T[]; +}; + +// one operator dispatch for every filter shape; the DTO schemas constrain which +// operators (and null literals) each filter can actually carry +function comparisonPredicates>( + eb: ExpressionBuilder, + column: RE, + filter: ComparisonFilter> = {}, +) { + const predicates: Expression[] = []; + if (filter.eq !== undefined) { + predicates.push(filter.eq === null ? eb(column, 'is', null) : eb(column, '=', filter.eq)); + } + if (filter.ne !== undefined) { + predicates.push(filter.ne === null ? eb(column, 'is not', null) : eb(column, '!=', filter.ne)); + } + if (filter.lt !== undefined) { + predicates.push(eb(column, '<', filter.lt)); + } + if (filter.lte !== undefined) { + predicates.push(eb(column, '<=', filter.lte)); + } + if (filter.gt !== undefined) { + predicates.push(eb(column, '>', filter.gt)); + } + if (filter.gte !== undefined) { + predicates.push(eb(column, '>=', filter.gte)); + } + if (filter.in !== undefined) { + predicates.push(eb(column, 'in', filter.in)); + } + if (filter.notIn !== undefined) { + predicates.push(eb(column, 'not in', filter.notIn)); + } + return predicates; +} + +type StringColumn = + | 'asset_exif.city' + | 'asset_exif.state' + | 'asset_exif.country' + | 'asset_exif.make' + | 'asset_exif.model' + | 'asset_exif.lensModel' + | 'asset_exif.description' + | 'asset.originalFileName' + | 'asset.originalPath'; + +function stringPatternPredicates(eb: AssetExpressionBuilder, column: StringColumn, filter: StringPatternFilter = {}) { + const ref = sql.ref(column); + const predicates = comparisonPredicates(eb, column, filter); + if (filter.like !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.like}) || '%')`); + } + if (filter.notLike !== undefined) { + predicates.push(sql`f_unaccent(${ref}) not ilike ('%' || f_unaccent(${filter.notLike}) || '%')`); + } + if (filter.startsWith !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike (f_unaccent(${filter.startsWith}) || '%')`); + } + if (filter.endsWith !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.endsWith}))`); + } + return predicates; +} + +function checksumPredicates(eb: AssetExpressionBuilder, filter: StringFilter = {}) { + return comparisonPredicates(eb, 'asset.checksum', { + eq: filter.eq === undefined ? undefined : fromChecksum(filter.eq), + ne: filter.ne === undefined ? undefined : fromChecksum(filter.ne), + in: filter.in?.map((checksum) => fromChecksum(checksum)), + notIn: filter.notIn?.map((checksum) => fromChecksum(checksum)), + }); +} + +const encodedVideoFiles = (eb: AssetExpressionBuilder) => + eb + .selectFrom('asset_file') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.EncodedVideo); + +function existsPredicates( + eb: AssetExpressionBuilder, + filter: { eq: boolean } | undefined, + subquery: () => Expression, +): Expression[] { + if (!filter) { + return []; + } + const exists = eb.exists(subquery()); + return [filter.eq ? exists : eb.not(exists)]; +} + +// predicates are collected as expressions rather than chained `where` calls so the same +// helpers can build each `or` branch, which must compose into eb.and/eb.or +function branchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch) { + const { encodedVideoPath } = branch; + return [ + ...comparisonPredicates(eb, 'asset.id', branch.id), + ...comparisonPredicates(eb, 'asset.libraryId', branch.libraryId), + ...comparisonPredicates(eb, 'asset.type', branch.type), + ...comparisonPredicates(eb, 'asset.visibility', branch.visibility), + ...(branch.isFavorite ? [eb('asset.isFavorite', '=', branch.isFavorite.eq)] : []), + ...(branch.isOffline ? [eb('asset.isOffline', '=', branch.isOffline.eq)] : []), + ...(branch.isMotion ? [eb('asset.livePhotoVideoId', branch.isMotion.eq ? 'is not' : 'is', null)] : []), + ...existsPredicates(eb, branch.isEncoded, () => encodedVideoFiles(eb)), + ...existsPredicates(eb, branch.hasAlbums, () => albumAssets(eb)), + ...existsPredicates(eb, branch.hasPeople, () => visibleFaces(eb)), + ...existsPredicates(eb, branch.hasTags, () => tagAssets(eb)), + ...comparisonPredicates(eb, 'asset_exif.city', branch.city), + ...comparisonPredicates(eb, 'asset_exif.state', branch.state), + ...comparisonPredicates(eb, 'asset_exif.country', branch.country), + ...comparisonPredicates(eb, 'asset_exif.make', branch.make), + ...comparisonPredicates(eb, 'asset_exif.model', branch.model), + ...comparisonPredicates(eb, 'asset_exif.lensModel', branch.lensModel), + ...stringPatternPredicates(eb, 'asset_exif.description', branch.description), + ...stringPatternPredicates(eb, 'asset.originalFileName', branch.originalFileName), + ...stringPatternPredicates(eb, 'asset.originalPath', branch.originalPath), + ...(branch.ocr + ? [ + eb.exists( + eb + .selectFrom('ocr_search') + .whereRef('ocr_search.assetId', '=', 'asset.id') + .where( + sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(branch.ocr.matches).join(' ')})`, + ), + ), + ] + : []), + ...comparisonPredicates(eb, 'asset_exif.rating', branch.rating), + ...comparisonPredicates(eb, 'asset_exif.fileSizeInByte', branch.fileSizeInBytes), + ...comparisonPredicates(eb, 'asset.fileCreatedAt', branch.takenAt), + ...comparisonPredicates(eb, 'asset.createdAt', branch.createdAt), + ...comparisonPredicates(eb, 'asset.updatedAt', branch.updatedAt), + ...comparisonPredicates(eb, 'asset.deletedAt', branch.trashedAt), + ...albumIdsPredicates(eb, branch.albumIds), + ...personIdsPredicates(eb, branch.personIds), + ...tagIdsPredicates(eb, branch.tagIds), + ...checksumPredicates(eb, branch.checksum), + ...(encodedVideoPath + ? [ + eb.exists( + encodedVideoFiles(eb) + .where('asset_file.isEdited', '=', false) + .where((eb) => eb.and(comparisonPredicates(eb, 'asset_file.path', encodedVideoPath))), + ), + ] + : []), + ]; +} + +// ordering is deliberately left to the caller so aggregate-only consumers (counts, stats) +// can compose the same filters without stripping an order by +export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderV3Options) { + const filter = options.filter ?? {}; + + return ( + kysely + .withPlugin(joinDeduplicationPlugin) + .selectFrom('asset') + // postgres eliminates the left join when no exif column is referenced, so unused joins are free + .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') + .$if(!!options.withExif, (qb) => qb.select(selectExifInfo)) + .$if(!!options.userIds && options.userIds.length > 0, (qb) => + qb.where('asset.ownerId', '=', anyUuid(options.userIds!)), + ) + .$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople)) + .$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null)) + .where((eb) => { + const predicates = branchPredicates(eb, filter); + if (filter.or && filter.or.length > 0) { + predicates.push(eb.or(filter.or.map((branch) => eb.and(branchPredicates(eb, branch))))); + } + return predicates.length > 0 ? eb.and(predicates) : eb.lit(true); + }) + ); +} + +const searchOrderColumns = { + [SearchOrderField.FileCreatedAt]: { column: 'asset.fileCreatedAt', nullable: false }, + [SearchOrderField.LocalDateTime]: { column: 'asset.localDateTime', nullable: false }, + [SearchOrderField.FileSizeInBytes]: { column: 'asset_exif.fileSizeInByte', nullable: true }, + [SearchOrderField.Rating]: { column: 'asset_exif.rating', nullable: true }, +} as const; + +export function withSearchOrder(qb: ReturnType, order?: SearchOrder) { + const { field, direction } = order ?? DEFAULT_SEARCH_ORDER; + const { column, nullable } = searchOrderColumns[field]; + return ( + qb + .orderBy(column, (ob) => { + const ordered = direction === AssetOrder.Asc ? ob.asc() : ob.desc(); + // nulls last: assets without an asset_exif row would otherwise lead descending results + return nullable ? ordered.nullsLast() : ordered; + }) + // id tie-break for deterministic pagination + .orderBy('asset.id', direction) + ); +} + +export const searchMetadataV3Examples: GenerateSqlQueries[] = [ + { name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] }, + { name: 'empty', params: [{ size: 100 }, {}] }, + { + name: 'or-exif-only', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }], + }, + { + name: 'string-eq-null', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }], + }, + { + name: 'string-pattern-like', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }], + }, + { + name: 'string-pattern-notLike', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }], + }, + { + name: 'string-pattern-startsWith', + params: [ + { size: 100 }, + { userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } }, + ], + }, + { + name: 'string-similarity-ocr', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }], + }, + { + name: 'ids-any', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }], + }, + { + name: 'ids-all', + params: [ + { size: 100 }, + { userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } }, + ], + }, + { + name: 'ids-all-single', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { all: [DummyValue.UUID] } } }], + }, + { + name: 'ids-none', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }], + }, + { + name: 'ids-tags-all', + params: [ + { size: 100 }, + { userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } }, + ], + }, + { + name: 'has-albums-false', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }], + }, + { + name: 'is-encoded', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }], + }, + { + name: 'number-range', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }], + }, + { + name: 'date-eq', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }], + }, + { + name: 'date-range', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } }, + }, + ], + }, + { + name: 'order-fileSize-noExif', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc }, + withExif: false, + }, + ], + }, + { + name: 'order-rating-withExif', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc }, + withExif: true, + }, + ], + }, + { + name: 'or-branches', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + filter: { + or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }], + }, + }, + ], + }, + { + name: 'or-with-top-level', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + filter: { + takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE }, + or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }], + }, + }, + ], + }, +]; + +export const searchStatisticsV3Examples: GenerateSqlQueries[] = [ + { name: 'baseline', params: [{ userIds: [DummyValue.UUID] }] }, + { + name: 'with-filter', + params: [ + { + userIds: [DummyValue.UUID], + filter: { + takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE }, + fileSizeInBytes: { gte: 100 }, + }, + }, + ], + }, + { + name: 'with-or', + params: [ + { + userIds: [DummyValue.UUID], + filter: { + or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }], + }, + }, + ], + }, +]; + export type ReindexVectorIndexOptions = { indexName: string; lists?: number }; type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions; diff --git a/server/src/validation.ts b/server/src/validation.ts index 7188de1bed..f8de2a68ff 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -42,7 +42,7 @@ export function nonEmptyPartial(shape: T) { .object(shape) .partial() .refine((data) => Object.values(data as Record).some((value) => value !== undefined), { - message: 'At least one field must be provided', + message: `At least one of the following fields is required: ${Object.keys(shape).join(', ')}`, }); } From 3bd580e37d665f3c7affdc8ffc3116bc5735fc24 Mon Sep 17 00:00:00 2001 From: okxint <130782884+okxint@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:36:59 +0530 Subject: [PATCH 006/127] fix(web): restore correct back route when opening person asset via direct URL (#30129) --- .../[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 1ef13c8504..3fe707c6df 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -95,6 +95,8 @@ const getPreviousRoute = $page.url.searchParams.get(QueryParameter.PREVIOUS_ROUTE); if (getPreviousRoute && !isExternalUrl(getPreviousRoute)) { previousRoute = getPreviousRoute; + } else if ($page.params.assetId) { + previousRoute = Route.viewPerson(data.person); } if (action == 'merge') { viewMode = PersonPageViewMode.MERGE_PEOPLE; From cbb565b7b7acb4c5360e856d1557f4a987cd942e Mon Sep 17 00:00:00 2001 From: NOBOIKE <53275945+noboike@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:22:45 +0300 Subject: [PATCH 007/127] fix(web): mirror asset viewer navigation icons in RTL (#30151) --- .../components/asset-viewer/actions/NextAssetAction.svelte | 5 +++-- .../asset-viewer/actions/PreviousAssetAction.svelte | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/asset-viewer/actions/NextAssetAction.svelte b/web/src/lib/components/asset-viewer/actions/NextAssetAction.svelte index 6389cf95e0..429c45cf52 100644 --- a/web/src/lib/components/asset-viewer/actions/NextAssetAction.svelte +++ b/web/src/lib/components/asset-viewer/actions/NextAssetAction.svelte @@ -1,9 +1,10 @@ - +
{#if loading} diff --git a/web/src/lib/modals/AssetAddToAlbumModal.svelte b/web/src/lib/modals/AssetAddToAlbumModal.svelte index b35c125d08..7259dd8245 100644 --- a/web/src/lib/modals/AssetAddToAlbumModal.svelte +++ b/web/src/lib/modals/AssetAddToAlbumModal.svelte @@ -24,4 +24,4 @@ }; - + From 4d9a27691ee00de3e519a47e1906f6e906bfbde4 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:53:12 +0530 Subject: [PATCH 102/127] fix: action provider overrides (#30480) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/presentation/actions/action.dart | 4 +++- .../presentation/actions/archive.action.dart | 2 +- .../presentation/actions/delete.action.dart | 4 ++-- .../presentation/actions/download.action.dart | 2 +- .../actions/edit_asset.action.dart | 2 +- .../actions/edit_datetime.action.dart | 2 +- .../actions/edit_location.action.dart | 2 +- .../presentation/actions/favorite.action.dart | 2 +- .../lib/presentation/actions/lock.action.dart | 2 +- .../actions/remove_from_album.action.dart | 2 +- .../presentation/actions/restore.action.dart | 2 +- .../actions/set_album_cover.action.dart | 2 +- .../presentation/actions/share.action.dart | 2 +- .../actions/share_link.action.dart | 2 +- .../presentation/actions/stack.action.dart | 2 +- .../lib/presentation/actions/tag.action.dart | 2 +- .../presentation/actions/upload.action.dart | 2 +- .../presentation/presentation_context.dart | 19 +++++++++++-------- 18 files changed, 31 insertions(+), 26 deletions(-) diff --git a/mobile/lib/presentation/actions/action.dart b/mobile/lib/presentation/actions/action.dart index 072c2524be..d880b7ddf7 100644 --- a/mobile/lib/presentation/actions/action.dart +++ b/mobile/lib/presentation/actions/action.dart @@ -33,6 +33,7 @@ final assetsActionProvider = Provider.family.autoDispose, null => const {}, }, }), + dependencies: [multiSelectProvider], ); final clearSelectionProvider = Provider.family.autoDispose((ref, source) { @@ -41,10 +42,11 @@ final clearSelectionProvider = Provider.family.autoDispose, ActionSource>( (ref, source) => ref.watch(assetsActionProvider(source)).owned(ref.watch(authUserProvider).id), + dependencies: [assetsActionProvider], ); abstract class AssetActionBuilder extends ActionBuilder { diff --git a/mobile/lib/presentation/actions/archive.action.dart b/mobile/lib/presentation/actions/archive.action.dart index f04611c4c0..f8e4a1e038 100644 --- a/mobile/lib/presentation/actions/archive.action.dart +++ b/mobile/lib/presentation/actions/archive.action.dart @@ -22,7 +22,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, .map((asset) => asset.id) .toList(growable: false); return assetIds.isEmpty ? null : (shouldArchive: shouldArchive, assetIds: assetIds); -}); +}, dependencies: [ownedAssetsActionProvider]); class ArchiveAction extends AssetActionBuilder { const ArchiveAction({required super.source}); diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index 0fe297bd03..31d01b32ef 100644 --- a/mobile/lib/presentation/actions/delete.action.dart +++ b/mobile/lib/presentation/actions/delete.action.dart @@ -40,7 +40,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked); return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash); -}); +}, dependencies: [assetsActionProvider]); class DeleteAction extends AssetActionBuilder { const DeleteAction({required super.source}); @@ -149,7 +149,7 @@ final _cleanupStateProvider = Provider.family.autoDispose?, ActionS final assets = ref.watch(assetsActionProvider(source)); final assetIds = assets.backedUp().map((asset) => asset.localId).nonNulls.toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [assetsActionProvider]); class CleanupLocalAction extends AssetActionBuilder { const CleanupLocalAction({required super.source}); diff --git a/mobile/lib/presentation/actions/download.action.dart b/mobile/lib/presentation/actions/download.action.dart index 1303fa6b03..60a1395bcd 100644 --- a/mobile/lib/presentation/actions/download.action.dart +++ b/mobile/lib/presentation/actions/download.action.dart @@ -14,7 +14,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSou final assets = ref.watch(assetsActionProvider(source)); final remote = assets.remote().toList(growable: false); return remote.isEmpty ? null : remote; -}); +}, dependencies: [assetsActionProvider]); class DownloadAction extends AssetActionBuilder { const DownloadAction({required super.source}); diff --git a/mobile/lib/presentation/actions/edit_asset.action.dart b/mobile/lib/presentation/actions/edit_asset.action.dart index d0a7e73122..9b11032df9 100644 --- a/mobile/lib/presentation/actions/edit_asset.action.dart +++ b/mobile/lib/presentation/actions/edit_asset.action.dart @@ -28,7 +28,7 @@ final _stateProvider = Provider.family.autoDispose(( final assets = ref.watch(ownedAssetsActionProvider(source)); return assets.where((asset) => asset.isEditable).singleOrNull; -}); +}, dependencies: [ownedAssetsActionProvider]); class EditAssetAction extends AssetActionBuilder { const EditAssetAction({required super.source}); diff --git a/mobile/lib/presentation/actions/edit_datetime.action.dart b/mobile/lib/presentation/actions/edit_datetime.action.dart index a3c825c4db..31de23d1a7 100644 --- a/mobile/lib/presentation/actions/edit_datetime.action.dart +++ b/mobile/lib/presentation/actions/edit_datetime.action.dart @@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, } return (assetIds: assets.map((asset) => asset.id).toList(growable: false), origin: assets.singleOrNull); -}); +}, dependencies: [ownedAssetsActionProvider]); class EditDateTimeAction extends AssetActionBuilder { const EditDateTimeAction({required super.source}); diff --git a/mobile/lib/presentation/actions/edit_location.action.dart b/mobile/lib/presentation/actions/edit_location.action.dart index f83a98099c..5ce74a0f40 100644 --- a/mobile/lib/presentation/actions/edit_location.action.dart +++ b/mobile/lib/presentation/actions/edit_location.action.dart @@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, } return (assetIds: assets.map((asset) => asset.id).toList(growable: false), origin: assets.singleOrNull); -}); +}, dependencies: [ownedAssetsActionProvider]); class EditLocationAction extends AssetActionBuilder { const EditLocationAction({required super.source}); diff --git a/mobile/lib/presentation/actions/favorite.action.dart b/mobile/lib/presentation/actions/favorite.action.dart index 17c841b745..402d2f3833 100644 --- a/mobile/lib/presentation/actions/favorite.action.dart +++ b/mobile/lib/presentation/actions/favorite.action.dart @@ -18,7 +18,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final shouldFavorite = assets.favorite(isFavorite: false).isNotEmpty; final assetIds = assets.favorite(isFavorite: !shouldFavorite).map((asset) => asset.id).toList(growable: false); return (shouldFavorite: shouldFavorite, assetIds: assetIds); -}); +}, dependencies: [ownedAssetsActionProvider]); class FavoriteAction extends AssetActionBuilder { const FavoriteAction({required super.source}); diff --git a/mobile/lib/presentation/actions/lock.action.dart b/mobile/lib/presentation/actions/lock.action.dart index b7fd01ad18..3d090b712d 100644 --- a/mobile/lib/presentation/actions/lock.action.dart +++ b/mobile/lib/presentation/actions/lock.action.dart @@ -23,7 +23,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, // Only locking has an on-device copy to clean up; unlocking leaves the device alone. localIds: shouldLock ? targets.map((asset) => asset.localId).nonNulls.toList(growable: false) : const [], ); -}); +}, dependencies: [ownedAssetsActionProvider]); class LockAction extends AssetActionBuilder { const LockAction({required super.source}); diff --git a/mobile/lib/presentation/actions/remove_from_album.action.dart b/mobile/lib/presentation/actions/remove_from_album.action.dart index 3d648a9cfd..9e2d9d582f 100644 --- a/mobile/lib/presentation/actions/remove_from_album.action.dart +++ b/mobile/lib/presentation/actions/remove_from_album.action.dart @@ -11,7 +11,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(assetsActionProvider(source)); final assetIds = assets.remote().map((asset) => asset.id).toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [assetsActionProvider]); class RemoveFromAlbumAction extends AssetActionBuilder { final String albumId; diff --git a/mobile/lib/presentation/actions/restore.action.dart b/mobile/lib/presentation/actions/restore.action.dart index 0a2f34abf8..0a1b707b39 100644 --- a/mobile/lib/presentation/actions/restore.action.dart +++ b/mobile/lib/presentation/actions/restore.action.dart @@ -11,7 +11,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(ownedAssetsActionProvider(source)); final assetIds = assets.trashed().map((asset) => asset.id).toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [ownedAssetsActionProvider]); class RestoreAction extends AssetActionBuilder { const RestoreAction({required super.source}); diff --git a/mobile/lib/presentation/actions/set_album_cover.action.dart b/mobile/lib/presentation/actions/set_album_cover.action.dart index 0c16c9a9db..d5ad367eed 100644 --- a/mobile/lib/presentation/actions/set_album_cover.action.dart +++ b/mobile/lib/presentation/actions/set_album_cover.action.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/error_handler.dart'; final _stateProvider = Provider.family.autoDispose((ref, source) { final assets = ref.watch(assetsActionProvider(source)); return assets.remote().map((asset) => asset.id).singleOrNull; -}); +}, dependencies: [assetsActionProvider]); class SetAlbumCoverAction extends AssetActionBuilder { final String albumId; diff --git a/mobile/lib/presentation/actions/share.action.dart b/mobile/lib/presentation/actions/share.action.dart index 4c36493265..3b8a6318de 100644 --- a/mobile/lib/presentation/actions/share.action.dart +++ b/mobile/lib/presentation/actions/share.action.dart @@ -16,7 +16,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSourc final assets = ref.watch(assetsActionProvider(source)); final shareable = assets.toList(growable: false); return shareable.isEmpty ? null : shareable; -}); +}, dependencies: [assetsActionProvider]); class ShareAction extends AssetActionBuilder { const ShareAction({required super.source}); diff --git a/mobile/lib/presentation/actions/share_link.action.dart b/mobile/lib/presentation/actions/share_link.action.dart index 1966dd8811..4285105ed3 100644 --- a/mobile/lib/presentation/actions/share_link.action.dart +++ b/mobile/lib/presentation/actions/share_link.action.dart @@ -12,7 +12,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(assetsActionProvider(source)); final remoteIds = assets.remote().map((asset) => asset.id).toList(growable: false); return remoteIds.isEmpty ? null : remoteIds; -}); +}, dependencies: [assetsActionProvider]); class ShareLinkAction extends AssetActionBuilder { const ShareLinkAction({required super.source}); diff --git a/mobile/lib/presentation/actions/stack.action.dart b/mobile/lib/presentation/actions/stack.action.dart index 9697dc02be..5ab978a7e4 100644 --- a/mobile/lib/presentation/actions/stack.action.dart +++ b/mobile/lib/presentation/actions/stack.action.dart @@ -23,7 +23,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, assetIds: assets.map((asset) => asset.id).toList(growable: false), stackIds: assets.map((asset) => asset.stackId).nonNulls.toList(growable: false), ); -}); +}, dependencies: [ownedAssetsActionProvider]); class StackAction extends AssetActionBuilder { const StackAction({required super.source}); diff --git a/mobile/lib/presentation/actions/tag.action.dart b/mobile/lib/presentation/actions/tag.action.dart index 749b23d150..978648708f 100644 --- a/mobile/lib/presentation/actions/tag.action.dart +++ b/mobile/lib/presentation/actions/tag.action.dart @@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(ownedAssetsActionProvider(source)); final assetIds = assets.map((asset) => asset.id).toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [ownedAssetsActionProvider]); class TagAction extends AssetActionBuilder { const TagAction({required super.source}); diff --git a/mobile/lib/presentation/actions/upload.action.dart b/mobile/lib/presentation/actions/upload.action.dart index ceb35c8786..e52659beb7 100644 --- a/mobile/lib/presentation/actions/upload.action.dart +++ b/mobile/lib/presentation/actions/upload.action.dart @@ -16,7 +16,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSour final assets = ref.watch(assetsActionProvider(source)); final local = assets.backedUp(isBackedUp: false).local().toList(growable: false); return local.isEmpty ? null : local; -}); +}, dependencies: [assetsActionProvider]); class UploadAction extends AssetActionBuilder { final bool showProgress; diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 5b23890ef8..36ae8d087d 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -101,15 +101,18 @@ extension PumpPresentationWidget on WidgetTester { useFallbackTranslations: true, assetLoader: const CodegenLoader(), child: ProviderScope( - overrides: [...context.overrides, ...overrides], + overrides: context.overrides, child: Builder( - builder: (context) => MaterialApp( - debugShowCheckedModeBanner: false, - scaffoldMessengerKey: scaffoldMessengerKey, - localizationsDelegates: context.localizationDelegates, - supportedLocales: context.supportedLocales, - locale: context.locale, - home: Scaffold(body: widget), + builder: (context) => ProviderScope( + overrides: overrides, + child: MaterialApp( + debugShowCheckedModeBanner: false, + scaffoldMessengerKey: scaffoldMessengerKey, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + home: Scaffold(body: widget), + ), ), ), ), From e5c3bdad17da1c70bc59d2d09398f8b35d820746 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Mon, 3 Aug 2026 20:23:29 +0600 Subject: [PATCH 103/127] fix(mobile): sync stack changes from the websocket (#30479) --- mobile/lib/providers/websocket.provider.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 2eb8ddc2b4..c6ed09360a 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -103,7 +103,8 @@ class WebsocketNotifier extends StateNotifier { socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2); socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1); socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2); - socket.on('on_album_update', _handleAlbumUpdate); + socket.on('on_album_update', _handleRemoteChange); + socket.on('on_asset_stack_update', _handleRemoteChange); socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_new_release', _handleReleaseUpdates); } catch (e) { @@ -185,7 +186,7 @@ class WebsocketNotifier extends StateNotifier { unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data)); } - void _handleAlbumUpdate(dynamic _) { + void _handleRemoteChange(dynamic _) { unawaited(_ref.read(backgroundSyncProvider).syncRemote()); } From 46c42e0935bb5eab65e395478623eb65f097df2b Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 3 Aug 2026 16:50:28 +0200 Subject: [PATCH 104/127] chore: delete mergify config (#30521) --- .mergify.yml | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .mergify.yml diff --git a/.mergify.yml b/.mergify.yml deleted file mode 100644 index 12f3ef6715..0000000000 --- a/.mergify.yml +++ /dev/null @@ -1,7 +0,0 @@ -merge_queue: - status_comments: outcomes - -queue_rules: - - name: default - batch_size: 3 - batch_max_wait_time: 2 min From 0d7147dceca9290c5f8b4fe8b3e3b138aac2afc2 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:18:24 +0200 Subject: [PATCH 105/127] fix: metadata extraction as LensModel can be a float (#30512) --- .../src/repositories/metadata.repository.ts | 6 +++++- server/src/services/metadata.service.ts | 4 +++- .../specs/services/metadata.service.spec.ts | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 94047bf326..1d504f6c71 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -20,7 +20,8 @@ type TagsWithWrongTypes = | 'TagsList' | 'Keywords' | 'HierarchicalSubject' - | 'ISO'; + | 'ISO' + | 'LensModel'; export interface ImmichTags extends Omit { ContentIdentifier?: string; @@ -43,6 +44,9 @@ export interface ImmichTags extends Omit { Description?: StringOrNumber; ImageDescription?: StringOrNumber; + // Apparently LensModel can also be a float: https://github.com/immich-app/immich/issues/30492 + LensModel?: StringOrNumber; + // Extended properties for image regions, such as faces RegionInfo?: { AppliedToDimensions: { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 171dcfe514..37dd92e27d 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -117,7 +117,9 @@ const validateRange = (value: number | undefined, min: number, max: number): Non }; const getLensModel = (exifTags: ImmichTags): string | null => { - const lensModel = (exifTags.LensID ?? exifTags.LensType ?? exifTags.LensSpec ?? exifTags.LensModel ?? '').trim(); + const lensModel = String( + exifTags.LensID ?? exifTags.LensType ?? exifTags.LensSpec ?? exifTags.LensModel ?? '', + ).trim(); if (lensModel === '----') { return null; } diff --git a/server/test/medium/specs/services/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts index 6dc66e3ed5..37603520f7 100644 --- a/server/test/medium/specs/services/metadata.service.spec.ts +++ b/server/test/medium/specs/services/metadata.service.spec.ts @@ -152,4 +152,23 @@ describe(MetadataService.name, () => { ).resolves.toEqual({ dateTimeOriginal: new Date('4260-03-05T04:04:12.000Z') }); }); }); + + it('should handle float lens models (#30492)', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ LensModel: 1.8 }); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .where('assetId', '=', asset.id) + .select('lensModel') + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lensModel: '1.8' }); + }); }); From 29e7ea5302bc3f6ed1eb845706cc77cb34f40048 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:40:08 +0530 Subject: [PATCH 106/127] chore(web): use FUTO F-Droid repo in utilities (#30527) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- web/src/lib/modals/AppDownloadModal.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/lib/modals/AppDownloadModal.svelte b/web/src/lib/modals/AppDownloadModal.svelte index 84d7630909..01a998bf5c 100644 --- a/web/src/lib/modals/AppDownloadModal.svelte +++ b/web/src/lib/modals/AppDownloadModal.svelte @@ -1,5 +1,5 @@