From 25c573bc7a73cd4601f91cdd87807e3dbd956018 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 29 Jan 2026 11:11:25 -0500 Subject: [PATCH 01/66] chore: remove random code snippet (#25677) --- web/src/lib/components/AssetViewerEvents.svelte | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/lib/components/AssetViewerEvents.svelte b/web/src/lib/components/AssetViewerEvents.svelte index 148da0e258..b636908b76 100644 --- a/web/src/lib/components/AssetViewerEvents.svelte +++ b/web/src/lib/components/AssetViewerEvents.svelte @@ -22,5 +22,3 @@ return assetViewerManager.on(events); }); - -const event = name.slice(2) as keyof Events; From 3ace578fc0b3434aebc46544c1c5b9abecb00364 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:14:44 -0500 Subject: [PATCH 02/66] chore(deps): update dependency opentofu to v1.11.4 (#24609) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- deployment/mise.toml | 2 +- mise.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deployment/mise.toml b/deployment/mise.toml index 73fb6dd47f..d77ec84125 100644 --- a/deployment/mise.toml +++ b/deployment/mise.toml @@ -1,6 +1,6 @@ [tools] terragrunt = "0.98.0" -opentofu = "1.10.7" +opentofu = "1.11.4" [tasks."tg:fmt"] run = "terragrunt hclfmt" diff --git a/mise.toml b/mise.toml index 652a8afaf2..ea66e3ad71 100644 --- a/mise.toml +++ b/mise.toml @@ -18,7 +18,7 @@ node = "24.13.0" flutter = "3.35.7" pnpm = "10.28.0" terragrunt = "0.98.0" -opentofu = "1.10.7" +opentofu = "1.11.4" java = "25.0.1" [tools."github:CQLabs/homebrew-dcm"] From 1a04caee294844ee65cd0716a159350bf6e05d00 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 29 Jan 2026 15:18:30 -0600 Subject: [PATCH 03/66] fix: reset and unsaved change states in editor (#25588) --- .../asset-viewer/asset-viewer.svelte | 2 -- .../asset-viewer/editor/editor-panel.svelte | 2 +- .../lib/managers/edit/edit-manager.svelte.ts | 8 +++++-- .../managers/edit/transform-manager.svelte.ts | 21 +++++++++++++------ 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 333e76d2ea..6284e207c6 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -194,9 +194,7 @@ const closeEditor = async () => { if (editManager.hasAppliedEdits) { - console.log(asset); const refreshedAsset = await getAssetInfo({ id: asset.id }); - console.log(refreshedAsset); onAssetChange?.(refreshedAsset); assetViewingStore.setAsset(refreshedAsset); } diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte index c52b63793b..d9a344cdc8 100644 --- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -75,7 +75,7 @@ - - -
- - -
- +
+
+ {$t('change_pin_code')} + + + +
- + +
+ + +
+
diff --git a/web/src/lib/components/user-settings-page/PinCodeSettings.svelte b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte index 1d18bc2448..53530b694d 100644 --- a/web/src/lib/components/user-settings-page/PinCodeSettings.svelte +++ b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte @@ -20,7 +20,7 @@ -
+
{#if hasPinCode}
diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index 5c3b59fafe..3e82b76418 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -59,7 +59,7 @@
-
+
themeManager.setSystem(checked)} /> diff --git a/web/src/lib/components/user-settings-page/change-password-settings.svelte b/web/src/lib/components/user-settings-page/change-password-settings.svelte index 8165f2e508..2579862056 100644 --- a/web/src/lib/components/user-settings-page/change-password-settings.svelte +++ b/web/src/lib/components/user-settings-page/change-password-settings.svelte @@ -23,7 +23,7 @@
-
+
diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte index 41f1e3b790..6bc94672df 100644 --- a/web/src/lib/components/user-settings-page/device-list.svelte +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -3,6 +3,7 @@ import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk'; import { Button, modalManager, Text, toastManager } from '@immich/ui'; import { t } from 'svelte-i18n'; + import { fade } from 'svelte/transition'; import DeviceCard from './device-card.svelte'; interface Props { @@ -50,33 +51,39 @@
- {#if currentSession} -
- - {$t('current_device')} - - -
- {/if} - {#if otherSessions.length > 0} -
- - {$t('other_devices')} - - {#each otherSessions as session, index (session.id)} - handleDelete(session)} /> - {#if index !== otherSessions.length - 1} -
- {/if} - {/each} -
+
+
+ {#if currentSession} +
+ + {$t('current_device')} + + +
+ {/if} + {#if otherSessions.length > 0} +
+ + {$t('other_devices')} + + {#each otherSessions as session, index (session.id)} + handleDelete(session)} /> + {#if index !== otherSessions.length - 1} +
+ {/if} + {/each} +
-
-
-
+
+
+
-
- +
+ +
+ {/if}
- {/if} +
diff --git a/web/src/lib/components/user-settings-page/download-settings.svelte b/web/src/lib/components/user-settings-page/download-settings.svelte index 79d6ba6a1d..78e97ffd11 100644 --- a/web/src/lib/components/user-settings-page/download-settings.svelte +++ b/web/src/lib/components/user-settings-page/download-settings.svelte @@ -39,7 +39,7 @@
-
+
-
+
-
+
diff --git a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte index 5aeed92d41..5635cb0fa7 100644 --- a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte @@ -105,7 +105,7 @@
-
+
{#if $isPurchased}
diff --git a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte index 423a581e19..817c8933a6 100644 --- a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte +++ b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte @@ -65,7 +65,7 @@ {/snippet} -
+
{$t('photos_and_videos')} From 9f52d864cfa4091c45585e46579e988377bea90a Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 2 Feb 2026 15:02:06 +0100 Subject: [PATCH 10/66] chore(ml): replace black with ruff format (#25578) --- .github/workflows/test.yml | 4 +-- machine-learning/pyproject.toml | 5 ---- machine-learning/uv.lock | 50 --------------------------------- 3 files changed, 2 insertions(+), 57 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6d80849f7e..28a74ff33f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -591,9 +591,9 @@ jobs: - name: Lint with ruff run: | uv run ruff check --output-format=github immich_ml - - name: Check black formatting + - name: Format with ruff run: | - uv run black --check immich_ml + uv run ruff format --check immich_ml - name: Run mypy type checking run: | uv run mypy --strict immich_ml/ diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 87ead41378..ae6d7c0e2c 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -41,7 +41,6 @@ types = [ "types-ujson>=5.10.0.20240515", ] lint = [ - "black>=23.3.0", "mypy>=1.3.0", "ruff>=0.0.272", { include-group = "types" }, @@ -93,9 +92,5 @@ target-version = "py311" select = ["E", "F", "I"] per-file-ignores = { "test_main.py" = ["F403"] } -[tool.black] -line-length = 120 -target-version = ['py311'] - [tool.pytest.ini_options] markers = ["providers", "ov_device_ids"] diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 99992b8d16..4ab5dbff36 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -85,43 +85,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, ] -[[package]] -name = "black" -version = "25.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" }, - { url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" }, - { url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" }, - { url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" }, - { url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" }, - { url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" }, - { url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" }, - { url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" }, - { url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" }, - { url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" }, - { url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" }, - { url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" }, - { url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" }, - { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, -] - [[package]] name = "blinker" version = "1.7.0" @@ -961,7 +924,6 @@ rknn = [ [package.dev-dependencies] dev = [ - { name = "black" }, { name = "httpx" }, { name = "locust" }, { name = "mypy" }, @@ -977,7 +939,6 @@ dev = [ { name = "types-ujson" }, ] lint = [ - { name = "black" }, { name = "mypy" }, { name = "ruff" }, { name = "types-pyyaml" }, @@ -1031,7 +992,6 @@ provides-extras = ["cpu", "cuda", "openvino", "armnn", "rknn", "rocm"] [package.metadata.requires-dev] dev = [ - { name = "black", specifier = ">=23.3.0" }, { name = "httpx", specifier = ">=0.24.1" }, { name = "locust", specifier = ">=2.15.1" }, { name = "mypy", specifier = ">=1.3.0" }, @@ -1047,7 +1007,6 @@ dev = [ { name = "types-ujson", specifier = ">=5.10.0.20240515" }, ] lint = [ - { name = "black", specifier = ">=23.3.0" }, { name = "mypy", specifier = ">=1.3.0" }, { name = "ruff", specifier = ">=0.0.272" }, { name = "types-pyyaml", specifier = ">=6.0.12.20241230" }, @@ -2232,15 +2191,6 @@ client = [ { name = "websocket-client" }, ] -[[package]] -name = "pytokens" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, -] - [[package]] name = "pywin32" version = "311" From 95e8e474b8cc823c86f01df677dbe555c143e290 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 2 Feb 2026 11:12:08 -0500 Subject: [PATCH 11/66] fix(web): enable asset viewer navigation across memory boundaries (#25741) --- e2e/src/generators/memory.ts | 2 + e2e/src/generators/memory/model-objects.ts | 84 +++++ e2e/src/mock-network/memory-network.ts | 65 ++++ .../web/specs/memory/memory-viewer.ui-spec.ts | 289 ++++++++++++++++++ e2e/src/web/specs/memory/utils.ts | 123 ++++++++ .../memory-page/memory-viewer.svelte | 6 +- 6 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 e2e/src/generators/memory.ts create mode 100644 e2e/src/generators/memory/model-objects.ts create mode 100644 e2e/src/mock-network/memory-network.ts create mode 100644 e2e/src/web/specs/memory/memory-viewer.ui-spec.ts create mode 100644 e2e/src/web/specs/memory/utils.ts diff --git a/e2e/src/generators/memory.ts b/e2e/src/generators/memory.ts new file mode 100644 index 0000000000..c17b4aa476 --- /dev/null +++ b/e2e/src/generators/memory.ts @@ -0,0 +1,2 @@ +export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects'; +export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects'; diff --git a/e2e/src/generators/memory/model-objects.ts b/e2e/src/generators/memory/model-objects.ts new file mode 100644 index 0000000000..1bcc703ed8 --- /dev/null +++ b/e2e/src/generators/memory/model-objects.ts @@ -0,0 +1,84 @@ +import { faker } from '@faker-js/faker'; +import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk'; +import { DateTime } from 'luxon'; +import { toAssetResponseDto } from 'src/generators/timeline/rest-response'; +import type { MockTimelineAsset } from 'src/generators/timeline/timeline-config'; +import { SeededRandom, selectRandomMultiple } from 'src/generators/timeline/utils'; + +export type MemoryConfig = { + id?: string; + ownerId: string; + year: number; + memoryAt: string; + isSaved?: boolean; +}; + +export type MemoryYearConfig = { + year: number; + assetCount: number; +}; + +export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto { + const now = new Date().toISOString(); + const memoryId = config.id ?? faker.string.uuid(); + + return { + id: memoryId, + assets: assets.map((asset) => toAssetResponseDto(asset)), + data: { year: config.year } as OnThisDayDto, + memoryAt: config.memoryAt, + createdAt: now, + updatedAt: now, + isSaved: config.isSaved ?? false, + ownerId: config.ownerId, + type: MemoryType.OnThisDay, + }; +} + +export function generateMemoriesFromTimeline( + timelineAssets: MockTimelineAsset[], + ownerId: string, + memoryConfigs: MemoryYearConfig[], + seed: number = 42, +): MemoryResponseDto[] { + const rng = new SeededRandom(seed); + const memories: MemoryResponseDto[] = []; + const usedAssetIds = new Set(); + + for (const config of memoryConfigs) { + const yearAssets = timelineAssets.filter((asset) => { + const assetYear = DateTime.fromISO(asset.fileCreatedAt).year; + return assetYear === config.year && !usedAssetIds.has(asset.id); + }); + + if (yearAssets.length === 0) { + continue; + } + + const countToSelect = Math.min(config.assetCount, yearAssets.length); + const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng); + + for (const asset of selectedAssets) { + usedAssetIds.add(asset.id); + } + + selectedAssets.sort( + (a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds, + ); + + const memoryAt = DateTime.now().set({ year: config.year }).toISO()!; + + memories.push( + generateMemory( + { + ownerId, + year: config.year, + memoryAt, + }, + selectedAssets, + ), + ); + } + + return memories; +} diff --git a/e2e/src/mock-network/memory-network.ts b/e2e/src/mock-network/memory-network.ts new file mode 100644 index 0000000000..9a3a9e6555 --- /dev/null +++ b/e2e/src/mock-network/memory-network.ts @@ -0,0 +1,65 @@ +import type { MemoryResponseDto } from '@immich/sdk'; +import { BrowserContext } from '@playwright/test'; + +export type MemoryChanges = { + memoryDeletions: string[]; + assetRemovals: Map; +}; + +export const setupMemoryMockApiRoutes = async ( + context: BrowserContext, + memories: MemoryResponseDto[], + changes: MemoryChanges, +) => { + await context.route('**/api/memories*', async (route, request) => { + const url = new URL(request.url()); + const pathname = url.pathname; + + if (pathname === '/api/memories' && request.method() === 'GET') { + const activeMemories = memories + .filter((memory) => !changes.memoryDeletions.includes(memory.id)) + .map((memory) => { + const removedAssets = changes.assetRemovals.get(memory.id) ?? []; + return { + ...memory, + assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)), + }; + }) + .filter((memory) => memory.assets.length > 0); + + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: activeMemories, + }); + } + + const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/); + if (memoryMatch && request.method() === 'GET') { + const memoryId = memoryMatch[1]; + const memory = memories.find((m) => m.id === memoryId); + + if (!memory || changes.memoryDeletions.includes(memoryId)) { + return route.fulfill({ status: 404 }); + } + + const removedAssets = changes.assetRemovals.get(memoryId) ?? []; + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + ...memory, + assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)), + }, + }); + } + + if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') { + const memoryId = pathname.split('/').pop()!; + changes.memoryDeletions.push(memoryId); + return route.fulfill({ status: 204 }); + } + + await route.fallback(); + }); +}; diff --git a/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts b/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts new file mode 100644 index 0000000000..11e73fbe25 --- /dev/null +++ b/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts @@ -0,0 +1,289 @@ +import { faker } from '@faker-js/faker'; +import type { MemoryResponseDto } from '@immich/sdk'; +import { test } from '@playwright/test'; +import { generateMemoriesFromTimeline } from 'src/generators/memory'; +import { + Changes, + createDefaultTimelineConfig, + generateTimelineData, + TimelineAssetConfig, + TimelineData, +} from 'src/generators/timeline'; +import { setupBaseMockApiRoutes } from 'src/mock-network/base-network'; +import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/mock-network/memory-network'; +import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network'; +import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from 'src/web/specs/memory/utils'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { + let adminUserId: string; + let timelineRestData: TimelineData; + let memories: MemoryResponseDto[]; + const assets: TimelineAssetConfig[] = []; + const testContext = new TimelineTestContext(); + const changes: Changes = { + albumAdditions: [], + assetDeletions: [], + assetArchivals: [], + assetFavorites: [], + }; + const memoryChanges: MemoryChanges = { + memoryDeletions: [], + assetRemovals: new Map(), + }; + + test.beforeAll(async () => { + adminUserId = faker.string.uuid(); + testContext.adminId = adminUserId; + + timelineRestData = generateTimelineData({ + ...createDefaultTimelineConfig(), + ownerId: adminUserId, + }); + + for (const timeBucket of timelineRestData.buckets.values()) { + assets.push(...timeBucket); + } + + memories = generateMemoriesFromTimeline( + assets, + adminUserId, + [ + { year: 2024, assetCount: 3 }, + { year: 2023, assetCount: 2 }, + { year: 2022, assetCount: 4 }, + ], + 42, + ); + }); + + test.beforeEach(async ({ context }) => { + await setupBaseMockApiRoutes(context, adminUserId); + await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext); + await setupMemoryMockApiRoutes(context, memories, memoryChanges); + }); + + test.afterEach(() => { + testContext.slowBucket = false; + changes.albumAdditions = []; + changes.assetDeletions = []; + changes.assetArchivals = []; + changes.assetFavorites = []; + memoryChanges.memoryDeletions = []; + memoryChanges.assetRemovals.clear(); + }); + + test.describe('Asset viewer navigation from gallery', () => { + test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => { + const firstMemory = memories[0]; + const middleAsset = firstMemory.assets[1]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id); + await memoryGalleryUtils.clickThumbnail(page, middleAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset); + + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); + + test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => { + const firstMemory = memories[0]; + const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!; + + await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id); + await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory); + + await memoryAssetViewerUtils.expectNextButtonVisible(page); + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + }); + + test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => { + const lastMemory = memories.at(-1)!; + const firstAssetOfLastMemory = lastMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id); + await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory); + + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); + + test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id); + await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + + await memoryAssetViewerUtils.clickNextButton(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + + await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id); + }); + + test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id); + await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + + await memoryAssetViewerUtils.clickPreviousButton(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + }); + + test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => { + const firstMemory = memories[0]; + const veryFirstAsset = firstMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id); + await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset); + + await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); + + test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => { + const lastMemory = memories.at(-1)!; + const veryLastAsset = lastMemory.assets.at(-1)!; + + await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id); + await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset); + + await memoryAssetViewerUtils.expectNextButtonNotVisible(page); + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + }); + }); + + test.describe('Keyboard navigation', () => { + test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id); + await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + + await page.keyboard.press('ArrowLeft'); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + }); + + test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id); + await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + + await page.keyboard.press('ArrowRight'); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + }); + }); +}); + +test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => { + let adminUserId: string; + let timelineRestData: TimelineData; + let memories: MemoryResponseDto[]; + const assets: TimelineAssetConfig[] = []; + const testContext = new TimelineTestContext(); + const changes: Changes = { + albumAdditions: [], + assetDeletions: [], + assetArchivals: [], + assetFavorites: [], + }; + const memoryChanges: MemoryChanges = { + memoryDeletions: [], + assetRemovals: new Map(), + }; + + test.beforeAll(async () => { + adminUserId = faker.string.uuid(); + testContext.adminId = adminUserId; + + timelineRestData = generateTimelineData({ + ...createDefaultTimelineConfig(), + ownerId: adminUserId, + }); + + for (const timeBucket of timelineRestData.buckets.values()) { + assets.push(...timeBucket); + } + + memories = generateMemoriesFromTimeline( + assets, + adminUserId, + [ + { year: 2024, assetCount: 2 }, + { year: 2023, assetCount: 1 }, + { year: 2022, assetCount: 2 }, + ], + 123, + ); + }); + + test.beforeEach(async ({ context }) => { + await setupBaseMockApiRoutes(context, adminUserId); + await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext); + await setupMemoryMockApiRoutes(context, memories, memoryChanges); + }); + + test.afterEach(() => { + testContext.slowBucket = false; + changes.albumAdditions = []; + changes.assetDeletions = []; + changes.assetArchivals = []; + changes.assetFavorites = []; + memoryChanges.memoryDeletions = []; + memoryChanges.assetRemovals.clear(); + }); + + test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => { + const singleAssetMemory = memories[1]; + const singleAsset = singleAssetMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id); + await memoryGalleryUtils.clickThumbnail(page, singleAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset); + + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); +}); diff --git a/e2e/src/web/specs/memory/utils.ts b/e2e/src/web/specs/memory/utils.ts new file mode 100644 index 0000000000..cf99033e7e --- /dev/null +++ b/e2e/src/web/specs/memory/utils.ts @@ -0,0 +1,123 @@ +import type { AssetResponseDto } from '@immich/sdk'; +import { expect, Page } from '@playwright/test'; + +function getAssetIdFromUrl(url: URL): string | null { + const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/); + if (pathMatch) { + return pathMatch[1]; + } + return url.searchParams.get('id'); +} + +export const memoryViewerUtils = { + locator(page: Page) { + return page.locator('#memory-viewer'); + }, + + async waitForMemoryLoad(page: Page) { + await expect(this.locator(page)).toBeVisible(); + await expect(page.locator('#memory-viewer img').first()).toBeVisible(); + }, + + async openMemoryPage(page: Page) { + await page.goto('/memory'); + await this.waitForMemoryLoad(page); + }, + + async openMemoryPageWithAsset(page: Page, assetId: string) { + await page.goto(`/memory?id=${assetId}`); + await this.waitForMemoryLoad(page); + }, +}; + +export const memoryGalleryUtils = { + locator(page: Page) { + return page.locator('#gallery-memory'); + }, + + thumbnailWithAssetId(page: Page, assetId: string) { + return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`); + }, + + async scrollToGallery(page: Page) { + const showGalleryButton = page.getByLabel('Show gallery'); + if (await showGalleryButton.isVisible()) { + await showGalleryButton.click(); + } + await expect(this.locator(page)).toBeInViewport(); + }, + + async clickThumbnail(page: Page, assetId: string) { + await this.scrollToGallery(page); + await this.thumbnailWithAssetId(page, assetId).click(); + }, + + async getAllThumbnails(page: Page) { + await this.scrollToGallery(page); + return page.locator('#gallery-memory [data-thumbnail-focus-container]'); + }, +}; + +export const memoryAssetViewerUtils = { + locator(page: Page) { + return page.locator('#immich-asset-viewer'); + }, + + async waitForViewerOpen(page: Page) { + await expect(this.locator(page)).toBeVisible(); + }, + + async waitForAssetLoad(page: Page, asset: AssetResponseDto) { + const viewer = this.locator(page); + const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`); + const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`); + + await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 }); + }, + + nextButton(page: Page) { + return page.getByLabel('View next asset'); + }, + + previousButton(page: Page) { + return page.getByLabel('View previous asset'); + }, + + async expectNextButtonVisible(page: Page) { + await expect(this.nextButton(page)).toBeVisible(); + }, + + async expectNextButtonNotVisible(page: Page) { + await expect(this.nextButton(page)).toHaveCount(0); + }, + + async expectPreviousButtonVisible(page: Page) { + await expect(this.previousButton(page)).toBeVisible(); + }, + + async expectPreviousButtonNotVisible(page: Page) { + await expect(this.previousButton(page)).toHaveCount(0); + }, + + async clickNextButton(page: Page) { + await this.nextButton(page).click(); + }, + + async clickPreviousButton(page: Page) { + await this.previousButton(page).click(); + }, + + async closeViewer(page: Page) { + await page.keyboard.press('Escape'); + await expect(this.locator(page)).not.toBeVisible(); + }, + + getCurrentAssetId(page: Page): string | null { + const url = new URL(page.url()); + return getAssetIdFromUrl(url); + }, + + async expectCurrentAssetId(page: Page, expectedAssetId: string) { + await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId); + }, +}; diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 3c7ec4b0db..304b5b278e 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -68,7 +68,11 @@ let currentMemoryAssetFull = $derived.by(async () => current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined, ); - let currentTimelineAssets = $derived(current?.memory.assets || []); + let currentTimelineAssets = $derived([ + ...(current?.previousMemory?.assets ?? []), + ...(current?.memory.assets ?? []), + ...(current?.nextMemory?.assets ?? []), + ]); let isSaved = $derived(current?.memory.isSaved); let viewerHeight = $state(0); From 25f2273e246d0a0e83b1ca2593d0ff7642d31080 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:56:56 +0100 Subject: [PATCH 12/66] chore(deps): update redis:6.2-alpine docker digest to 46884be (#25839) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- e2e/docker-compose.dev.yml | 2 +- e2e/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/docker-compose.dev.yml b/e2e/docker-compose.dev.yml index cd1d3d4982..14e159ed50 100644 --- a/e2e/docker-compose.dev.yml +++ b/e2e/docker-compose.dev.yml @@ -70,7 +70,7 @@ services: restart: unless-stopped redis: - image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb + image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef database: image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index a33cb6573c..a98a7013a4 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -42,7 +42,7 @@ services: - 2285:2285 redis: - image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb + image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef database: image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 From 23445fdcc113cb33f8246791c3f315163469b39b Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 3 Feb 2026 09:28:29 -0600 Subject: [PATCH 13/66] fix: upload progress bar flickering (#25829) * fix: upload progress bar flickering * pr feedback and more logs --- .../lib/providers/backup/drift_backup.provider.dart | 13 +++++++++---- mobile/lib/services/background_upload.service.dart | 4 ++++ mobile/pubspec.lock | 8 ++++---- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 2f067fdf67..624c21f158 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -259,6 +259,11 @@ class DriftBackupNotifier extends StateNotifier { } Future startForegroundBackup(String userId) async { + // Cancel any existing backup before starting a new one + if (state.cancelToken != null) { + await stopForegroundBackup(); + } + state = state.copyWith(error: BackupError.none); final cancelToken = CancellationToken(); @@ -375,21 +380,21 @@ class DriftBackupNotifier extends StateNotifier { _logger.warning("Skip handleBackupResume (pre-call): notifier disposed"); return; } - _logger.info("Resuming backup tasks..."); + _logger.info("Start background backup sequence"); state = state.copyWith(error: BackupError.none); final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup); if (!mounted) { _logger.warning("Skip handleBackupResume (post-call): notifier disposed"); return; } - _logger.info("Found ${tasks.length} tasks"); + _logger.info("Found ${tasks.length} pending tasks"); if (tasks.isEmpty) { - _logger.info("Start backup with URLSession"); + _logger.info("No pending tasks, starting new upload"); return _backgroundUploadService.uploadBackupCandidates(userId); } - _logger.info("Tasks to resume: ${tasks.length}"); + _logger.info("Resuming upload ${tasks.length} assets"); return _backgroundUploadService.resume(); } } diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index 4eece142d2..d54a677c24 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -164,9 +164,12 @@ class BackgroundUploadService { final candidates = await _backupRepository.getCandidates(userId); if (candidates.isEmpty) { + _logger.info("No new backup candidates found, finishing background upload"); return; } + _logger.info("Found ${candidates.length} backup candidates for background tasks"); + const batchSize = 100; final batch = candidates.take(batchSize).toList(); List tasks = []; @@ -179,6 +182,7 @@ class BackgroundUploadService { } if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { + _logger.info("Enqueuing ${tasks.length} background upload tasks"); await enqueueTasks(tasks); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d237c02023..c8aa680e07 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1249,10 +1249,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1942,10 +1942,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" thumbhash: dependency: "direct main" description: From 8872d2c7aea307a03358aa29b603509dd504dfec Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 3 Feb 2026 10:00:17 -0600 Subject: [PATCH 14/66] chore: remove swift logs (#25857) --- mobile/ios/Runner/Images/LocalImagesImpl.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 4f2090443a..3af524f424 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -133,7 +133,6 @@ class LocalImageApiImpl: LocalImageApi { "height": Int64(buffer.height), "rowBytes": Int64(buffer.rowBytes) ])) - print("Successful response for \(requestId)") Self.remove(requestId: requestId) } catch { Self.remove(requestId: requestId) From 94965f6d66638a6aefa706761dc796540555830e Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:06:26 +0100 Subject: [PATCH 15/66] chore: rework tags sidebar (#25855) --- .../asset-viewer/asset-viewer.svelte | 5 ++ .../asset-viewer/detail-panel-tags.svelte | 66 ++++++++----------- .../timeline/actions/TagAction.svelte | 7 +- web/src/lib/managers/event-manager.svelte.ts | 1 + web/src/lib/modals/AssetTagModal.svelte | 7 +- web/src/lib/services/asset.service.ts | 15 ++++- 6 files changed, 54 insertions(+), 47 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 6284e207c6..06f4e5a0b2 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -14,6 +14,7 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; import { imageManager } from '$lib/managers/ImageManager.svelte'; import { Route } from '$lib/route'; + import { getAssetActions } from '$lib/services/asset.service'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; @@ -36,6 +37,7 @@ type PersonResponseDto, type StackResponseDto, } from '@immich/sdk'; + import { CommandPaletteDefaultProvider } from '@immich/ui'; import { onDestroy, onMount, untrack } from 'svelte'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; @@ -426,8 +428,11 @@ !assetViewerManager.isShowEditor && ocrManager.hasOcrData, ); + + const { Tag } = $derived(getAssetActions($t, asset)); + diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte index cd9b1a40d2..09c4432723 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte @@ -1,12 +1,13 @@ - + {#if isOwner && !authManager.isSharedLink}
@@ -42,36 +44,24 @@
{#each tags as tag (tag.id)} -
- + -

- {tag.value} -

-
- - -
+ size="tiny" + class="hover:bg-primary-400" + shape="round" + /> + {/each} - +
{/if} diff --git a/web/src/lib/components/timeline/actions/TagAction.svelte b/web/src/lib/components/timeline/actions/TagAction.svelte index d2235d7c74..dfe24b476b 100644 --- a/web/src/lib/components/timeline/actions/TagAction.svelte +++ b/web/src/lib/components/timeline/actions/TagAction.svelte @@ -20,11 +20,8 @@ const handleTagAssets = async () => { const assets = [...getOwnedAssets()]; - const success = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) }); - - if (success) { - clearSelect(); - } + await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) }); + clearSelect(); }; diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 4825dbc93b..4093413d1a 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -37,6 +37,7 @@ export type Events = { AssetsArchive: [string[]]; AssetsDelete: [string[]]; AssetEditsApplied: [string]; + AssetsTag: [string[]]; AlbumAddAssets: []; AlbumUpdate: [AlbumResponseDto]; diff --git a/web/src/lib/modals/AssetTagModal.svelte b/web/src/lib/modals/AssetTagModal.svelte index e541e24b60..00862ce1b1 100644 --- a/web/src/lib/modals/AssetTagModal.svelte +++ b/web/src/lib/modals/AssetTagModal.svelte @@ -1,4 +1,5 @@ + +
+
+ + + + {#if slug} + /s/{encodeURIComponent(slug)} + {/if} +
+ + + + + + + + + + + + + + + + + + + + + +
diff --git a/web/src/lib/modals/SharedLinkCreateModal.svelte b/web/src/lib/modals/SharedLinkCreateModal.svelte index f36500c5d8..953076667e 100644 --- a/web/src/lib/modals/SharedLinkCreateModal.svelte +++ b/web/src/lib/modals/SharedLinkCreateModal.svelte @@ -1,8 +1,8 @@
-
- +
From 57e0835b4667dc03b85c855311a40a8d095b9b31 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:36:20 +0100 Subject: [PATCH 32/66] fix: ensure theme stays in sync with @immich/ui (#25922) --- web/src/lib/managers/theme-manager.svelte.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/lib/managers/theme-manager.svelte.ts b/web/src/lib/managers/theme-manager.svelte.ts index 26c3fe31d5..0ce0172c1a 100644 --- a/web/src/lib/managers/theme-manager.svelte.ts +++ b/web/src/lib/managers/theme-manager.svelte.ts @@ -2,7 +2,7 @@ import { browser } from '$app/environment'; import { Theme } from '$lib/constants'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { PersistedLocalStorage } from '$lib/utils/persisted'; -import { theme as uiTheme, type Theme as UiTheme } from '@immich/ui'; +import { onThemeChange as onUiThemeChange, theme as uiTheme, type Theme as UiTheme } from '@immich/ui'; export interface ThemeSetting { value: Theme; @@ -55,15 +55,14 @@ class ThemeManager { } #onAppInit() { - globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener( - 'change', - () => { - if (this.theme.system) { - this.#update('system'); - } - }, - { passive: true }, - ); + const syncSystemTheme = () => { + this.#update(this.theme.system ? 'system' : this.theme.value); + }; + + syncSystemTheme(); + globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncSystemTheme, { + passive: true, + }); } #update(value: Theme | 'system') { @@ -75,6 +74,7 @@ class ThemeManager { this.#theme.current = theme; uiTheme.value = theme.value as unknown as UiTheme; + onUiThemeChange(); eventManager.emit('ThemeChange', theme); } From 810e9254f331e2633c1d7eb8334b7e01613e5f70 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:51:30 +0100 Subject: [PATCH 33/66] fix: preserve hidden people state across pagination (#25886) * fix: preserve hidden people state across pagination * track overrides instead * use event instead of bind:people * update test --- .../manage-people-visibility.spec.ts | 88 +++++++++++++++++++ .../manage-people-visibility.svelte | 50 ++++++----- ...nage-people-visibility.test-wrapper.svelte | 20 +++++ web/src/routes/(user)/people/+page.svelte | 4 +- 4 files changed, 139 insertions(+), 23 deletions(-) create mode 100644 web/src/lib/components/faces-page/manage-people-visibility.spec.ts create mode 100644 web/src/lib/components/faces-page/manage-people-visibility.test-wrapper.svelte diff --git a/web/src/lib/components/faces-page/manage-people-visibility.spec.ts b/web/src/lib/components/faces-page/manage-people-visibility.spec.ts new file mode 100644 index 0000000000..9bead61162 --- /dev/null +++ b/web/src/lib/components/faces-page/manage-people-visibility.spec.ts @@ -0,0 +1,88 @@ +import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock'; +import { personFactory } from '@test-data/factories/person-factory'; +import { render } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; +import ManagePeopleVisibilityWrapper from './manage-people-visibility.test-wrapper.svelte'; + +describe('ManagePeopleVisibility component', () => { + beforeEach(() => { + vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); + }); + + it('keeps toggled hidden state when loading more people', async () => { + const onClose = vi.fn(); + const onUpdate = vi.fn(); + const loadNextPage = vi.fn(); + + const [personA, personB, personC] = [ + personFactory.build({ id: 'a', isHidden: false }), + personFactory.build({ id: 'b', isHidden: false }), + personFactory.build({ id: 'c', isHidden: true }), + ]; + + const { container, rerender } = render(ManagePeopleVisibilityWrapper, { + props: { + people: [personA, personB], + totalPeopleCount: 3, + onClose, + onUpdate, + loadNextPage, + }, + }); + const user = userEvent.setup(); + + let personButtons = container.querySelectorAll('button[aria-pressed]'); + expect(personButtons).toHaveLength(2); + + await user.click(personButtons[0]); + expect(personButtons[0].getAttribute('aria-pressed')).toBe('true'); + + await rerender({ + people: [personA, personB, personC], + totalPeopleCount: 3, + onClose, + onUpdate, + loadNextPage, + }); + + personButtons = container.querySelectorAll('button[aria-pressed]'); + expect(personButtons).toHaveLength(3); + expect(personButtons[0].getAttribute('aria-pressed')).toBe('true'); + expect(personButtons[2].getAttribute('aria-pressed')).toBe('true'); + }); + + it('shows newly loaded hidden people as hidden', async () => { + const onClose = vi.fn(); + const onUpdate = vi.fn(); + const loadNextPage = vi.fn(); + + const [personA, personB, personC] = [ + personFactory.build({ id: 'a', isHidden: false }), + personFactory.build({ id: 'b', isHidden: false }), + personFactory.build({ id: 'c', isHidden: true }), + ]; + + const { container, rerender } = render(ManagePeopleVisibilityWrapper, { + props: { + people: [personA, personB], + totalPeopleCount: 3, + onClose, + onUpdate, + loadNextPage, + }, + }); + + await rerender({ + people: [personA, personB, personC], + totalPeopleCount: 3, + onClose, + onUpdate, + loadNextPage, + }); + + const personButtons = container.querySelectorAll('button[aria-pressed]'); + expect(personButtons).toHaveLength(3); + expect(personButtons[2].getAttribute('aria-pressed')).toBe('true'); + }); +}); diff --git a/web/src/lib/components/faces-page/manage-people-visibility.svelte b/web/src/lib/components/faces-page/manage-people-visibility.svelte index 5771766f64..77696b5983 100644 --- a/web/src/lib/components/faces-page/manage-people-visibility.svelte +++ b/web/src/lib/components/faces-page/manage-people-visibility.svelte @@ -10,27 +10,22 @@ import { Button, IconButton, toastManager } from '@immich/ui'; import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js'; import { t } from 'svelte-i18n'; + import { SvelteMap } from 'svelte/reactivity'; interface Props { people: PersonResponseDto[]; totalPeopleCount: number; titleId?: string | undefined; onClose: () => void; + onUpdate: (people: PersonResponseDto[]) => void; loadNextPage: () => void; } - let { people = $bindable(), totalPeopleCount, titleId = undefined, onClose, loadNextPage }: Props = $props(); + let { people, totalPeopleCount, titleId = undefined, onClose, onUpdate, loadNextPage }: Props = $props(); let toggleVisibility = $state(ToggleVisibility.SHOW_ALL); let showLoadingSpinner = $state(false); - - const getPersonIsHidden = (people: PersonResponseDto[]) => { - const personIsHidden: Record = {}; - for (const person of people) { - personIsHidden[person.id] = person.isHidden; - } - return personIsHidden; - }; + const overrides = new SvelteMap(); const getNextVisibility = (toggleVisibility: ToggleVisibility) => { if (toggleVisibility === ToggleVisibility.SHOW_ALL) { @@ -46,23 +41,23 @@ toggleVisibility = getNextVisibility(toggleVisibility); for (const person of people) { + let isHidden = overrides.get(person.id) ?? person.isHidden; + if (toggleVisibility === ToggleVisibility.HIDE_ALL) { - personIsHidden[person.id] = true; + isHidden = true; } else if (toggleVisibility === ToggleVisibility.SHOW_ALL) { - personIsHidden[person.id] = false; + isHidden = false; } else if (toggleVisibility === ToggleVisibility.HIDE_UNNANEMD && !person.name) { - personIsHidden[person.id] = true; + isHidden = true; } + + setHiddenOverride(person, isHidden); } }; - const handleResetVisibility = () => (personIsHidden = getPersonIsHidden(people)); - const handleSaveVisibility = async () => { showLoadingSpinner = true; - const changed = people - .filter((person) => person.isHidden !== personIsHidden[person.id]) - .map((person) => ({ id: person.id, isHidden: personIsHidden[person.id] })); + const changed = Array.from(overrides, ([id, isHidden]) => ({ id, isHidden })); try { if (changed.length > 0) { @@ -76,9 +71,14 @@ } for (const person of people) { - person.isHidden = personIsHidden[person.id]; + const isHidden = overrides.get(person.id); + if (isHidden !== undefined) { + person.isHidden = isHidden; + } } + overrides.clear(); + onUpdate(people); onClose(); } catch (error) { handleError(error, $t('errors.unable_to_change_visibility', { values: { count: changed.length } })); @@ -87,7 +87,13 @@ } }; - let personIsHidden = $state(getPersonIsHidden(people)); + const setHiddenOverride = (person: PersonResponseDto, isHidden: boolean) => { + if (isHidden === person.isHidden) { + overrides.delete(person.id); + return; + } + overrides.set(person.id, isHidden); + }; let toggleButtonOptions: Record = $derived({ [ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') }, @@ -124,7 +130,7 @@ variant="ghost" aria-label={$t('reset_people_visibility')} icon={mdiRestart} - onclick={handleResetVisibility} + onclick={() => overrides.clear()} /> {#snippet children({ person })} - {@const hidden = personIsHidden[person.id]} + {@const hidden = overrides.get(person.id) ?? person.isHidden}