diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 1a17385132..214d692848 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -409,7 +409,7 @@ To decrease Redis logs, you can add the following line to the `redis:` section o You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service. -[Example docker-compose.yml file](https://github.com/immich-app/immich/blob/main/docker/docker-compose.rootless.yml) +[Example docker-compose.rootless.yml file](https://github.com/immich-app/immich/blob/main/docker/docker-compose.rootless.yml) You may need to add mount points or docker volumes for the following internal container paths: diff --git a/i18n/en.json b/i18n/en.json index 53d1e4a6b2..1dbd50e294 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1524,6 +1524,7 @@ "not_available": "N/A", "not_in_any_album": "Not in any album", "not_selected": "Not selected", + "not_set": "Not set", "notes": "Notes", "nothing_here_yet": "Nothing here yet", "notification_backup_reliability": "Enable notifications to improve background backup reliability", diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7f42677909..da0f59f87d 100644 --- a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -140,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "8da8818fccd9959bd683934ddc62cf45bb65b3c8", - "version" : "0.31.1" + "revision" : "dafddd843f10630aa6b5be47c1b6a59cc4ab6586", + "version" : "0.34.0" } }, { diff --git a/mobile/ios/WidgetExtension/ImageWidgetView.swift b/mobile/ios/WidgetExtension/ImageWidgetView.swift index 8e810b051e..17088d43b8 100644 --- a/mobile/ios/WidgetExtension/ImageWidgetView.swift +++ b/mobile/ios/WidgetExtension/ImageWidgetView.swift @@ -17,44 +17,67 @@ struct ImmichWidgetView: View { var entry: ImageEntry var body: some View { - if entry.image == nil { - VStack { - Image("LaunchImage") - .tintedWidgetImageModifier() - Text(entry.metadata.error?.errorDescription ?? "") - .minimumScaleFactor(0.25) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - } - .padding(16) + if let image = entry.image { + ImmichWidgetContentView(image: image, subtitle: entry.metadata.subtitle, deepLink: entry.metadata.deepLink) } else { - ZStack(alignment: .leading) { - Color.clear.overlay( - Image(uiImage: entry.image!) - .resizable() - .tintedWidgetImageModifier() - .scaledToFill() - - ) - VStack { - Spacer() - if let subtitle = entry.metadata.subtitle { - Text(subtitle) - .foregroundColor(.white) - .padding(8) - .background(Color.black.opacity(0.6)) - .cornerRadius(8) - .font(.system(size: 16)) - } - } - .padding(16) - } - .widgetURL(entry.metadata.deepLink) + ImmichWidgetLoadingView(message: entry.metadata.error?.errorDescription) } } } +private struct ImmichWidgetLoadingView: View { + let message: String? + + var body: some View { + let messageText = Text(message ?? "") + .minimumScaleFactor(0.25) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + + VStack(spacing: 8) { + // This is used as a nicer way to center the image, rather than using offsets + messageText.hidden() + + Image("LaunchImage") + .tintedWidgetImageModifier() + + messageText + } + } +} + +private struct ImmichWidgetContentView: View { + let image: UIImage + let subtitle: String? + let deepLink: URL? + + var body: some View { + ZStack(alignment: .leading) { + Color.clear.overlay( + Image(uiImage: image) + .resizable() + .tintedWidgetImageModifier() + .scaledToFill() + ) + + VStack { + Spacer() + if let subtitle { + Text(subtitle) + .foregroundColor(.white) + .padding(6) + .background(ContainerRelativeShape().fill(Color.black.opacity(0.6))) + .font(.system(size: 16)) + } + } + .padding(16) + } + .widgetURL(deepLink) + } +} + #Preview( + "Medium", as: .systemMedium, widget: { ImmichRandomWidget() @@ -63,10 +86,93 @@ struct ImmichWidgetView: View { let date = Date() ImageEntry( date: date, - image: UIImage(named: "ImmichLogo"), + image: UIImage(named: "LaunchImage"), metadata: EntryMetadata( subtitle: "1 year ago" ) ) } ) + +#Preview( + "Medium No Data", + as: .systemMedium, + widget: { + ImmichRandomWidget() + }, + timeline: { + let date = Date() + ImageEntry( + date: date, + image: nil + ) + } +) + +#Preview( + "Medium No Data Error", + as: .systemMedium, + widget: { + ImmichRandomWidget() + }, + timeline: { + let date = Date() + ImageEntry( + date: date, + image: nil, + metadata: EntryMetadata(error: WidgetError.fetchFailed) + ) + } +) + +#Preview( + "Small", + as: .systemSmall, + widget: { + ImmichRandomWidget() + }, + timeline: { + let date = Date() + ImageEntry( + date: date, + image: UIImage(named: "LaunchImage"), + metadata: EntryMetadata( + subtitle: "Yesterday" + ) + ) + } +) + +#Preview( + "Small No Data Error", + as: .systemSmall, + widget: { + ImmichRandomWidget() + }, + timeline: { + let date = Date() + ImageEntry( + date: date, + image: nil, + metadata: EntryMetadata(error: WidgetError.fetchFailed) + ) + } +) + +#Preview( + "Large", + as: .systemLarge, + widget: { + ImmichRandomWidget() + }, + timeline: { + let date = Date() + ImageEntry( + date: date, + image: UIImage(named: "LaunchImage"), + metadata: EntryMetadata( + subtitle: "2000 seconds ago" + ) + ) + } +) diff --git a/mobile/ios/WidgetExtension/ImmichAPI.swift b/mobile/ios/WidgetExtension/ImmichAPI.swift index f5ee578dad..7e9dcd904a 100644 --- a/mobile/ios/WidgetExtension/ImmichAPI.swift +++ b/mobile/ios/WidgetExtension/ImmichAPI.swift @@ -25,7 +25,7 @@ extension WidgetError: LocalizedError { return "Login to Immich" case .fetchFailed: - return "Unable to connect to your Immich instance" + return "Unable to connect to Immich" case .albumNotFound: return "Album not found" 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/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'), diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index 6a66d7ed23..e4e11baf9d 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -153,6 +153,8 @@ class AppConfig { .timelineStorageIndicator => timeline.storageIndicator, .mapShowFavoriteOnly => map.favoritesOnly, .mapRelativeDate => map.relativeDays, + .mapCustomFrom => map.customFrom, + .mapCustomTo => map.customTo, .mapIncludeArchived => map.includeArchived, .mapThemeMode => map.themeMode, .mapWithPartners => map.withPartners, @@ -207,6 +209,8 @@ class AppConfig { .timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)), .mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)), .mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)), + .mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))), + .mapCustomTo => copyWith(map: map.copyWith(customTo: .fromNullable(value as DateTime?))), .mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)), .mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)), .mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)), diff --git a/mobile/lib/domain/models/config/map_config.dart b/mobile/lib/domain/models/config/map_config.dart index e37ab0f431..85cac80081 100644 --- a/mobile/lib/domain/models/config/map_config.dart +++ b/mobile/lib/domain/models/config/map_config.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:immich_mobile/utils/option.dart'; class MapConfig { final int relativeDays; @@ -6,13 +7,17 @@ class MapConfig { final bool includeArchived; final ThemeMode themeMode; final bool withPartners; + final DateTime? customFrom; + final DateTime? customTo; const MapConfig({ this.relativeDays = 0, this.favoritesOnly = false, this.includeArchived = false, - this.themeMode = ThemeMode.system, + this.themeMode = .system, this.withPartners = false, + this.customFrom, + this.customTo, }); MapConfig copyWith({ @@ -21,12 +26,16 @@ class MapConfig { bool? includeArchived, ThemeMode? themeMode, bool? withPartners, + Option? customFrom, + Option? customTo, }) => MapConfig( relativeDays: relativeDays ?? this.relativeDays, favoritesOnly: favoritesOnly ?? this.favoritesOnly, includeArchived: includeArchived ?? this.includeArchived, themeMode: themeMode ?? this.themeMode, withPartners: withPartners ?? this.withPartners, + customFrom: customFrom.patch(this.customFrom), + customTo: customTo.patch(this.customTo), ); @override @@ -37,12 +46,15 @@ class MapConfig { other.favoritesOnly == favoritesOnly && other.includeArchived == includeArchived && other.themeMode == themeMode && - other.withPartners == withPartners); + other.withPartners == withPartners && + other.customFrom == customFrom && + other.customTo == customTo); @override - int get hashCode => Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners); + int get hashCode => + Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners, customFrom, customTo); @override String toString() => - 'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners)'; + 'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners, customFrom: $customFrom, customTo: $customTo)'; } diff --git a/mobile/lib/domain/models/settings_key.dart b/mobile/lib/domain/models/settings_key.dart index 4308d69555..85e58ffcf1 100644 --- a/mobile/lib/domain/models/settings_key.dart +++ b/mobile/lib/domain/models/settings_key.dart @@ -55,6 +55,8 @@ enum SettingsKey { // Map mapShowFavoriteOnly(), mapRelativeDate(), + mapCustomFrom(), + mapCustomTo(), mapIncludeArchived(), mapThemeMode(codec: EnumCodec(ThemeMode.values)), mapWithPartners(), diff --git a/mobile/lib/domain/models/time_range.model.dart b/mobile/lib/domain/models/time_range.model.dart new file mode 100644 index 0000000000..2727a9d5c8 --- /dev/null +++ b/mobile/lib/domain/models/time_range.model.dart @@ -0,0 +1,15 @@ +import 'package:immich_mobile/utils/option.dart'; + +class TimeRange { + final DateTime? from; + final DateTime? to; + + const TimeRange({this.from, this.to}); + + TimeRange copyWith({Option? from, Option? to}) { + return TimeRange(from: from.patch(this.from), to: to.patch(this.to)); + } + + TimeRange clearFrom() => TimeRange(to: to); + TimeRange clearTo() => TimeRange(from: from); +} diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index d490b226c7..8161df5c51 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -6,7 +6,10 @@ import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/services/hash.service.dart'; +import 'package:immich_mobile/domain/services/local_sync.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/domain/services/sync_stream.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -14,11 +17,16 @@ import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.d import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; -import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; @@ -58,12 +66,43 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final BackgroundWorkerBgHostApi _backgroundHostApi; final _cancellationToken = Completer(); final Logger _logger = Logger('BackgroundWorkerBgService'); + late LocalSyncService _localSyncService; + late SyncStreamService _remoteSyncService; + late HashService _hashService; bool _isCleanedUp = false; BackgroundWorkerBgService({required this._drift, required this._driftLogger}) : _backgroundHostApi = BackgroundWorkerBgHostApi() { - _ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]); + final ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]); + _ref = ref; + _localSyncService = LocalSyncService( + localAlbumRepository: ref.read(localAlbumRepository), + localAssetRepository: ref.read(localAssetRepository), + nativeSyncApi: ref.read(nativeSyncApiProvider), + trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository), + assetMediaRepository: ref.read(assetMediaRepositoryProvider), + permissionRepository: ref.read(permissionRepositoryProvider), + cancellation: _cancellationToken, + ); + _remoteSyncService = SyncStreamService( + syncApiRepository: ref.read(syncApiRepositoryProvider), + syncStreamRepository: ref.read(syncStreamRepositoryProvider), + localAssetRepository: ref.read(localAssetRepository), + trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository), + assetMediaRepository: ref.read(assetMediaRepositoryProvider), + permissionRepository: ref.read(permissionRepositoryProvider), + syncMigrationRepository: ref.read(syncMigrationRepositoryProvider), + api: ref.read(apiServiceProvider), + cancellation: _cancellationToken, + ); + _hashService = HashService( + localAlbumRepository: ref.read(localAlbumRepository), + localAssetRepository: ref.read(localAssetRepository), + nativeSyncApi: ref.read(nativeSyncApiProvider), + trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository), + cancellation: _cancellationToken, + ); BackgroundWorkerFlutterApi.setUp(this); } @@ -119,11 +158,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { try { final budget = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null; - final sync = _ref?.read(backgroundSyncProvider); - if (sync == null) { - return; - } - // Only for Background Processing tasks if (maxSeconds == null) { await _optimizeDB(); @@ -135,9 +169,23 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { // hash and handle_backup read drift state and tolerate stale reads // (server-side dedup catches the rare race). The single budget caps the // whole batch; no phase needs its own timeout. - final all = Future.wait([sync.syncLocal(), sync.syncRemote(), sync.hashAssets(), _handleBackup()]); + final all = Future.wait([ + _localSyncService.sync(), + _remoteSyncService.sync(), + _hashService.hashAssets(), + _handleBackup(), + ]); if (budget != null) { - await all.timeout(budget, onTimeout: () => []); + await all.timeout( + budget, + onTimeout: () { + if (!_cancellationToken.isCompleted) { + _logger.warning("iOS background upload timed out after ${budget.inSeconds}s, cancelling tasks"); + _cancellationToken.complete(); + } + return []; + }, + ); } else { await all; } @@ -221,7 +269,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { try { _isCleanedUp = true; - final backgroundSyncManager = _ref?.read(backgroundSyncProvider); final nativeSyncApi = _ref?.read(nativeSyncApiProvider); _logger.info("Cleaning up background worker"); @@ -230,10 +277,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } // Workers share one sqlite connection, so DB teardown must wait until every worker has stopped using it. - await Future.wait([ - if (backgroundSyncManager != null) backgroundSyncManager.cancel(), - if (nativeSyncApi != null) nativeSyncApi.cancelHashing(), - ]); + await Future.wait([if (nativeSyncApi != null) nativeSyncApi.cancelHashing()]); await workerManagerPatch.dispose().catchError((_) async {}); await Future.wait([LogService.I.dispose(), Store.dispose()]); await _drift.close(); @@ -279,18 +323,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } Future _syncAssets({Duration? hashTimeout}) async { - await _ref?.read(backgroundSyncProvider).syncLocal(); + await _localSyncService.sync(); if (_isCleanedUp) { return false; } - final isSuccess = await _ref?.read(backgroundSyncProvider).syncRemote() ?? false; + final isSuccess = await _remoteSyncService.sync(); if (_isCleanedUp) { return isSuccess; } - var hashFuture = _ref?.read(backgroundSyncProvider).hashAssets(); - if (hashTimeout != null && hashFuture != null) { + var hashFuture = _hashService.hashAssets(); + if (hashTimeout != null) { hashFuture = hashFuture.timeout( hashTimeout, onTimeout: () { diff --git a/mobile/lib/infrastructure/repositories/map.repository.dart b/mobile/lib/infrastructure/repositories/map.repository.dart index 95e42337fc..267ea08d20 100644 --- a/mobile/lib/infrastructure/repositories/map.repository.dart +++ b/mobile/lib/infrastructure/repositories/map.repository.dart @@ -27,7 +27,18 @@ class DriftMapRepository extends DriftDatabaseRepository { condition = condition & _db.remoteAssetEntity.isFavorite.equals(true); } - if (options.relativeDays != 0) { + final timeRange = options.timeRange; + final hasCustomRange = timeRange.from != null || timeRange.to != null; + + if (hasCustomRange) { + if (timeRange.from != null) { + condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!); + } + + if (timeRange.to != null) { + condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!); + } + } else if (options.relativeDays > 0) { final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate); } diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index e3ccd07696..aae0ed467b 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 { @@ -298,4 +299,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/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 481eb6de1b..db41ae6275 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -4,6 +4,7 @@ import 'package:drift/drift.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/time_range.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; @@ -20,6 +21,7 @@ class TimelineMapOptions { final bool includeArchived; final bool withPartners; final int relativeDays; + final TimeRange timeRange; const TimelineMapOptions({ required this.bounds, @@ -27,6 +29,7 @@ class TimelineMapOptions { this.includeArchived = false, this.withPartners = false, this.relativeDays = 0, + this.timeRange = const TimeRange(), }); } @@ -550,8 +553,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository { query.where(_db.remoteAssetEntity.isFavorite.equals(true)); } - if (options.relativeDays != 0) { + final timeRange = options.timeRange; + + final hasCustomRange = timeRange.from != null || timeRange.to != null; + + if (hasCustomRange) { + if (timeRange.from != null) { + query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!)); + } + + if (timeRange.to != null) { + query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!)); + } + } else if (options.relativeDays > 0) { final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); + query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); } @@ -592,8 +608,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository { query.where(_db.remoteAssetEntity.isFavorite.equals(true)); } - if (options.relativeDays != 0) { + final timeRange = options.timeRange; + + final hasCustomRange = timeRange.from != null || timeRange.to != null; + + if (hasCustomRange) { + if (timeRange.from != null) { + query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!)); + } + + if (timeRange.to != null) { + query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!)); + } + } else if (options.relativeDays > 0) { final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); + query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); } diff --git a/mobile/lib/presentation/widgets/map/map.state.dart b/mobile/lib/presentation/widgets/map/map.state.dart index 57ce619771..f1b4f80ec1 100644 --- a/mobile/lib/presentation/widgets/map/map.state.dart +++ b/mobile/lib/presentation/widgets/map/map.state.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/models/time_range.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/providers/infrastructure/map.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; +import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class MapState { @@ -15,6 +17,7 @@ class MapState { final bool includeArchived; final bool withPartners; final int relativeDays; + final TimeRange timeRange; const MapState({ this.themeMode = ThemeMode.system, @@ -23,6 +26,7 @@ class MapState { this.includeArchived = false, this.withPartners = false, this.relativeDays = 0, + this.timeRange = const TimeRange(), }); @override @@ -40,6 +44,7 @@ class MapState { bool? includeArchived, bool? withPartners, int? relativeDays, + TimeRange? timeRange, }) { return MapState( bounds: bounds ?? this.bounds, @@ -48,6 +53,7 @@ class MapState { includeArchived: includeArchived ?? this.includeArchived, withPartners: withPartners ?? this.withPartners, relativeDays: relativeDays ?? this.relativeDays, + timeRange: timeRange ?? this.timeRange, ); } @@ -57,6 +63,7 @@ class MapState { includeArchived: includeArchived, withPartners: withPartners, relativeDays: relativeDays, + timeRange: timeRange, ); } @@ -103,6 +110,24 @@ class MapStateNotifier extends Notifier { EventStream.shared.emit(const MapMarkerReloadEvent()); } + void setCustomTimeRange(TimeRange range) { + ref.read(settingsProvider).write(.mapCustomFrom, range.from); + ref.read(settingsProvider).write(.mapCustomTo, range.to); + state = state.copyWith(timeRange: range); + EventStream.shared.emit(const MapMarkerReloadEvent()); + } + + Option parseDateOption(String s) { + try { + if (s.trim().isEmpty) { + return const Option.none(); + } + return Option.some(DateTime.parse(s)); + } catch (_) { + return const Option.none(); + } + } + @override MapState build() { final mapConfig = ref.read(appConfigProvider.select((config) => config.map)); @@ -113,6 +138,7 @@ class MapStateNotifier extends Notifier { withPartners: mapConfig.withPartners, relativeDays: mapConfig.relativeDays, bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)), + timeRange: TimeRange(from: mapConfig.customFrom, to: mapConfig.customTo), ); } } diff --git a/mobile/lib/presentation/widgets/map/map_settings_sheet.dart b/mobile/lib/presentation/widgets/map/map_settings_sheet.dart index c581dd6292..2680850bfe 100644 --- a/mobile/lib/presentation/widgets/map/map_settings_sheet.dart +++ b/mobile/lib/presentation/widgets/map/map_settings_sheet.dart @@ -1,21 +1,39 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/time_range.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; +import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart'; -class DriftMapSettingsSheet extends HookConsumerWidget { +class DriftMapSettingsSheet extends ConsumerStatefulWidget { const DriftMapSettingsSheet({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _DriftMapSettingsSheetState(); +} + +class _DriftMapSettingsSheetState extends ConsumerState { + late bool useCustomRange; + + @override + void initState() { + super.initState(); + final mapState = ref.read(mapStateProvider); + final timeRange = mapState.timeRange; + useCustomRange = timeRange.from != null || timeRange.to != null; + } + + @override + Widget build(BuildContext context) { final mapState = ref.watch(mapStateProvider); return DraggableScrollableSheet( expand: false, - initialChildSize: 0.6, + initialChildSize: useCustomRange ? 0.7 : 0.6, builder: (ctx, scrollController) => SingleChildScrollView( controller: scrollController, child: Card( @@ -47,10 +65,41 @@ class DriftMapSettingsSheet extends HookConsumerWidget { selected: mapState.withPartners, onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners), ), - MapTimeDropDown( - relativeTime: mapState.relativeDays, - onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time), - ), + if (useCustomRange) ...[ + MapTimeRange( + timeRange: mapState.timeRange, + onChanged: (range) { + ref.read(mapStateProvider.notifier).setCustomTimeRange(range); + }, + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () => setState(() { + useCustomRange = false; + ref.read(mapStateProvider.notifier).setRelativeTime(0); + ref.read(mapStateProvider.notifier).setCustomTimeRange(const TimeRange()); + }), + child: Text(context.t.remove_custom_date_range), + ), + ), + ] else ...[ + MapTimeDropDown( + relativeTime: mapState.relativeDays, + onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () => setState(() { + useCustomRange = true; + ref.read(mapStateProvider.notifier).setRelativeTime(0); + ref.read(mapStateProvider.notifier).setCustomTimeRange(const TimeRange()); + }), + child: Text(context.t.use_custom_date_range), + ), + ), + ], const SizedBox(height: 20), ], ), 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/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/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/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/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/services/api.service.dart b/mobile/lib/services/api.service.dart index 59ef935f2f..ab05ffc18f 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -96,12 +96,12 @@ class ApiService { /// port - optional (default: based on schema) /// path - optional Future resolveEndpoint(String serverUrl) async { - String url = sanitizeUrl(serverUrl); + String url = normalizeServerUrl(serverUrl); // Check for /.well-known/immich final wellKnownEndpoint = await _getWellKnownEndpoint(url); if (wellKnownEndpoint.isNotEmpty) { - url = sanitizeUrl(wellKnownEndpoint); + url = normalizeServerUrl(wellKnownEndpoint); } if (!await _isEndpointAvailable(url)) { diff --git a/mobile/lib/utils/option.dart b/mobile/lib/utils/option.dart index d98dad1995..93e939a6c0 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,11 +22,27 @@ 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(), }; + Option flatMap(Option Function(T value) f) => switch (this) { + Some(:final value) => f(value), + None() => const Option.none(), + }; + + void ifPresent(void Function(T value) f) { + if (this case Some(:final value)) { + f(value); + } + } + @override String toString() => switch (this) { Some(:final value) => 'Some($value)', @@ -65,3 +82,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(), + }; +} diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart index b7dc41c4cf..3bcdca06f3 100644 --- a/mobile/lib/utils/url_helper.dart +++ b/mobile/lib/utils/url_helper.dart @@ -2,12 +2,31 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:punycode/punycode.dart'; -String sanitizeUrl(String url) { +/// Normalizes a server URL, guaranteeing that it has a schema and no trailing slashes +String normalizeServerUrl(String url) { + final trimmedUrl = url.trim(); + // Add schema if none is set - final urlWithSchema = url.trimLeft().startsWith(RegExp(r"https?://")) ? url : "https://$url"; + final urlWithSchema = trimmedUrl.contains('://') ? trimmedUrl : "https://$trimmedUrl"; // Remove trailing slash(es) - return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), ""); + return urlWithSchema.replaceFirst(RegExp(r"/+$"), ""); +} + +/// Validates a user-entered server URL +bool _validateServerUrl(String url) { + final parsedUrl = Uri.tryParse(url); + return parsedUrl != null && parsedUrl.scheme.startsWith(RegExp(r'^https?$')) && parsedUrl.host.isNotEmpty; +} + +/// Normalizes and validates that a server URL is supported +bool normalizeAndValidateServerUrl(String? url) { + if (url == null || url.isEmpty) { + return true; + } + + final normalizedUrl = normalizeServerUrl(url); + return _validateServerUrl(normalizedUrl); } String? getServerUrl() { diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 17e5afe262..79617f8fe4 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -43,18 +43,7 @@ class LoginForm extends HookConsumerWidget { final log = Logger('LoginForm'); - String? _validateUrl(String? url) { - if (url == null || url.isEmpty) { - return null; - } - - final parsedUrl = Uri.tryParse(url); - if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) { - return 'login_form_err_invalid_url'.tr(); - } - - return null; - } + String? _validateUrl(String? url) => normalizeAndValidateServerUrl(url) ? null : 'login_form_err_invalid_url'.tr(); String? _validateEmail(String? email) { if (email == null || email == '') { @@ -101,7 +90,7 @@ class LoginForm extends HookConsumerWidget { /// Fetch the server login credential and enables oAuth login if necessary /// Returns true if successful, false otherwise Future getServerAuthSettings() async { - final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text); + final sanitizeServerUrl = normalizeServerUrl(serverEndpointController.text); final serverUrl = punycodeEncodeUrl(sanitizeServerUrl); // Guard empty URL @@ -304,7 +293,7 @@ class LoginForm extends HookConsumerWidget { try { oAuthServerUrl = await oAuthService.getOAuthServerUrl( - sanitizeUrl(serverEndpointController.text), + normalizeServerUrl(serverEndpointController.text), state, codeChallenge, ); @@ -436,7 +425,7 @@ class LoginForm extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(bottom: ImmichSpacing.md), child: Text( - sanitizeUrl(serverEndpointController.text), + normalizeServerUrl(serverEndpointController.text), style: context.textTheme.displaySmall, textAlign: TextAlign.center, ), diff --git a/mobile/lib/widgets/map/map_settings/map_custom_time_range.dart b/mobile/lib/widgets/map/map_settings/map_custom_time_range.dart new file mode 100644 index 0000000000..6fa929852a --- /dev/null +++ b/mobile/lib/widgets/map/map_settings/map_custom_time_range.dart @@ -0,0 +1,73 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/time_range.model.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/utils/option.dart'; + +class MapTimeRange extends StatelessWidget { + const MapTimeRange({super.key, required this.timeRange, required this.onChanged}); + + final TimeRange timeRange; + final Function(TimeRange) onChanged; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(context.t.date_after), + subtitle: Text( + timeRange.from != null + ? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.from!) + : context.t.not_set, + ), + trailing: timeRange.from != null + ? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearFrom())) + : null, + onTap: () async { + final initial = timeRange.from ?? DateTime.now(); + final currentTo = timeRange.to; + + final picked = await showDatePicker( + context: context, + initialDate: currentTo != null && initial.isAfter(currentTo) ? currentTo : initial, + firstDate: DateTime(1970), + lastDate: currentTo ?? DateTime.now(), + ); + + if (picked != null) { + onChanged(timeRange.copyWith(from: Option.some(picked))); + } + }, + ), + ListTile( + title: Text(context.t.date_before), + subtitle: Text( + timeRange.to != null + ? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.to!) + : context.t.not_set, + ), + trailing: timeRange.to != null + ? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearTo())) + : null, + onTap: () async { + final initial = timeRange.to ?? DateTime.now(); + final currentFrom = timeRange.from; + + final picked = await showDatePicker( + context: context, + initialDate: currentFrom != null && initial.isBefore(currentFrom) ? currentFrom : initial, + firstDate: currentFrom ?? DateTime(1970), + lastDate: DateTime.now(), + ); + + if (picked != null) { + onChanged(timeRange.copyWith(to: Option.some(picked))); + } + }, + ), + ], + ); + } +} diff --git a/mobile/mise.lock b/mobile/mise.lock index 61aff529ec..fda7ec56b0 100644 --- a/mobile/mise.lock +++ b/mobile/mise.lock @@ -1,29 +1,30 @@ # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html [[tools."aqua:flutter/flutter"]] -version = "3.44.6" +version = "3.44.8" backend = "aqua:flutter/flutter" [tools."aqua:flutter/flutter"."platforms.linux-arm64"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz" [tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz" [tools."aqua:flutter/flutter"."platforms.linux-x64"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz" [tools."aqua:flutter/flutter"."platforms.linux-x64-musl"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz" [tools."aqua:flutter/flutter"."platforms.macos-arm64"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.6-stable.zip" +checksum = "blake3:79c780876c64f5a66c015845f544bdb20275f7f3bf819e6c831f23df3e96b8db" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.8-stable.zip" [tools."aqua:flutter/flutter"."platforms.macos-x64"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.6-stable.zip" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.8-stable.zip" [tools."aqua:flutter/flutter"."platforms.windows-x64"] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.6-stable.zip" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.8-stable.zip" [[tools."github:CQLabs/homebrew-dcm"]] version = "1.37.0" diff --git a/mobile/mise.toml b/mobile/mise.toml index 085f89e017..c5f06af006 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -1,5 +1,5 @@ [tools] -"aqua:flutter/flutter" = "3.44.6" +"aqua:flutter/flutter" = "3.44.8" java = "21.0.2" [tools."github:CQLabs/homebrew-dcm"] diff --git a/mobile/packages/ui/lib/src/components/column_button.dart b/mobile/packages/ui/lib/src/components/column_button.dart index bf0023c1bc..f990dbc065 100644 --- a/mobile/packages/ui/lib/src/components/column_button.dart +++ b/mobile/packages/ui/lib/src/components/column_button.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:core'; import 'package:flutter/material.dart'; import 'package:immich_ui/src/constants.dart'; @@ -8,6 +9,7 @@ class ImmichColumnButton extends StatefulWidget { final IconData icon; final String label; final FutureOr Function() onPressed; + final FutureOr Function()? onLongPress; final bool disabled; final bool? loading; @@ -16,6 +18,7 @@ class ImmichColumnButton extends StatefulWidget { required this.icon, required this.label, required this.onPressed, + this.onLongPress, this.disabled = false, this.loading, }); @@ -25,26 +28,44 @@ class ImmichColumnButton extends StatefulWidget { } class _ImmichColumnButtonState extends State { - bool _loading = false; - bool get _isLoading => widget.loading ?? _loading; + bool _running = false; + bool get _isLoading => widget.loading ?? _running; + bool get _isDisabled => widget.disabled || _isLoading; - Future _onPressed() async { - setState(() => _loading = true); + Future _runAction(FutureOr Function() action) async { + setState(() => _running = true); try { - await widget.onPressed(); + await action(); } finally { if (mounted) { - setState(() => _loading = false); + setState(() => _running = false); } } } + Future? _onPressed() { + if (_isDisabled) { + return null; + } + + return _runAction(widget.onPressed); + } + + Future? _onLongPress() { + if (_isDisabled || widget.onLongPress == null) { + return null; + } + + return _runAction(widget.onLongPress!); + } + @override Widget build(BuildContext context) { final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface; return TextButton( - onPressed: widget.disabled || _isLoading ? null : _onPressed, + onPressed: _onPressed, + onLongPress: _onLongPress, style: TextButton.styleFrom( foregroundColor: foreground, padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md), diff --git a/mobile/packages/ui/lib/src/components/icon_button.dart b/mobile/packages/ui/lib/src/components/icon_button.dart index b9bdd3a406..1c02f30ed3 100644 --- a/mobile/packages/ui/lib/src/components/icon_button.dart +++ b/mobile/packages/ui/lib/src/components/icon_button.dart @@ -7,6 +7,7 @@ import 'package:immich_ui/src/internal.dart'; class ImmichIconButton extends StatefulWidget { final IconData icon; final FutureOr Function() onPressed; + final FutureOr Function()? onLongPress; final ImmichVariant variant; final ImmichColor color; final bool disabled; @@ -16,6 +17,7 @@ class ImmichIconButton extends StatefulWidget { super.key, required this.icon, required this.onPressed, + this.onLongPress, this.color = .primary, this.variant = .filled, this.disabled = false, @@ -27,20 +29,37 @@ class ImmichIconButton extends StatefulWidget { } class _ImmichIconButtonState extends State { - bool _loading = false; - bool get _isLoading => widget.loading ?? _loading; + bool _running = false; + bool get _isLoading => widget.loading ?? _running; + bool get _isDisabled => widget.disabled || _isLoading; - Future _onPressed() async { - setState(() => _loading = true); + Future _runAction(FutureOr Function() action) async { + setState(() => _running = true); try { - await widget.onPressed(); + await action(); } finally { if (mounted) { - setState(() => _loading = false); + setState(() => _running = false); } } } + Future? _onPressed() { + if (_isDisabled) { + return null; + } + + return _runAction(widget.onPressed); + } + + Future? _onLongPress() { + if (_isDisabled || widget.onLongPress == null) { + return null; + } + + return _runAction(widget.onLongPress!); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -73,7 +92,8 @@ class _ImmichIconButtonState extends State { child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md), ) : Icon(widget.icon), - onPressed: widget.disabled || _isLoading ? null : _onPressed, + onPressed: _onPressed, + onLongPress: _onLongPress, style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground), ); } diff --git a/mobile/packages/ui/lib/src/components/text_button.dart b/mobile/packages/ui/lib/src/components/text_button.dart index fb3459897c..bbcfe4a3a7 100644 --- a/mobile/packages/ui/lib/src/components/text_button.dart +++ b/mobile/packages/ui/lib/src/components/text_button.dart @@ -7,6 +7,7 @@ class ImmichTextButton extends StatefulWidget { final String labelText; final IconData? icon; final FutureOr Function() onPressed; + final FutureOr Function()? onLongPress; final ImmichVariant variant; final bool expanded; final bool disabled; @@ -17,6 +18,7 @@ class ImmichTextButton extends StatefulWidget { required this.labelText, this.icon, required this.onPressed, + this.onLongPress, this.variant = .filled, this.expanded = true, @@ -29,20 +31,37 @@ class ImmichTextButton extends StatefulWidget { } class _ImmichTextButtonState extends State { - bool _loading = false; - bool get _isLoading => widget.loading ?? _loading; + bool _running = false; + bool get _isLoading => widget.loading ?? _running; + bool get _isDisabled => widget.disabled || _isLoading; - Future _onPressed() async { - setState(() => _loading = true); + Future _runAction(FutureOr Function() action) async { + setState(() => _running = true); try { - await widget.onPressed(); + await action(); } finally { if (mounted) { - setState(() => _loading = false); + setState(() => _running = false); } } } + Future? _onPressed() { + if (_isDisabled) { + return null; + } + + return _runAction(widget.onPressed); + } + + Future? _onLongPress() { + if (_isDisabled || widget.onLongPress == null) { + return null; + } + + return _runAction(widget.onLongPress!); + } + @override Widget build(BuildContext context) { final Widget? icon = _isLoading @@ -59,11 +78,22 @@ class _ImmichTextButtonState extends State { style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold), ); final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md)); - final onPressed = widget.disabled || _isLoading ? null : _onPressed; final button = switch (widget.variant) { - ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label), - ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label), + ImmichVariant.filled => ElevatedButton.icon( + style: style, + onPressed: _onPressed, + onLongPress: _onLongPress, + icon: icon, + label: label, + ), + ImmichVariant.ghost => TextButton.icon( + style: style, + onPressed: _onPressed, + onLongPress: _onLongPress, + icon: icon, + label: label, + ), }; if (widget.expanded) { 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(); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index f8caf6f6c9..caee4cde22 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -2014,4 +2014,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: "3.44.6" + flutter: "3.44.8" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f66d55dc99..f89b95b7dc 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -6,7 +6,7 @@ version: 3.0.3+3056 environment: sdk: '>=3.12.0 <4.0.0' - flutter: 3.44.6 + flutter: 3.44.8 dependencies: async: ^2.13.1 diff --git a/mobile/test/modules/utils/url_helper_test.dart b/mobile/test/modules/utils/url_helper_test.dart index 0e8a8e2aa0..245f68e767 100644 --- a/mobile/test/modules/utils/url_helper_test.dart +++ b/mobile/test/modules/utils/url_helper_test.dart @@ -77,6 +77,40 @@ void main() { }); }); + group('normalizeAndValidateServerUrl', () { + test('should treat null as valid', () { + expect(normalizeAndValidateServerUrl(null), isTrue); + }); + + test('should treat empty string as valid', () { + expect(normalizeAndValidateServerUrl(''), isTrue); + }); + + test('should accept a bare host', () { + expect(normalizeAndValidateServerUrl('demo.immich.app'), isTrue); + }); + + test('should accept a bare host with a port', () { + expect(normalizeAndValidateServerUrl('192.168.1.1:2283'), isTrue); + }); + + test('should accept an http URL', () { + expect(normalizeAndValidateServerUrl('http://demo.immich.app'), isTrue); + }); + + test('should accept an https URL', () { + expect(normalizeAndValidateServerUrl('https://demo.immich.app:2283/api'), isTrue); + }); + + test('should reject a non-http scheme', () { + expect(normalizeAndValidateServerUrl('ftp://demo.immich.app'), isFalse); + }); + + test('should reject scheme only input', () { + expect(normalizeAndValidateServerUrl('https://'), isFalse); + }); + }); + group('punycodeDecodeUrl', () { test('should return null for null input', () { expect(punycodeDecodeUrl(null), isNull); 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)); + }); + } + } + }); +} diff --git a/mobile/test/presentation/widgets/timeline/timeline_args_test.dart b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart index 4eb49bd3d6..0828e8e989 100644 --- a/mobile/test/presentation/widgets/timeline/timeline_args_test.dart +++ b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart @@ -25,6 +25,8 @@ class _FrozenBucketService implements TimelineService { } class _EmptyBucketService implements TimelineService { + const _EmptyBucketService(); + @override Stream> Function() get watchBuckets => () => Stream.value(const []); @@ -109,7 +111,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ - timelineServiceProvider.overrideWithValue(_EmptyBucketService()), + timelineServiceProvider.overrideWithValue(const _EmptyBucketService()), appConfigProvider.overrideWithValue(const AppConfig()), ], child: MaterialApp( diff --git a/mobile/test/unit/utils/value_codec_test.dart b/mobile/test/unit/utils/value_codec_test.dart new file mode 100644 index 0000000000..8754af1e95 --- /dev/null +++ b/mobile/test/unit/utils/value_codec_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/value_codec.dart'; + +enum _Fruit { apple, banana, cherry } + +void main() { + group('MapCodec', () { + group('encode', () { + test('serializes an empty map to an empty JSON object', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.string); + expect(codec.encode({}), '{}'); + }); + + test('encodes a string-to-string map as a JSON object', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.string); + expect(codec.encode({'a': '1', 'b': '2'}), '{"a":"1","b":"2"}'); + }); + + test('stringifies non-string values via the value codec', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.integer); + expect(codec.encode({'x': 10, 'y': 20}), '{"x":"10","y":"20"}'); + }); + + test('stringifies non-string keys via the key codec', () { + const codec = MapCodec(PrimitiveCodec.integer, PrimitiveCodec.string); + expect(codec.encode({1: 'one', 2: 'two'}), '{"1":"one","2":"two"}'); + }); + }); + + group('decode', () { + test('reconstructs a string-to-string map', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.string); + expect(codec.decode('{"a":"1","b":"2"}'), {'a': '1', 'b': '2'}); + }); + + test('parses values back to their domain type', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.integer); + expect(codec.decode('{"x":"10","y":"20"}'), {'x': 10, 'y': 20}); + }); + + test('parses keys back to their domain type', () { + const codec = MapCodec(PrimitiveCodec.integer, PrimitiveCodec.string); + expect(codec.decode('{"1":"one","2":"two"}'), {1: 'one', 2: 'two'}); + }); + + test('returns an empty map for an empty JSON object', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.string); + expect(codec.decode('{}'), isEmpty); + }); + + test('returns an empty map when the payload is not valid JSON', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.string); + expect(codec.decode('not json'), isEmpty); + }); + + test('returns an empty map when the JSON root is not an object', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.string); + expect(codec.decode('[]'), isEmpty); + expect(codec.decode('"a string"'), isEmpty); + expect(codec.decode('42'), isEmpty); + }); + + test('skips entries whose value is not a JSON string, keeping the rest', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.integer); + expect(codec.decode('{"x":1,"y":"20"}'), {'y': 20}); + }); + + test('skips entries whose value is a nested object, keeping the rest', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.string); + expect(codec.decode('{"a":{"nested":"value"},"b":"ok"}'), {'b': 'ok'}); + }); + + test('returns an empty map when every entry is malformed', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.integer); + expect(codec.decode('{"x":1,"y":2}'), isEmpty); + }); + }); + + group('round trip', () { + test('preserves a primitive map through encode then decode', () { + const codec = MapCodec(PrimitiveCodec.string, PrimitiveCodec.integer); + const original = {'one': 1, 'two': 2, 'three': 3}; + expect(codec.decode(codec.encode(original)), original); + }); + + test('preserves an enum-valued map by composing with EnumCodec', () { + const codec = MapCodec(PrimitiveCodec.string, EnumCodec(_Fruit.values)); + const original = {'breakfast': _Fruit.banana, 'snack': _Fruit.apple}; + expect(codec.decode(codec.encode(original)), original); + }); + + test('preserves a DateTime-valued map by composing with DateTimeCodec', () { + const codec = MapCodec(PrimitiveCodec.string, DateTimeCodec()); + final original = {'created': DateTime.utc(2024, 1, 1, 12, 30)}; + expect(codec.decode(codec.encode(original)), original); + }); + }); + }); +} 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/schema/migrations/1784836013770-MinFacePreferenceMigration.ts b/server/src/schema/migrations/1784836013770-MinFacePreferenceMigration.ts new file mode 100644 index 0000000000..74d28768e1 --- /dev/null +++ b/server/src/schema/migrations/1784836013770-MinFacePreferenceMigration.ts @@ -0,0 +1,25 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + INSERT INTO "user_metadata" ("userId", "key", "value") + SELECT "user"."id", 'preferences', jsonb_build_object('people', jsonb_build_object('minimumFaces', "config"."minFaces")) + FROM "user" + CROSS JOIN ( + SELECT "value"->'machineLearning'->'facialRecognition'->'minFaces' AS "minFaces" + FROM "system_metadata" + WHERE "key" = 'system-config' + AND "value"->'machineLearning'->'facialRecognition'->'minFaces' IS NOT NULL + ) AS "config" + ON CONFLICT ("userId", "key") DO UPDATE + SET "value" = "user_metadata"."value" || jsonb_build_object( + 'people', + COALESCE("user_metadata"."value"->'people', '{}'::jsonb) || (EXCLUDED."value"->'people') + ) + WHERE "user_metadata"."value"->'people'->'minimumFaces' IS NULL + `.execute(db); +} + +export async function down(): Promise { + // not supported +} diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 58b797a18b..290bf947a1 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1507,12 +1507,17 @@ describe(MediaService.name, () => { }); describe('handleGeneratePersonThumbnail', () => { - it('should skip if machine learning is disabled', async () => { + it('should generate a thumbnail even if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); + mocks.media.generateThumbnail.mockResolvedValue(); + mocks.media.decodeImage.mockResolvedValue({ + data: Buffer.from(''), + info: { width: 1000, height: 1000 } as OutputInfo, + }); - await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Skipped); - expect(mocks.asset.getByIds).not.toHaveBeenCalled(); - expect(mocks.systemMetadata.get).toHaveBeenCalled(); + await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Success); + expect(mocks.media.generateThumbnail).toHaveBeenCalled(); }); it('should skip a person not found', async () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 37817e0508..956636dd51 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -43,7 +43,7 @@ import { getAssetFile, getDimensions } from 'src/utils/asset.util'; import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; -import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; +import { clamp } from 'src/utils/misc'; import { getOutputDimensions } from 'src/utils/transform'; interface UpsertFileOptions { @@ -410,11 +410,7 @@ export class MediaService extends BaseService { @OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration }) async handleGeneratePersonThumbnail({ id }: JobOf): Promise { - const { machineLearning, metadata, image } = await this.getConfig({ withCache: true }); - if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) { - return JobStatus.Skipped; - } - + const { image } = await this.getConfig({ withCache: true }); const data = await this.personRepository.getDataForThumbnailGenerationJob(id); if (!data) { this.logger.error(`Could not generate person thumbnail for ${id}: missing data`); 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(', ')}`, }); } diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index 5596a47fdc..89b10b2d77 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -63,6 +63,7 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk'; import { untrack, type Snippet } from 'svelte'; + import { languageManager } from '$lib/managers/language-manager.svelte'; type Props = { asset: AssetResponseDto; @@ -232,7 +233,7 @@ style:width={rasterWidth} style:height={rasterHeight} style:transform="scale({rasterScale})" - style:transform-origin="0 0" + style:transform-origin={languageManager.rtl ? 'right top' : 'left top'} style:will-change={maxRasterPixels > 0 ? 'transform' : undefined} > {#if show.alphaBackground} 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 @@