Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
e736719dd8 chore(deps): update machine-learning 2026-07-22 09:56:15 +00:00
66 changed files with 654 additions and 3217 deletions

View File

@@ -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.rootless.yml file](https://github.com/immich-app/immich/blob/main/docker/docker-compose.rootless.yml)
[Example docker-compose.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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -35,7 +35,6 @@ Found Admin:
- Email=admin@example.com
- Name=Immich Admin
? Please choose a new password (optional) immich-is-cool
? Invalidate existing sessions? Yes
The admin password has been updated.
```

View File

@@ -650,7 +650,7 @@ describe(`immich upload`, () => {
]);
expect(stdout).toBe('');
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '--skip-hash'`);
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '-h, --skip-hash'`);
expect(exitCode).not.toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });

View File

@@ -65,10 +65,6 @@ describe(`immich-admin`, () => {
child.stdout.on('data', (chunk) => {
data += chunk;
if (data.includes('Please choose a new password (optional)')) {
child.stdin.write('\n');
}
if (data.includes('Invalidate existing sessions?')) {
child.stdin.end('\n');
}
});

View File

@@ -1524,7 +1524,6 @@
"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",

View File

@@ -1,8 +1,8 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:20ec607c68642c64c73269ce245aa0f060913f4129d15d9687850aa158e6269c AS builder-cpu
FROM python:3.11-bookworm@sha256:5c34b355088846dddc8afb7442c20b9433dccdc8d66192dc52c616adeaa106a3 AS builder-cpu
FROM python:3.13-slim-trixie@sha256:f82c96458eedc847b233e582eb31336f4954b39cae020b6dcf5b3ed0e5cbcd74 AS builder-openvino
FROM python:3.13-slim-trixie@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS builder-openvino
FROM builder-cpu AS builder-cuda
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
FROM python:3.11-slim-bookworm@sha256:e2d3af735aff6eeee600b1933bedd99da6645fedf572cc12ef4cc1331f2ceebe AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:b18992999dbe963a45a8a4da40ac2b1975be1a776d939d098c647482bcad5cba AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.13-slim-trixie@sha256:f82c96458eedc847b233e582eb31336f4954b39cae020b6dcf5b3ed0e5cbcd74 AS prod-openvino
FROM python:3.13-slim-trixie@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \

725
machine-learning/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -94,23 +94,6 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
@@ -129,12 +112,17 @@ version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
@@ -145,6 +133,18 @@ checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
backend = "github:webassembly/binaryen"

View File

@@ -1,4 +1,4 @@
monorepo_root = true
experimental_monorepo_root = true
[monorepo]
config_roots = [
@@ -35,6 +35,7 @@ macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" }
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
[settings]
experimental = true
pin = true
lockfile = true

View File

@@ -140,8 +140,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-structured-queries",
"state" : {
"revision" : "dafddd843f10630aa6b5be47c1b6a59cc4ab6586",
"version" : "0.34.0"
"revision" : "8da8818fccd9959bd683934ddc62cf45bb65b3c8",
"version" : "0.31.1"
}
},
{

View File

@@ -83,13 +83,7 @@ struct ImmichMemoryProvider: TimelineProvider {
return
}
let memories: [MemoryResult]
do {
memories = try await api.fetchMemory(for: Date.now)
} catch {
completion(ImageEntry.handleError(for: cacheKey))
return
}
let memories = try await api.fetchMemory(for: Date.now)
await withTaskGroup(of: ImageEntry?.self) { group in
var totalAssets = 0

View File

@@ -9,6 +9,8 @@ enum SortOrder {
enum TextSearchType { context, filename, description, ocr }
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum ActionSource { timeline, viewer }
enum ShareAssetType { original, preview }

View File

@@ -6,7 +6,6 @@ const Map<String, Locale> 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'),

View File

@@ -153,8 +153,6 @@ 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,
@@ -209,8 +207,6 @@ 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)),

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/option.dart';
class MapConfig {
final int relativeDays;
@@ -7,17 +6,13 @@ 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 = .system,
this.themeMode = ThemeMode.system,
this.withPartners = false,
this.customFrom,
this.customTo,
});
MapConfig copyWith({
@@ -26,16 +21,12 @@ class MapConfig {
bool? includeArchived,
ThemeMode? themeMode,
bool? withPartners,
Option<DateTime>? customFrom,
Option<DateTime>? 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
@@ -46,15 +37,12 @@ class MapConfig {
other.favoritesOnly == favoritesOnly &&
other.includeArchived == includeArchived &&
other.themeMode == themeMode &&
other.withPartners == withPartners &&
other.customFrom == customFrom &&
other.customTo == customTo);
other.withPartners == withPartners);
@override
int get hashCode =>
Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners, customFrom, customTo);
int get hashCode => Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners);
@override
String toString() =>
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners, customFrom: $customFrom, customTo: $customTo)';
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners)';
}

View File

@@ -55,8 +55,6 @@ enum SettingsKey<T> {
// Map
mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(),
mapCustomFrom<DateTime?>(),
mapCustomTo<DateTime?>(),
mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(),

View File

@@ -1,15 +0,0 @@
import 'package:immich_mobile/utils/option.dart';
class TimeRange {
final DateTime? from;
final DateTime? to;
const TimeRange({this.from, this.to});
TimeRange copyWith({Option<DateTime>? from, Option<DateTime>? to}) {
return TimeRange(from: from.patch(this.from), to: to.patch(this.to));
}
TimeRange clearFrom() => TimeRange(to: to);
TimeRange clearTo() => TimeRange(from: from);
}

View File

@@ -6,10 +6,7 @@ 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';
@@ -17,16 +14,11 @@ 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/api.provider.dart';
import 'package:immich_mobile/providers/background_sync.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';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
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';
@@ -66,43 +58,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final BackgroundWorkerBgHostApi _backgroundHostApi;
final _cancellationToken = Completer<void>();
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() {
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,
);
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
BackgroundWorkerFlutterApi.setUp(this);
}
@@ -158,6 +119,11 @@ 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();
@@ -169,23 +135,9 @@ 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<dynamic>([
_localSyncService.sync(),
_remoteSyncService.sync(),
_hashService.hashAssets(),
_handleBackup(),
]);
final all = Future.wait<dynamic>([sync.syncLocal(), sync.syncRemote(), sync.hashAssets(), _handleBackup()]);
if (budget != null) {
await all.timeout(
budget,
onTimeout: () {
if (!_cancellationToken.isCompleted) {
_logger.warning("iOS background upload timed out after ${budget.inSeconds}s, cancelling tasks");
_cancellationToken.complete();
}
return <dynamic>[];
},
);
await all.timeout(budget, onTimeout: () => <dynamic>[]);
} else {
await all;
}
@@ -269,6 +221,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
try {
_isCleanedUp = true;
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
_logger.info("Cleaning up background worker");
@@ -277,7 +230,10 @@ 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 (nativeSyncApi != null) nativeSyncApi.cancelHashing()]);
await Future.wait([
if (backgroundSyncManager != null) backgroundSyncManager.cancel(),
if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
]);
await workerManagerPatch.dispose().catchError((_) async {});
await Future.wait([LogService.I.dispose(), Store.dispose()]);
await _drift.close();
@@ -323,18 +279,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}
Future<bool> _syncAssets({Duration? hashTimeout}) async {
await _localSyncService.sync();
await _ref?.read(backgroundSyncProvider).syncLocal();
if (_isCleanedUp) {
return false;
}
final isSuccess = await _remoteSyncService.sync();
final isSuccess = await _ref?.read(backgroundSyncProvider).syncRemote() ?? false;
if (_isCleanedUp) {
return isSuccess;
}
var hashFuture = _hashService.hashAssets();
if (hashTimeout != null) {
var hashFuture = _ref?.read(backgroundSyncProvider).hashAssets();
if (hashTimeout != null && hashFuture != null) {
hashFuture = hashFuture.timeout(
hashTimeout,
onTimeout: () {

View File

@@ -27,18 +27,7 @@ class DriftMapRepository extends DriftDatabaseRepository {
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
}
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) {
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
}

View File

@@ -10,7 +10,6 @@ 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 {
@@ -293,20 +292,4 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
return query.map((row) => row.toDto()!).get();
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> 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));
}
});
}
}

View File

@@ -4,7 +4,6 @@ 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';
@@ -21,7 +20,6 @@ class TimelineMapOptions {
final bool includeArchived;
final bool withPartners;
final int relativeDays;
final TimeRange timeRange;
const TimelineMapOptions({
required this.bounds,
@@ -29,7 +27,6 @@ class TimelineMapOptions {
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
this.timeRange = const TimeRange(),
});
}
@@ -555,21 +552,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
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) {
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
@@ -610,21 +594,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
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) {
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}

View File

@@ -886,6 +886,7 @@ class _QuickLinkList extends StatelessWidget {
_QuickLink(
title: context.t.recently_added,
icon: Icons.upload_outlined,
isTop: true,
onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()),
),
_QuickLink(

View File

@@ -1,13 +1,11 @@
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 {
@@ -17,7 +15,6 @@ class MapState {
final bool includeArchived;
final bool withPartners;
final int relativeDays;
final TimeRange timeRange;
const MapState({
this.themeMode = ThemeMode.system,
@@ -26,7 +23,6 @@ class MapState {
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
this.timeRange = const TimeRange(),
});
@override
@@ -44,7 +40,6 @@ class MapState {
bool? includeArchived,
bool? withPartners,
int? relativeDays,
TimeRange? timeRange,
}) {
return MapState(
bounds: bounds ?? this.bounds,
@@ -53,7 +48,6 @@ class MapState {
includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners,
relativeDays: relativeDays ?? this.relativeDays,
timeRange: timeRange ?? this.timeRange,
);
}
@@ -63,7 +57,6 @@ class MapState {
includeArchived: includeArchived,
withPartners: withPartners,
relativeDays: relativeDays,
timeRange: timeRange,
);
}
@@ -110,24 +103,6 @@ class MapStateNotifier extends Notifier<MapState> {
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<DateTime> 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));
@@ -138,7 +113,6 @@ class MapStateNotifier extends Notifier<MapState> {
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),
);
}
}

View File

@@ -1,39 +1,21 @@
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 ConsumerStatefulWidget {
class DriftMapSettingsSheet extends HookConsumerWidget {
const DriftMapSettingsSheet({super.key});
@override
ConsumerState<DriftMapSettingsSheet> createState() => _DriftMapSettingsSheetState();
}
class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
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) {
Widget build(BuildContext context, WidgetRef ref) {
final mapState = ref.watch(mapStateProvider);
return DraggableScrollableSheet(
expand: false,
initialChildSize: useCustomRange ? 0.7 : 0.6,
initialChildSize: 0.6,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
child: Card(
@@ -65,41 +47,10 @@ class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
selected: mapState.withPartners,
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
),
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),
),
),
],
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
const SizedBox(height: 20),
],
),

View File

@@ -65,7 +65,6 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: ScrollDatePicker(
viewType: datePickerColumnOrder(DateFormat.yMd(context.locale.toLanguageTag()).pattern),
options: DatePickerOptions(
backgroundColor: context.colorScheme.surfaceContainerHigh,
itemExtent: 50,
@@ -119,18 +118,3 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
);
}
}
List<DatePickerViewType>? 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]!));
}

View File

@@ -1,4 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/repositories/toast.repository.dart';
final toastRepositoryProvider = Provider<ToastRepository>((ref) => const .new());

View File

@@ -1,19 +1,8 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/routing/router.dart';
@visibleForTesting
bool isRouteInStack(Ref ref, String routeName) {
final router = ref.watch(appRouterProvider);
void onChange() => ref.invalidateSelf();
router.addListener(onChange);
ref.onDispose(() => router.removeListener(onChange));
return router.stackData.any((route) => route.name == routeName);
}
final inLockedViewProvider = Provider<bool>((ref) => isRouteInStack(ref, DriftLockedFolderRoute.name));
final isAssetViewerOpenProvider = Provider<bool>((ref) => isRouteInStack(ref, AssetViewerRoute.name));
final inLockedViewProvider = StateProvider<bool>((ref) => false);
final isAssetViewerOpenProvider = StateProvider<bool>((ref) => false);
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);

View File

@@ -1,14 +1,12 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/constants/enums.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' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
import 'package:openapi/api.dart';
final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository(
@@ -43,7 +41,7 @@ class AssetApiRepository extends ApiRepository {
return response?.count ?? 0;
}
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
}
@@ -79,11 +77,11 @@ class AssetApiRepository extends ApiRepository {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
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,
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
AssetVisibilityEnum.locked => AssetVisibility.locked,
AssetVisibilityEnum.archive => AssetVisibility.archive,
};
Future<String?> getAssetMIMEType(String assetId) async {
@@ -108,20 +106,6 @@ class AssetApiRepository extends ApiRepository {
Future<void> removeEdits(String assetId) async {
return _api.removeAssetEdits(assetId);
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) {
return _api.updateAssets(
AssetBulkUpdateDto(
ids: remoteIds,
isFavorite: isFavorite.toOptional(),
visibility: visibility.map(_mapVisibility).toOptional(),
),
);
}
}
extension on StackResponseDto {

View File

@@ -1,26 +0,0 @@
import 'dart:async';
import 'package:immich_ui/immich_ui.dart';
class ToastOption {
final Duration? timeout;
final FutureOr<void> Function()? onUndo;
const ToastOption({this.timeout, this.onUndo});
}
class ToastRepository {
const ToastRepository();
FutureOr<void> success(String message, {ToastOption? toast}) {
snackbar.success(message, duration: toast?.timeout);
}
FutureOr<void> info(String message, {ToastOption? toast}) {
snackbar.info(message, duration: toast?.timeout);
}
FutureOr<void> error(String message, {ToastOption? toast}) {
snackbar.error(message, duration: toast?.timeout);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AppNavigationObserver extends AutoRouterObserver {
/// Riverpod Instance
@@ -11,12 +12,46 @@ class AppNavigationObserver extends AutoRouterObserver {
AppNavigationObserver({required this.ref});
@override
Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async {
unawaited(Future(() => ref.read(inLockedViewProvider.notifier).state = false));
}
@override
void didPush(Route route, Route? previousRoute) {
_handleDriftLockedFolderState(route, previousRoute);
Future(() {
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
if (route.settings.name == AssetViewerRoute.name) {
ref.read(isAssetViewerOpenProvider.notifier).state = true;
}
});
}
@override
void didPop(Route route, Route? previousRoute) {
_handleDriftLockedFolderState(previousRoute ?? route, null);
if (route.settings.name == AssetViewerRoute.name) {
Future(() => ref.read(isAssetViewerOpenProvider.notifier).state = false);
}
}
_handleDriftLockedFolderState(Route route, Route? previousRoute) {
final isInLockedView = ref.read(inLockedViewProvider);
final isFromLockedViewToDetailView =
route.settings.name == AssetViewerRoute.name && previousRoute?.settings.name == DriftLockedFolderRoute.name;
final isFromDetailViewToInfoPanelView =
route.settings.name == null && previousRoute?.settings.name == AssetViewerRoute.name && isInLockedView;
if (route.settings.name == DriftLockedFolderRoute.name ||
isFromLockedViewToDetailView ||
isFromDetailViewToInfoPanelView) {
Future(() => ref.read(inLockedViewProvider.notifier).state = true);
} else {
Future(() => ref.read(inLockedViewProvider.notifier).state = false);
}
}
}

View File

@@ -79,17 +79,17 @@ class ActionService {
}
Future<void> archive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .archive);
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
}
Future<void> unArchive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .locked);
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
// Ask user if they want to delete local copies
@@ -99,7 +99,7 @@ class ActionService {
}
Future<void> removeFromLockFolder(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}

View File

@@ -96,12 +96,12 @@ class ApiService {
/// port - optional (default: based on schema)
/// path - optional
Future<String> resolveEndpoint(String serverUrl) async {
String url = normalizeServerUrl(serverUrl);
String url = sanitizeUrl(serverUrl);
// Check for /.well-known/immich
final wellKnownEndpoint = await _getWellKnownEndpoint(url);
if (wellKnownEndpoint.isNotEmpty) {
url = normalizeServerUrl(wellKnownEndpoint);
url = sanitizeUrl(wellKnownEndpoint);
}
if (!await _isEndpointAvailable(url)) {

View File

@@ -290,10 +290,13 @@ class BackgroundUploadService {
return null;
}
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those.
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name);
final originalFileName = p.setExtension(fileName, extension);
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
String metadata = UploadTaskMetadata(
localAssetId: asset.id,

View File

@@ -309,10 +309,17 @@ class ForegroundUploadService {
return;
}
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those.
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name);
final originalFileName = p.setExtension(fileName, extension);
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
/// Handle special file name from DJI or Fusion app
/// If the file name has no extension, likely due to special renaming template by specific apps
/// we append the original extension from the asset name
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId);
final fields = {

View File

@@ -1,4 +1,3 @@
import 'package:drift/drift.dart';
import 'package:openapi/api.dart' show Optional;
sealed class Option<T> {
@@ -22,27 +21,11 @@ sealed class Option<T> {
None() => null,
};
Option<U> map<U>(U Function(T value) f) => switch (this) {
Some(:final value) => Some(f(value)),
None() => None<U>(),
};
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
Some(:final value) => onSome(value),
None() => onNone(),
};
Option<U> flatMap<U>(Option<U> 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)',
@@ -82,10 +65,3 @@ extension OptionToOptional<T> on Option<T> {
Some(:final value) => Optional.present(value),
};
}
extension OptionToDriftValue<T> on Option<T> {
Value<T> toDriftValue() => switch (this) {
Some(:final value) => Value(value),
None() => const Value.absent(),
};
}

View File

@@ -2,31 +2,12 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:punycode/punycode.dart';
/// Normalizes a server URL, guaranteeing that it has a schema and no trailing slashes
String normalizeServerUrl(String url) {
final trimmedUrl = url.trim();
String sanitizeUrl(String url) {
// Add schema if none is set
final urlWithSchema = trimmedUrl.contains('://') ? trimmedUrl : "https://$trimmedUrl";
final urlWithSchema = url.trimLeft().startsWith(RegExp(r"https?://")) ? url : "https://$url";
// Remove trailing slash(es)
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);
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
}
String? getServerUrl() {

View File

@@ -43,7 +43,18 @@ class LoginForm extends HookConsumerWidget {
final log = Logger('LoginForm');
String? _validateUrl(String? url) => normalizeAndValidateServerUrl(url) ? null : 'login_form_err_invalid_url'.tr();
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? _validateEmail(String? email) {
if (email == null || email == '') {
@@ -90,7 +101,7 @@ class LoginForm extends HookConsumerWidget {
/// Fetch the server login credential and enables oAuth login if necessary
/// Returns true if successful, false otherwise
Future<void> getServerAuthSettings() async {
final sanitizeServerUrl = normalizeServerUrl(serverEndpointController.text);
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
// Guard empty URL
@@ -293,7 +304,7 @@ class LoginForm extends HookConsumerWidget {
try {
oAuthServerUrl = await oAuthService.getOAuthServerUrl(
normalizeServerUrl(serverEndpointController.text),
sanitizeUrl(serverEndpointController.text),
state,
codeChallenge,
);
@@ -425,7 +436,7 @@ class LoginForm extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(bottom: ImmichSpacing.md),
child: Text(
normalizeServerUrl(serverEndpointController.text),
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),

View File

@@ -1,73 +0,0 @@
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)));
}
},
),
],
);
}
}

View File

@@ -6,23 +6,18 @@ final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class SnackbarManager {
const SnackbarManager();
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(
String message,
SnackbarType type, {
Duration? duration,
}) {
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
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, duration));
return messenger.showSnackBar(_build(context, message, type));
}
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) {
SnackBar _build(BuildContext context, String message, SnackbarType type) {
final theme = Theme.of(context);
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
final (IconData icon, Color background, Color foreground) = switch (type) {
@@ -34,7 +29,7 @@ class SnackbarManager {
return SnackBar(
behavior: .floating,
backgroundColor: background,
duration: duration,
duration: const .new(seconds: 4),
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
content: Row(
children: [
@@ -53,14 +48,11 @@ class SnackbarManager {
);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
show(message, .info, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
show(message, .success, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
show(message, .error, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
}
const snackbar = SnackbarManager();

View File

@@ -77,40 +77,6 @@ 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);

View File

@@ -1,69 +0,0 @@
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));
});
}
}
});
}

View File

@@ -25,8 +25,6 @@ class _FrozenBucketService implements TimelineService {
}
class _EmptyBucketService implements TimelineService {
const _EmptyBucketService();
@override
Stream<List<Bucket>> Function() get watchBuckets =>
() => Stream.value(const []);
@@ -111,7 +109,7 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
timelineServiceProvider.overrideWithValue(const _EmptyBucketService()),
timelineServiceProvider.overrideWithValue(_EmptyBucketService()),
appConfigProvider.overrideWithValue(const AppConfig()),
],
child: MaterialApp(

View File

@@ -136,51 +136,6 @@ void main() {
expect(task, isNotNull);
expect(task!.fields.containsKey('visibility'), isFalse);
});
test('corrects the extension when iOS returns a rendered file for a .dng asset', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/IMG_6499.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('IMG_6499.jpg'));
});
test('keeps the .dng extension for a genuine RAW original', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/IMG_5210.dng');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('IMG_5210.dng'));
});
test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/DJI_0001');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields['filename'], equals('DJI_0001.jpg'));
});
});
group('getLivePhotoUploadTask', () {

View File

@@ -81,24 +81,6 @@ void main() {
return captured;
}
List<String> captureOriginalFileNames() {
final captured = <String>[];
when(
() => mockUploadRepository.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer((invocation) async {
captured.add(invocation.namedArguments[#originalFileName] as String);
return UploadResult.success(remoteAssetId: 'remote-${captured.length}');
});
return captured;
}
group('uploadSingleAsset', () {
test('should upload the motion part hidden and keep the still image visible', () async {
final asset = LocalAssetStub.image1;
@@ -142,59 +124,5 @@ void main() {
expect(captured, hasLength(1));
expect(captured[0].containsKey('visibility'), isFalse);
});
test('corrects the extension when iOS returns a rendered file for a .dng asset', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/IMG_6499.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng');
final names = captureOriginalFileNames();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(names, equals(['IMG_6499.jpg']));
});
test('keeps the .dng extension for a genuine RAW original', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/IMG_5210.dng');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng');
final names = captureOriginalFileNames();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(names, equals(['IMG_5210.dng']));
});
test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/DJI_0001');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001');
final names = captureOriginalFileNames();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(names, equals(['DJI_0001.jpg']));
});
});
}

View File

@@ -1,99 +0,0 @@
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<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.encode({}), '{}');
});
test('encodes a string-to-string map as a JSON object', () {
const codec = MapCodec<String, String>(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<String, int>(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<int, String>(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<String, String>(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<String, int>(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<int, String>(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<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{}'), isEmpty);
});
test('returns an empty map when the payload is not valid JSON', () {
const codec = MapCodec<String, String>(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<String, String>(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<String, int>(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<String, String>(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<String, int>(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<String, int>(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<String, _Fruit>(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<String, DateTime>(PrimitiveCodec.string, DateTimeCodec());
final original = {'created': DateTime.utc(2024, 1, 1, 12, 30)};
expect(codec.decode(codec.encode(original)), original);
});
});
});
}

View File

@@ -47,7 +47,7 @@ program
.usage('[paths...] [options]')
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(new Option('-i, --ignore <pattern>', 'Pattern to ignore').env('IMMICH_IGNORE_PATHS'))
.addOption(new Option('--skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
.addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
.addOption(
new Option('-a, --album', 'Automatically create albums based on folder name')

View File

@@ -8,13 +8,13 @@ import {
} from 'src/commands/media-location.command';
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
import { PromptPasswordResetQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
import { SchemaCheck } from 'src/commands/schema-check';
import { VersionCommand } from 'src/commands/version.command';
export const commandsAndQuestions = [
ResetAdminPasswordCommand,
PromptPasswordResetQuestions,
PromptPasswordQuestions,
PromptEmailQuestion,
EnablePasswordLoginCommand,
DisablePasswordLoginCommand,

View File

@@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { CliService } from 'src/services/cli.service';
const prompt = (inquirer: InquirerService) => {
return (admin: UserAdminResponseDto) => {
return function ask(admin: UserAdminResponseDto) {
const { id, oauthId, email, name } = admin;
console.log(`Found Admin:
- ID=${id}
@@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => {
- Email=${email}
- Name=${name}`);
return inquirer.ask<{ newPassword: string; invalidateSessions: boolean }>('prompt-password-reset', {});
return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password);
};
};
@@ -43,23 +43,13 @@ export class ResetAdminPasswordCommand extends CommandRunner {
}
}
@QuestionSet({ name: 'prompt-password-reset' })
export class PromptPasswordResetQuestions {
@QuestionSet({ name: 'prompt-password' })
export class PromptPasswordQuestions {
@Question({
message: 'Please choose a new password (optional)',
name: 'newPassword',
name: 'password',
})
parsePassword(value: string) {
return value;
}
@Question({
type: 'confirm',
message: 'Invalidate existing sessions?',
default: true,
name: 'invalidateSessions',
})
parseInvalidate(value: boolean): boolean {
return value;
}
}

View File

@@ -106,11 +106,7 @@ 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 of the following fields is required: isSaved, seenAt, memoryAt' },
]),
);
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }]));
});
});

View File

@@ -304,36 +304,6 @@ 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',

View File

@@ -108,11 +108,9 @@ 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',

View File

@@ -3,15 +3,8 @@ 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,
SearchOrderField,
SearchOrderFieldSchema,
} from 'src/enum';
import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation';
import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum';
import { isoDatetimeToDate, stringToBool } from 'src/validation';
import z from 'zod';
const BaseSearchSchema = z.object({
@@ -149,176 +142,6 @@ 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 = <T extends z.core.util.EnumLike>(values: z.ZodEnum<T>, 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<typeof IdFilterSchema>;
export type IdFilterNullable = z.infer<typeof IdFilterNullableSchema>;
export type IdsFilter = z.infer<typeof IdsFilterSchema>;
export type StringFilter = z.infer<typeof StringFilterSchema>;
export type StringFilterNullable = z.infer<typeof StringFilterNullableSchema>;
export type StringPatternFilter = z.infer<typeof StringPatternFilterSchema>;
export type NumberFilter = z.infer<typeof NumberFilterSchema>;
export type NumberFilterNullable = z.infer<typeof NumberFilterNullableSchema>;
export type DateFilter = z.infer<typeof DateFilterSchema>;
export type DateFilterNullable = z.infer<typeof DateFilterNullableSchema>;
export type SearchOrder = z.infer<typeof SearchOrderSchema>;
export type SearchFilter = z.infer<typeof SearchFilterSchema>;
export type SearchFilterBranch = z.infer<typeof SearchFilterBranchSchema>;
export class RandomSearchDto extends createZodDto(RandomSearchSchema) {}
export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {}
export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {}

View File

@@ -1234,12 +1234,3 @@ 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' });

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,12 @@
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,
searchAssetBuilderLegacy,
searchMetadataV3Examples,
searchStatisticsV3Examples,
withExifInner,
withSearchOrder,
} from 'src/utils/database';
import { anyUuid, searchAssetBuilder, withExifInner } from 'src/utils/database';
import { paginationHelper } from 'src/utils/pagination';
import z from 'zod';
@@ -133,21 +122,6 @@ export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
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 &
@@ -222,10 +196,9 @@ export class SearchRepository {
})
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) {
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
const items = await searchAssetBuilderLegacy(this.db, options)
.select(columns.searchAsset)
const items = await searchAssetBuilder(this.db, options)
.selectAll('asset')
.orderBy('asset.fileCreatedAt', orderDirection)
.orderBy('asset.id', orderDirection)
.limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size)
.execute();
@@ -244,7 +217,7 @@ export class SearchRepository {
],
})
searchStatistics(options: AssetSearchOptions) {
return searchAssetBuilderLegacy(this.db, options)
return searchAssetBuilder(this.db, options)
.select((qb) => qb.fn.countAll<number>().as('total'))
.executeTakeFirstOrThrow();
}
@@ -262,8 +235,8 @@ export class SearchRepository {
],
})
async searchRandom(size: number, options: AssetSearchOptions) {
return searchAssetBuilderLegacy(this.db, options)
.select(columns.searchAsset)
return searchAssetBuilder(this.db, options)
.selectAll('asset')
.orderBy(sql`random()`)
.limit(size)
.execute();
@@ -283,8 +256,8 @@ export class SearchRepository {
})
searchLargeAssets(size: number, options: LargeAssetSearchOptions) {
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
return searchAssetBuilderLegacy(this.db, options)
.select(columns.searchAsset)
return searchAssetBuilder(this.db, options)
.selectAll('asset')
.$call(withExifInner)
.where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0)
.orderBy('asset_exif.fileSizeInByte', orderDirection)
@@ -312,11 +285,10 @@ 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 searchAssetBuilderLegacy(trx, options)
.select(columns.searchAsset)
const items = await searchAssetBuilder(trx, options)
.selectAll('asset')
.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();
@@ -445,7 +417,7 @@ export class SearchRepository {
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.innerJoin('cte', 'asset.id', 'cte.assetId')
.select(columns.searchAsset)
.selectAll('asset')
.select((eb) =>
eb
.fn('to_jsonb', [eb.table('asset_exif')])
@@ -518,24 +490,6 @@ export class SearchRepository {
return res.map((row) => row.lensModel!);
}
@GenerateSql(...searchMetadataV3Examples)
searchMetadataV3(
pagination: AssetSearchPaginationV3Options,
options: AssetSearchBuilderV3Options,
): Promise<MapAsset[]> {
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<number>().as('total'))
.executeTakeFirstOrThrow();
}
private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) {
return this.db
.selectFrom('asset_exif')

View File

@@ -568,34 +568,6 @@ describe(AssetService.name, () => {
expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId);
});
it('should delete the stack when a non-primary asset is deleted and only the primary would remain', async () => {
const asset = AssetFactory.from().build();
const deletionAsset = {
...getForAssetDeletion(asset),
stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }] },
};
mocks.stack.delete.mockResolvedValue();
mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset);
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
expect(mocks.stack.delete).toHaveBeenCalledWith(deletionAsset.stack.id);
});
it('should keep the stack when a non-primary asset is deleted and the primary plus another asset remain', async () => {
const asset = AssetFactory.from().build();
const deletionAsset = {
...getForAssetDeletion(asset),
stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }, { id: newUuid() }] },
};
mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset);
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
expect(mocks.stack.delete).not.toHaveBeenCalled();
expect(mocks.stack.update).not.toHaveBeenCalled();
});
it('should delete a live photo', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });

View File

@@ -316,25 +316,18 @@ export class AssetService extends BaseService {
return JobStatus.Failed;
}
if (asset.stack) {
// asset.stack.assets only includes timeline visible assets and excludes the primary asset
const remainingStackAssetIds = asset.stack.assets.map((a) => a.id).filter((assetId) => assetId !== id);
// the primary survives unless it is the asset being deleted
let remainingCount = remainingStackAssetIds.length;
if (asset.stack.primaryAssetId !== id) {
remainingCount++;
}
if (remainingCount < 2) {
// 0 or 1 asset would remain: dissolve the stack so it does not linger as a single-asset stack
await this.stackRepository.delete(asset.stack.id);
} else if (asset.stack.primaryAssetId === id) {
// the primary is being deleted but others remain: promote a new primary
// replace the parent of the stack children with a new asset
if (asset.stack?.primaryAssetId === id) {
// this only includes timeline visible assets and excludes the primary asset
const stackAssetIds = asset.stack.assets.map((a) => a.id);
if (stackAssetIds.length >= 2) {
const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!;
await this.stackRepository.update(asset.stack.id, {
id: asset.stack.id,
primaryAssetId: remainingStackAssetIds[0],
primaryAssetId: newPrimaryAssetId,
});
} else {
await this.stackRepository.delete(asset.stack.id);
}
}

View File

@@ -37,7 +37,7 @@ describe(CliService.name, () => {
mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true }));
const ask = vitest.fn().mockResolvedValue({ newPassword: undefined, invalidateSessions: false });
const ask = vitest.fn().mockImplementation(() => {});
const response = await sut.resetAdminPassword(ask);
@@ -47,7 +47,6 @@ describe(CliService.name, () => {
expect(ask).toHaveBeenCalled();
expect(id).toEqual(admin.id);
expect(update.password).toBeDefined();
expect(mocks.session.invalidateAll).not.toHaveBeenCalled();
});
it('should use the supplied password', async () => {
@@ -56,7 +55,7 @@ describe(CliService.name, () => {
mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(admin);
const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: false });
const ask = vitest.fn().mockResolvedValue('new-password');
const response = await sut.resetAdminPassword(ask);
@@ -67,20 +66,6 @@ describe(CliService.name, () => {
expect(id).toEqual(admin.id);
expect(update.password).toBeDefined();
});
it('should invalidate existing sessions when requested', async () => {
const admin = UserFactory.create({ isAdmin: true });
mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(admin);
mocks.session.invalidateAll.mockResolvedValue(void 0);
const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: true });
await sut.resetAdminPassword(ask);
expect(mocks.session.invalidateAll).toHaveBeenCalledWith({ userId: admin.id });
});
});
describe('disablePasswordLogin', () => {

View File

@@ -58,24 +58,18 @@ export class CliService extends BaseService {
return users.map((user) => mapUserAdmin(user));
}
async resetAdminPassword(
ask: (admin: UserAdminResponseDto) => Promise<{ newPassword: string | undefined; invalidateSessions: boolean }>,
) {
async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {
const admin = await this.userRepository.getAdmin();
if (!admin) {
throw new Error('Admin account does not exist');
}
const { newPassword: providedPassword, invalidateSessions } = await ask(mapUserAdmin(admin));
const providedPassword = await ask(mapUserAdmin(admin));
const password = providedPassword || this.cryptoRepository.randomBytesAsText(24);
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
await this.userRepository.update(admin.id, { password: hashedPassword });
if (invalidateSessions) {
await this.sessionRepository.invalidateAll({ userId: admin.id });
}
return { admin, password, provided: !!providedPassword };
}

View File

@@ -7,42 +7,21 @@ 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 {
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 { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
import { AssetSearchBuilderOptions } 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 {
@@ -75,8 +54,6 @@ export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyCon
};
};
const uniqueIds = (ids: string[]) => [...new Set(ids)];
export const asUuid = (id: string | Expression<string>) => sql<string>`${id}::uuid`;
export const anyUuid = (ids: string[]) => sql<string>`any(${`{${ids}}`}::uuid[])`;
@@ -108,15 +85,16 @@ export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>)
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<ShallowDehydrateObject<Selectable<AssetExifTable>> | null>()
.as('exifInfo');
// TODO come up with a better query that only selects the fields we need
export function withExif<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
return qb.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId').select(selectExifInfo);
return qb
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select((eb) =>
eb.fn
.toJson(eb.table('asset_exif'))
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>> | null>()
.as('exifInfo'),
);
}
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
@@ -395,7 +373,7 @@ export function withEdits(eb: ExpressionBuilder<DB, 'asset'>): AliasedEditAction
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
return kysely
@@ -515,458 +493,6 @@ export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearc
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
}
type AssetExpressionBuilder = ExpressionBuilder<DB, 'asset' | 'asset_exif'>;
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<SqlBool>;
matchesAll: (ids: string[]) => Expression<SqlBool>;
},
) {
const predicates: Expression<SqlBool>[] = [];
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<T> = {
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<TB extends keyof DB, RE extends ReferenceExpression<DB, TB>>(
eb: ExpressionBuilder<DB, TB>,
column: RE,
filter: ComparisonFilter<OperandValueExpression<DB, TB, RE>> = {},
) {
const predicates: Expression<SqlBool>[] = [];
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<SqlBool>`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.like}) || '%')`);
}
if (filter.notLike !== undefined) {
predicates.push(sql<SqlBool>`f_unaccent(${ref}) not ilike ('%' || f_unaccent(${filter.notLike}) || '%')`);
}
if (filter.startsWith !== undefined) {
predicates.push(sql<SqlBool>`f_unaccent(${ref}) ilike (f_unaccent(${filter.startsWith}) || '%')`);
}
if (filter.endsWith !== undefined) {
predicates.push(sql<SqlBool>`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<unknown>,
): Expression<SqlBool>[] {
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<SqlBool>`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<DB>, 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<typeof searchAssetBuilder>, 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;

View File

@@ -42,7 +42,7 @@ export function nonEmptyPartial<T extends z.ZodRawShape>(shape: T) {
.object(shape)
.partial()
.refine((data) => Object.values(data as Record<string, unknown>).some((value) => value !== undefined), {
message: `At least one of the following fields is required: ${Object.keys(shape).join(', ')}`,
message: 'At least one field must be provided',
});
}

View File

@@ -1,10 +1,9 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { Icon } from '@immich/ui';
import { mdiChevronRight, mdiChevronLeft } from '@mdi/js';
import { mdiChevronRight } from '@mdi/js';
import { t } from 'svelte-i18n';
import NavigationArea from '../NavigationArea.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
interface Props {
onNextAsset: () => void;
@@ -21,5 +20,5 @@
/>
<NavigationArea onClick={onNextAsset} label={$t('view_next_asset')}>
<Icon icon={languageManager.rtl ? mdiChevronLeft : mdiChevronRight} size="36" aria-hidden />
<Icon icon={mdiChevronRight} size="36" aria-hidden />
</NavigationArea>

View File

@@ -1,10 +1,9 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { Icon } from '@immich/ui';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { mdiChevronLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
import NavigationArea from '../NavigationArea.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
interface Props {
onPreviousAsset: () => void;
@@ -21,5 +20,5 @@
/>
<NavigationArea onClick={onPreviousAsset} label={$t('view_previous_asset')}>
<Icon icon={languageManager.rtl ? mdiChevronRight : mdiChevronLeft} size="36" aria-hidden />
<Icon icon={mdiChevronLeft} size="36" aria-hidden />
</NavigationArea>

View File

@@ -95,8 +95,6 @@
const getPreviousRoute = $page.url.searchParams.get(QueryParameter.PREVIOUS_ROUTE);
if (getPreviousRoute && !isExternalUrl(getPreviousRoute)) {
previousRoute = getPreviousRoute;
} else if ($page.params.assetId) {
previousRoute = Route.viewPerson(data.person);
}
if (action == 'merge') {
viewMode = PersonPageViewMode.MERGE_PEOPLE;

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { languageManager } from '$lib/managers/language-manager.svelte';
import { Button, Icon } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiCheck } from '@mdi/js';
import type { Snippet } from 'svelte';
@@ -53,7 +52,7 @@
<div class="flex w-full place-content-start">
<Button
shape="round"
leadingIcon={languageManager.rtl ? mdiArrowRight : mdiArrowLeft}
leadingIcon={mdiArrowLeft}
class="flex place-content-center gap-2"
onclick={() => {
onLeave?.();
@@ -68,7 +67,7 @@
<div class="flex w-full place-content-end">
<Button
shape="round"
trailingIcon={nextTitle ? (languageManager.rtl ? mdiArrowLeft : mdiArrowRight) : mdiCheck}
trailingIcon={nextTitle ? mdiArrowRight : mdiCheck}
onclick={() => {
onLeave?.();
onNext?.();