Compare commits

..

19 Commits

Author SHA1 Message Date
renovate[bot] b7cccef840 fix(deps): update typescript-projects 2026-06-16 17:17:39 +00:00
renovate[bot] 76c042abc7 chore(deps): lock file maintenance (mise) (#29091) 2026-06-16 19:08:09 +02:00
shenlong 0f49bcbd27 chore: do not optimize on cleanup (#29118) 2026-06-16 12:42:12 -04:00
Mees Frensel df4a708aed chore(e2e): remove vite-tsconfig-paths (#29145) 2026-06-16 18:21:24 +02:00
renovate[bot] 34f78e3fd4 chore(deps): update github-actions (#29124) 2026-06-16 17:53:55 +02:00
Mees Frensel 3c2296b818 chore(web): use ui's Badge (#29143) 2026-06-16 17:12:25 +02:00
Daniel Dietzler 805ca1a2bf chore: maintenance page design tweaks (#29087) 2026-06-16 09:15:54 -05:00
Santo Shakil 9ee412110f fix(mobile): stop sync albums crashing on the main isolate (#29133)
the album sync provider read cancellationProvider, which only exists in the background isolate and throws on the main one. moved the cancel signal onto the isolate call path.

fixes #29125
fixes #29119
2026-06-16 09:14:33 -05:00
Brandon Wees 010220d588 fix: video thumbnail quality sharing (#29104) 2026-06-16 09:13:31 -05:00
shenlong 10fddf2d51 fix: resize map after scroll (#29112)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-16 09:13:28 -05:00
Mees Frensel d307ab60ca fix: update datetimeRelative description to minutes instead of seconds (#29137) 2026-06-16 16:09:45 +02:00
Timon ce59cc9241 docs(server): clarify AssetBulkUploadCheckItem.id is a correlation token (#29141) 2026-06-16 14:07:36 +00:00
Mees Frensel 1fa034125c fix(web): language selector (#29065) 2026-06-16 15:49:47 +02:00
okxint 8036dc4b8c fix(web): respect local timezone when building date range for search (#29128) 2026-06-16 13:45:49 +00:00
renovate[bot] 54895fb10e chore(deps): update node to ^24.13.2 (#29129) 2026-06-16 13:43:22 +00:00
Daniel Dietzler e70a1163f3 fix: too strict cron expression validation (#29138) 2026-06-16 13:20:40 +00:00
Daniel Dietzler a23a7c69ae fix: map settings (#29134) 2026-06-16 15:18:57 +02:00
Daniel Dietzler f21a753aff fix: integrity report checksum query (#29136) 2026-06-16 15:11:20 +02:00
Timon cc8d3b4107 fix(server): do not merge metadata when multiple duplicates are kept (#29035)
* fix(server): do not merge metadata when multiple duplicates are kept

* Update server/src/services/duplicate.service.spec.ts

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-06-15 16:05:04 -04:00
52 changed files with 3517 additions and 3273 deletions
+1 -1
View File
@@ -237,7 +237,7 @@ jobs:
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
uses: ruby/setup-ruby@12fd324f1d0b43274fdc8130f6980590a667c455 # v1.312.0
with:
ruby-version: '3.3'
bundler-cache: true
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@a8c7f0e5649d20d623edb5b38446d3ab3d82d43c # v0.0.53
uses: oasdiff/oasdiff-action/breaking@3530478ec30f84adedbfeb28f0d9527a290f50a9 # v0.0.57
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+4 -1
View File
@@ -1,5 +1,8 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.wrangler]]
version = "4.66.0"
version = "4.98.0"
backend = "npm:wrangler"
[tools.wrangler.options]
allow_builds = '["esbuild", "sharp", "workerd"]'
+1 -1
View File
@@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools]
wrangler = "4.98.0"
wrangler = "4.100.0"
+1 -2
View File
@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.12.4",
"@types/node": "^24.13.2",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^7.0.0",
@@ -54,7 +54,6 @@
"typescript": "^6.0.0",
"typescript-eslint": "^8.28.0",
"utimes": "^5.2.1",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.0"
}
}
+3 -2
View File
@@ -1,4 +1,3 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
@@ -24,5 +23,7 @@ export default defineConfig({
maxWorkers: 1,
isolate: false,
},
plugins: [tsconfigPaths()],
resolve: {
tsconfigPaths: true,
},
});
+3 -2
View File
@@ -1,4 +1,3 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
@@ -24,5 +23,7 @@ export default defineConfig({
maxWorkers: 1,
isolate: false,
},
plugins: [tsconfigPaths()],
resolve: {
tsconfigPaths: true,
},
});
+5
View File
@@ -189,18 +189,23 @@
"machine_learning_smart_search_enabled": "Enable smart search",
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
"maintenance_backup_management": "Backup management",
"maintenance_delete_backup": "Delete Backup",
"maintenance_delete_backup_description": "This file will be irrevocably deleted.",
"maintenance_delete_error": "Failed to delete backup.",
"maintenance_integrity_check": "Check",
"maintenance_integrity_check_all": "Check All",
"maintenance_integrity_checksum_mismatch": "Checksum Mismatch",
"maintenance_integrity_checksum_mismatch_description": "Files whose on-disk checksum mismatches the checksum Immich has stored in its database.",
"maintenance_integrity_checksum_mismatch_job": "Check for checksum mismatches",
"maintenance_integrity_checksum_mismatch_refresh_job": "Refresh checksum mismatch reports",
"maintenance_integrity_missing_file": "Missing Files",
"maintenance_integrity_missing_file_description": "Files that Immich has tracked in its database but do not exist on the file system.",
"maintenance_integrity_missing_file_job": "Check for missing files",
"maintenance_integrity_missing_file_refresh_job": "Refresh missing file reports",
"maintenance_integrity_report": "Integrity Report",
"maintenance_integrity_untracked_file": "Untracked Files",
"maintenance_integrity_untracked_file_description": "Files in Immich's directories that Immich does not have any record of.",
"maintenance_integrity_untracked_file_job": "Check for untracked files",
"maintenance_integrity_untracked_file_refresh_job": "Refresh untracked file reports",
"maintenance_restore_backup": "Restore Backup",
+103 -13
View File
@@ -82,6 +82,69 @@ url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
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"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
[[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_macarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"]
checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1"
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"
@@ -145,6 +208,33 @@ url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c55847602
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools.java.options]
shorthand_vendor = "openjdk"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.node]]
version = "24.15.0"
backend = "core:node"
@@ -214,37 +304,37 @@ checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c70773
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
[[tools.pnpm]]
version = "11.5.2"
version = "11.5.3"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:7fef0c74081135d777754fccf25272f698e504b26ba0568504846c0cea402f8f"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-arm64.tar.gz"
checksum = "sha256:c661c9a538a0a0f9212ebc64a2cf26e87e49b529baaf49254584404fdbc5b920"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.3/pnpm-linux-arm64.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:843beed7bca760276d29f8950ca219600995d345dbc93fad8150b3e5f83b74d4"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-arm64-musl.tar.gz"
checksum = "sha256:9fb1a110033427f7a5da664903ea23e3f478f674d1588dd61daa76f2e5f9a9b2"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.3/pnpm-linux-arm64-musl.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:2033a702618c8576dc6bb0f6adb3a67ab506031351ddd59ca50d1bcaf5d13dc5"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-x64.tar.gz"
checksum = "sha256:a360e22e34ebe3fc77f26750f1e570d0fae3a0f6f9d2bfcd1c80e78263766205"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.3/pnpm-linux-x64.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:0b794b23461c7475f7ffc29c4945692838b51ddadd857a2ace8edf0018798305"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-x64-musl.tar.gz"
checksum = "sha256:feccf5cb1b12748823c5e5d2b3c5cdbbf7333d7153ce04af375da381d0b69a0c"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.3/pnpm-linux-x64-musl.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:54993dae26bea0f3c1b0e15f9427f6f6a86827d56f32d1d1554d8cda59a62399"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-darwin-arm64.tar.gz"
checksum = "sha256:252d9a126d37bcdf640275ac40946f7f9d4ab5d801811f66207ad97d7a9aa7ee"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.3/pnpm-darwin-arm64.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:b3ddff2c2bf87d3996fadf074bac58cd2259f718a17912a04ae930e3775b30e9"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-win32-x64.zip"
checksum = "sha256:ab3dacab436d0b0e4975859e3e0480c4d36fdd0bbdca3c47f3bde09372467674"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.3/pnpm-win32-x64.zip"
provenance = "github-attestations"
[[tools.terragrunt]]
+1 -1
View File
@@ -16,7 +16,7 @@ config_roots = [
[tools]
node = "24.15.0"
pnpm = "11.5.2"
pnpm = "11.5.3"
terragrunt = "1.0.3"
opentofu = "1.11.6"
"npm:oazapfts" = "7.5.0"
+2 -1
View File
@@ -17,7 +17,8 @@ const Map<String, Locale> locales = {
'Dutch (nl)': Locale('nl'),
'English (United Kingdom) (en_GB)': Locale('en', 'GB'),
'Estonian (et)': Locale('et'),
'Filipino (tl)': Locale('tl'),
'Filipino (fil)': Locale('fil'),
'Filipino (Tagalog) (tl)': Locale('tl'),
'Finnish (fi)': Locale('fi'),
'French (fr)': Locale('fr'),
'Galician (gl)': Locale('gl'),
@@ -104,6 +104,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
Future<void> onAndroidUpload(int? maxMinutes) async {
final hashTimeout = Duration(minutes: _isBackupEnabled ? 3 : 6);
final backupTimeout = maxMinutes != null ? Duration(minutes: maxMinutes - 1) : null;
await _optimizeDB();
return _backgroundLoop(
hashTimeout: hashTimeout,
backupTimeout: backupTimeout,
@@ -123,6 +124,11 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
return;
}
// Only for Background Processing tasks
if (maxSeconds == null) {
await _optimizeDB();
}
// Run sync local, sync remote, hash and backup concurrently so the bg
// refresh task (20s budget) can make progress on all four instead of
// racing them sequentially. Phases are independent at the data layer:
@@ -193,6 +199,14 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}
}
Future<void> _optimizeDB() async {
try {
await (_drift.optimize(allTables: true), _driftLogger.optimize()).wait;
} catch (error, stack) {
dPrint(() => "Error during background worker optimize: $error, $stack");
}
}
Future<void> _cleanup() async {
await runZonedGuarded(_handleCleanup, (error, stack) {
dPrint(() => "Error during background worker cleanup: $error, $stack");
@@ -221,7 +235,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
]);
await workerManagerPatch.dispose().catchError((_) async {});
await Future.wait([LogService.I.dispose(), Store.dispose(), _drift.optimize(allTables: true)]);
await Future.wait([LogService.I.dispose(), Store.dispose()]);
await _drift.close();
await _driftLogger.close();
@@ -7,7 +7,6 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@@ -19,7 +18,6 @@ final syncLinkedAlbumServiceProvider = Provider(
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(storeServiceProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -28,19 +26,17 @@ class SyncLinkedAlbumService {
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
final StoreService _storeService;
final Completer<void>? _cancellation;
SyncLinkedAlbumService(
this._localAlbumRepository,
this._remoteAlbumRepository,
this._albumApiRepository,
this._storeService, {
this._cancellation,
});
this._storeService,
);
final _log = Logger("SyncLinkedAlbumService");
Future<void> syncLinkedAlbums(String userId) async {
Future<void> syncLinkedAlbums(String userId, {Completer<void>? cancellation}) async {
final selectedAlbums = await _localAlbumRepository.getBackupAlbums();
await Future.wait(
@@ -64,7 +60,7 @@ class SyncLinkedAlbumService {
final album = await _albumApiRepository.addAssets(
remoteAlbum.id,
assetIds,
abortTrigger: _cancellation?.future,
abortTrigger: cancellation?.future,
);
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
}
@@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:logging/logging.dart';
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
@@ -10,5 +11,7 @@ Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
Logger("SyncLinkedAlbum").warning("No user logged in, skipping linked album sync");
return Future.value();
}
return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id);
return ref
.read(syncLinkedAlbumServiceProvider)
.syncLinkedAlbums(user.id, cancellation: ref.read(cancellationProvider));
}
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase(tables: [LogMessageEntity])
@@ -13,6 +14,14 @@ class DriftLogger extends $DriftLogger {
@override
int get schemaVersion => 1;
Future<void> optimize() async {
try {
await customStatement('PRAGMA optimize=0x10002');
} catch (error) {
dPrint(() => 'Failed to optimize logger database: $error');
}
}
@override
MigrationStrategy get migration => MigrationStrategy(
beforeOpen: (details) async {
@@ -6,10 +6,12 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -51,7 +53,9 @@ class _SharePreparingDialog extends StatelessWidget {
}
class _ShareFileTypeDialog extends StatelessWidget {
const _ShareFileTypeDialog();
final bool showPreview;
const _ShareFileTypeDialog({this.showPreview = true});
@override
Widget build(BuildContext context) {
@@ -66,11 +70,12 @@ class _ShareFileTypeDialog extends StatelessWidget {
title: Text(context.t.share_original),
onTap: () => context.pop(ShareAssetType.original),
),
ListTile(
leading: const Icon(Icons.photo_size_select_large_rounded),
title: Text(context.t.share_preview),
onTap: () => context.pop(ShareAssetType.preview),
),
if (showPreview)
ListTile(
leading: const Icon(Icons.photo_size_select_large_rounded),
title: Text(context.t.share_preview),
onTap: () => context.pop(ShareAssetType.preview),
),
],
),
actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))],
@@ -85,6 +90,16 @@ class ShareActionButton extends ConsumerWidget {
const ShareActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Set<BaseAsset> _getSelectedAssets(WidgetRef ref) {
return switch (source) {
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
BaseAsset asset => {asset},
null => const {},
},
};
}
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
@@ -99,9 +114,14 @@ class ShareActionButton extends ConsumerWidget {
return;
}
// only show preview option when at least one of the assets is not a video
// we cant share previews of videos
final assets = _getSelectedAssets(ref);
final showPreview = assets.isEmpty || assets.any((asset) => !asset.isVideo);
final fileType = await showDialog<ShareAssetType>(
context: context,
builder: (_) => const _ShareFileTypeDialog(),
builder: (_) => _ShareFileTypeDialog(showPreview: showPreview),
useRootNavigator: false,
);
@@ -23,7 +23,9 @@ class MapBottomSheet extends StatelessWidget {
resizeOnScroll: false,
actions: [],
backgroundColor: context.themeData.colorScheme.surface,
slivers: [const SliverFillRemaining(hasScrollBody: true, child: _ScopedMapTimeline())],
slivers: [
const SliverFillRemaining(hasScrollBody: false, child: SizedBox(height: 0, child: _ScopedMapTimeline())),
],
);
}
}
@@ -285,7 +285,9 @@ class AssetMediaRepository {
return 0;
}
final shareFile = switch (fileType) {
final effectiveFileType = asset.isVideo ? ShareAssetType.original : fileType;
final shareFile = switch (effectiveFileType) {
ShareAssetType.original => await _getOriginalShareFile(
asset,
cancelCompleter: cancelCompleter,
+97
View File
@@ -64,6 +64,76 @@ checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e3158
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-linux-arm-release.zip"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-linux-x64-release.zip"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-macos-x64-release.zip"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-windows-release.zip"
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".options]
asset_pattern = "dcm-macos-arm-release.zip"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
@@ -87,3 +157,30 @@ url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c55847602
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools.java.options]
shorthand_vendor = "openjdk"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
+1 -1
View File
@@ -35,7 +35,7 @@ class AssetBulkUpdateDto {
///
Optional<String?> dateTimeOriginal;
/// Relative time offset in seconds
/// Relative time offset in minutes
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
+1 -1
View File
@@ -20,7 +20,7 @@ class AssetBulkUploadCheckItem {
/// Base64 or hex encoded SHA1 hash
String checksum;
/// Asset ID
/// Client-side identifier echoed in the response to match results to inputs (e.g. filename)
String id;
@override
@@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import '../../infrastructure/repository.mock.dart';
import '../service.mock.dart';
void main() {
// A container with the service's deps overridden but cancellationProvider left
// alone, i.e. the root (main) isolate, where cancellationProvider has no
// override and throws if read. The UI reads this provider here.
ProviderContainer rootContainer() {
final container = ProviderContainer(
overrides: [
localAlbumRepository.overrideWithValue(MockLocalAlbumRepository()),
remoteAlbumRepository.overrideWithValue(MockRemoteAlbumRepository()),
driftAlbumApiRepositoryProvider.overrideWithValue(MockDriftAlbumApiRepository()),
storeServiceProvider.overrideWithValue(MockStoreService()),
],
);
addTearDown(container.dispose);
return container;
}
// Regression for #29125 (Sync Albums toggle) and #29119 (can't leave the album
// selection screen): #28694 made the provider watch cancellationProvider, so
// reading it off the isolate threw. The cancellation now lives on the isolate
// call path, not the provider, so the UI can build it.
test('builds on the root isolate without a cancellationProvider override', () {
final container = rootContainer();
expect(() => container.read(syncLinkedAlbumServiceProvider), returnsNormally);
expect(container.read(syncLinkedAlbumServiceProvider), isA<SyncLinkedAlbumService>());
});
test('manageLinkedAlbums runs from the UI without a cancellation signal', () {
final service = rootContainer().read(syncLinkedAlbumServiceProvider);
expect(service.manageLinkedAlbums(const [], 'user-1'), completes);
});
}
+2 -6
View File
@@ -16868,7 +16868,7 @@
"type": "string"
},
"dateTimeRelative": {
"description": "Relative time offset in seconds",
"description": "Relative time offset in minutes",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
@@ -16965,7 +16965,7 @@
"type": "string"
},
"id": {
"description": "Asset ID",
"description": "Client-side identifier echoed in the response to match results to inputs (e.g. filename)",
"type": "string"
}
},
@@ -18490,7 +18490,6 @@
"properties": {
"cronExpression": {
"description": "Cron expression",
"pattern": "(((\\d+,)+\\d+|(\\d+(\\/|-)\\d+)|\\d+|\\*) ?){5,7}",
"type": "string"
},
"enabled": {
@@ -25677,7 +25676,6 @@
"properties": {
"cronExpression": {
"description": "Cron expression for when the integrity check should run",
"pattern": "(((\\d+,)+\\d+|(\\d+(\\/|-)\\d+)|\\d+|\\*) ?){5,7}",
"type": "string"
},
"enabled": {
@@ -25710,7 +25708,6 @@
"properties": {
"cronExpression": {
"description": "Cron expression for when the integrity check should run",
"pattern": "(((\\d+,)+\\d+|(\\d+(\\/|-)\\d+)|\\d+|\\*) ?){5,7}",
"type": "string"
},
"enabled": {
@@ -25810,7 +25807,6 @@
"properties": {
"cronExpression": {
"description": "Cron expression",
"pattern": "(((\\d+,)+\\d+|(\\d+(\\/|-)\\d+)|\\d+|\\*) ?){5,7}",
"type": "string"
},
"enabled": {
+2 -2
View File
@@ -11,12 +11,12 @@
"release": "./misc/release/pump-version.sh",
"pump": "node ./misc/release/pump-wrapper.js"
},
"packageManager": "pnpm@11.5.2",
"packageManager": "pnpm@11.5.3",
"engines": {
"pnpm": ">=10.0.0"
},
"devDependencies": {
"@types/node": "^24.12.4",
"@types/node": "^24.13.2",
"prettier": "^3.8.3",
"prettier-plugin-sort-json": "^4.2.0",
"semver": "^7.8.1",
+1 -1
View File
@@ -25,7 +25,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.12.4",
"@types/node": "^24.13.2",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
+1 -1
View File
@@ -13,5 +13,5 @@
"oidc-provider": "^9.0.0",
"tsx": "^4.20.6"
},
"packageManager": "pnpm@11.5.2"
"packageManager": "pnpm@11.5.3"
}
+2 -2
View File
@@ -24,11 +24,11 @@
"keywords": [],
"author": "",
"license": "GNU Affero General Public License version 3",
"packageManager": "pnpm@11.5.2",
"packageManager": "pnpm@11.5.3",
"devDependencies": {
"@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*",
"@types/node": "^24.12.4",
"@types/node": "^24.13.2",
"esbuild": "^0.28.0",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3"
+1 -1
View File
@@ -24,7 +24,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.12.4",
"@types/node": "^24.13.2",
"typescript": "^6.0.0"
}
}
+2 -2
View File
@@ -673,7 +673,7 @@ export type AssetMediaResponseDto = {
export type AssetBulkUpdateDto = {
/** Original date and time */
dateTimeOriginal?: string;
/** Relative time offset in seconds */
/** Relative time offset in minutes */
dateTimeRelative?: number;
/** Asset description */
description?: string;
@@ -696,7 +696,7 @@ export type AssetBulkUpdateDto = {
export type AssetBulkUploadCheckItem = {
/** Base64 or hex encoded SHA1 hash */
checksum: string;
/** Asset ID */
/** Client-side identifier echoed in the response to match results to inputs (e.g. filename) */
id: string;
};
export type AssetBulkUploadCheckDto = {
+2998 -2953
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -65,3 +65,5 @@ preferWorkspacePackages: true
injectWorkspacePackages: true
shamefullyHoist: false
verifyDepsBeforeRun: install
minimumReleaseAgeExclude:
- '@immich/ui@0.81.1'
+9 -9
View File
@@ -49,14 +49,14 @@
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.218.0",
"@opentelemetry/instrumentation-http": "^0.218.0",
"@opentelemetry/instrumentation-ioredis": "^0.66.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.64.0",
"@opentelemetry/instrumentation-pg": "^0.70.0",
"@opentelemetry/exporter-prometheus": "^0.219.0",
"@opentelemetry/instrumentation-http": "^0.219.0",
"@opentelemetry/instrumentation-ioredis": "^0.67.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.65.0",
"@opentelemetry/instrumentation-pg": "^0.71.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/sdk-node": "^0.219.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^1.0.0",
"@react-email/render": "^2.0.0",
@@ -84,7 +84,7 @@
"jose": "^6.0.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.17",
"kysely": "0.29.2",
"kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
@@ -116,7 +116,7 @@
"ua-parser-js": "^2.0.0",
"uuid": "^14.0.0",
"validator": "^13.12.0",
"zod": "4.3.6"
"zod": "4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.0",
@@ -138,7 +138,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.12.4",
"@types/node": "^24.13.2",
"@types/nodemailer": "^8.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
+1 -1
View File
@@ -58,7 +58,7 @@ const AssetMediaCreateSchema = AssetMediaBaseSchema.extend({
const AssetBulkUploadCheckItemSchema = z
.object({
id: z.string().describe('Asset ID'),
id: z.string().describe('Client-side identifier echoed in the response to match results to inputs (e.g. filename)'),
checksum: z.string().describe('Base64 or hex encoded SHA1 hash'),
})
.meta({ id: 'AssetBulkUploadCheckItem' });
+1 -1
View File
@@ -41,7 +41,7 @@ const UpdateAssetBaseSchema = z
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
duplicateId: z.string().nullish().describe('Duplicate ID'),
dateTimeRelative: z.int().optional().describe('Relative time offset in seconds'),
dateTimeRelative: z.int().optional().describe('Relative time offset in minutes'),
timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
});
+11 -1
View File
@@ -1,3 +1,4 @@
import { validateCronExpression } from 'cron';
import { createZodDto } from 'nestjs-zod';
import { SystemConfig } from 'src/config';
import {
@@ -43,7 +44,16 @@ const JobSettingsSchema = z
const cronExpressionSchema = z
.string()
.regex(/(((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,7}/, 'Invalid cron expression')
.superRefine((value, ctx) => {
const validated = validateCronExpression(value);
if (!validated.valid) {
ctx.addIssue({
code: 'custom',
message: `Invalid cron expression. ${validated.error?.message ?? ''}`,
input: value,
});
}
})
.describe('Cron expression');
const DatabaseBackupSchema = z
+3 -3
View File
@@ -129,10 +129,10 @@ from
and "integrity_report"."type" = $1
where
"asset"."deletedAt" is null
and "createdAt" >= $2
and "createdAt" <= $3
and "integrity_report"."createdAt" >= $2
and "integrity_report"."createdAt" <= $3
order by
"createdAt" asc
"integrity_report"."createdAt" asc
-- IntegrityRepository.streamIntegrityReports
select
@@ -177,9 +177,9 @@ export class IntegrityRepository {
'asset.id as assetId',
'integrity_report.id as reportId',
])
.$if(startMarker !== undefined, (qb) => qb.where('createdAt', '>=', startMarker!))
.$if(endMarker !== undefined, (qb) => qb.where('createdAt', '<=', endMarker!))
.orderBy('createdAt', 'asc')
.$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
.$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
.orderBy('integrity_report.createdAt', 'asc')
.stream();
}
@@ -369,6 +369,26 @@ describe(DuplicateService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: asset1.id } }]);
});
it('should not merge metadata when multiple assets are kept', async () => {
const asset1 = AssetFactory.create({ isFavorite: true });
const asset2 = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset],
});
const result = await sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id, asset2.id], trashAssetIds: [] }],
});
expect(result[0].success).toBe(true);
expect(mocks.album.addAssetIdsToAlbums).not.toHaveBeenCalled();
expect(mocks.tag.replaceAssetTags).not.toHaveBeenCalled();
expect(mocks.asset.updateAllExif).not.toHaveBeenCalled();
expect(mocks.asset.updateAll).toHaveBeenCalledWith([asset1.id, asset2.id], { duplicateId: null });
});
// NOTE: The following integration-style tests are covered by E2E tests instead
// to avoid complex mock setup. The validation and error-handling logic above
// is thoroughly unit tested.
+37 -35
View File
@@ -156,51 +156,51 @@ export class DuplicateService extends BaseService {
}
}
const assetAlbumMap = await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]);
// Only merge metadata into the keeper when exactly one asset can absorb trashed duplicates.
if (idsToKeep.length === 1 && idsToTrash.length > 0) {
const assetAlbumMap = await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]);
const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds, mergedTagValues } = this.getSyncMergeResult(
duplicateGroup.assets,
assetAlbumMap,
);
const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds, mergedTagValues } = this.getSyncMergeResult(
duplicateGroup.assets,
assetAlbumMap,
);
if (mergedAlbumIds.length > 0) {
const allowedAlbumIds = await this.checkAccess({
auth,
permission: Permission.AlbumAssetCreate,
ids: mergedAlbumIds,
});
if (mergedAlbumIds.length > 0) {
const allowedAlbumIds = await this.checkAccess({
auth,
permission: Permission.AlbumAssetCreate,
ids: mergedAlbumIds,
});
const allowedShareIds = await this.checkAccess({
auth,
permission: Permission.AssetShare,
ids: idsToKeep,
});
const allowedShareIds = await this.checkAccess({
auth,
permission: Permission.AssetShare,
ids: idsToKeep,
});
if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) {
await this.albumRepository.addAssetIdsToAlbums(
[...allowedAlbumIds].flatMap((albumId) => [...allowedShareIds].map((assetId) => ({ albumId, assetId }))),
);
if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) {
await this.albumRepository.addAssetIdsToAlbums(
[...allowedAlbumIds].flatMap((albumId) => [...allowedShareIds].map((assetId) => ({ albumId, assetId }))),
);
}
}
}
if (mergedTagIds.length > 0) {
const allowedTagIds = await this.checkAccess({
auth,
permission: Permission.TagAsset,
ids: mergedTagIds,
});
if (mergedTagIds.length > 0) {
const allowedTagIds = await this.checkAccess({
auth,
permission: Permission.TagAsset,
ids: mergedTagIds,
});
if (allowedTagIds.size > 0) {
// Replace tags for each keeper asset to ensure all merged tags are applied
await Promise.all(idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds])));
if (allowedTagIds.size > 0) {
await Promise.all(
idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds])),
);
// Update asset_exif.tags so the subsequent SidecarWrite + MetadataExtraction
// cycle preserves the merged tags (updateAllExif locks the property automatically)
await this.assetRepository.updateAllExif(idsToKeep, { tags: mergedTagValues });
await this.assetRepository.updateAllExif(idsToKeep, { tags: mergedTagValues });
}
}
}
if (idsToKeep.length > 0) {
const hasExifUpdate = Object.keys(exifUpdate).length > 0;
const hasTagUpdate = mergedTagIds.length > 0;
@@ -213,6 +213,8 @@ export class DuplicateService extends BaseService {
}
await this.assetRepository.updateAll(idsToKeep, { duplicateId: null, ...assetUpdate });
} else if (idsToKeep.length > 0) {
await this.assetRepository.updateAll(idsToKeep, { duplicateId: null });
}
if (idsToTrash.length > 0) {
+1 -4
View File
@@ -399,10 +399,7 @@ export class IntegrityService extends BaseService {
await this.integrityRepository.deleteByIds(outdatedReports);
}
const missingFiles = Object.values(
Object.fromEntries(results.filter(({ exists }) => !exists).map((file) => [file.path, file])),
);
const missingFiles = results.filter(({ exists }) => !exists);
if (missingFiles.length > 0) {
await this.integrityRepository.create(
missingFiles.map(({ path, assetId, fileAssetId }) => ({
@@ -319,14 +319,14 @@ describe(SystemConfigService.name, () => {
it('should accept valid cron expressions', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(
JSON.stringify({ library: { scan: { cronExpression: '0 0 * * *' } } }),
JSON.stringify({ library: { scan: { cronExpression: '0 0 */3 * *' } } }),
);
await expect(sut.getSystemConfig()).resolves.toMatchObject({
library: {
scan: {
enabled: true,
cronExpression: '0 0 * * *',
cronExpression: '0 0 */3 * *',
},
},
});
@@ -512,82 +512,6 @@ describe(IntegrityService.name, () => {
nextCursor: undefined,
});
});
it('should not fail when the same path is duplicated within a batch', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const {
result: { id: ownerId },
} = await ctx.newUser();
const {
result: { id: assetId1 },
} = await ctx.newAsset({ ownerId, originalPath: '/path/to/duplicate' });
const {
result: { id: assetId2 },
} = await ctx.newAsset({ ownerId, originalPath: '/path/to/duplicate' });
const fileAssetId1 = randomUUID();
await ctx.newAssetFile({
id: fileAssetId1,
assetId: assetId1,
type: AssetFileType.Thumbnail,
path: '/path/to/duplicate-file',
});
const fileAssetId2 = randomUUID();
await ctx.newAssetFile({
id: fileAssetId2,
assetId: assetId1,
type: AssetFileType.Preview,
path: '/path/to/duplicate-file',
});
const {
result: { id: assetId3 },
} = await ctx.newAsset({ ownerId, originalPath: '/path/to/duplicate-cross' });
const fileAssetId3 = randomUUID();
await ctx.newAssetFile({
id: fileAssetId3,
assetId: assetId3,
type: AssetFileType.Thumbnail,
path: '/path/to/duplicate-cross',
});
storage.stat.mockRejectedValue(new Error('ENOENT'));
await expect(
sut.handleMissingFiles({
items: [
{ path: '/path/to/duplicate', assetId: assetId1, fileAssetId: null, reportId: null },
{ path: '/path/to/duplicate', assetId: assetId2, fileAssetId: null, reportId: null },
{ path: '/path/to/duplicate-file', assetId: null, fileAssetId: fileAssetId1, reportId: null },
{ path: '/path/to/duplicate-file', assetId: null, fileAssetId: fileAssetId2, reportId: null },
{ path: '/path/to/duplicate-cross', assetId: assetId3, fileAssetId: null, reportId: null },
{ path: '/path/to/duplicate-cross', assetId: null, fileAssetId: fileAssetId3, reportId: null },
],
}),
).resolves.toBe(JobStatus.Success);
await expect(
ctx.get(IntegrityRepository).getIntegrityReport(
{
limit: 100,
},
IntegrityReport.MissingFile,
),
).resolves.toEqual({
items: expect.arrayContaining([
expect.objectContaining({ path: '/path/to/duplicate' }),
expect.objectContaining({ path: '/path/to/duplicate-file' }),
expect.objectContaining({ path: '/path/to/duplicate-cross' }),
]),
nextCursor: undefined,
});
});
});
describe('handleMissingRefresh', () => {
@@ -762,40 +686,6 @@ describe(IntegrityService.name, () => {
nextCursor: undefined,
});
});
it('should not fail when the same path is duplicated across assets', async () => {
const { sut, ctx } = setup();
const storage = ctx.getMock(StorageRepository);
const job = ctx.getMock(JobRepository);
job.queue.mockResolvedValue(void 0);
const {
result: { id: ownerId },
} = await ctx.newUser();
await ctx.newAsset({ ownerId, originalPath: '/path/to/duplicate', checksum: Buffer.from('mismatch-a') });
await ctx.newAsset({ ownerId, originalPath: '/path/to/duplicate', checksum: Buffer.from('mismatch-b') });
storage.createPlainReadStream.mockImplementation(() => Readable.from('garbage data'));
await expect(sut.handleChecksumFiles({ refreshOnly: false })).resolves.toBe(JobStatus.Success);
await expect(
ctx.get(IntegrityRepository).getIntegrityReport(
{
limit: 100,
},
IntegrityReport.ChecksumFail,
),
).resolves.toEqual({
items: [
expect.objectContaining({
path: '/path/to/duplicate',
}),
],
nextCursor: undefined,
});
});
});
describe('handleChecksumRefresh', () => {
+2 -2
View File
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.80.0",
"@immich/ui": "^0.81.1",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@mdi/js": "^7.4.47",
"@noble/hashes": "^2.2.0",
@@ -105,7 +105,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^4.0.0",
"rollup-plugin-visualizer": "^7.0.0",
"svelte": "5.56.2",
"svelte": "5.56.3",
"svelte-check": "^4.4.6",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.2.4",
@@ -12,10 +12,11 @@
icon?: string;
title: string;
valuePromise: MaybePromise<ValueData>;
tooltip?: string;
footer?: Snippet;
}
let { icon, title, valuePromise, footer }: Props = $props();
let { icon, title, valuePromise, tooltip, footer }: Props = $props();
const zeros = (data?: ValueData) => {
let length = 13;
if (data) {
@@ -32,7 +33,7 @@
{#if icon}
<Icon {icon} size="40" />
{/if}
<Text size="giant" fontWeight="medium">{title}</Text>
<Text size="giant" fontWeight="medium" title={tooltip}>{title}</Text>
</div>
{#await valuePromise}
@@ -208,13 +208,13 @@
if (relativeDate) {
const duration = Duration.fromISO(relativeDate);
return {
fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined,
fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toUTC().toISO() : undefined,
};
}
return {
fileCreatedAfter: dateAfter?.toUTC().toISO(),
fileCreatedBefore: dateBefore?.toUTC().toISO(),
fileCreatedAfter: dateAfter,
fileCreatedBefore: dateBefore,
};
}
@@ -243,7 +243,7 @@
}
const handleSettingsClick = async () => {
const settings = await modalManager.show(MapSettingsModal, { settings: { ...$mapSettings } });
const settings = await modalManager.show(MapSettingsModal);
if (settings) {
const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
$mapSettings = settings;
-18
View File
@@ -1,18 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
rounded?: boolean | 'full';
children?: Snippet;
}
let { rounded = true, children }: Props = $props();
</script>
<span
class="inline-block h-min bg-primary px-3 py-1 text-center align-baseline text-xs leading-none whitespace-nowrap text-subtle"
class:rounded-md={rounded === true}
class:rounded-full={rounded === 'full'}
>
{@render children?.()}
</span>
+13 -7
View File
@@ -1,17 +1,16 @@
<script lang="ts">
import type { MapSettings } from '$lib/stores/preferences.store';
import { mapSettings, type MapSettings } from '$lib/stores/preferences.store';
import { Button, DatePicker, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
import { Duration } from 'luxon';
import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
type Props = {
settings: MapSettings;
onClose: (settings?: MapSettings) => void;
};
let { settings: initialValues, onClose }: Props = $props();
let settings = $state(initialValues);
let { onClose }: Props = $props();
let settings = $state({ ...$mapSettings });
let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore);
@@ -41,10 +40,17 @@
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<Field label={$t('date_after')}>
<DatePicker bind:value={settings.dateAfter} maxDate={settings.dateBefore} />
<DatePicker
value={DateTime.fromISO(settings.dateAfter ?? '')}
maxDate={DateTime.fromISO(settings.dateBefore ?? '')}
onChange={(date) => (settings.dateAfter = date?.toUTC().toISO() ?? undefined)}
/>
</Field>
<Field label={$t('date_before')}>
<DatePicker bind:value={settings.dateBefore} />
<DatePicker
value={DateTime.fromISO(settings.dateBefore ?? '')}
onChange={(date) => (settings.dateBefore = date?.toUTC().toISO() ?? undefined)}
/>
</Field>
<div class="flex justify-center">
<Button
+7 -4
View File
@@ -11,7 +11,7 @@
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { SearchFilter } from '$lib/types';
import { parseUtcDate } from '$lib/utils/date-time';
import { asLocalTimeISO, parseUtcDate } from '$lib/utils/date-time';
import { generateId } from '$lib/utils/generate-id';
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
@@ -27,7 +27,6 @@
let { searchQuery, onClose }: Props = $props();
const parseOptionalDate = (dateString?: DateTime) => (dateString ? parseUtcDate(dateString.toString()) : undefined);
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day') || undefined;
const formId = generateId();
@@ -144,8 +143,12 @@
make: filter.camera.make,
model: filter.camera.model,
lensModel: filter.camera.lensModel,
takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined,
takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined,
takenAfter: filter.date.takenAfter
? asLocalTimeISO(filter.date.takenAfter.startOf('day') as DateTime<true>)
: undefined,
takenBefore: filter.date.takenBefore
? asLocalTimeISO(filter.date.takenBefore.endOf('day') as DateTime<true>)
: undefined,
visibility: filter.display.isArchive ? AssetVisibility.Archive : undefined,
isFavorite: filter.display.isFavorite || undefined,
isNotInAlbum: filter.display.isNotInAlbum || undefined,
+2 -3
View File
@@ -1,4 +1,3 @@
import type { DateTime } from 'luxon';
import { persisted } from 'svelte-persisted-store';
import { browser } from '$app/environment';
import { defaultLang } from '$lib/constants';
@@ -27,8 +26,8 @@ export interface MapSettings {
withPartners: boolean;
withSharedAlbums: boolean;
relativeDate: string;
dateAfter?: DateTime<true>;
dateBefore?: DateTime<true>;
dateAfter?: string;
dateBefore?: string;
}
const defaultMapSettings = {
+1
View File
@@ -116,6 +116,7 @@ const nonIntlNames: Record<string, string> = {
kxm: 'Khmer Surin',
mfa: 'Malay (Pattani)',
swg: 'Schwäbisch',
tl: 'Tagalog',
};
const getLanguageName = (code: string) =>
+46 -31
View File
@@ -21,6 +21,7 @@
import { onMount } from 'svelte';
import { t, type Translations } from 'svelte-i18n';
import type { PageData } from './$types';
import { SvelteSet } from 'svelte/reactivity';
type Props = {
data: PageData;
@@ -50,7 +51,7 @@
};
let jobs: QueuesResponseLegacyDto | undefined = $state();
let expectingUpdate: boolean = $state(false);
const activeJobs = new SvelteSet<ManualJobName>();
const getReportTypeTranslation = (report: IntegrityReport): Translations => {
switch (report) {
@@ -66,13 +67,27 @@
}
};
const getReportTypeDescriptionKey = (report: IntegrityReport): Translations => {
switch (report) {
case IntegrityReport.UntrackedFile: {
return 'admin.maintenance_integrity_untracked_file_description';
}
case IntegrityReport.MissingFile: {
return 'admin.maintenance_integrity_missing_file_description';
}
case IntegrityReport.ChecksumMismatch: {
return 'admin.maintenance_integrity_checksum_mismatch_description';
}
}
};
const updateReports = async () => {
jobs = await getQueuesLegacy();
if (jobs.integrityCheck.queueStatus.isActive) {
expectingUpdate = true;
} else if (expectingUpdate) {
activeJobs.add(ManualJobName.IntegrityUntrackedFilesRefresh);
} else if (activeJobs.size > 0) {
activeJobs.clear();
integrityReport = await getIntegrityReportSummary();
expectingUpdate = false;
}
};
@@ -84,7 +99,7 @@
const onJobCreate = ({ dto }: { dto: JobCreateDto }) => {
if ((Object.values(jobNames).includes(dto.name) || Object.values(refreshJobNames).includes(dto.name)) && jobs) {
expectingUpdate = true;
activeJobs.add(dto.name);
jobs.integrityCheck.queueStatus.isActive = true;
}
};
@@ -106,7 +121,7 @@
}
}}
class="mt-1 self-end"
disabled={expectingUpdate}>{$t('admin.maintenance_integrity_check_all')}</Button
disabled={activeJobs.size > 0}>{$t('admin.maintenance_integrity_check_all')}</Button
>
<Button
size="tiny"
@@ -117,7 +132,7 @@
}
}}
class="mt-1 self-end"
disabled={expectingUpdate}>{$t('refresh')}</Button
disabled={activeJobs.size > 0}>{$t('refresh')}</Button
></HStack
>
@@ -125,36 +140,36 @@
{#each reportTypes as reportType (reportType)}
<ServerStatisticsCard
title={$t(getReportTypeTranslation(reportType))}
tooltip={$t(getReportTypeDescriptionKey(reportType))}
valuePromise={{ value: integrityReport[reportType] }}
>
{#snippet footer()}
<HStack gap={1} class="justify-end">
<Button
onclick={() =>
handleCreateJob({
name: jobNames[reportType],
})}
size="tiny"
variant="ghost"
class="mt-1 self-end"
disabled={expectingUpdate}>{$t('admin.maintenance_integrity_check_all')}</Button
>
<Button
onclick={() =>
handleCreateJob({
name: refreshJobNames[reportType],
})}
size="tiny"
variant="ghost"
class="mt-1 self-end"
disabled={expectingUpdate}>{$t('refresh')}</Button
>
<HStack gap={1} class="justify-between">
<HStack gap={0}>
<Button
onclick={() =>
handleCreateJob({
name: jobNames[reportType],
})}
size="tiny"
variant="ghost"
disabled={activeJobs.has(jobNames[reportType])}>{$t('admin.maintenance_integrity_check')}</Button
>
<Button
onclick={() =>
handleCreateJob({
name: refreshJobNames[reportType],
})}
size="tiny"
variant="ghost"
disabled={activeJobs.has(refreshJobNames[reportType])}>{$t('refresh')}</Button
>
</HStack>
<Button
href={`${Route.systemMaintenanceIntegrityReport({
reportType,
})}`}
size="tiny"
class="mt-1 self-end">{$t('view')}</Button
size="tiny">{$t('view')}</Button
>
</HStack>
{/snippet}
@@ -166,7 +181,7 @@
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-212.5">
<Text size="small">{$t('admin.maintenance_settings')}</Text>
<Text size="small">{$t('admin.maintenance_backup_management')}</Text>
<SettingAccordion
title={$t('admin.maintenance_restore_database_backup')}
+7 -19
View File
@@ -2,13 +2,12 @@
import { cleanClass } from '$lib';
import QueueCardBadge from './QueueCardBadge.svelte';
import QueueCardButton from './QueueCardButton.svelte';
import Badge from '$lib/elements/Badge.svelte';
import { Route } from '$lib/route';
import { asQueueItem } from '$lib/services/queue.service';
import { locale } from '$lib/stores/preferences.store';
import { transformToTitleCase } from '$lib/utils';
import { QueueCommand, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk';
import { Icon, IconButton, Link } from '@immich/ui';
import { Badge, Icon, IconButton, Link } from '@immich/ui';
import {
mdiAlertCircle,
mdiAllInclusive,
@@ -67,27 +66,16 @@
/>
<div class="flex gap-2">
{#if statistics.failed > 0}
<Badge>
<div class="flex flex-row gap-1">
<span class="text-sm">
{$t('admin.jobs_failed', { values: { jobCount: statistics.failed.toLocaleString($locale) } })}
</span>
<IconButton
color="primary"
icon={mdiClose}
aria-label={$t('clear_message')}
size="tiny"
shape="round"
onclick={() => onCommand({ command: QueueCommand.ClearFailed, force: false })}
/>
</div>
<Badge
onClose={() => onCommand({ command: QueueCommand.ClearFailed, force: false })}
translations={{ close: $t('clear_message') }}
>
{$t('admin.jobs_failed', { values: { jobCount: statistics.failed.toLocaleString($locale) } })}
</Badge>
{/if}
{#if statistics.delayed > 0}
<Badge>
<span class="text-sm">
{$t('admin.jobs_delayed', { values: { jobCount: statistics.delayed.toLocaleString($locale) } })}
</span>
{$t('admin.jobs_delayed', { values: { jobCount: statistics.delayed.toLocaleString($locale) } })}
</Badge>
{/if}
</div>