From 04b311bd93e9d60e965ab4d3c5bb587bb62ef3bd Mon Sep 17 00:00:00 2001
From: Alex <alex.tran1502@gmail.com>
Date: Sun, 8 Dec 2024 17:22:39 -0600
Subject: [PATCH 01/25] chore(mobile): disable Impeller (#14589)

---
 mobile/android/app/src/main/AndroidManifest.xml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index bbc562c103..e49cf5b8da 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -37,7 +37,7 @@
 
     <meta-data
       android:name="io.flutter.embedding.android.EnableImpeller"
-      android:value="true" />
+      android:value="false" />
 
     <meta-data
       android:name="com.google.firebase.messaging.default_notification_icon"

From 03eb5903fe590532346f87a6cfbc62730911088e Mon Sep 17 00:00:00 2001
From: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 8 Dec 2024 23:41:22 +0000
Subject: [PATCH 02/25] chore: version v1.122.2

---
 cli/package-lock.json                       |   6 +++---
 cli/package.json                            |   2 +-
 docs/static/archived-versions.json          |   4 ++++
 e2e/package-lock.json                       |   8 ++++----
 e2e/package.json                            |   2 +-
 machine-learning/pyproject.toml             |   2 +-
 mobile/android/fastlane/Fastfile            |   4 ++--
 mobile/ios/fastlane/Fastfile                |   2 +-
 mobile/openapi/README.md                    | Bin 32447 -> 32447 bytes
 mobile/pubspec.yaml                         |   2 +-
 open-api/immich-openapi-specs.json          |   2 +-
 open-api/typescript-sdk/package-lock.json   |   4 ++--
 open-api/typescript-sdk/package.json        |   2 +-
 open-api/typescript-sdk/src/fetch-client.ts |   2 +-
 server/package-lock.json                    |   4 ++--
 server/package.json                         |   2 +-
 web/package-lock.json                       |   6 +++---
 web/package.json                            |   2 +-
 18 files changed, 30 insertions(+), 26 deletions(-)

diff --git a/cli/package-lock.json b/cli/package-lock.json
index b0611f1af0..03b8061efb 100644
--- a/cli/package-lock.json
+++ b/cli/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@immich/cli",
-  "version": "2.2.34",
+  "version": "2.2.35",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@immich/cli",
-      "version": "2.2.34",
+      "version": "2.2.35",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
     },
     "../open-api/typescript-sdk": {
       "name": "@immich/sdk",
-      "version": "1.122.1",
+      "version": "1.122.2",
       "dev": true,
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
diff --git a/cli/package.json b/cli/package.json
index b69a36cf19..b58825b2b9 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@immich/cli",
-  "version": "2.2.34",
+  "version": "2.2.35",
   "description": "Command Line Interface (CLI) for Immich",
   "type": "module",
   "exports": "./dist/index.js",
diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json
index 0d664e1272..7ba9125c03 100644
--- a/docs/static/archived-versions.json
+++ b/docs/static/archived-versions.json
@@ -1,4 +1,8 @@
 [
+  {
+    "label": "v1.122.2",
+    "url": "https://v1.122.2.archive.immich.app"
+  },
   {
     "label": "v1.122.1",
     "url": "https://v1.122.1.archive.immich.app"
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
index d962cb3368..011e6b2fdd 100644
--- a/e2e/package-lock.json
+++ b/e2e/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "immich-e2e",
-  "version": "1.122.1",
+  "version": "1.122.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "immich-e2e",
-      "version": "1.122.1",
+      "version": "1.122.2",
       "license": "GNU Affero General Public License version 3",
       "devDependencies": {
         "@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
     },
     "../cli": {
       "name": "@immich/cli",
-      "version": "2.2.34",
+      "version": "2.2.35",
       "dev": true,
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
@@ -92,7 +92,7 @@
     },
     "../open-api/typescript-sdk": {
       "name": "@immich/sdk",
-      "version": "1.122.1",
+      "version": "1.122.2",
       "dev": true,
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
diff --git a/e2e/package.json b/e2e/package.json
index 42ea62d64b..a47b4bbae9 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -1,6 +1,6 @@
 {
   "name": "immich-e2e",
-  "version": "1.122.1",
+  "version": "1.122.2",
   "description": "",
   "main": "index.js",
   "type": "module",
diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml
index e4d1d7fbf5..cff2a432b3 100644
--- a/machine-learning/pyproject.toml
+++ b/machine-learning/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "machine-learning"
-version = "1.122.1"
+version = "1.122.2"
 description = ""
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 readme = "README.md"
diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile
index 9e384e8591..4fc00ce6c7 100644
--- a/mobile/android/fastlane/Fastfile
+++ b/mobile/android/fastlane/Fastfile
@@ -35,8 +35,8 @@ platform :android do
       task: 'bundle', 
       build_type: 'Release',
       properties: {
-        "android.injected.version.code" => 169,
-        "android.injected.version.name" => "1.122.1",
+        "android.injected.version.code" => 170,
+        "android.injected.version.name" => "1.122.2",
       }
     )
     upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile
index 1c28c050aa..d7604b4283 100644
--- a/mobile/ios/fastlane/Fastfile
+++ b/mobile/ios/fastlane/Fastfile
@@ -19,7 +19,7 @@ platform :ios do
   desc "iOS Release"
   lane :release do
     increment_version_number(
-      version_number: "1.122.1"
+      version_number: "1.122.2"
     )
     increment_build_number(
       build_number: latest_testflight_build_number + 1,
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 7cfd48d83dc30b70e198adb50c455d06b172a23c..d9e10bc316bf4dadd00e5cb55b9f8458e7eb5f68 100644
GIT binary patch
delta 14
Wcmdo0mvR4J#tCy7jW*6xtOEczt_H~f

delta 14
Wcmdo0mvR4J#tCy74L8nHtOEczsRqaZ

diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 6974c560a2..39621a953e 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -2,7 +2,7 @@ name: immich_mobile
 description: Immich - selfhosted backup media file on mobile phone
 
 publish_to: 'none'
-version: 1.122.1+169
+version: 1.122.2+170
 
 environment:
   sdk: '>=3.3.0 <4.0.0'
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index a13fb6e696..706d6a28ee 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -7436,7 +7436,7 @@
   "info": {
     "title": "Immich",
     "description": "Immich API",
-    "version": "1.122.1",
+    "version": "1.122.2",
     "contact": {}
   },
   "tags": [],
diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json
index 2e69c4cfc6..c0d5d329d1 100644
--- a/open-api/typescript-sdk/package-lock.json
+++ b/open-api/typescript-sdk/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@immich/sdk",
-  "version": "1.122.1",
+  "version": "1.122.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@immich/sdk",
-      "version": "1.122.1",
+      "version": "1.122.2",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@oazapfts/runtime": "^1.0.2"
diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json
index bc3a3023e4..22f480e68d 100644
--- a/open-api/typescript-sdk/package.json
+++ b/open-api/typescript-sdk/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@immich/sdk",
-  "version": "1.122.1",
+  "version": "1.122.2",
   "description": "Auto-generated TypeScript SDK for the Immich API",
   "type": "module",
   "main": "./build/index.js",
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index ef82b82954..68f44d7bed 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -1,6 +1,6 @@
 /**
  * Immich
- * 1.122.1
+ * 1.122.2
  * DO NOT MODIFY - This file has been generated using oazapfts.
  * See https://www.npmjs.com/package/oazapfts
  */
diff --git a/server/package-lock.json b/server/package-lock.json
index 3a01c83fa1..4ad00c90f7 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "immich",
-  "version": "1.122.1",
+  "version": "1.122.2",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "immich",
-      "version": "1.122.1",
+      "version": "1.122.2",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@nestjs/bullmq": "^10.0.1",
diff --git a/server/package.json b/server/package.json
index 84cd0e4aae..a7005deafa 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
 {
   "name": "immich",
-  "version": "1.122.1",
+  "version": "1.122.2",
   "description": "",
   "author": "",
   "private": true,
diff --git a/web/package-lock.json b/web/package-lock.json
index 4669730ae1..cab21cd4dc 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "immich-web",
-  "version": "1.122.1",
+  "version": "1.122.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "immich-web",
-      "version": "1.122.1",
+      "version": "1.122.2",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@formatjs/icu-messageformat-parser": "^2.7.8",
@@ -74,7 +74,7 @@
     },
     "../open-api/typescript-sdk": {
       "name": "@immich/sdk",
-      "version": "1.122.1",
+      "version": "1.122.2",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@oazapfts/runtime": "^1.0.2"
diff --git a/web/package.json b/web/package.json
index 6bac30054a..2bd429cc1a 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
 {
   "name": "immich-web",
-  "version": "1.122.1",
+  "version": "1.122.2",
   "license": "GNU Affero General Public License version 3",
   "scripts": {
     "dev": "vite dev --host 0.0.0.0 --port 3000",

From e4b76e8efea5cddd20f2527316b939ed61fa0087 Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Mon, 9 Dec 2024 00:52:10 +0100
Subject: [PATCH 03/25] chore: add language requests from weblate (#14578)

---
 i18n/bn.json             | 1 +
 i18n/ur.json             | 1 +
 web/src/lib/constants.ts | 2 ++
 3 files changed, 4 insertions(+)
 create mode 100644 i18n/bn.json
 create mode 100644 i18n/ur.json

diff --git a/i18n/bn.json b/i18n/bn.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/i18n/bn.json
@@ -0,0 +1 @@
+{}
diff --git a/i18n/ur.json b/i18n/ur.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/i18n/ur.json
@@ -0,0 +1 @@
+{}
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts
index 8d4fb809a5..b7ea2cfb52 100644
--- a/web/src/lib/constants.ts
+++ b/web/src/lib/constants.ts
@@ -265,6 +265,7 @@ export const langs = [
   { name: 'Belarusian', code: 'be', loader: () => import('$i18n/be.json') },
   { name: 'Bulgarian', code: 'bg', loader: () => import('$i18n/bg.json') },
   { name: 'Bislama', code: 'bi', loader: () => import('$i18n/bi.json') },
+  { name: 'Bengali', code: 'bn', loader: () => import('$i18n/bn.json') },
   { name: 'Catalan', code: 'ca', loader: () => import('$i18n/ca.json') },
   { name: 'Czech', code: 'cs', loader: () => import('$i18n/cs.json') },
   { name: 'Chuvash', code: 'cv', loader: () => import('$i18n/cv.json') },
@@ -319,6 +320,7 @@ export const langs = [
   { name: 'Thai', code: 'th', loader: () => import('$i18n/th.json') },
   { name: 'Turkish', code: 'tr', loader: () => import('$i18n/tr.json') },
   { name: 'Ukrainian', code: 'uk', loader: () => import('$i18n/uk.json') },
+  { name: 'Urdu', code: 'ur', loader: () => import('$i18n/ur.json') },
   { name: 'Vietnamese', code: 'vi', loader: () => import('$i18n/vi.json') },
   {
     name: 'Chinese (Traditional)',

From 1ba622adc95d7e0845e7bf7fc8a77103d73e8c5b Mon Sep 17 00:00:00 2001
From: Lukas <lukas@lschaefer.xyz>
Date: Sun, 8 Dec 2024 21:35:23 -0500
Subject: [PATCH 04/25] feat: Add support for vob (#14590)

Add support for vob
---
 server/src/services/asset-media.service.spec.ts | 2 +-
 server/src/utils/mime-types.spec.ts             | 1 +
 server/src/utils/mime-types.ts                  | 1 +
 3 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts
index da7e23be54..1daeb99d0b 100644
--- a/server/src/services/asset-media.service.spec.ts
+++ b/server/src/services/asset-media.service.spec.ts
@@ -98,7 +98,7 @@ const validImages = [
   '.x3f',
 ];
 
-const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv'];
+const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.vob', '.webm', '.wmv'];
 
 const uploadTests = [
   {
diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts
index 50fe760a04..05cd8566c8 100644
--- a/server/src/utils/mime-types.spec.ts
+++ b/server/src/utils/mime-types.spec.ts
@@ -92,6 +92,7 @@ describe('mimeTypes', () => {
     { mimetype: 'video/x-matroska', extension: '.mkv' },
     { mimetype: 'video/x-ms-wmv', extension: '.wmv' },
     { mimetype: 'video/x-msvideo', extension: '.avi' },
+    { mimetype: 'video/mpeg', extension: '.vob' },
   ]) {
     it(`should map ${extension} to ${mimetype}`, () => {
       expect({ ...mimeTypes.image, ...mimeTypes.video }[extension]).toContain(mimetype);
diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts
index cbf6e5b489..165eb44a4f 100644
--- a/server/src/utils/mime-types.ts
+++ b/server/src/utils/mime-types.ts
@@ -74,6 +74,7 @@ const video: Record<string, string[]> = {
   '.mpeg': ['video/mpeg'],
   '.mpg': ['video/mpeg'],
   '.mts': ['video/mp2t'],
+  '.vob': ['video/mpeg'],
   '.webm': ['video/webm'],
   '.wmv': ['video/x-ms-wmv'],
 };

From 60c783bbe9b9fdf91e7ba2a28974018fd291f574 Mon Sep 17 00:00:00 2001
From: Mert <101130780+mertalev@users.noreply.github.com>
Date: Tue, 10 Dec 2024 12:11:19 -0500
Subject: [PATCH 05/25] fix(server): partial fallback for hardware transcoding
 (#14611)

---
 server/src/interfaces/media.interface.ts  |   5 +
 server/src/services/media.service.spec.ts | 106 ++++++++++++----------
 server/src/services/media.service.ts      |  77 +++++++---------
 server/src/utils/media.ts                 |  54 +++++------
 4 files changed, 121 insertions(+), 121 deletions(-)

diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts
index 468a6ad88d..b90dfb483c 100644
--- a/server/src/interfaces/media.interface.ts
+++ b/server/src/interfaces/media.interface.ts
@@ -130,6 +130,11 @@ export interface ProbeOptions {
   countFrames: boolean;
 }
 
+export interface VideoInterfaces {
+  dri: string[];
+  mali: boolean;
+}
+
 export interface IMediaRepository {
   // image
   extract(input: string, output: string): Promise<boolean>;
diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts
index 909b9d02e3..36a9045677 100644
--- a/server/src/services/media.service.spec.ts
+++ b/server/src/services/media.service.spec.ts
@@ -1,4 +1,3 @@
-import type { Stats } from 'node:fs';
 import { SystemConfig } from 'src/config';
 import { AssetEntity } from 'src/entities/asset.entity';
 import { ExifEntity } from 'src/entities/exif.entity';
@@ -303,7 +302,7 @@ describe(MediaService.name, () => {
     it('should skip video thumbnail generation if no video stream', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
       assetMock.getById.mockResolvedValue(assetStub.video);
-      await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toBeInstanceOf(Error);
+      await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toThrowError();
       expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
       expect(assetMock.update).not.toHaveBeenCalledWith();
     });
@@ -770,6 +769,7 @@ describe(MediaService.name, () => {
   describe('handleVideoConversion', () => {
     beforeEach(() => {
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      sut.videoInterfaces = { dri: ['renderD128'], mali: true };
     });
 
     it('should skip transcoding if asset not found', async () => {
@@ -826,7 +826,7 @@ describe(MediaService.name, () => {
       systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig);
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
 
-      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toBeDefined();
+      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
@@ -1079,7 +1079,7 @@ describe(MediaService.name, () => {
       mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
       systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } });
 
-      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrow();
+      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
@@ -1434,7 +1434,7 @@ describe(MediaService.name, () => {
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
-      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
+      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
@@ -1442,7 +1442,7 @@ describe(MediaService.name, () => {
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
-      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
+      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
@@ -1628,7 +1628,6 @@ describe(MediaService.name, () => {
     });
 
     it('should set options for qsv', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -1664,7 +1663,6 @@ describe(MediaService.name, () => {
     });
 
     it('should set options for qsv with custom dri node', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({
         ffmpeg: {
@@ -1690,7 +1688,6 @@ describe(MediaService.name, () => {
     });
 
     it('should omit preset for qsv if invalid', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -1710,7 +1707,6 @@ describe(MediaService.name, () => {
     });
 
     it('should set low power mode for qsv if target video codec is vp9', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -1730,17 +1726,18 @@ describe(MediaService.name, () => {
     });
 
     it('should fail for qsv if no hw devices', async () => {
-      storageMock.readdir.mockRejectedValue(new Error('Could not read directory'));
+      sut.videoInterfaces = { dri: [], mali: false };
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
-      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
+
+      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
+
       expect(mediaMock.transcode).not.toHaveBeenCalled();
-      expect(loggerMock.debug).toHaveBeenCalledWith('No devices found in /dev/dri.');
     });
 
     it('should prefer higher index renderD* device for qsv', async () => {
-      storageMock.readdir.mockResolvedValue(['card1', 'renderD129', 'card0', 'renderD128']);
+      sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false };
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -1760,7 +1757,6 @@ describe(MediaService.name, () => {
     });
 
     it('should use hardware decoding for qsv if enabled', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true },
@@ -1790,7 +1786,6 @@ describe(MediaService.name, () => {
     });
 
     it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true },
@@ -1820,7 +1815,7 @@ describe(MediaService.name, () => {
     });
 
     it('should use preferred device for qsv when hardware decoding', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']);
+      sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false };
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true, preferredHwDevice: 'renderD129' },
@@ -1840,7 +1835,6 @@ describe(MediaService.name, () => {
     });
 
     it('should set format to nv12 for qsv if input is not yuv420p', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true },
@@ -1866,7 +1860,6 @@ describe(MediaService.name, () => {
     });
 
     it('should set options for vaapi', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -1898,7 +1891,6 @@ describe(MediaService.name, () => {
     });
 
     it('should set vbr options for vaapi when max bitrate is enabled', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -1924,7 +1916,6 @@ describe(MediaService.name, () => {
     });
 
     it('should set cq options for vaapi when max bitrate is disabled', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -1950,7 +1941,6 @@ describe(MediaService.name, () => {
     });
 
     it('should omit preset for vaapi if invalid', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -1970,7 +1960,7 @@ describe(MediaService.name, () => {
     });
 
     it('should prefer higher index renderD* device for vaapi', async () => {
-      storageMock.readdir.mockResolvedValue(['card1', 'renderD129', 'card0', 'renderD128']);
+      sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false };
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -1990,7 +1980,7 @@ describe(MediaService.name, () => {
     });
 
     it('should select specific gpu node if selected', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']);
+      sut.videoInterfaces = { dri: ['renderD129', 'card1', 'card0', 'renderD128'], mali: false };
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' },
@@ -2012,7 +2002,6 @@ describe(MediaService.name, () => {
     });
 
     it('should use hardware decoding for vaapi if enabled', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true },
@@ -2041,7 +2030,6 @@ describe(MediaService.name, () => {
     });
 
     it('should use hardware tone-mapping for vaapi if hardware decoding is enabled and should tone map', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true },
@@ -2066,7 +2054,6 @@ describe(MediaService.name, () => {
     });
 
     it('should set format to nv12 for vaapi if input is not yuv420p', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
       mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true },
@@ -2087,7 +2074,7 @@ describe(MediaService.name, () => {
     });
 
     it('should use preferred device for vaapi when hardware decoding', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']);
+      sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false };
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true, preferredHwDevice: 'renderD129' },
@@ -2106,8 +2093,47 @@ describe(MediaService.name, () => {
       );
     });
 
-    it('should fallback to sw transcoding if hw transcoding fails', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
+    it('should fallback to hw encoding and sw decoding if hw transcoding fails and hw decoding is enabled', async () => {
+      mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
+      systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      mediaMock.transcode.mockRejectedValueOnce(new Error('error'));
+      await sut.handleVideoConversion({ id: assetStub.video.id });
+      expect(mediaMock.transcode).toHaveBeenCalledTimes(2);
+      expect(mediaMock.transcode).toHaveBeenLastCalledWith(
+        '/original/path.ext',
+        'upload/encoded-video/user-id/as/se/asset-id.mp4',
+        expect.objectContaining({
+          inputOptions: expect.arrayContaining([
+            '-init_hw_device vaapi=accel:/dev/dri/renderD128',
+            '-filter_hw_device accel',
+          ]),
+          outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]),
+          twoPass: false,
+        }),
+      );
+    });
+
+    it('should fallback to sw decoding if fallback to sw decoding + hw encoding fails', async () => {
+      mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
+      systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      mediaMock.transcode.mockRejectedValueOnce(new Error('error'));
+      mediaMock.transcode.mockRejectedValueOnce(new Error('error'));
+      await sut.handleVideoConversion({ id: assetStub.video.id });
+      expect(mediaMock.transcode).toHaveBeenCalledTimes(3);
+      expect(mediaMock.transcode).toHaveBeenLastCalledWith(
+        '/original/path.ext',
+        'upload/encoded-video/user-id/as/se/asset-id.mp4',
+        expect.objectContaining({
+          inputOptions: expect.any(Array),
+          outputOptions: expect.arrayContaining(['-c:v h264']),
+          twoPass: false,
+        }),
+      );
+    });
+
+    it('should fallback to sw transcoding if hw transcoding fails and hw decoding is disabled', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -2126,17 +2152,15 @@ describe(MediaService.name, () => {
     });
 
     it('should fail for vaapi if no hw devices', async () => {
-      storageMock.readdir.mockResolvedValue([]);
+      sut.videoInterfaces = { dri: [], mali: true };
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
-      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
+      await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError();
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
     it('should set options for rkmpp', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
-      storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } });
       assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -2171,8 +2195,6 @@ describe(MediaService.name, () => {
     });
 
     it('should set vbr options for rkmpp when max bitrate is enabled', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
-      storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
       mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
       systemMock.get.mockResolvedValue({
         ffmpeg: {
@@ -2196,8 +2218,6 @@ describe(MediaService.name, () => {
     });
 
     it('should set cqp options for rkmpp when max bitrate is disabled', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
-      storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
@@ -2216,8 +2236,6 @@ describe(MediaService.name, () => {
     });
 
     it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
-      storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
       mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
@@ -2240,8 +2258,7 @@ describe(MediaService.name, () => {
     });
 
     it('should set hardware decoding options for rkmpp when hardware decoding is enabled with no OpenCL on non-HDR file', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
-      storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats);
+      sut.videoInterfaces = { dri: ['renderD128'], mali: false };
       mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
@@ -2262,8 +2279,6 @@ describe(MediaService.name, () => {
     });
 
     it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
-      storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
       mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: false, crf: 30, maxBitrate: '0' },
@@ -2286,8 +2301,7 @@ describe(MediaService.name, () => {
     });
 
     it('should use software tone-mapping if opencl is not available', async () => {
-      storageMock.readdir.mockResolvedValue(['renderD128']);
-      storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats);
+      sut.videoInterfaces = { dri: ['renderD128'], mali: false };
       mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
       systemMock.get.mockResolvedValue({
         ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts
index f433748ec4..7036bd32e8 100644
--- a/server/src/services/media.service.ts
+++ b/server/src/services/media.service.ts
@@ -1,7 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { dirname } from 'node:path';
 import { StorageCore } from 'src/cores/storage.core';
-import { OnJob } from 'src/decorators';
+import { OnEvent, OnJob } from 'src/decorators';
 import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
 import { AssetEntity } from 'src/entities/asset.entity';
 import {
@@ -27,7 +27,7 @@ import {
   JobStatus,
   QueueName,
 } from 'src/interfaces/job.interface';
-import { AudioStreamInfo, TranscodeCommand, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface';
+import { AudioStreamInfo, VideoFormat, VideoInterfaces, VideoStreamInfo } from 'src/interfaces/media.interface';
 import { BaseService } from 'src/services/base.service';
 import { getAssetFiles } from 'src/utils/asset.util';
 import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
@@ -36,8 +36,13 @@ import { usePagination } from 'src/utils/pagination';
 
 @Injectable()
 export class MediaService extends BaseService {
-  private maliOpenCL?: boolean;
-  private devices?: string[];
+  videoInterfaces: VideoInterfaces = { dri: [], mali: false };
+
+  @OnEvent({ name: 'app.bootstrap' })
+  async onBootstrap() {
+    const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]);
+    this.videoInterfaces = { dri, mali };
+  }
 
   @OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
   async handleQueueGenerateThumbnails({ force }: JobOf<JobName.QUEUE_GENERATE_THUMBNAILS>): Promise<JobStatus> {
@@ -300,19 +305,19 @@ export class MediaService extends BaseService {
     const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
       countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs
     });
-    const mainVideoStream = this.getMainStream(videoStreams);
-    const mainAudioStream = this.getMainStream(audioStreams);
-    if (!mainVideoStream || !format.formatName) {
+    const videoStream = this.getMainStream(videoStreams);
+    const audioStream = this.getMainStream(audioStreams);
+    if (!videoStream || !format.formatName) {
       return JobStatus.FAILED;
     }
 
-    if (!mainVideoStream.height || !mainVideoStream.width) {
+    if (!videoStream.height || !videoStream.width) {
       this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`);
       return JobStatus.FAILED;
     }
 
-    const { ffmpeg } = await this.getConfig({ withCache: true });
-    const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream);
+    let { ffmpeg } = await this.getConfig({ withCache: true });
+    const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
     if (target === TranscodeTarget.NONE && !this.isRemuxRequired(ffmpeg, format)) {
       if (asset.encodedVideoPath) {
         this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
@@ -325,15 +330,7 @@ export class MediaService extends BaseService {
       return JobStatus.SKIPPED;
     }
 
-    let command: TranscodeCommand;
-    try {
-      const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL());
-      command = config.getCommand(target, mainVideoStream, mainAudioStream);
-    } catch (error) {
-      this.logger.error(`An error occurred while configuring transcoding options: ${error}`);
-      return JobStatus.FAILED;
-    }
-
+    const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
     if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
       this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`);
     } else {
@@ -354,8 +351,8 @@ export class MediaService extends BaseService {
       if (ffmpeg.accelDecode) {
         try {
           this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`);
-          const config = BaseConfig.create({ ...ffmpeg, accelDecode: false });
-          command = config.getCommand(target, mainVideoStream, mainAudioStream);
+          ffmpeg = { ...ffmpeg, accelDecode: false };
+          const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
           await this.mediaRepository.transcode(input, output, command);
           partialFallbackSuccess = true;
         } catch (error: any) {
@@ -365,8 +362,8 @@ export class MediaService extends BaseService {
 
       if (!partialFallbackSuccess) {
         this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
-        const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
-        command = config.getCommand(target, mainVideoStream, mainAudioStream);
+        ffmpeg = { ...ffmpeg, accel: TranscodeHWAccel.DISABLED };
+        const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
         await this.mediaRepository.transcode(input, output, command);
       }
     }
@@ -507,30 +504,24 @@ export class MediaService extends BaseService {
   }
 
   private async getDevices() {
-    if (!this.devices) {
-      try {
-        this.devices = await this.storageRepository.readdir('/dev/dri');
-      } catch {
-        this.logger.debug('No devices found in /dev/dri.');
-        this.devices = [];
-      }
+    try {
+      return await this.storageRepository.readdir('/dev/dri');
+    } catch {
+      this.logger.debug('No devices found in /dev/dri.');
+      return [];
     }
-
-    return this.devices;
   }
 
   private async hasMaliOpenCL() {
-    if (this.maliOpenCL === undefined) {
-      try {
-        const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd');
-        const maliDeviceStat = await this.storageRepository.stat('/dev/mali0');
-        this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
-      } catch {
-        this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
-        this.maliOpenCL = false;
-      }
+    try {
+      const [maliIcdStat, maliDeviceStat] = await Promise.all([
+        this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'),
+        this.storageRepository.stat('/dev/mali0'),
+      ]);
+      return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
+    } catch {
+      this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
+      return false;
     }
-
-    return this.maliOpenCL;
   }
 }
diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts
index 226f95b4bb..678e8cb15a 100644
--- a/server/src/utils/media.ts
+++ b/server/src/utils/media.ts
@@ -7,6 +7,7 @@ import {
   VideoCodecHWConfig,
   VideoCodecSWConfig,
   VideoFormat,
+  VideoInterfaces,
   VideoStreamInfo,
 } from 'src/interfaces/media.interface';
 
@@ -14,11 +15,11 @@ export class BaseConfig implements VideoCodecSWConfig {
   readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
   protected constructor(protected config: SystemConfigFFmpegDto) {}
 
-  static create(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false): VideoCodecSWConfig {
+  static create(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces): VideoCodecSWConfig {
     if (config.accel === TranscodeHWAccel.DISABLED) {
       return this.getSWCodecConfig(config);
     }
-    return this.getHWCodecConfig(config, devices, hasMaliOpenCL);
+    return this.getHWCodecConfig(config, interfaces);
   }
 
   private static getSWCodecConfig(config: SystemConfigFFmpegDto) {
@@ -41,27 +42,31 @@ export class BaseConfig implements VideoCodecSWConfig {
     }
   }
 
-  private static getHWCodecConfig(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false) {
+  private static getHWCodecConfig(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces) {
     let handler: VideoCodecHWConfig;
     switch (config.accel) {
       case TranscodeHWAccel.NVENC: {
-        handler = config.accelDecode ? new NvencHwDecodeConfig(config) : new NvencSwDecodeConfig(config);
+        handler = config.accelDecode
+          ? new NvencHwDecodeConfig(config, interfaces)
+          : new NvencSwDecodeConfig(config, interfaces);
         break;
       }
       case TranscodeHWAccel.QSV: {
-        handler = config.accelDecode ? new QsvHwDecodeConfig(config, devices) : new QsvSwDecodeConfig(config, devices);
+        handler = config.accelDecode
+          ? new QsvHwDecodeConfig(config, interfaces)
+          : new QsvSwDecodeConfig(config, interfaces);
         break;
       }
       case TranscodeHWAccel.VAAPI: {
         handler = config.accelDecode
-          ? new VaapiHwDecodeConfig(config, devices)
-          : new VaapiSwDecodeConfig(config, devices);
+          ? new VaapiHwDecodeConfig(config, interfaces)
+          : new VaapiSwDecodeConfig(config, interfaces);
         break;
       }
       case TranscodeHWAccel.RKMPP: {
         handler = config.accelDecode
-          ? new RkmppHwDecodeConfig(config, devices, hasMaliOpenCL)
-          : new RkmppSwDecodeConfig(config, devices);
+          ? new RkmppHwDecodeConfig(config, interfaces)
+          : new RkmppSwDecodeConfig(config, interfaces);
         break;
       }
       default: {
@@ -323,13 +328,15 @@ export class BaseConfig implements VideoCodecSWConfig {
 
 export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
   protected device: string;
+  protected interfaces: VideoInterfaces;
 
   constructor(
     protected config: SystemConfigFFmpegDto,
-    devices: string[] = [],
+    interfaces: VideoInterfaces,
   ) {
     super(config);
-    this.device = this.getDevice(devices);
+    this.interfaces = interfaces;
+    this.device = this.getDevice(interfaces);
   }
 
   getSupportedCodecs() {
@@ -346,16 +353,16 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
     });
   }
 
-  getDevice(devices: string[]) {
+  getDevice({ dri }: VideoInterfaces) {
     if (this.config.preferredHwDevice === 'auto') {
       // eslint-disable-next-line unicorn/no-array-reduce
-      return `/dev/dri/${this.validateDevices(devices).reduce(function (a, b) {
+      return `/dev/dri/${this.validateDevices(dri).reduce(function (a, b) {
         return a.localeCompare(b) < 0 ? b : a;
       })}`;
     }
 
     const deviceName = this.config.preferredHwDevice.replace('/dev/dri/', '');
-    if (!devices.includes(deviceName)) {
+    if (!dri.includes(deviceName)) {
       throw new Error(`Device '${deviceName}' does not exist. If using Docker, make sure this device is mounted`);
     }
 
@@ -886,13 +893,6 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
 }
 
 export class RkmppSwDecodeConfig extends BaseHWConfig {
-  constructor(
-    protected config: SystemConfigFFmpegDto,
-    devices: string[] = [],
-  ) {
-    super(config, devices);
-  }
-
   eligibleForTwoPass(): boolean {
     return false;
   }
@@ -937,16 +937,6 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
 }
 
 export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig {
-  protected hasMaliOpenCL: boolean;
-  constructor(
-    protected config: SystemConfigFFmpegDto,
-    devices: string[] = [],
-    hasMaliOpenCL = false,
-  ) {
-    super(config, devices);
-    this.hasMaliOpenCL = hasMaliOpenCL;
-  }
-
   getBaseInputOptions() {
     return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga', '-noautorotate'];
   }
@@ -954,7 +944,7 @@ export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig {
   getFilterOptions(videoStream: VideoStreamInfo) {
     if (this.shouldToneMap(videoStream)) {
       const { primaries, transfer, matrix } = this.getColors();
-      if (this.hasMaliOpenCL) {
+      if (this.interfaces.mali) {
         return [
           // use RKMPP for scaling, OpenCL for tone mapping
           `scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1:async_depth=4`,

From 25ca3b112483a8fa42d534473525d450dc7b3f3a Mon Sep 17 00:00:00 2001
From: Mert <101130780+mertalev@users.noreply.github.com>
Date: Tue, 10 Dec 2024 16:22:37 -0500
Subject: [PATCH 06/25] refactor(server): use `includeNull` in query for search
 suggestions (#14626)

* use `includeNull`

* push down `includeNull` into query, inner joins

* remove filter

* update sql

* fix tests

* maybe fix e2e

* more e2e tests

* handle no exif row

* whoops

* update sql
---
 e2e/src/api/specs/search.e2e-spec.ts         | 117 ++++++++++++++++++-
 server/src/interfaces/search.interface.ts    |  24 +++-
 server/src/queries/search.repository.sql     |  25 ++--
 server/src/repositories/search.repository.ts |  50 +++++---
 server/src/services/search.service.spec.ts   |  78 +++++++++++--
 server/src/services/search.service.ts        |  17 +--
 6 files changed, 259 insertions(+), 52 deletions(-)

diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts
index 627fbb3e9e..11bb37be18 100644
--- a/e2e/src/api/specs/search.e2e-spec.ts
+++ b/e2e/src/api/specs/search.e2e-spec.ts
@@ -98,6 +98,7 @@ describe('/search', () => {
       { latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
       { latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
       { latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
+      { latitude: 0, longitude: 0 }, // null island
     ];
 
     const updates = coordinates.map((dto, i) =>
@@ -532,7 +533,7 @@ describe('/search', () => {
       expect(body).toEqual(errorDto.unauthorized);
     });
 
-    it('should get suggestions for country', async () => {
+    it('should get suggestions for country (including null)', async () => {
       const { status, body } = await request(app)
         .get('/search/suggestions?type=country&includeNull=true')
         .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -555,7 +556,29 @@ describe('/search', () => {
       expect(status).toBe(200);
     });
 
-    it('should get suggestions for state', async () => {
+    it('should get suggestions for country', async () => {
+      const { status, body } = await request(app)
+        .get('/search/suggestions?type=country')
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(body).toEqual([
+        'Cuba',
+        'France',
+        'Georgia',
+        'Germany',
+        'Ghana',
+        'Japan',
+        'Morocco',
+        "People's Republic of China",
+        'Russian Federation',
+        'Singapore',
+        'Spain',
+        'Switzerland',
+        'United States of America',
+      ]);
+      expect(status).toBe(200);
+    });
+
+    it('should get suggestions for state (including null)', async () => {
       const { status, body } = await request(app)
         .get('/search/suggestions?type=state&includeNull=true')
         .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -579,7 +602,30 @@ describe('/search', () => {
       expect(status).toBe(200);
     });
 
-    it('should get suggestions for city', async () => {
+    it('should get suggestions for state', async () => {
+      const { status, body } = await request(app)
+        .get('/search/suggestions?type=state')
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(body).toEqual([
+        'Andalusia',
+        'Berlin',
+        'Glarus',
+        'Greater Accra',
+        'Havana',
+        'Île-de-France',
+        'Marrakesh-Safi',
+        'Mississippi',
+        'New York',
+        'Shanghai',
+        'St.-Petersburg',
+        'Tbilisi',
+        'Tokyo',
+        'Virginia',
+      ]);
+      expect(status).toBe(200);
+    });
+
+    it('should get suggestions for city (including null)', async () => {
       const { status, body } = await request(app)
         .get('/search/suggestions?type=city&includeNull=true')
         .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -604,7 +650,31 @@ describe('/search', () => {
       expect(status).toBe(200);
     });
 
-    it('should get suggestions for camera make', async () => {
+    it('should get suggestions for city', async () => {
+      const { status, body } = await request(app)
+        .get('/search/suggestions?type=city')
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(body).toEqual([
+        'Accra',
+        'Berlin',
+        'Glarus',
+        'Havana',
+        'Marrakesh',
+        'Montalbán de Córdoba',
+        'New York City',
+        'Novena',
+        'Paris',
+        'Philadelphia',
+        'Saint Petersburg',
+        'Shanghai',
+        'Stanley',
+        'Tbilisi',
+        'Tokyo',
+      ]);
+      expect(status).toBe(200);
+    });
+
+    it('should get suggestions for camera make (including null)', async () => {
       const { status, body } = await request(app)
         .get('/search/suggestions?type=camera-make&includeNull=true')
         .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -621,7 +691,23 @@ describe('/search', () => {
       expect(status).toBe(200);
     });
 
-    it('should get suggestions for camera model', async () => {
+    it('should get suggestions for camera make', async () => {
+      const { status, body } = await request(app)
+        .get('/search/suggestions?type=camera-make')
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(body).toEqual([
+        'Apple',
+        'Canon',
+        'FUJIFILM',
+        'NIKON CORPORATION',
+        'PENTAX Corporation',
+        'samsung',
+        'SONY',
+      ]);
+      expect(status).toBe(200);
+    });
+
+    it('should get suggestions for camera model (including null)', async () => {
       const { status, body } = await request(app)
         .get('/search/suggestions?type=camera-model&includeNull=true')
         .set('Authorization', `Bearer ${admin.accessToken}`);
@@ -642,5 +728,26 @@ describe('/search', () => {
       ]);
       expect(status).toBe(200);
     });
+
+    it('should get suggestions for camera model', async () => {
+      const { status, body } = await request(app)
+        .get('/search/suggestions?type=camera-model')
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(body).toEqual([
+        'Canon EOS 7D',
+        'Canon EOS R5',
+        'DSLR-A550',
+        'FinePix S3Pro',
+        'iPhone 7',
+        'NIKON D700',
+        'NIKON D750',
+        'NIKON D80',
+        'PENTAX K10D',
+        'SM-F711N',
+        'SM-S906U',
+        'SM-T970',
+      ]);
+      expect(status).toBe(200);
+    });
   });
 });
diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts
index 87bf1bc4b1..d59291c883 100644
--- a/server/src/interfaces/search.interface.ts
+++ b/server/src/interfaces/search.interface.ts
@@ -170,6 +170,22 @@ export interface AssetDuplicateResult {
   distance: number;
 }
 
+export interface GetStatesOptions {
+  country?: string;
+}
+
+export interface GetCitiesOptions extends GetStatesOptions {
+  state?: string;
+}
+
+export interface GetCameraModelsOptions {
+  make?: string;
+}
+
+export interface GetCameraMakesOptions {
+  model?: string;
+}
+
 export interface ISearchRepository {
   searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity>;
   searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
@@ -183,8 +199,8 @@ export interface ISearchRepository {
   getDimensionSize(): Promise<number>;
   setDimensionSize(dimSize: number): Promise<void>;
   getCountries(userIds: string[]): Promise<Array<string | null>>;
-  getStates(userIds: string[], country?: string): Promise<Array<string | null>>;
-  getCities(userIds: string[], country?: string, state?: string): Promise<Array<string | null>>;
-  getCameraMakes(userIds: string[], model?: string): Promise<Array<string | null>>;
-  getCameraModels(userIds: string[], make?: string): Promise<Array<string | null>>;
+  getStates(userIds: string[], options: GetStatesOptions): Promise<Array<string | null>>;
+  getCities(userIds: string[], options: GetCitiesOptions): Promise<Array<string | null>>;
+  getCameraMakes(userIds: string[], options: GetCameraMakesOptions): Promise<Array<string | null>>;
+  getCameraModels(userIds: string[], options: GetCameraModelsOptions): Promise<Array<string | null>>;
 }
diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql
index 7de61ad03c..1084375059 100644
--- a/server/src/queries/search.repository.sql
+++ b/server/src/queries/search.repository.sql
@@ -585,52 +585,57 @@ SELECT DISTINCT
   ON ("exif"."country") "exif"."country" AS "country"
 FROM
   "exif" "exif"
-  LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
+  INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
   AND ("asset"."deletedAt" IS NULL)
 WHERE
   "asset"."ownerId" IN ($1)
+  AND "exif"."country" != ''
+  AND "exif"."country" IS NOT NULL
 
 -- SearchRepository.getStates
 SELECT DISTINCT
   ON ("exif"."state") "exif"."state" AS "state"
 FROM
   "exif" "exif"
-  LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
+  INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
   AND ("asset"."deletedAt" IS NULL)
 WHERE
   "asset"."ownerId" IN ($1)
-  AND "exif"."country" = $2
+  AND "exif"."state" != ''
+  AND "exif"."state" IS NOT NULL
 
 -- SearchRepository.getCities
 SELECT DISTINCT
   ON ("exif"."city") "exif"."city" AS "city"
 FROM
   "exif" "exif"
-  LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
+  INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
   AND ("asset"."deletedAt" IS NULL)
 WHERE
   "asset"."ownerId" IN ($1)
-  AND "exif"."country" = $2
-  AND "exif"."state" = $3
+  AND "exif"."city" != ''
+  AND "exif"."city" IS NOT NULL
 
 -- SearchRepository.getCameraMakes
 SELECT DISTINCT
   ON ("exif"."make") "exif"."make" AS "make"
 FROM
   "exif" "exif"
-  LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
+  INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
   AND ("asset"."deletedAt" IS NULL)
 WHERE
   "asset"."ownerId" IN ($1)
-  AND "exif"."model" = $2
+  AND "exif"."make" != ''
+  AND "exif"."make" IS NOT NULL
 
 -- SearchRepository.getCameraModels
 SELECT DISTINCT
   ON ("exif"."model") "exif"."model" AS "model"
 FROM
   "exif" "exif"
-  LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
+  INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
   AND ("asset"."deletedAt" IS NULL)
 WHERE
   "asset"."ownerId" IN ($1)
-  AND "exif"."make" = $2
+  AND "exif"."model" != ''
+  AND "exif"."model" IS NOT NULL
diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts
index ba7d779e02..0a529f2f6e 100644
--- a/server/src/repositories/search.repository.ts
+++ b/server/src/repositories/search.repository.ts
@@ -17,6 +17,10 @@ import {
   AssetSearchOptions,
   FaceEmbeddingSearch,
   FaceSearchResult,
+  GetCameraMakesOptions,
+  GetCameraModelsOptions,
+  GetCitiesOptions,
+  GetStatesOptions,
   ISearchRepository,
   SearchPaginationOptions,
   SmartSearchOptions,
@@ -342,23 +346,27 @@ export class SearchRepository implements ISearchRepository {
 
   @GenerateSql({ params: [[DummyValue.UUID]] })
   async getCountries(userIds: string[]): Promise<string[]> {
-    const results = await this.exifRepository
+    const query = this.exifRepository
       .createQueryBuilder('exif')
-      .leftJoin('exif.asset', 'asset')
+      .innerJoin('exif.asset', 'asset')
       .where('asset.ownerId IN (:...userIds )', { userIds })
+      .andWhere(`exif.country != ''`)
+      .andWhere('exif.country IS NOT NULL')
       .select('exif.country', 'country')
-      .distinctOn(['exif.country'])
-      .getRawMany<{ country: string }>();
+      .distinctOn(['exif.country']);
 
-    return results.map(({ country }) => country).filter((item) => item !== '');
+    const results = await query.getRawMany<{ country: string }>();
+    return results.map(({ country }) => country);
   }
 
   @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
-  async getStates(userIds: string[], country: string | undefined): Promise<string[]> {
+  async getStates(userIds: string[], { country }: GetStatesOptions): Promise<string[]> {
     const query = this.exifRepository
       .createQueryBuilder('exif')
-      .leftJoin('exif.asset', 'asset')
+      .innerJoin('exif.asset', 'asset')
       .where('asset.ownerId IN (:...userIds )', { userIds })
+      .andWhere(`exif.state != ''`)
+      .andWhere('exif.state IS NOT NULL')
       .select('exif.state', 'state')
       .distinctOn(['exif.state']);
 
@@ -367,16 +375,17 @@ export class SearchRepository implements ISearchRepository {
     }
 
     const result = await query.getRawMany<{ state: string }>();
-
-    return result.map(({ state }) => state).filter((item) => item !== '');
+    return result.map(({ state }) => state);
   }
 
   @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
-  async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise<string[]> {
+  async getCities(userIds: string[], { country, state }: GetCitiesOptions): Promise<string[]> {
     const query = this.exifRepository
       .createQueryBuilder('exif')
-      .leftJoin('exif.asset', 'asset')
+      .innerJoin('exif.asset', 'asset')
       .where('asset.ownerId IN (:...userIds )', { userIds })
+      .andWhere(`exif.city != ''`)
+      .andWhere('exif.city IS NOT NULL')
       .select('exif.city', 'city')
       .distinctOn(['exif.city']);
 
@@ -389,16 +398,17 @@ export class SearchRepository implements ISearchRepository {
     }
 
     const results = await query.getRawMany<{ city: string }>();
-
-    return results.map(({ city }) => city).filter((item) => item !== '');
+    return results.map(({ city }) => city);
   }
 
   @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
-  async getCameraMakes(userIds: string[], model: string | undefined): Promise<string[]> {
+  async getCameraMakes(userIds: string[], { model }: GetCameraMakesOptions): Promise<string[]> {
     const query = this.exifRepository
       .createQueryBuilder('exif')
-      .leftJoin('exif.asset', 'asset')
+      .innerJoin('exif.asset', 'asset')
       .where('asset.ownerId IN (:...userIds )', { userIds })
+      .andWhere(`exif.make != ''`)
+      .andWhere('exif.make IS NOT NULL')
       .select('exif.make', 'make')
       .distinctOn(['exif.make']);
 
@@ -407,15 +417,17 @@ export class SearchRepository implements ISearchRepository {
     }
 
     const results = await query.getRawMany<{ make: string }>();
-    return results.map(({ make }) => make).filter((item) => item !== '');
+    return results.map(({ make }) => make);
   }
 
   @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
-  async getCameraModels(userIds: string[], make: string | undefined): Promise<string[]> {
+  async getCameraModels(userIds: string[], { make }: GetCameraModelsOptions): Promise<string[]> {
     const query = this.exifRepository
       .createQueryBuilder('exif')
-      .leftJoin('exif.asset', 'asset')
+      .innerJoin('exif.asset', 'asset')
       .where('asset.ownerId IN (:...userIds )', { userIds })
+      .andWhere(`exif.model != ''`)
+      .andWhere('exif.model IS NOT NULL')
       .select('exif.model', 'model')
       .distinctOn(['exif.model']);
 
@@ -424,7 +436,7 @@ export class SearchRepository implements ISearchRepository {
     }
 
     const results = await query.getRawMany<{ model: string }>();
-    return results.map(({ model }) => model).filter((item) => item !== '');
+    return results.map(({ model }) => model);
   }
 
   private getRuntimeConfig(numResults?: number): string | undefined {
diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts
index 0f95d88083..3933526167 100644
--- a/server/src/services/search.service.spec.ts
+++ b/server/src/services/search.service.spec.ts
@@ -59,20 +59,84 @@ describe(SearchService.name, () => {
   });
 
   describe('getSearchSuggestions', () => {
-    it('should return search suggestions (including null)', async () => {
-      searchMock.getCountries.mockResolvedValue(['USA', null]);
+    it('should return search suggestions for country', async () => {
+      searchMock.getCountries.mockResolvedValue(['USA']);
+      await expect(
+        sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }),
+      ).resolves.toEqual(['USA']);
+      expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
+    });
+
+    it('should return search suggestions for country (including null)', async () => {
+      searchMock.getCountries.mockResolvedValue(['USA']);
       await expect(
         sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }),
       ).resolves.toEqual(['USA', null]);
       expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
     });
 
-    it('should return search suggestions (without null)', async () => {
-      searchMock.getCountries.mockResolvedValue(['USA', null]);
+    it('should return search suggestions for state', async () => {
+      searchMock.getStates.mockResolvedValue(['California']);
       await expect(
-        sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }),
-      ).resolves.toEqual(['USA']);
-      expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
+        sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.STATE }),
+      ).resolves.toEqual(['California']);
+      expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
+    });
+
+    it('should return search suggestions for state (including null)', async () => {
+      searchMock.getStates.mockResolvedValue(['California']);
+      await expect(
+        sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.STATE }),
+      ).resolves.toEqual(['California', null]);
+      expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
+    });
+
+    it('should return search suggestions for city', async () => {
+      searchMock.getCities.mockResolvedValue(['Denver']);
+      await expect(
+        sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CITY }),
+      ).resolves.toEqual(['Denver']);
+      expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
+    });
+
+    it('should return search suggestions for city (including null)', async () => {
+      searchMock.getCities.mockResolvedValue(['Denver']);
+      await expect(
+        sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CITY }),
+      ).resolves.toEqual(['Denver', null]);
+      expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
+    });
+
+    it('should return search suggestions for camera make', async () => {
+      searchMock.getCameraMakes.mockResolvedValue(['Nikon']);
+      await expect(
+        sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MAKE }),
+      ).resolves.toEqual(['Nikon']);
+      expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
+    });
+
+    it('should return search suggestions for camera make (including null)', async () => {
+      searchMock.getCameraMakes.mockResolvedValue(['Nikon']);
+      await expect(
+        sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MAKE }),
+      ).resolves.toEqual(['Nikon', null]);
+      expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
+    });
+
+    it('should return search suggestions for camera model', async () => {
+      searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']);
+      await expect(
+        sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MODEL }),
+      ).resolves.toEqual(['Fujifilm X100VI']);
+      expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
+    });
+
+    it('should return search suggestions for camera model (including null)', async () => {
+      searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']);
+      await expect(
+        sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MODEL }),
+      ).resolves.toEqual(['Fujifilm X100VI', null]);
+      expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
     });
   });
 });
diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts
index bf5bf9e311..7fc947a8b5 100644
--- a/server/src/services/search.service.ts
+++ b/server/src/services/search.service.ts
@@ -108,8 +108,11 @@ export class SearchService extends BaseService {
 
   async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto) {
     const userIds = await this.getUserIdsToSearch(auth);
-    const results = await this.getSuggestions(userIds, dto);
-    return results.filter((result) => (dto.includeNull ? true : result !== null));
+    const suggestions = await this.getSuggestions(userIds, dto);
+    if (dto.includeNull) {
+      suggestions.push(null);
+    }
+    return suggestions;
   }
 
   private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) {
@@ -118,19 +121,19 @@ export class SearchService extends BaseService {
         return this.searchRepository.getCountries(userIds);
       }
       case SearchSuggestionType.STATE: {
-        return this.searchRepository.getStates(userIds, dto.country);
+        return this.searchRepository.getStates(userIds, dto);
       }
       case SearchSuggestionType.CITY: {
-        return this.searchRepository.getCities(userIds, dto.country, dto.state);
+        return this.searchRepository.getCities(userIds, dto);
       }
       case SearchSuggestionType.CAMERA_MAKE: {
-        return this.searchRepository.getCameraMakes(userIds, dto.model);
+        return this.searchRepository.getCameraMakes(userIds, dto);
       }
       case SearchSuggestionType.CAMERA_MODEL: {
-        return this.searchRepository.getCameraModels(userIds, dto.make);
+        return this.searchRepository.getCameraModels(userIds, dto);
       }
       default: {
-        return [];
+        return [] as (string | null)[];
       }
     }
   }

From 9eff1c4b34ace08871c8abf628adc9fcfe37a41d Mon Sep 17 00:00:00 2001
From: Mert <101130780+mertalev@users.noreply.github.com>
Date: Tue, 10 Dec 2024 16:22:47 -0500
Subject: [PATCH 07/25] refactor(server): move filters to getByDayOfYear query
 (#14628)

move filters to getByDayOfYear query
---
 server/src/interfaces/asset.interface.ts    |  7 ++++-
 server/src/queries/asset.repository.sql     | 19 ++++++--------
 server/src/repositories/asset.repository.ts | 23 +++++++++++++---
 server/src/services/asset.service.spec.ts   | 15 ++++++++++-
 server/src/services/asset.service.ts        | 29 +++++----------------
 5 files changed, 55 insertions(+), 38 deletions(-)

diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts
index 1b32c57d41..b25e42ba0e 100644
--- a/server/src/interfaces/asset.interface.ts
+++ b/server/src/interfaces/asset.interface.ts
@@ -146,6 +146,11 @@ export interface UpsertFileOptions {
 
 export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>;
 
+export interface DayOfYearAssets {
+  yearsAgo: number;
+  assets: AssetEntity[];
+}
+
 export const IAssetRepository = 'IAssetRepository';
 
 export interface IAssetRepository {
@@ -156,7 +161,7 @@ export interface IAssetRepository {
     select?: FindOptionsSelect<AssetEntity>,
   ): Promise<AssetEntity[]>;
   getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
-  getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
+  getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<DayOfYearAssets[]>;
   getByChecksum(options: { ownerId: string; checksum: Buffer; libraryId?: string }): Promise<AssetEntity | null>;
   getByChecksums(userId: string, checksums: Buffer[]): Promise<AssetEntity[]>;
   getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>;
diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql
index e7f5b558b0..f4b1b2fea1 100644
--- a/server/src/queries/asset.repository.sql
+++ b/server/src/queries/asset.repository.sql
@@ -68,22 +68,19 @@ SELECT
 FROM
   "assets" "entity"
   LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id"
-  LEFT JOIN "asset_files" "files" ON "files"."assetId" = "entity"."id"
+  INNER JOIN "asset_files" "files" ON "files"."assetId" = "entity"."id"
 WHERE
   (
-    "entity"."ownerId" IN ($1)
-    AND "entity"."isVisible" = true
-    AND "entity"."isArchived" = false
+    "files"."type" = $1
     AND EXTRACT(
-      DAY
+      YEAR
+      FROM
+        CURRENT_DATE AT TIME ZONE 'UTC'
+    ) - EXTRACT(
+      YEAR
       FROM
         "entity"."localDateTime" AT TIME ZONE 'UTC'
-    ) = $2
-    AND EXTRACT(
-      MONTH
-      FROM
-        "entity"."localDateTime" AT TIME ZONE 'UTC'
-    ) = $3
+    ) > 0
   )
   AND ("entity"."deletedAt" IS NULL)
 ORDER BY
diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts
index ce7d257b40..b3066a37bc 100644
--- a/server/src/repositories/asset.repository.ts
+++ b/server/src/repositories/asset.repository.ts
@@ -17,6 +17,7 @@ import {
   AssetUpdateAllOptions,
   AssetUpdateDuplicateOptions,
   AssetUpdateOptions,
+  DayOfYearAssets,
   IAssetRepository,
   LivePhotoSearchOptions,
   MonthDay,
@@ -74,8 +75,8 @@ export class AssetRepository implements IAssetRepository {
   }
 
   @GenerateSql({ params: [[DummyValue.UUID], { day: 1, month: 1 }] })
-  getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise<AssetEntity[]> {
-    return this.repository
+  async getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise<DayOfYearAssets[]> {
+    const assets = await this.repository
       .createQueryBuilder('entity')
       .where(
         `entity.ownerId IN (:...ownerIds)
@@ -90,9 +91,25 @@ export class AssetRepository implements IAssetRepository {
         },
       )
       .leftJoinAndSelect('entity.exifInfo', 'exifInfo')
-      .leftJoinAndSelect('entity.files', 'files')
+      .innerJoinAndSelect('entity.files', 'files')
+      .where('files.type = :type', { type: AssetFileType.THUMBNAIL })
+      .andWhere(
+        `EXTRACT(YEAR FROM CURRENT_DATE AT TIME ZONE 'UTC') - EXTRACT(YEAR FROM entity.localDateTime AT TIME ZONE 'UTC') > 0`,
+      )
       .orderBy('entity.fileCreatedAt', 'ASC')
       .getMany();
+
+    const groups: Record<number, DayOfYearAssets> = {};
+    const currentYear = new Date().getFullYear();
+    for (const asset of assets) {
+      const yearsAgo = currentYear - asset.localDateTime.getFullYear();
+      if (!groups[yearsAgo]) {
+        groups[yearsAgo] = { yearsAgo, assets: [] };
+      }
+      groups[yearsAgo].assets.push(asset);
+    }
+
+    return Object.values(groups);
   }
 
   @GenerateSql({ params: [[DummyValue.UUID]] })
diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts
index 9063df9dc2..5aab5032af 100755
--- a/server/src/services/asset.service.spec.ts
+++ b/server/src/services/asset.service.spec.ts
@@ -80,7 +80,20 @@ describe(AssetService.name, () => {
       const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) };
 
       partnerMock.getAll.mockResolvedValue([]);
-      assetMock.getByDayOfYear.mockResolvedValue([image1, image2, image3, image4]);
+      assetMock.getByDayOfYear.mockResolvedValue([
+        {
+          yearsAgo: 1,
+          assets: [image1, image2],
+        },
+        {
+          yearsAgo: 9,
+          assets: [image3],
+        },
+        {
+          yearsAgo: 15,
+          assets: [image4],
+        },
+      ]);
 
       await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
         { yearsAgo: 1, title: '1 year ago', assets: [mapAsset(image1), mapAsset(image2)] },
diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts
index 98d6ec00f6..8751037119 100644
--- a/server/src/services/asset.service.ts
+++ b/server/src/services/asset.service.ts
@@ -43,28 +43,13 @@ export class AssetService extends BaseService {
     });
     const userIds = [auth.user.id, ...partnerIds];
 
-    const assets = await this.assetRepository.getByDayOfYear(userIds, dto);
-    const assetsWithThumbnails = assets.filter(({ files }) => !!getAssetFiles(files).thumbnailFile);
-    const groups: Record<number, AssetEntity[]> = {};
-    const currentYear = new Date().getFullYear();
-    for (const asset of assetsWithThumbnails) {
-      const yearsAgo = currentYear - asset.localDateTime.getFullYear();
-      if (!groups[yearsAgo]) {
-        groups[yearsAgo] = [];
-      }
-      groups[yearsAgo].push(asset);
-    }
-
-    return Object.keys(groups)
-      .map(Number)
-      .sort((a, b) => a - b)
-      .filter((yearsAgo) => yearsAgo > 0)
-      .map((yearsAgo) => ({
-        yearsAgo,
-        // TODO move this to clients
-        title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
-        assets: groups[yearsAgo].map((asset) => mapAsset(asset, { auth })),
-      }));
+    const groups = await this.assetRepository.getByDayOfYear(userIds, dto);
+    return groups.map(({ yearsAgo, assets }) => ({
+      yearsAgo,
+      // TODO move this to clients
+      title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
+      assets: assets.map((asset) => mapAsset(asset, { auth })),
+    }));
   }
 
   async getStatistics(auth: AuthDto, dto: AssetStatsDto) {

From 345f918784ca8701b34c5d28be2edd74a89b0cb2 Mon Sep 17 00:00:00 2001
From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com>
Date: Wed, 11 Dec 2024 01:42:45 +0400
Subject: [PATCH 08/25] chore(docs): stronger discouraging of non-Linux
 installations (#14620)

* no windows!

* 2

* 3

* Update docs/docs/install/requirements.md

Co-authored-by: bo0tzz <git@bo0tzz.me>

* Update requirements.md

---------

Co-authored-by: bo0tzz <git@bo0tzz.me>
---
 docs/docs/administration/backup-and-restore.md | 7 ++++++-
 docs/docs/install/requirements.md              | 7 +++++--
 2 files changed, 11 insertions(+), 3 deletions(-)

diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md
index 9ae4e3e51f..1f8d489728 100644
--- a/docs/docs/administration/backup-and-restore.md
+++ b/docs/docs/administration/backup-and-restore.md
@@ -65,12 +65,17 @@ docker compose up -d            # Start remainder of Immich apps
 docker compose down -v  # CAUTION! Deletes all Immich data to start from scratch
 ## Uncomment the next line and replace DB_DATA_LOCATION with your Postgres path to permanently reset the Postgres database
 # Remove-Item -Recurse -Force DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch
+## You should mount the backup (as a volume, example: - 'C:\path\to\backup\dump.sql':/dump.sql) into the immich_postgres container using the docker-compose.yml
 docker compose pull             # Update to latest version of Immich (if desired)
 docker compose create           # Create Docker containers for Immich apps without running them
 docker start immich_postgres    # Start Postgres server
 sleep 10                        # Wait for Postgres server to start up
+docker exec -it immich_postgres bash    # Enter the Docker shell and run the following command
 # Check the database user if you deviated from the default
-gc "C:\path\to\backup\dump.sql" | docker exec -i immich_postgres psql --username=postgres  # Restore Backup
+cat "/dump.sql" \
+| sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \
+| psql --username=postgres      # Restore Backup
+exit                            # Exit the Docker shell
 docker compose up -d            # Start remainder of Immich apps
 ```
 
diff --git a/docs/docs/install/requirements.md b/docs/docs/install/requirements.md
index 74c4a2f831..6dc613389e 100644
--- a/docs/docs/install/requirements.md
+++ b/docs/docs/install/requirements.md
@@ -18,8 +18,11 @@ Immich requires the command `docker compose` - the similarly named `docker-compo
 ## Hardware
 
 - **OS**: Recommended Linux operating system (Ubuntu, Debian, etc).
-  - Windows is supported with [Docker Desktop on Windows](https://docs.docker.com/desktop/install/windows-install/) or [WSL 2](https://docs.docker.com/desktop/wsl/).
-  - macOS is supported with [Docker Desktop on Mac](https://docs.docker.com/desktop/install/mac-install/).
+  - Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
+    Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
+    If you still want to try to use a non-Linux OS, you can set it up as follows:
+    - Windows: [Docker Desktop on Windows](https://docs.docker.com/desktop/install/windows-install/) or [WSL 2](https://docs.docker.com/desktop/wsl/).
+    - macOS: [Docker Desktop on Mac](https://docs.docker.com/desktop/install/mac-install/).
 - **RAM**: Minimum 4GB, recommended 6GB.
 - **CPU**: Minimum 2 cores, recommended 4 cores.
 - **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.

From 70b4647a21df9c6d6ed875a61ca8d70e2bc88f7d Mon Sep 17 00:00:00 2001
From: Alex <alex.tran1502@gmail.com>
Date: Tue, 10 Dec 2024 15:55:59 -0600
Subject: [PATCH 09/25] chore(mobile): post release tasks (#14603)

---
 mobile/ios/Runner/Info.plist | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
index e85afdc852..28d21e266e 100644
--- a/mobile/ios/Runner/Info.plist
+++ b/mobile/ios/Runner/Info.plist
@@ -58,7 +58,7 @@
 	<key>CFBundlePackageType</key>
 	<string>APPL</string>
 	<key>CFBundleShortVersionString</key>
-	<string>1.122.0</string>
+	<string>1.122.2</string>
 	<key>CFBundleSignature</key>
 	<string>????</string>
 	<key>CFBundleVersion</key>

From f6909a3b110fb4a82ffab8f9f865dd19d737107c Mon Sep 17 00:00:00 2001
From: vladd11 <rozhkov.2006@gmail.com>
Date: Wed, 11 Dec 2024 00:58:14 +0300
Subject: [PATCH 10/25] chore(docs): add Kodi plugin for Immich to the
 Community Projects list (#14586)

---
 docs/src/components/community-projects.tsx | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx
index 596bf9dfc4..2dbab979f2 100644
--- a/docs/src/components/community-projects.tsx
+++ b/docs/src/components/community-projects.tsx
@@ -89,6 +89,11 @@ const projects: CommunityProjectProps[] = [
       'Share your Immich photos and albums in a safe way without exposing your Immich instance to the public.',
     url: 'https://github.com/alangrainger/immich-public-proxy',
   },
+  {
+    title: 'Immich Kodi',
+    description: 'Unofficial Kodi plugin for Immich.',
+    url: 'https://github.com/vladd11/immich-kodi',
+  },
 ];
 
 function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {

From 7cae25c28b6ecdcb7bc0cd8976212d53abe5e883 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 10 Dec 2024 15:59:45 -0600
Subject: [PATCH 11/25] chore(deps): update prom/prometheus docker digest to
 565ee86 (#14535)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
 docker/docker-compose.prod.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml
index 704f3bdfc8..d58b20ef76 100644
--- a/docker/docker-compose.prod.yml
+++ b/docker/docker-compose.prod.yml
@@ -91,7 +91,7 @@ services:
     container_name: immich_prometheus
     ports:
       - 9090:9090
-    image: prom/prometheus@sha256:3b9b2a15d376334da8c286d995777d3b9315aa666d2311170ada6059a517b74f
+    image: prom/prometheus@sha256:565ee86501224ebbb98fc10b332fa54440b100469924003359edf49cbce374bd
     volumes:
       - ./prometheus.yml:/etc/prometheus/prometheus.yml
       - prometheus-data:/prometheus

From bcc438eafbd6908306a41bc82afe0fd2dfe6fecc Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 10 Dec 2024 22:00:01 +0000
Subject: [PATCH 12/25] fix(deps): update dependency python-multipart to
 v0.0.18 [security] (#14458)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
 machine-learning/poetry.lock | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock
index 867b76c9e5..229db1303b 100644
--- a/machine-learning/poetry.lock
+++ b/machine-learning/poetry.lock
@@ -2746,13 +2746,13 @@ cli = ["click (>=5.0)"]
 
 [[package]]
 name = "python-multipart"
-version = "0.0.17"
+version = "0.0.19"
 description = "A streaming multipart parser for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "python_multipart-0.0.17-py3-none-any.whl", hash = "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d"},
-    {file = "python_multipart-0.0.17.tar.gz", hash = "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538"},
+    {file = "python_multipart-0.0.19-py3-none-any.whl", hash = "sha256:f8d5b0b9c618575bf9df01c684ded1d94a338839bdd8223838afacfb4bb2082d"},
+    {file = "python_multipart-0.0.19.tar.gz", hash = "sha256:905502ef39050557b7a6af411f454bc19526529ca46ae6831508438890ce12cc"},
 ]
 
 [[package]]

From 5814a1b22320b75137086c35407e9864519ad17b Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 10 Dec 2024 17:07:16 -0600
Subject: [PATCH 13/25] chore(deps): update docker/build-push-action action to
 v6.10.0 (#14631)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
 .github/workflows/cli.yml    | 2 +-
 .github/workflows/docker.yml | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index 7052fa6ef9..da383c3e2d 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -88,7 +88,7 @@ jobs:
             type=raw,value=latest,enable=${{ github.event_name == 'release' }}
 
       - name: Build and push image
-        uses: docker/build-push-action@v6.9.0
+        uses: docker/build-push-action@v6.10.0
         with:
           file: cli/Dockerfile
           platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 034fbe0008..7ec0cc0947 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -174,7 +174,7 @@ jobs:
           fi
 
       - name: Build and push image
-        uses: docker/build-push-action@v6.9.0
+        uses: docker/build-push-action@v6.10.0
         with:
           context: ${{ env.context }}
           file: ${{ env.file }}
@@ -265,7 +265,7 @@ jobs:
           fi
 
       - name: Build and push image
-        uses: docker/build-push-action@v6.9.0
+        uses: docker/build-push-action@v6.10.0
         with:
           context: ${{ env.context }}
           file: ${{ env.file }}

From 11f585d0adf384cbabae78a8ec3b4709449cb90f Mon Sep 17 00:00:00 2001
From: dvbthien <89862334+dvbthien@users.noreply.github.com>
Date: Wed, 11 Dec 2024 23:30:56 +0700
Subject: [PATCH 14/25] refactor(mobile): refactor theme management (#14415)

---
 mobile/lib/constants/colors.dart              |  23 +
 mobile/lib/main.dart                          |  36 +-
 mobile/lib/pages/common/settings.page.dart    |   1 +
 mobile/lib/pages/search/map/map.page.dart     |   2 +-
 .../search/map/map_location_picker.page.dart  |   2 +-
 mobile/lib/providers/theme.provider.dart      |  74 ++++
 mobile/lib/services/app_settings.service.dart |   2 +-
 .../color_scheme.dart}                        |  29 +-
 mobile/lib/theme/dynamic_theme.dart           |  38 ++
 .../theme_data.dart}                          | 407 +++++++-----------
 .../asset_viewer/motion_photo_button.dart     |   2 +-
 .../widgets/asset_viewer/video_position.dart  |   2 +-
 mobile/lib/widgets/backup/error_chip.dart     |   2 +-
 .../lib/widgets/backup/error_chip_text.dart   |   2 +-
 .../lib/widgets/map/map_theme_override.dart   |  11 +-
 mobile/lib/widgets/map/map_thumbnail.dart     |   2 +-
 .../primary_color_setting.dart                |  16 +-
 .../preference_settings/theme_setting.dart    |   2 +-
 .../modules/map/map_theme_override_test.dart  |  12 +-
 19 files changed, 343 insertions(+), 322 deletions(-)
 create mode 100644 mobile/lib/constants/colors.dart
 create mode 100644 mobile/lib/providers/theme.provider.dart
 rename mobile/lib/{constants/immich_colors.dart => theme/color_scheme.dart} (80%)
 create mode 100644 mobile/lib/theme/dynamic_theme.dart
 rename mobile/lib/{utils/immich_app_theme.dart => theme/theme_data.dart} (58%)

diff --git a/mobile/lib/constants/colors.dart b/mobile/lib/constants/colors.dart
new file mode 100644
index 0000000000..ade878d6f6
--- /dev/null
+++ b/mobile/lib/constants/colors.dart
@@ -0,0 +1,23 @@
+import 'package:flutter/material.dart';
+
+enum ImmichColorPreset {
+  indigo,
+  deepPurple,
+  pink,
+  red,
+  orange,
+  yellow,
+  lime,
+  green,
+  cyan,
+  slateGray
+}
+
+const ImmichColorPreset defaultColorPreset = ImmichColorPreset.indigo;
+const String defaultColorPresetName = "indigo";
+
+const Color immichBrandColorLight = Color(0xFF4150AF);
+const Color immichBrandColorDark = Color(0xFFACCBFA);
+const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
+const Color red400 = Color(0xFFEF5350);
+const Color grey200 = Color(0xFFEEEEEE);
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 7729972aa2..807212fc65 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -4,23 +4,26 @@ import 'dart:io';
 import 'package:background_downloader/background_downloader.dart';
 import 'package:device_info_plus/device_info_plus.dart';
 import 'package:easy_localization/easy_localization.dart';
+import 'package:intl/date_symbol_data_local.dart';
+import 'package:timezone/data/latest.dart';
+import 'package:isar/isar.dart';
+import 'package:logging/logging.dart';
+import 'package:path_provider/path_provider.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_displaymode/flutter_displaymode.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
-import 'package:immich_mobile/providers/locale_provider.dart';
-import 'package:immich_mobile/utils/download.dart';
-import 'package:intl/date_symbol_data_local.dart';
-import 'package:timezone/data/latest.dart';
 import 'package:immich_mobile/constants/locales.dart';
-import 'package:immich_mobile/services/background.service.dart';
-import 'package:immich_mobile/entities/backup_album.entity.dart';
-import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
+import 'package:immich_mobile/providers/locale_provider.dart';
+import 'package:immich_mobile/providers/theme.provider.dart';
+import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
+import 'package:immich_mobile/providers/db.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/tab_navigation_observer.dart';
-import 'package:immich_mobile/utils/cache/widgets_binding.dart';
+import 'package:immich_mobile/entities/backup_album.entity.dart';
+import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
 import 'package:immich_mobile/entities/album.entity.dart';
 import 'package:immich_mobile/entities/android_device_asset.entity.dart';
 import 'package:immich_mobile/entities/asset.entity.dart';
@@ -30,16 +33,15 @@ import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
 import 'package:immich_mobile/entities/logger_message.entity.dart';
 import 'package:immich_mobile/entities/store.entity.dart';
 import 'package:immich_mobile/entities/user.entity.dart';
-import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
-import 'package:immich_mobile/providers/db.provider.dart';
+import 'package:immich_mobile/services/background.service.dart';
 import 'package:immich_mobile/services/immich_logger.service.dart';
 import 'package:immich_mobile/services/local_notification.service.dart';
-import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
-import 'package:immich_mobile/utils/immich_app_theme.dart';
 import 'package:immich_mobile/utils/migration.dart';
-import 'package:isar/isar.dart';
-import 'package:logging/logging.dart';
-import 'package:path_provider/path_provider.dart';
+import 'package:immich_mobile/utils/download.dart';
+import 'package:immich_mobile/utils/cache/widgets_binding.dart';
+import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
+import 'package:immich_mobile/theme/theme_data.dart';
+import 'package:immich_mobile/theme/dynamic_theme.dart';
 
 void main() async {
   ImmichWidgetsBinding();
@@ -69,12 +71,12 @@ Future<void> initApp() async {
     }
   }
 
-  await fetchSystemPalette();
+  await DynamicTheme.fetchSystemPalette();
 
   // Initialize Immich Logger Service
   ImmichLogger();
 
-  var log = Logger("ImmichErrorLogger");
+  final log = Logger("ImmichErrorLogger");
 
   FlutterError.onError = (details) {
     FlutterError.presentError(details);
diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart
index ba3150c046..3cbded1787 100644
--- a/mobile/lib/pages/common/settings.page.dart
+++ b/mobile/lib/pages/common/settings.page.dart
@@ -133,6 +133,7 @@ class _MobileLayout extends StatelessWidget {
                   ).tr(),
                   subtitle: Text(
                     setting.subtitle,
+                    style: context.textTheme.labelLarge,
                   ).tr(),
                   onTap: () =>
                       context.pushRoute(SettingsSubRoute(section: setting)),
diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart
index 10fe8de541..52ce13f958 100644
--- a/mobile/lib/pages/search/map/map.page.dart
+++ b/mobile/lib/pages/search/map/map.page.dart
@@ -264,7 +264,7 @@ class MapPage extends HookConsumerWidget {
       selectedAssets.value = selected ? selection : {};
     }
 
-    return MapThemeOveride(
+    return MapThemeOverride(
       mapBuilder: (style) => context.isMobile
           // Single-column
           ? Scaffold(
diff --git a/mobile/lib/pages/search/map/map_location_picker.page.dart b/mobile/lib/pages/search/map/map_location_picker.page.dart
index 2fd1e1ee9e..487de69a1e 100644
--- a/mobile/lib/pages/search/map/map_location_picker.page.dart
+++ b/mobile/lib/pages/search/map/map_location_picker.page.dart
@@ -58,7 +58,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
       controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng));
     }
 
-    return MapThemeOveride(
+    return MapThemeOverride(
       mapBuilder: (style) => Builder(
         builder: (ctx) => Scaffold(
           backgroundColor: ctx.themeData.cardColor,
diff --git a/mobile/lib/providers/theme.provider.dart b/mobile/lib/providers/theme.provider.dart
new file mode 100644
index 0000000000..73623bd026
--- /dev/null
+++ b/mobile/lib/providers/theme.provider.dart
@@ -0,0 +1,74 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import 'package:immich_mobile/constants/colors.dart';
+import 'package:immich_mobile/theme/color_scheme.dart';
+import 'package:immich_mobile/theme/theme_data.dart';
+import 'package:immich_mobile/theme/dynamic_theme.dart';
+import 'package:immich_mobile/providers/app_settings.provider.dart';
+import 'package:immich_mobile/services/app_settings.service.dart';
+
+final immichThemeModeProvider = StateProvider<ThemeMode>((ref) {
+  final themeMode = ref
+      .watch(appSettingsServiceProvider)
+      .getSetting(AppSettingsEnum.themeMode);
+
+  debugPrint("Current themeMode $themeMode");
+
+  if (themeMode == ThemeMode.light.name) {
+    return ThemeMode.light;
+  } else if (themeMode == ThemeMode.dark.name) {
+    return ThemeMode.dark;
+  } else {
+    return ThemeMode.system;
+  }
+});
+
+final immichThemePresetProvider = StateProvider<ImmichColorPreset>((ref) {
+  final appSettingsProvider = ref.watch(appSettingsServiceProvider);
+  final primaryColorPreset =
+      appSettingsProvider.getSetting(AppSettingsEnum.primaryColor);
+
+  debugPrint("Current theme preset $primaryColorPreset");
+
+  try {
+    return ImmichColorPreset.values
+        .firstWhere((e) => e.name == primaryColorPreset);
+  } catch (e) {
+    debugPrint(
+      "Theme preset $primaryColorPreset not found. Applying default preset.",
+    );
+    appSettingsProvider.setSetting(
+      AppSettingsEnum.primaryColor,
+      defaultColorPresetName,
+    );
+    return defaultColorPreset;
+  }
+});
+
+final dynamicThemeSettingProvider = StateProvider<bool>((ref) {
+  return ref
+      .watch(appSettingsServiceProvider)
+      .getSetting(AppSettingsEnum.dynamicTheme);
+});
+
+final colorfulInterfaceSettingProvider = StateProvider<bool>((ref) {
+  return ref
+      .watch(appSettingsServiceProvider)
+      .getSetting(AppSettingsEnum.colorfulInterface);
+});
+
+// Provider for current selected theme
+final immichThemeProvider = StateProvider<ImmichTheme>((ref) {
+  final primaryColorPreset = ref.read(immichThemePresetProvider);
+  final useSystemColor = ref.watch(dynamicThemeSettingProvider);
+  final useColorfulInterface = ref.watch(colorfulInterfaceSettingProvider);
+  final ImmichTheme? dynamicTheme = DynamicTheme.theme;
+  final currentTheme = (useSystemColor && dynamicTheme != null)
+      ? dynamicTheme
+      : primaryColorPreset.themeOfPreset;
+
+  return useColorfulInterface
+      ? currentTheme
+      : decolorizeSurfaces(theme: currentTheme);
+});
diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart
index 14d800a4ef..c3fde894d5 100644
--- a/mobile/lib/services/app_settings.service.dart
+++ b/mobile/lib/services/app_settings.service.dart
@@ -1,4 +1,4 @@
-import 'package:immich_mobile/constants/immich_colors.dart';
+import 'package:immich_mobile/constants/colors.dart';
 import 'package:immich_mobile/entities/store.entity.dart';
 
 enum AppSettingsEnum<T> {
diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/theme/color_scheme.dart
similarity index 80%
rename from mobile/lib/constants/immich_colors.dart
rename to mobile/lib/theme/color_scheme.dart
index 847887de8c..c01b7cfa5a 100644
--- a/mobile/lib/constants/immich_colors.dart
+++ b/mobile/lib/theme/color_scheme.dart
@@ -1,29 +1,8 @@
 import 'package:flutter/material.dart';
-import 'package:immich_mobile/utils/immich_app_theme.dart';
+import 'package:immich_mobile/constants/colors.dart';
+import 'package:immich_mobile/theme/theme_data.dart';
 
-enum ImmichColorPreset {
-  indigo,
-  deepPurple,
-  pink,
-  red,
-  orange,
-  yellow,
-  lime,
-  green,
-  cyan,
-  slateGray
-}
-
-const ImmichColorPreset defaultColorPreset = ImmichColorPreset.indigo;
-const String defaultColorPresetName = "indigo";
-
-const Color immichBrandColorLight = Color(0xFF4150AF);
-const Color immichBrandColorDark = Color(0xFFACCBFA);
-const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
-const Color red400 = Color(0xFFEF5350);
-const Color grey200 = Color(0xFFEEEEEE);
-
-final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
+final Map<ImmichColorPreset, ImmichTheme> _themePresets = {
   ImmichColorPreset.indigo: ImmichTheme(
     light: ColorScheme.fromSeed(
       seedColor: immichBrandColorLight,
@@ -110,5 +89,5 @@ final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
 };
 
 extension ImmichColorModeExtension on ImmichColorPreset {
-  ImmichTheme getTheme() => _themePresetsMap[this]!;
+  ImmichTheme get themeOfPreset => _themePresets[this]!;
 }
diff --git a/mobile/lib/theme/dynamic_theme.dart b/mobile/lib/theme/dynamic_theme.dart
new file mode 100644
index 0000000000..39d6b6ee45
--- /dev/null
+++ b/mobile/lib/theme/dynamic_theme.dart
@@ -0,0 +1,38 @@
+import 'package:flutter/material.dart';
+import 'package:dynamic_color/dynamic_color.dart';
+
+import 'package:immich_mobile/theme/theme_data.dart';
+
+abstract final class DynamicTheme {
+  DynamicTheme._();
+
+  static ImmichTheme? _theme;
+  // Method to fetch dynamic system colors
+  static Future<void> fetchSystemPalette() async {
+    try {
+      final corePalette = await DynamicColorPlugin.getCorePalette();
+      if (corePalette != null) {
+        final primaryColor = corePalette.toColorScheme().primary;
+        debugPrint('dynamic_color: Core palette detected.');
+
+        // Some palettes do not generate surface container colors accurately,
+        // so we regenerate all colors using the primary color
+        _theme = ImmichTheme(
+          light: ColorScheme.fromSeed(
+            seedColor: primaryColor,
+            brightness: Brightness.light,
+          ),
+          dark: ColorScheme.fromSeed(
+            seedColor: primaryColor,
+            brightness: Brightness.dark,
+          ),
+        );
+      }
+    } catch (error) {
+      debugPrint('dynamic_color: Failed to obtain core palette: $error');
+    }
+  }
+
+  static ImmichTheme? get theme => _theme;
+  static bool get isAvailable => _theme != null;
+}
diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/theme/theme_data.dart
similarity index 58%
rename from mobile/lib/utils/immich_app_theme.dart
rename to mobile/lib/theme/theme_data.dart
index 2ca4fe3aff..de96e12c5d 100644
--- a/mobile/lib/utils/immich_app_theme.dart
+++ b/mobile/lib/theme/theme_data.dart
@@ -1,11 +1,7 @@
-import 'package:dynamic_color/dynamic_color.dart';
 import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/immich_colors.dart';
+
 import 'package:immich_mobile/constants/locales.dart';
 import 'package:immich_mobile/extensions/theme_extensions.dart';
-import 'package:immich_mobile/providers/app_settings.provider.dart';
-import 'package:immich_mobile/services/app_settings.service.dart';
 
 class ImmichTheme {
   final ColorScheme light;
@@ -14,104 +10,166 @@ class ImmichTheme {
   const ImmichTheme({required this.light, required this.dark});
 }
 
-ImmichTheme? _immichDynamicTheme;
-bool get isDynamicThemeAvailable => _immichDynamicTheme != null;
+ThemeData getThemeData({
+  required ColorScheme colorScheme,
+  required Locale locale,
+}) {
+  final isDark = colorScheme.brightness == Brightness.dark;
 
-final immichThemeModeProvider = StateProvider<ThemeMode>((ref) {
-  var themeMode = ref
-      .watch(appSettingsServiceProvider)
-      .getSetting(AppSettingsEnum.themeMode);
-
-  debugPrint("Current themeMode $themeMode");
-
-  if (themeMode == "light") {
-    return ThemeMode.light;
-  } else if (themeMode == "dark") {
-    return ThemeMode.dark;
-  } else {
-    return ThemeMode.system;
-  }
-});
-
-final immichThemePresetProvider = StateProvider<ImmichColorPreset>((ref) {
-  var appSettingsProvider = ref.watch(appSettingsServiceProvider);
-  var primaryColorName =
-      appSettingsProvider.getSetting(AppSettingsEnum.primaryColor);
-
-  debugPrint("Current theme preset $primaryColorName");
-
-  try {
-    return ImmichColorPreset.values
-        .firstWhere((e) => e.name == primaryColorName);
-  } catch (e) {
-    debugPrint(
-      "Theme preset $primaryColorName not found. Applying default preset.",
-    );
-    appSettingsProvider.setSetting(
-      AppSettingsEnum.primaryColor,
-      defaultColorPresetName,
-    );
-    return defaultColorPreset;
-  }
-});
-
-final dynamicThemeSettingProvider = StateProvider<bool>((ref) {
-  return ref
-      .watch(appSettingsServiceProvider)
-      .getSetting(AppSettingsEnum.dynamicTheme);
-});
-
-final colorfulInterfaceSettingProvider = StateProvider<bool>((ref) {
-  return ref
-      .watch(appSettingsServiceProvider)
-      .getSetting(AppSettingsEnum.colorfulInterface);
-});
-
-// Provider for current selected theme
-final immichThemeProvider = StateProvider<ImmichTheme>((ref) {
-  var primaryColor = ref.read(immichThemePresetProvider);
-  var useSystemColor = ref.watch(dynamicThemeSettingProvider);
-  var useColorfulInterface = ref.watch(colorfulInterfaceSettingProvider);
-
-  var currentTheme = (useSystemColor && _immichDynamicTheme != null)
-      ? _immichDynamicTheme!
-      : primaryColor.getTheme();
-
-  return useColorfulInterface
-      ? currentTheme
-      : _decolorizeSurfaces(theme: currentTheme);
-});
-
-// Method to fetch dynamic system colors
-Future<void> fetchSystemPalette() async {
-  try {
-    final corePalette = await DynamicColorPlugin.getCorePalette();
-    if (corePalette != null) {
-      final primaryColor = corePalette.toColorScheme().primary;
-      debugPrint('dynamic_color: Core palette detected.');
-
-      // Some palettes do not generate surface container colors accurately,
-      // so we regenerate all colors using the primary color
-      _immichDynamicTheme = ImmichTheme(
-        light: ColorScheme.fromSeed(
-          seedColor: primaryColor,
-          brightness: Brightness.light,
+  return ThemeData(
+    useMaterial3: true,
+    brightness: colorScheme.brightness,
+    colorScheme: colorScheme,
+    primaryColor: colorScheme.primary,
+    hintColor: colorScheme.onSurfaceSecondary,
+    focusColor: colorScheme.primary,
+    scaffoldBackgroundColor: colorScheme.surface,
+    splashColor: colorScheme.primary.withOpacity(0.1),
+    highlightColor: colorScheme.primary.withOpacity(0.1),
+    dialogBackgroundColor: colorScheme.surfaceContainer,
+    bottomSheetTheme: BottomSheetThemeData(
+      backgroundColor: colorScheme.surfaceContainer,
+    ),
+    fontFamily: _getFontFamilyFromLocale(locale),
+    snackBarTheme: SnackBarThemeData(
+      contentTextStyle: TextStyle(
+        fontFamily: _getFontFamilyFromLocale(locale),
+        color: colorScheme.primary,
+        fontWeight: FontWeight.bold,
+      ),
+      backgroundColor: colorScheme.surfaceContainerHighest,
+    ),
+    appBarTheme: AppBarTheme(
+      titleTextStyle: TextStyle(
+        color: colorScheme.primary,
+        fontFamily: _getFontFamilyFromLocale(locale),
+        fontWeight: FontWeight.bold,
+        fontSize: 18,
+      ),
+      backgroundColor:
+          isDark ? colorScheme.surfaceContainer : colorScheme.surface,
+      foregroundColor: colorScheme.primary,
+      elevation: 0,
+      scrolledUnderElevation: 0,
+      centerTitle: true,
+    ),
+    textTheme: const TextTheme(
+      displayLarge: TextStyle(
+        fontSize: 26,
+        fontWeight: FontWeight.bold,
+      ),
+      displayMedium: TextStyle(
+        fontSize: 14,
+        fontWeight: FontWeight.bold,
+      ),
+      displaySmall: TextStyle(
+        fontSize: 12,
+        fontWeight: FontWeight.bold,
+      ),
+      titleSmall: TextStyle(
+        fontSize: 16.0,
+        fontWeight: FontWeight.bold,
+      ),
+      titleMedium: TextStyle(
+        fontSize: 18.0,
+        fontWeight: FontWeight.bold,
+      ),
+      titleLarge: TextStyle(
+        fontSize: 26.0,
+        fontWeight: FontWeight.bold,
+      ),
+    ),
+    elevatedButtonTheme: ElevatedButtonThemeData(
+      style: ElevatedButton.styleFrom(
+        backgroundColor: colorScheme.primary,
+        foregroundColor: isDark ? Colors.black87 : Colors.white,
+      ),
+    ),
+    chipTheme: const ChipThemeData(
+      side: BorderSide.none,
+    ),
+    sliderTheme: const SliderThemeData(
+      thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
+      trackHeight: 2.0,
+    ),
+    bottomNavigationBarTheme: const BottomNavigationBarThemeData(
+      type: BottomNavigationBarType.fixed,
+    ),
+    popupMenuTheme: const PopupMenuThemeData(
+      shape: RoundedRectangleBorder(
+        borderRadius: BorderRadius.all(Radius.circular(10)),
+      ),
+    ),
+    navigationBarTheme: NavigationBarThemeData(
+      backgroundColor:
+          isDark ? colorScheme.surfaceContainer : colorScheme.surface,
+      labelTextStyle: const WidgetStatePropertyAll(
+        TextStyle(
+          fontSize: 14,
+          fontWeight: FontWeight.w500,
         ),
-        dark: ColorScheme.fromSeed(
-          seedColor: primaryColor,
-          brightness: Brightness.dark,
+      ),
+    ),
+    inputDecorationTheme: InputDecorationTheme(
+      focusedBorder: OutlineInputBorder(
+        borderSide: BorderSide(
+          color: colorScheme.primary,
         ),
-      );
-    }
-  } catch (e) {
-    debugPrint('dynamic_color: Failed to obtain core palette.');
-  }
+        borderRadius: const BorderRadius.all(Radius.circular(15)),
+      ),
+      enabledBorder: OutlineInputBorder(
+        borderSide: BorderSide(
+          color: colorScheme.outlineVariant,
+        ),
+        borderRadius: const BorderRadius.all(Radius.circular(15)),
+      ),
+      labelStyle: TextStyle(
+        color: colorScheme.primary,
+      ),
+      hintStyle: const TextStyle(
+        fontSize: 14.0,
+        fontWeight: FontWeight.normal,
+      ),
+    ),
+    textSelectionTheme: TextSelectionThemeData(
+      cursorColor: colorScheme.primary,
+    ),
+    dropdownMenuTheme: DropdownMenuThemeData(
+      menuStyle: const MenuStyle(
+        shape: WidgetStatePropertyAll<OutlinedBorder>(
+          RoundedRectangleBorder(
+            borderRadius: BorderRadius.all(Radius.circular(15)),
+          ),
+        ),
+      ),
+      inputDecorationTheme: InputDecorationTheme(
+        focusedBorder: OutlineInputBorder(
+          borderSide: BorderSide(
+            color: colorScheme.primary,
+          ),
+        ),
+        enabledBorder: OutlineInputBorder(
+          borderSide: BorderSide(
+            color: colorScheme.outlineVariant,
+          ),
+          borderRadius: const BorderRadius.all(Radius.circular(15)),
+        ),
+        labelStyle: TextStyle(
+          color: colorScheme.primary,
+        ),
+        hintStyle: const TextStyle(
+          fontSize: 14.0,
+          fontWeight: FontWeight.normal,
+        ),
+      ),
+    ),
+  );
 }
 
 // This method replaces all surface shades in ImmichTheme to a static ones
 // as we are creating the colorscheme through seedColor the default surfaces are
 // tinted with primary color
-ImmichTheme _decolorizeSurfaces({
+ImmichTheme decolorizeSurfaces({
   required ImmichTheme theme,
 }) {
   return ImmichTheme(
@@ -146,167 +204,10 @@ ImmichTheme _decolorizeSurfaces({
   );
 }
 
-String? getFontFamilyFromLocale(Locale locale) {
+String? _getFontFamilyFromLocale(Locale locale) {
   if (localesNotSupportedByOverpass.contains(locale)) {
     // Let Flutter use the default font
     return null;
   }
   return 'Overpass';
 }
-
-ThemeData getThemeData({
-  required ColorScheme colorScheme,
-  required Locale locale,
-}) {
-  var isDark = colorScheme.brightness == Brightness.dark;
-  var primaryColor = colorScheme.primary;
-
-  return ThemeData(
-    useMaterial3: true,
-    brightness: colorScheme.brightness,
-    colorScheme: colorScheme,
-    primaryColor: primaryColor,
-    hintColor: colorScheme.onSurfaceSecondary,
-    focusColor: primaryColor,
-    scaffoldBackgroundColor: colorScheme.surface,
-    splashColor: primaryColor.withOpacity(0.1),
-    highlightColor: primaryColor.withOpacity(0.1),
-    dialogBackgroundColor: colorScheme.surfaceContainer,
-    bottomSheetTheme: BottomSheetThemeData(
-      backgroundColor: colorScheme.surfaceContainer,
-    ),
-    fontFamily: getFontFamilyFromLocale(locale),
-    snackBarTheme: SnackBarThemeData(
-      contentTextStyle: TextStyle(
-        fontFamily: getFontFamilyFromLocale(locale),
-        color: primaryColor,
-        fontWeight: FontWeight.bold,
-      ),
-      backgroundColor: colorScheme.surfaceContainerHighest,
-    ),
-    appBarTheme: AppBarTheme(
-      titleTextStyle: TextStyle(
-        color: primaryColor,
-        fontFamily: getFontFamilyFromLocale(locale),
-        fontWeight: FontWeight.bold,
-        fontSize: 18,
-      ),
-      backgroundColor:
-          isDark ? colorScheme.surfaceContainer : colorScheme.surface,
-      foregroundColor: primaryColor,
-      elevation: 0,
-      scrolledUnderElevation: 0,
-      centerTitle: true,
-    ),
-    textTheme: const TextTheme(
-      displayLarge: TextStyle(
-        fontSize: 26,
-        fontWeight: FontWeight.bold,
-      ),
-      displayMedium: TextStyle(
-        fontSize: 14,
-        fontWeight: FontWeight.bold,
-      ),
-      displaySmall: TextStyle(
-        fontSize: 12,
-        fontWeight: FontWeight.bold,
-      ),
-      titleSmall: TextStyle(
-        fontSize: 16.0,
-        fontWeight: FontWeight.bold,
-      ),
-      titleMedium: TextStyle(
-        fontSize: 18.0,
-        fontWeight: FontWeight.bold,
-      ),
-      titleLarge: TextStyle(
-        fontSize: 26.0,
-        fontWeight: FontWeight.bold,
-      ),
-    ),
-    elevatedButtonTheme: ElevatedButtonThemeData(
-      style: ElevatedButton.styleFrom(
-        backgroundColor: primaryColor,
-        foregroundColor: isDark ? Colors.black87 : Colors.white,
-      ),
-    ),
-    chipTheme: const ChipThemeData(
-      side: BorderSide.none,
-    ),
-    sliderTheme: const SliderThemeData(
-      thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
-      trackHeight: 2.0,
-    ),
-    bottomNavigationBarTheme: const BottomNavigationBarThemeData(
-      type: BottomNavigationBarType.fixed,
-    ),
-    popupMenuTheme: const PopupMenuThemeData(
-      shape: RoundedRectangleBorder(
-        borderRadius: BorderRadius.all(Radius.circular(10)),
-      ),
-    ),
-    navigationBarTheme: NavigationBarThemeData(
-      backgroundColor:
-          isDark ? colorScheme.surfaceContainer : colorScheme.surface,
-      labelTextStyle: const WidgetStatePropertyAll(
-        TextStyle(
-          fontSize: 14,
-          fontWeight: FontWeight.w500,
-        ),
-      ),
-    ),
-    inputDecorationTheme: InputDecorationTheme(
-      focusedBorder: OutlineInputBorder(
-        borderSide: BorderSide(
-          color: primaryColor,
-        ),
-        borderRadius: const BorderRadius.all(Radius.circular(15)),
-      ),
-      enabledBorder: OutlineInputBorder(
-        borderSide: BorderSide(
-          color: colorScheme.outlineVariant,
-        ),
-        borderRadius: const BorderRadius.all(Radius.circular(15)),
-      ),
-      labelStyle: TextStyle(
-        color: primaryColor,
-      ),
-      hintStyle: const TextStyle(
-        fontSize: 14.0,
-        fontWeight: FontWeight.normal,
-      ),
-    ),
-    textSelectionTheme: TextSelectionThemeData(
-      cursorColor: primaryColor,
-    ),
-    dropdownMenuTheme: DropdownMenuThemeData(
-      menuStyle: MenuStyle(
-        shape: WidgetStatePropertyAll<OutlinedBorder>(
-          RoundedRectangleBorder(
-            borderRadius: BorderRadius.circular(15),
-          ),
-        ),
-      ),
-      inputDecorationTheme: InputDecorationTheme(
-        focusedBorder: OutlineInputBorder(
-          borderSide: BorderSide(
-            color: primaryColor,
-          ),
-        ),
-        enabledBorder: OutlineInputBorder(
-          borderSide: BorderSide(
-            color: colorScheme.outlineVariant,
-          ),
-          borderRadius: const BorderRadius.all(Radius.circular(15)),
-        ),
-        labelStyle: TextStyle(
-          color: primaryColor,
-        ),
-        hintStyle: const TextStyle(
-          fontSize: 14.0,
-          fontWeight: FontWeight.normal,
-        ),
-      ),
-    ),
-  );
-}
diff --git a/mobile/lib/widgets/asset_viewer/motion_photo_button.dart b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart
index e4dd355554..f5479ab86e 100644
--- a/mobile/lib/widgets/asset_viewer/motion_photo_button.dart
+++ b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart
@@ -1,6 +1,6 @@
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/immich_colors.dart';
+import 'package:immich_mobile/constants/colors.dart';
 import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
 
 class MotionPhotoButton extends ConsumerWidget {
diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart
index b1f70b8686..4d0e7aa17f 100644
--- a/mobile/lib/widgets/asset_viewer/video_position.dart
+++ b/mobile/lib/widgets/asset_viewer/video_position.dart
@@ -3,7 +3,7 @@ import 'dart:math';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/immich_colors.dart';
+import 'package:immich_mobile/constants/colors.dart';
 import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
 import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
diff --git a/mobile/lib/widgets/backup/error_chip.dart b/mobile/lib/widgets/backup/error_chip.dart
index 4bbc040d4d..4df3e50f64 100644
--- a/mobile/lib/widgets/backup/error_chip.dart
+++ b/mobile/lib/widgets/backup/error_chip.dart
@@ -1,7 +1,7 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/immich_colors.dart';
+import 'package:immich_mobile/constants/colors.dart';
 import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/widgets/backup/error_chip_text.dart';
diff --git a/mobile/lib/widgets/backup/error_chip_text.dart b/mobile/lib/widgets/backup/error_chip_text.dart
index 94148da176..540e136722 100644
--- a/mobile/lib/widgets/backup/error_chip_text.dart
+++ b/mobile/lib/widgets/backup/error_chip_text.dart
@@ -1,7 +1,7 @@
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/immich_colors.dart';
+import 'package:immich_mobile/constants/colors.dart';
 import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
 
 class BackupErrorChipText extends ConsumerWidget {
diff --git a/mobile/lib/widgets/map/map_theme_override.dart b/mobile/lib/widgets/map/map_theme_override.dart
index 68a2146bfb..65425f9e78 100644
--- a/mobile/lib/widgets/map/map_theme_override.dart
+++ b/mobile/lib/widgets/map/map_theme_override.dart
@@ -3,21 +3,22 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/providers/locale_provider.dart';
 import 'package:immich_mobile/providers/map/map_state.provider.dart';
-import 'package:immich_mobile/utils/immich_app_theme.dart';
+import 'package:immich_mobile/providers/theme.provider.dart';
+import 'package:immich_mobile/theme/theme_data.dart';
 
 /// Overrides the theme below the widget tree to use the theme data based on the
 /// map settings instead of the one from the app settings
-class MapThemeOveride extends StatefulHookConsumerWidget {
+class MapThemeOverride extends StatefulHookConsumerWidget {
   final ThemeMode? themeMode;
   final Widget Function(AsyncValue<String> style) mapBuilder;
 
-  const MapThemeOveride({required this.mapBuilder, this.themeMode, super.key});
+  const MapThemeOverride({required this.mapBuilder, this.themeMode, super.key});
 
   @override
-  ConsumerState createState() => _MapThemeOverideState();
+  ConsumerState createState() => _MapThemeOverrideState();
 }
 
-class _MapThemeOverideState extends ConsumerState<MapThemeOveride>
+class _MapThemeOverrideState extends ConsumerState<MapThemeOverride>
     with WidgetsBindingObserver {
   late ThemeMode _theme;
   bool _isDarkTheme = false;
diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart
index d02c016791..b856f09787 100644
--- a/mobile/lib/widgets/map/map_thumbnail.dart
+++ b/mobile/lib/widgets/map/map_thumbnail.dart
@@ -62,7 +62,7 @@ class MapThumbnail extends HookConsumerWidget {
       }
     }
 
-    return MapThemeOveride(
+    return MapThemeOverride(
       themeMode: themeMode,
       mapBuilder: (style) => SizedBox(
         height: height,
diff --git a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart
index 1c7cd1f207..119407ccad 100644
--- a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart
+++ b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart
@@ -2,12 +2,14 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/immich_colors.dart';
+import 'package:immich_mobile/constants/colors.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/extensions/theme_extensions.dart';
+import 'package:immich_mobile/providers/theme.provider.dart';
 import 'package:immich_mobile/services/app_settings.service.dart';
-import 'package:immich_mobile/utils/immich_app_theme.dart';
 import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
+import 'package:immich_mobile/theme/color_scheme.dart';
+import 'package:immich_mobile/theme/dynamic_theme.dart';
 
 class PrimaryColorSetting extends HookConsumerWidget {
   const PrimaryColorSetting({
@@ -124,7 +126,7 @@ class PrimaryColorSetting extends HookConsumerWidget {
               style: context.textTheme.titleLarge,
             ),
           ),
-          if (isDynamicThemeAvailable)
+          if (DynamicTheme.isAvailable)
             Container(
               padding: const EdgeInsets.symmetric(horizontal: 20),
               margin: const EdgeInsets.only(top: 10),
@@ -153,16 +155,16 @@ class PrimaryColorSetting extends HookConsumerWidget {
             padding: const EdgeInsets.symmetric(horizontal: 20),
             child: Wrap(
               crossAxisAlignment: WrapCrossAlignment.center,
-              children: ImmichColorPreset.values.map((themePreset) {
-                var theme = themePreset.getTheme();
+              children: ImmichColorPreset.values.map((preset) {
+                final theme = preset.themeOfPreset;
 
                 return GestureDetector(
-                  onTap: () => onPrimaryColorChange(themePreset),
+                  onTap: () => onPrimaryColorChange(preset),
                   child: buildPrimaryColorTile(
                     topColor: theme.light.primary,
                     bottomColor: theme.dark.primary,
                     tileSize: tileSize,
-                    showSelector: currentPreset.value == themePreset &&
+                    showSelector: currentPreset.value == preset &&
                         !systemPrimaryColorSetting.value,
                   ),
                 );
diff --git a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart
index 3e1f388e84..b9ba7aa7b7 100644
--- a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart
+++ b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart
@@ -3,12 +3,12 @@ import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/providers/theme.provider.dart';
 import 'package:immich_mobile/services/app_settings.service.dart';
 import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart';
 import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
 import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
 import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
-import 'package:immich_mobile/utils/immich_app_theme.dart';
 
 class ThemeSetting extends HookConsumerWidget {
   const ThemeSetting({
diff --git a/mobile/test/modules/map/map_theme_override_test.dart b/mobile/test/modules/map/map_theme_override_test.dart
index c21f9bf166..bd000c8715 100644
--- a/mobile/test/modules/map/map_theme_override_test.dart
+++ b/mobile/test/modules/map/map_theme_override_test.dart
@@ -35,7 +35,7 @@ void main() {
       (tester) async {
     AsyncValue<String>? mapStyle;
     await tester.pumpConsumerWidget(
-      MapThemeOveride(
+      MapThemeOverride(
         mapBuilder: (AsyncValue<String> style) {
           mapStyle = style;
           return const Text("Mock");
@@ -53,7 +53,7 @@ void main() {
   testWidgets("Return error when style is not fetched", (tester) async {
     AsyncValue<String>? mapStyle;
     await tester.pumpConsumerWidget(
-      MapThemeOveride(
+      MapThemeOverride(
         mapBuilder: (AsyncValue<String> style) {
           mapStyle = style;
           return const Text("Mock");
@@ -73,7 +73,7 @@ void main() {
       (tester) async {
     AsyncValue<String>? mapStyle;
     await tester.pumpConsumerWidget(
-      MapThemeOveride(
+      MapThemeOverride(
         mapBuilder: (AsyncValue<String> style) {
           mapStyle = style;
           return const Text("Mock");
@@ -94,7 +94,7 @@ void main() {
     testWidgets("Return dark theme style when system is dark", (tester) async {
       AsyncValue<String>? mapStyle;
       await tester.pumpConsumerWidget(
-        MapThemeOveride(
+        MapThemeOverride(
           mapBuilder: (AsyncValue<String> style) {
             mapStyle = style;
             return const Text("Mock");
@@ -118,7 +118,7 @@ void main() {
         (tester) async {
       AsyncValue<String>? mapStyle;
       await tester.pumpConsumerWidget(
-        MapThemeOveride(
+        MapThemeOverride(
           mapBuilder: (AsyncValue<String> style) {
             mapStyle = style;
             return const Text("Mock");
@@ -142,7 +142,7 @@ void main() {
         (tester) async {
       AsyncValue<String>? mapStyle;
       await tester.pumpConsumerWidget(
-        MapThemeOveride(
+        MapThemeOverride(
           mapBuilder: (AsyncValue<String> style) {
             mapStyle = style;
             return const Text("Mock");

From e40c7c51ee0cec49a7d1cd33f714401e0308f97f Mon Sep 17 00:00:00 2001
From: Travis Menghini <menghinitravis@gmail.com>
Date: Wed, 11 Dec 2024 10:31:11 -0600
Subject: [PATCH 15/25] feat(web): allow tags to be applied in bulk on search,
 personID, and memory-viewer pages (#14368)

* Allow Tags to be applied in bulk on search page

* Added Tags Action To PersonID Page

* Fixed Formatting Issues

* Added Tags Option to Memory-Viewer
---
 web/src/lib/components/memory-page/memory-viewer.svelte     | 6 ++++++
 .../[[photos=photos]]/[[assetId=id]]/+page.svelte           | 6 ++++++
 .../search/[[photos=photos]]/[[assetId=id]]/+page.svelte    | 6 ++++++
 3 files changed, 18 insertions(+)

diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte
index 72723670e6..65ef47c9ca 100644
--- a/web/src/lib/components/memory-page/memory-viewer.svelte
+++ b/web/src/lib/components/memory-page/memory-viewer.svelte
@@ -46,6 +46,8 @@
   import { tweened } from 'svelte/motion';
   import { derived as storeDerived } from 'svelte/store';
   import { fade } from 'svelte/transition';
+  import { preferences, user } from '$lib/stores/user.store';
+  import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
 
   type MemoryIndex = {
     memoryIndex: number;
@@ -221,6 +223,7 @@
   $effect(() => {
     handlePromiseError(handleAction(galleryInView ? 'pause' : 'play'));
   });
+  let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id));
 </script>
 
 <svelte:window
@@ -253,6 +256,9 @@
         <ChangeDate menuItem />
         <ChangeLocation menuItem />
         <ArchiveAction menuItem unarchive={isAllArchived} onArchive={handleRemove} />
+        {#if $preferences.tags.enabled && isAllUserOwned}
+          <TagAction menuItem />
+        {/if}
         <DeleteAssets menuItem onAssetDelete={handleRemove} />
       </ButtonContextMenu>
     </AssetSelectControlBar>
diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 48e194dda4..143a19dd5c 100644
--- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -58,6 +58,8 @@
   import { listNavigation } from '$lib/actions/list-navigation';
   import { t } from 'svelte-i18n';
   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
+  import { preferences, user } from '$lib/stores/user.store';
+  import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
 
   interface Props {
     data: PageData;
@@ -337,6 +339,7 @@
 
   let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived));
   let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite));
+  let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id));
 </script>
 
 {#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS}
@@ -391,6 +394,9 @@
         <ChangeDate menuItem />
         <ChangeLocation menuItem />
         <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
+        {#if $preferences.tags.enabled && isAllUserOwned}
+          <TagAction menuItem />
+        {/if}
         <DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} />
       </ButtonContextMenu>
     </AssetSelectControlBar>
diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
index c800dd7014..7372f05e77 100644
--- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -44,6 +44,8 @@
   import { t } from 'svelte-i18n';
   import { onMount, tick } from 'svelte';
   import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
+  import { preferences, user } from '$lib/stores/user.store';
+  import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
 
   const MAX_ASSET_COUNT = 5000;
   let { isViewing: showAssetViewer } = assetViewingStore;
@@ -229,6 +231,7 @@
   function getObjectKeys<T extends object>(obj: T): (keyof T)[] {
     return Object.keys(obj) as (keyof T)[];
   }
+  let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id));
 </script>
 
 <svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} bind:scrollY />
@@ -250,6 +253,9 @@
           <ChangeDate menuItem />
           <ChangeLocation menuItem />
           <ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
+          {#if $preferences.tags.enabled && isAllUserOwned}
+            <TagAction menuItem />
+          {/if}
           <DeleteAssets menuItem {onAssetDelete} />
           <hr />
           <AssetJobActions />

From 3053d84e49e2e0a8f7f54c51e5d236d1cdc2a8a2 Mon Sep 17 00:00:00 2001
From: Mert <101130780+mertalev@users.noreply.github.com>
Date: Wed, 11 Dec 2024 12:23:20 -0500
Subject: [PATCH 16/25] fix(mobile): not being able to zoom into live photos
 (#14608)

fix live photo zoom
---
 .../lib/pages/common/gallery_viewer.page.dart |  6 ++--
 .../common/native_video_viewer.page.dart      | 35 +++----------------
 .../asset_grid/immich_asset_grid_view.dart    |  2 ++
 3 files changed, 10 insertions(+), 33 deletions(-)

diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart
index 2ea446ea71..5f77f28d8e 100644
--- a/mobile/lib/pages/common/gallery_viewer.page.dart
+++ b/mobile/lib/pages/common/gallery_viewer.page.dart
@@ -61,6 +61,7 @@ class GalleryViewerPage extends HookConsumerWidget {
     final localPosition = useRef<Offset?>(null);
     final currentIndex = useValueNotifier(initialIndex);
     final loadAsset = renderList.loadAsset;
+    final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
 
     Future<void> precacheNextImage(int index) async {
       if (!context.mounted) {
@@ -249,7 +250,6 @@ class GalleryViewerPage extends HookConsumerWidget {
     }
 
     PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) {
-      ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
       var newAsset = loadAsset(index);
       final stackId = newAsset.stackId;
       if (stackId != null && currentIndex.value == index) {
@@ -260,7 +260,7 @@ class GalleryViewerPage extends HookConsumerWidget {
         }
       }
 
-      if (newAsset.isImage && !newAsset.isMotionPhoto) {
+      if (newAsset.isImage && !isPlayingMotionVideo) {
         return buildImage(context, newAsset);
       }
       return buildVideo(context, newAsset);
@@ -275,7 +275,7 @@ class GalleryViewerPage extends HookConsumerWidget {
         body: Stack(
           children: [
             PhotoViewGallery.builder(
-              key: const ValueKey('gallery'),
+              key: ValueKey(isPlayingMotionVideo),
               scaleStateChangedCallback: (state) {
                 final asset = ref.read(currentAssetProvider);
                 if (asset == null) {
diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart
index 536c7f6303..33acad0fdf 100644
--- a/mobile/lib/pages/common/native_video_viewer.page.dart
+++ b/mobile/lib/pages/common/native_video_viewer.page.dart
@@ -40,7 +40,6 @@ class NativeVideoViewerPage extends HookConsumerWidget {
     final controller = useState<NativeVideoPlayerController?>(null);
     final lastVideoPosition = useRef(-1);
     final isBuffering = useRef(false);
-    final showMotionVideo = useState(false);
 
     // When a video is opened through the timeline, `isCurrent` will immediately be true.
     // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
@@ -50,30 +49,10 @@ class NativeVideoViewerPage extends HookConsumerWidget {
     final isCurrent = currentAsset.value == asset;
 
     // Used to show the placeholder during hero animations for remote videos to avoid a stutter
-    final isVisible =
-        useState((Platform.isIOS && asset.isLocal) || asset.isMotionPhoto);
+    final isVisible = useState(Platform.isIOS && asset.isLocal);
 
     final log = Logger('NativeVideoViewerPage');
 
-    ref.listen(isPlayingMotionVideoProvider, (_, value) async {
-      final videoController = controller.value;
-      if (!asset.isMotionPhoto || videoController == null || !context.mounted) {
-        return;
-      }
-
-      showMotionVideo.value = value;
-      try {
-        if (value) {
-          await videoController.seekTo(0);
-          await videoController.play();
-        } else {
-          await videoController.pause();
-        }
-      } catch (error) {
-        log.severe('Error toggling motion video: $error');
-      }
-    });
-
     Future<VideoSource?> createSource() async {
       if (!context.mounted) {
         return null;
@@ -81,7 +60,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
 
       try {
         final local = asset.local;
-        if (local != null && !asset.isMotionPhoto) {
+        if (local != null) {
           final file = await local.file;
           if (file == null) {
             throw Exception('No file found for the video');
@@ -204,9 +183,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
       ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
 
       try {
-        if (asset.isVideo || showMotionVideo.value) {
-          await videoController.play();
-        }
+        await videoController.play();
         await videoController.setVolume(0.9);
       } catch (error) {
         log.severe('Error playing video: $error');
@@ -268,8 +245,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
         return;
       }
 
-      if (showMotionVideo.value &&
-          videoController.playbackInfo?.status == PlaybackStatus.stopped &&
+      if (videoController.playbackInfo?.status == PlaybackStatus.stopped &&
           !ref
               .read(appSettingsServiceProvider)
               .getSetting<bool>(AppSettingsEnum.loopVideo)) {
@@ -388,8 +364,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
         if (aspectRatio.value != null)
           Visibility.maintain(
             key: ValueKey(asset),
-            visible:
-                (asset.isVideo || showMotionVideo.value) && isVisible.value,
+            visible: isVisible.value,
             child: Center(
               key: ValueKey(asset),
               child: AspectRatio(
diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart
index 5670aa388f..c38e61a473 100644
--- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart
+++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart
@@ -13,6 +13,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/extensions/collection_extensions.dart';
 import 'package:immich_mobile/extensions/theme_extensions.dart';
 import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
 import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
 import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart';
@@ -206,6 +207,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
       heroOffset: widget.heroOffset,
       onAssetTap: (asset) {
         ref.read(currentAssetProvider.notifier).set(asset);
+        ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
         if (asset.isVideo) {
           ref.read(showControlsProvider.notifier).show = false;
         }

From 71b48b11e697d5f7e029a294d8614f76416a455f Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 11 Dec 2024 11:24:13 -0600
Subject: [PATCH 17/25] chore(deps): update dependency pytest-cov to v6
 (#13925)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
 machine-learning/poetry.lock | 128 +++++++++++++++++++----------------
 1 file changed, 69 insertions(+), 59 deletions(-)

diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock
index 229db1303b..bb7cd95149 100644
--- a/machine-learning/poetry.lock
+++ b/machine-learning/poetry.lock
@@ -575,63 +575,73 @@ test-no-images = ["pytest", "pytest-cov", "pytest-xdist", "wurlitzer"]
 
 [[package]]
 name = "coverage"
-version = "7.4.0"
+version = "7.6.4"
 description = "Code coverage measurement for Python"
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
 files = [
-    {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"},
-    {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"},
-    {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"},
-    {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"},
-    {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"},
-    {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"},
-    {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"},
-    {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"},
-    {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"},
-    {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"},
-    {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"},
-    {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"},
-    {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"},
-    {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"},
-    {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"},
-    {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"},
-    {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"},
-    {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"},
-    {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"},
-    {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"},
-    {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"},
-    {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"},
-    {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"},
-    {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"},
-    {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"},
-    {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"},
-    {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"},
-    {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"},
-    {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"},
-    {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"},
-    {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"},
-    {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"},
-    {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"},
-    {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"},
-    {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"},
-    {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"},
-    {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"},
-    {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"},
-    {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"},
-    {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"},
-    {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"},
-    {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"},
-    {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"},
-    {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"},
-    {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"},
-    {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"},
-    {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"},
-    {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"},
-    {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"},
-    {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"},
-    {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"},
-    {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"},
+    {file = "coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07"},
+    {file = "coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0"},
+    {file = "coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72"},
+    {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51"},
+    {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491"},
+    {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b"},
+    {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea"},
+    {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a"},
+    {file = "coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa"},
+    {file = "coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172"},
+    {file = "coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b"},
+    {file = "coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25"},
+    {file = "coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546"},
+    {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b"},
+    {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e"},
+    {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718"},
+    {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db"},
+    {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522"},
+    {file = "coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf"},
+    {file = "coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19"},
+    {file = "coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2"},
+    {file = "coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117"},
+    {file = "coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613"},
+    {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27"},
+    {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52"},
+    {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2"},
+    {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1"},
+    {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5"},
+    {file = "coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17"},
+    {file = "coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08"},
+    {file = "coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9"},
+    {file = "coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba"},
+    {file = "coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c"},
+    {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06"},
+    {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f"},
+    {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b"},
+    {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21"},
+    {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a"},
+    {file = "coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e"},
+    {file = "coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963"},
+    {file = "coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f"},
+    {file = "coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806"},
+    {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11"},
+    {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3"},
+    {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a"},
+    {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc"},
+    {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70"},
+    {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef"},
+    {file = "coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e"},
+    {file = "coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1"},
+    {file = "coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3"},
+    {file = "coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c"},
+    {file = "coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076"},
+    {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376"},
+    {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0"},
+    {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858"},
+    {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111"},
+    {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901"},
+    {file = "coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09"},
+    {file = "coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f"},
+    {file = "coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e"},
+    {file = "coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73"},
 ]
 
 [package.dependencies]
@@ -2683,17 +2693,17 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
 
 [[package]]
 name = "pytest-cov"
-version = "5.0.0"
+version = "6.0.0"
 description = "Pytest plugin for measuring coverage."
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
 files = [
-    {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"},
-    {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"},
+    {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"},
+    {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"},
 ]
 
 [package.dependencies]
-coverage = {version = ">=5.2.1", extras = ["toml"]}
+coverage = {version = ">=7.5", extras = ["toml"]}
 pytest = ">=4.6"
 
 [package.extras]

From 0c037536422ecae950eb208b6612f558785d6e8b Mon Sep 17 00:00:00 2001
From: Alex <alex.tran1502@gmail.com>
Date: Wed, 11 Dec 2024 14:51:56 -0600
Subject: [PATCH 18/25] fix(server): fix getByDayOfYear query (#14655)

* fix(server): fix getByDayOfYear query

* generate sql
---
 server/src/queries/asset.repository.sql     | 15 ++++++++++++++-
 server/src/repositories/asset.repository.ts |  2 +-
 2 files changed, 15 insertions(+), 2 deletions(-)

diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql
index f4b1b2fea1..4694cd20fc 100644
--- a/server/src/queries/asset.repository.sql
+++ b/server/src/queries/asset.repository.sql
@@ -71,7 +71,20 @@ FROM
   INNER JOIN "asset_files" "files" ON "files"."assetId" = "entity"."id"
 WHERE
   (
-    "files"."type" = $1
+    "entity"."ownerId" IN ($1)
+    AND "entity"."isVisible" = true
+    AND "entity"."isArchived" = false
+    AND EXTRACT(
+      DAY
+      FROM
+        "entity"."localDateTime" AT TIME ZONE 'UTC'
+    ) = $2
+    AND EXTRACT(
+      MONTH
+      FROM
+        "entity"."localDateTime" AT TIME ZONE 'UTC'
+    ) = $3
+    AND "files"."type" = $4
     AND EXTRACT(
       YEAR
       FROM
diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts
index b3066a37bc..33d1e2457e 100644
--- a/server/src/repositories/asset.repository.ts
+++ b/server/src/repositories/asset.repository.ts
@@ -92,7 +92,7 @@ export class AssetRepository implements IAssetRepository {
       )
       .leftJoinAndSelect('entity.exifInfo', 'exifInfo')
       .innerJoinAndSelect('entity.files', 'files')
-      .where('files.type = :type', { type: AssetFileType.THUMBNAIL })
+      .andWhere('files.type = :type', { type: AssetFileType.THUMBNAIL })
       .andWhere(
         `EXTRACT(YEAR FROM CURRENT_DATE AT TIME ZONE 'UTC') - EXTRACT(YEAR FROM entity.localDateTime AT TIME ZONE 'UTC') > 0`,
       )

From c52f1bae81aae8d0c86b2cf3f9384eb92e39ab36 Mon Sep 17 00:00:00 2001
From: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 11 Dec 2024 21:15:03 +0000
Subject: [PATCH 19/25] chore: version v1.122.3

---
 cli/package-lock.json                       |   6 +++---
 cli/package.json                            |   2 +-
 docs/static/archived-versions.json          |   4 ++++
 e2e/package-lock.json                       |   8 ++++----
 e2e/package.json                            |   2 +-
 machine-learning/pyproject.toml             |   2 +-
 mobile/android/fastlane/Fastfile            |   4 ++--
 mobile/ios/fastlane/Fastfile                |   2 +-
 mobile/openapi/README.md                    | Bin 32447 -> 32447 bytes
 mobile/pubspec.yaml                         |   2 +-
 open-api/immich-openapi-specs.json          |   2 +-
 open-api/typescript-sdk/package-lock.json   |   4 ++--
 open-api/typescript-sdk/package.json        |   2 +-
 open-api/typescript-sdk/src/fetch-client.ts |   2 +-
 server/package-lock.json                    |   4 ++--
 server/package.json                         |   2 +-
 web/package-lock.json                       |   6 +++---
 web/package.json                            |   2 +-
 18 files changed, 30 insertions(+), 26 deletions(-)

diff --git a/cli/package-lock.json b/cli/package-lock.json
index 03b8061efb..137565a22d 100644
--- a/cli/package-lock.json
+++ b/cli/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@immich/cli",
-  "version": "2.2.35",
+  "version": "2.2.36",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@immich/cli",
-      "version": "2.2.35",
+      "version": "2.2.36",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
     },
     "../open-api/typescript-sdk": {
       "name": "@immich/sdk",
-      "version": "1.122.2",
+      "version": "1.122.3",
       "dev": true,
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
diff --git a/cli/package.json b/cli/package.json
index b58825b2b9..9b3a417385 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@immich/cli",
-  "version": "2.2.35",
+  "version": "2.2.36",
   "description": "Command Line Interface (CLI) for Immich",
   "type": "module",
   "exports": "./dist/index.js",
diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json
index 7ba9125c03..960e00caa9 100644
--- a/docs/static/archived-versions.json
+++ b/docs/static/archived-versions.json
@@ -1,4 +1,8 @@
 [
+  {
+    "label": "v1.122.3",
+    "url": "https://v1.122.3.archive.immich.app"
+  },
   {
     "label": "v1.122.2",
     "url": "https://v1.122.2.archive.immich.app"
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
index 011e6b2fdd..ec20557358 100644
--- a/e2e/package-lock.json
+++ b/e2e/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "immich-e2e",
-  "version": "1.122.2",
+  "version": "1.122.3",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "immich-e2e",
-      "version": "1.122.2",
+      "version": "1.122.3",
       "license": "GNU Affero General Public License version 3",
       "devDependencies": {
         "@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
     },
     "../cli": {
       "name": "@immich/cli",
-      "version": "2.2.35",
+      "version": "2.2.36",
       "dev": true,
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
@@ -92,7 +92,7 @@
     },
     "../open-api/typescript-sdk": {
       "name": "@immich/sdk",
-      "version": "1.122.2",
+      "version": "1.122.3",
       "dev": true,
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
diff --git a/e2e/package.json b/e2e/package.json
index a47b4bbae9..12316e910c 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -1,6 +1,6 @@
 {
   "name": "immich-e2e",
-  "version": "1.122.2",
+  "version": "1.122.3",
   "description": "",
   "main": "index.js",
   "type": "module",
diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml
index cff2a432b3..0f8186c41f 100644
--- a/machine-learning/pyproject.toml
+++ b/machine-learning/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "machine-learning"
-version = "1.122.2"
+version = "1.122.3"
 description = ""
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 readme = "README.md"
diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile
index 4fc00ce6c7..f3c30770e1 100644
--- a/mobile/android/fastlane/Fastfile
+++ b/mobile/android/fastlane/Fastfile
@@ -35,8 +35,8 @@ platform :android do
       task: 'bundle', 
       build_type: 'Release',
       properties: {
-        "android.injected.version.code" => 170,
-        "android.injected.version.name" => "1.122.2",
+        "android.injected.version.code" => 171,
+        "android.injected.version.name" => "1.122.3",
       }
     )
     upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile
index d7604b4283..0574a5e78f 100644
--- a/mobile/ios/fastlane/Fastfile
+++ b/mobile/ios/fastlane/Fastfile
@@ -19,7 +19,7 @@ platform :ios do
   desc "iOS Release"
   lane :release do
     increment_version_number(
-      version_number: "1.122.2"
+      version_number: "1.122.3"
     )
     increment_build_number(
       build_number: latest_testflight_build_number + 1,
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index d9e10bc316bf4dadd00e5cb55b9f8458e7eb5f68..6bacbc74233b3ebf6320fac7dbb020c4edfdb2b3 100644
GIT binary patch
delta 14
Wcmdo0mvR4J#tCy7jW^CytOEczvj)ll

delta 14
Wcmdo0mvR4J#tCy7jW*6xtOEczt_H~f

diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 39621a953e..c1cf25d008 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -2,7 +2,7 @@ name: immich_mobile
 description: Immich - selfhosted backup media file on mobile phone
 
 publish_to: 'none'
-version: 1.122.2+170
+version: 1.122.3+171
 
 environment:
   sdk: '>=3.3.0 <4.0.0'
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 706d6a28ee..3afda881cd 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -7436,7 +7436,7 @@
   "info": {
     "title": "Immich",
     "description": "Immich API",
-    "version": "1.122.2",
+    "version": "1.122.3",
     "contact": {}
   },
   "tags": [],
diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json
index c0d5d329d1..fa7d83feb5 100644
--- a/open-api/typescript-sdk/package-lock.json
+++ b/open-api/typescript-sdk/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@immich/sdk",
-  "version": "1.122.2",
+  "version": "1.122.3",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@immich/sdk",
-      "version": "1.122.2",
+      "version": "1.122.3",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@oazapfts/runtime": "^1.0.2"
diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json
index 22f480e68d..7a812e87fb 100644
--- a/open-api/typescript-sdk/package.json
+++ b/open-api/typescript-sdk/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@immich/sdk",
-  "version": "1.122.2",
+  "version": "1.122.3",
   "description": "Auto-generated TypeScript SDK for the Immich API",
   "type": "module",
   "main": "./build/index.js",
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index 68f44d7bed..7770f0c578 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -1,6 +1,6 @@
 /**
  * Immich
- * 1.122.2
+ * 1.122.3
  * DO NOT MODIFY - This file has been generated using oazapfts.
  * See https://www.npmjs.com/package/oazapfts
  */
diff --git a/server/package-lock.json b/server/package-lock.json
index 4ad00c90f7..6d898a9735 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "immich",
-  "version": "1.122.2",
+  "version": "1.122.3",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "immich",
-      "version": "1.122.2",
+      "version": "1.122.3",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@nestjs/bullmq": "^10.0.1",
diff --git a/server/package.json b/server/package.json
index a7005deafa..385cbccd3d 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
 {
   "name": "immich",
-  "version": "1.122.2",
+  "version": "1.122.3",
   "description": "",
   "author": "",
   "private": true,
diff --git a/web/package-lock.json b/web/package-lock.json
index cab21cd4dc..f3c1f4b12e 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "immich-web",
-  "version": "1.122.2",
+  "version": "1.122.3",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "immich-web",
-      "version": "1.122.2",
+      "version": "1.122.3",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@formatjs/icu-messageformat-parser": "^2.7.8",
@@ -74,7 +74,7 @@
     },
     "../open-api/typescript-sdk": {
       "name": "@immich/sdk",
-      "version": "1.122.2",
+      "version": "1.122.3",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@oazapfts/runtime": "^1.0.2"
diff --git a/web/package.json b/web/package.json
index 2bd429cc1a..c35344c12f 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
 {
   "name": "immich-web",
-  "version": "1.122.2",
+  "version": "1.122.3",
   "license": "GNU Affero General Public License version 3",
   "scripts": {
     "dev": "vite dev --host 0.0.0.0 --port 3000",

From 11be85feb3016fd9007e8390034e0d24c1a4480b Mon Sep 17 00:00:00 2001
From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Date: Thu, 12 Dec 2024 16:48:50 +0100
Subject: [PATCH 20/25] fix(web): live photo link action (#14668)

---
 web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index 7e233fcd17..b76143142e 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -49,7 +49,7 @@
     const isLivePhotoCandidate =
       selection.length === 2 &&
       selection.some((asset) => asset.type === AssetTypeEnum.Image) &&
-      selection.some((asset) => asset.type === AssetTypeEnum.Image);
+      selection.some((asset) => asset.type === AssetTypeEnum.Video);
     isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate);
   });
 

From 58d63d9f1ce3a27a2562ca9f427393d7039e8c68 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 12 Dec 2024 10:10:51 -0600
Subject: [PATCH 21/25] chore(deps): update grafana/grafana docker tag to
 v11.4.0 (#14633)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
 docker/docker-compose.prod.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml
index d58b20ef76..8521390079 100644
--- a/docker/docker-compose.prod.yml
+++ b/docker/docker-compose.prod.yml
@@ -103,7 +103,7 @@ services:
     command: ['./run.sh', '-disable-reporting']
     ports:
       - 3000:3000
-    image: grafana/grafana:11.3.1-ubuntu@sha256:7ca40d20250157abd70a907a93617a70c9b0ad9d7e59e8e6b5c8140781350d6a
+    image: grafana/grafana:11.4.0-ubuntu@sha256:afccec22ba0e4815cca1d2bf3836e414322390dc78d77f1851976ffa8d61051c
     volumes:
       - grafana-data:/var/lib/grafana
 

From 59d6af54c7a22a66c2212bed85dc8f05a7150b11 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 12 Dec 2024 10:12:44 -0600
Subject: [PATCH 22/25] chore(deps): update node.js to v22.12.0 (#14650)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
 cli/.nvmrc                           | 2 +-
 cli/package.json                     | 2 +-
 docs/.nvmrc                          | 2 +-
 docs/package.json                    | 2 +-
 e2e/.nvmrc                           | 2 +-
 e2e/package.json                     | 2 +-
 open-api/typescript-sdk/.nvmrc       | 2 +-
 open-api/typescript-sdk/package.json | 2 +-
 server/.nvmrc                        | 2 +-
 server/package.json                  | 2 +-
 web/.nvmrc                           | 2 +-
 web/package.json                     | 2 +-
 12 files changed, 12 insertions(+), 12 deletions(-)

diff --git a/cli/.nvmrc b/cli/.nvmrc
index 7af24b7ddb..1d9b7831ba 100644
--- a/cli/.nvmrc
+++ b/cli/.nvmrc
@@ -1 +1 @@
-22.11.0
+22.12.0
diff --git a/cli/package.json b/cli/package.json
index 9b3a417385..86f54cc342 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -67,6 +67,6 @@
     "lodash-es": "^4.17.21"
   },
   "volta": {
-    "node": "22.11.0"
+    "node": "22.12.0"
   }
 }
diff --git a/docs/.nvmrc b/docs/.nvmrc
index 7af24b7ddb..1d9b7831ba 100644
--- a/docs/.nvmrc
+++ b/docs/.nvmrc
@@ -1 +1 @@
-22.11.0
+22.12.0
diff --git a/docs/package.json b/docs/package.json
index 6b0595a7b0..498a0c4d7c 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -56,6 +56,6 @@
     "node": ">=20"
   },
   "volta": {
-    "node": "22.11.0"
+    "node": "22.12.0"
   }
 }
diff --git a/e2e/.nvmrc b/e2e/.nvmrc
index 7af24b7ddb..1d9b7831ba 100644
--- a/e2e/.nvmrc
+++ b/e2e/.nvmrc
@@ -1 +1 @@
-22.11.0
+22.12.0
diff --git a/e2e/package.json b/e2e/package.json
index 12316e910c..9e9ce3b362 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -53,6 +53,6 @@
     "vitest": "^2.0.5"
   },
   "volta": {
-    "node": "22.11.0"
+    "node": "22.12.0"
   }
 }
diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc
index 7af24b7ddb..1d9b7831ba 100644
--- a/open-api/typescript-sdk/.nvmrc
+++ b/open-api/typescript-sdk/.nvmrc
@@ -1 +1 @@
-22.11.0
+22.12.0
diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json
index 7a812e87fb..c05d21b696 100644
--- a/open-api/typescript-sdk/package.json
+++ b/open-api/typescript-sdk/package.json
@@ -28,6 +28,6 @@
     "directory": "open-api/typescript-sdk"
   },
   "volta": {
-    "node": "22.11.0"
+    "node": "22.12.0"
   }
 }
diff --git a/server/.nvmrc b/server/.nvmrc
index 7af24b7ddb..1d9b7831ba 100644
--- a/server/.nvmrc
+++ b/server/.nvmrc
@@ -1 +1 @@
-22.11.0
+22.12.0
diff --git a/server/package.json b/server/package.json
index 385cbccd3d..f57c0e557b 100644
--- a/server/package.json
+++ b/server/package.json
@@ -139,6 +139,6 @@
     "vitest": "^2.0.5"
   },
   "volta": {
-    "node": "22.11.0"
+    "node": "22.12.0"
   }
 }
diff --git a/web/.nvmrc b/web/.nvmrc
index 7af24b7ddb..1d9b7831ba 100644
--- a/web/.nvmrc
+++ b/web/.nvmrc
@@ -1 +1 @@
-22.11.0
+22.12.0
diff --git a/web/package.json b/web/package.json
index c35344c12f..84158674a8 100644
--- a/web/package.json
+++ b/web/package.json
@@ -87,6 +87,6 @@
     "thumbhash": "^0.1.1"
   },
   "volta": {
-    "node": "22.11.0"
+    "node": "22.12.0"
   }
 }

From 6abe696d0bee48f82dd5d33416e1db414f212749 Mon Sep 17 00:00:00 2001
From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Date: Thu, 12 Dec 2024 17:13:42 +0100
Subject: [PATCH 23/25] fix(web): allow minimizing upload panel (#14663)

---
 .../components/shared-components/upload-panel.svelte | 12 +-----------
 1 file changed, 1 insertion(+), 11 deletions(-)

diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte
index 7dd6d25596..2381b5a423 100644
--- a/web/src/lib/components/shared-components/upload-panel.svelte
+++ b/web/src/lib/components/shared-components/upload-panel.svelte
@@ -17,19 +17,9 @@
 
   let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore;
 
-  const autoHide = () => {
-    if (!$isUploading && showDetail) {
-      showDetail = false;
-    }
-
-    if ($isUploading && !showDetail) {
-      showDetail = true;
-    }
-  };
-
   $effect(() => {
     if ($isUploading) {
-      autoHide();
+      showDetail = true;
     }
   });
 </script>

From 40a0bf6ad57a4524ad28de28795769218efa66ec Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 12 Dec 2024 10:14:34 -0600
Subject: [PATCH 24/25] chore(deps): update terraform cloudflare to v4.48.0
 (#14669)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
 .../docs-release/.terraform.lock.hcl          | 60 +++++++++----------
 .../modules/cloudflare/docs-release/config.tf |  2 +-
 .../cloudflare/docs/.terraform.lock.hcl       | 60 +++++++++----------
 deployment/modules/cloudflare/docs/config.tf  |  2 +-
 4 files changed, 62 insertions(+), 62 deletions(-)

diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl
index 160c4f7ba5..00222921f1 100644
--- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl
+++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl
@@ -2,37 +2,37 @@
 # Manual edits may be lost in future updates.
 
 provider "registry.opentofu.org/cloudflare/cloudflare" {
-  version     = "4.46.0"
-  constraints = "4.46.0"
+  version     = "4.48.0"
+  constraints = "4.48.0"
   hashes = [
-    "h1:3U4N3bbMacXTAdyaEwT305kETMETh1jZmGApmN6gdyE=",
-    "h1:3fhZhGNgtS9ugcZ2CIH6kk8LzN6yPxqOdkDUZqkP3+w=",
-    "h1:JWluJxBRSr8GVUhWVv83xse9SmbpwCLctCDddMXUnVk=",
-    "h1:KDHwakGt+3iBKXaoALCCAolPaJgpEHbkh3BfjnpuqoM=",
-    "h1:QFFZshAvwr9L5TQmsNQC6/sDqokk5pjbP8Ae4BQqMLQ=",
-    "h1:Qdi+vXwzDNii7ytSaOQtnlqhjZ3ZlRoUkFoi6CD2COI=",
-    "h1:TPcJXcVb/+C91hUuu8CEn98QUoNgLtnHfd4sgAOV+5k=",
-    "h1:WDy5wiNroXaCnw+r8rJnCP+J1RVsm2Qu3AOZ/iV4lLo=",
-    "h1:hMuL+dwHj3JbePqYcDrn/ZQN9R0WzeJX0AIDJ02Iteo=",
-    "h1:hQKCaUEARzJKbFt1CePP06E/+CiHWe/H6lc1AwK7y6w=",
-    "h1:l4DQ3WXmSzR/GBel3m2CRKWtaziVjBoxvUgL63t1GK0=",
-    "h1:nN9uVSLyrb/DjfZl6rPtCq5j0TX+6WypzNDexdzCQ08=",
-    "h1:rAX7njl6lKT9XIKMk6pLjVi7u/42wafRolWWgMHMkI0=",
-    "h1:t2IQYNu8YNykqYlEB+TTX+XpUd5z2flwGw8km9UgbnQ=",
-    "zh:2ee426ef3389022db0026792fdc4f2980dcf2600e31adf5a31b4bddfa8d68343",
-    "zh:2f993edb23df55dc1c18150fa187d80aa7d87e6439698ee34b6a6aad23ac2dd7",
-    "zh:3d6601333975e55979b1b454e50ff9a482ce4e0269dd6c72a50202163a8f4463",
-    "zh:4e5f48dce22f7a6d618018d65d1d443bb718defa23f514d5c6385860541fbe79",
-    "zh:5ebf5aea960fc30de381ffd6db20876d249673cf938fe67f1dfb6b9caa1db418",
-    "zh:80ed3fb901141f53b4b56ddb7eea5f2e0c0830d501387539d2c2b8e0cc7e587a",
+    "h1:0IKUOR32xEI1suS5QCOjfxjQ2mRd058btXk8hVnaOJ4=",
+    "h1:3YG6vu/bFPcYOeLdSUZhiAWiWKaFlOAR34z2o8cbE9k=",
+    "h1:FvGy06/i9AMtVkSIUnCrXNv5xF6jqBqMH8oPVLyeeAg=",
+    "h1:GXH7nIF0ocMqebbA41+fSGIYfM+VAM/PvTe7fJr8UrQ=",
+    "h1:H0ll0ph4404vFE868W3qJ3zhOyy4jbXrOMtdkViEZsU=",
+    "h1:SX42e3k73IcFcrQlZ2e/Veqt2tvCMy6fwlo5yNUktCE=",
+    "h1:Uu/gjBc99GefdPdSrlBwU75DWU0ZcwGcrd3ZFyTeL0s=",
+    "h1:VZw0uN41PWRmNlhg7Ze0Eh7cdoklX1oZbfNAXNYnU1I=",
+    "h1:cMdV7ql6PsFa4qtb0EoZSctvTaTqV7yplBSDwcLRCLc=",
+    "h1:ePGvSurmlqOCkD761vkhRmz7bsK36/EnIvx2Xy8TdXo=",
+    "h1:fOYufF+1bzw2N3aHLpkLB6E8VbZ4ysXDODYQOlwhwd4=",
+    "h1:qe8RbnWq0T4xhqjn9QcbO6YW5YDx47P+eJ0NUMIfwCc=",
+    "h1:tRD2av6PafHDP/b9jDQsG5/aX+lHeKxpbIEHYYLBVUc=",
+    "h1:zyl6Gvx/CFpwYW8pFFDesfO8Lxv+a6CopyAsIMhp54s=",
+    "zh:04c0a49c2b23140b2f21cfd0d52f9798d70d3bdae3831613e156aabe519bbc6c",
+    "zh:185f21b4834ba63e8df1f84aa34639d8a7e126429a4007bb5f9ad82f2602a997",
+    "zh:234724f52cb4c0c3f7313d3b2697caef26d921d134f26ae14801e7afac522f7b",
+    "zh:38a56fcd1b3e40706af995611c977816543b53f1e55fe2720944aae2b6828fcb",
+    "zh:419938f5430fc78eff933470aefbf94a460a478f867cf7761a3dea177b4eb153",
+    "zh:4b46d92bfde1deab7de7ba1a6bbf4ba7c711e4fd925341ddf09d4cc28dae03d8",
+    "zh:537acd4a31c752f1bae305ba7190f60b71ad1a459f22d464f3f914336c9e919f",
+    "zh:5ff36b005aad07697dd0b30d4f0c35dbcdc30dc52b41722552060792fa87ce04",
+    "zh:635c5ee419daea098060f794d9d7d999275301181e49562c4e4c08f043076937",
+    "zh:859277c330d61f91abe9e799389467ca11b77131bf34bedbef52f8da68b2bb49",
     "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
-    "zh:9aeae8b3be4a577ced46987fd9159262c5b4c54a510f66592fbcdb40fef55b10",
-    "zh:a0479ef2d308c4a7894f1fe77467cd07e04c7b40d281088f4f204af1bdf94ac6",
-    "zh:a2bdc0c25130665af0b9559942b9813a1ba4889513e7185d4abc9c02e9bb99bd",
-    "zh:b10be9755fe80395ced6f0bbda38b8c8681714cf1eca1d895be239c75c2ffc2a",
-    "zh:ba3d55e722d9f48646574ce7c448f0084fe21fa884b5f8b6d6146a82a99c4baa",
-    "zh:ec1fd0ecaedc787a77d5342b51ae8dea8362a67f1e19123f6521a0e8e012d9e8",
-    "zh:ed49590e69faef14550179f965b4451b31415b8f6be6d33427ad48f65c76b6cf",
-    "zh:f4baa3a2dac719ad20dcfa525bc3f737ad95650b8d0de0c648dc9a87f993b2c3",
+    "zh:927dfdb8d9aef37ead03fceaa29e87ba076a3dd24e19b6cefdbb0efe9987ff8c",
+    "zh:bbf2226f07f6b1e721877328e69ded4b64f9c196634d2e2429e3cfabbe41e532",
+    "zh:daeed873d6f38604232b46ee4a5830c85d195b967f8dbcafe2fcffa98daf9c5f",
+    "zh:f8f2fc4646c1ba44085612fa7f4dbb7cbcead43b4e661f2b98ddfb4f68afc758",
   ]
 }
diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf
index f06c083bb0..c5397ea410 100644
--- a/deployment/modules/cloudflare/docs-release/config.tf
+++ b/deployment/modules/cloudflare/docs-release/config.tf
@@ -5,7 +5,7 @@ terraform {
   required_providers {
     cloudflare = {
       source = "cloudflare/cloudflare"
-      version = "4.46.0"
+      version = "4.48.0"
     }
   }
 }
diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl
index 160c4f7ba5..00222921f1 100644
--- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl
+++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl
@@ -2,37 +2,37 @@
 # Manual edits may be lost in future updates.
 
 provider "registry.opentofu.org/cloudflare/cloudflare" {
-  version     = "4.46.0"
-  constraints = "4.46.0"
+  version     = "4.48.0"
+  constraints = "4.48.0"
   hashes = [
-    "h1:3U4N3bbMacXTAdyaEwT305kETMETh1jZmGApmN6gdyE=",
-    "h1:3fhZhGNgtS9ugcZ2CIH6kk8LzN6yPxqOdkDUZqkP3+w=",
-    "h1:JWluJxBRSr8GVUhWVv83xse9SmbpwCLctCDddMXUnVk=",
-    "h1:KDHwakGt+3iBKXaoALCCAolPaJgpEHbkh3BfjnpuqoM=",
-    "h1:QFFZshAvwr9L5TQmsNQC6/sDqokk5pjbP8Ae4BQqMLQ=",
-    "h1:Qdi+vXwzDNii7ytSaOQtnlqhjZ3ZlRoUkFoi6CD2COI=",
-    "h1:TPcJXcVb/+C91hUuu8CEn98QUoNgLtnHfd4sgAOV+5k=",
-    "h1:WDy5wiNroXaCnw+r8rJnCP+J1RVsm2Qu3AOZ/iV4lLo=",
-    "h1:hMuL+dwHj3JbePqYcDrn/ZQN9R0WzeJX0AIDJ02Iteo=",
-    "h1:hQKCaUEARzJKbFt1CePP06E/+CiHWe/H6lc1AwK7y6w=",
-    "h1:l4DQ3WXmSzR/GBel3m2CRKWtaziVjBoxvUgL63t1GK0=",
-    "h1:nN9uVSLyrb/DjfZl6rPtCq5j0TX+6WypzNDexdzCQ08=",
-    "h1:rAX7njl6lKT9XIKMk6pLjVi7u/42wafRolWWgMHMkI0=",
-    "h1:t2IQYNu8YNykqYlEB+TTX+XpUd5z2flwGw8km9UgbnQ=",
-    "zh:2ee426ef3389022db0026792fdc4f2980dcf2600e31adf5a31b4bddfa8d68343",
-    "zh:2f993edb23df55dc1c18150fa187d80aa7d87e6439698ee34b6a6aad23ac2dd7",
-    "zh:3d6601333975e55979b1b454e50ff9a482ce4e0269dd6c72a50202163a8f4463",
-    "zh:4e5f48dce22f7a6d618018d65d1d443bb718defa23f514d5c6385860541fbe79",
-    "zh:5ebf5aea960fc30de381ffd6db20876d249673cf938fe67f1dfb6b9caa1db418",
-    "zh:80ed3fb901141f53b4b56ddb7eea5f2e0c0830d501387539d2c2b8e0cc7e587a",
+    "h1:0IKUOR32xEI1suS5QCOjfxjQ2mRd058btXk8hVnaOJ4=",
+    "h1:3YG6vu/bFPcYOeLdSUZhiAWiWKaFlOAR34z2o8cbE9k=",
+    "h1:FvGy06/i9AMtVkSIUnCrXNv5xF6jqBqMH8oPVLyeeAg=",
+    "h1:GXH7nIF0ocMqebbA41+fSGIYfM+VAM/PvTe7fJr8UrQ=",
+    "h1:H0ll0ph4404vFE868W3qJ3zhOyy4jbXrOMtdkViEZsU=",
+    "h1:SX42e3k73IcFcrQlZ2e/Veqt2tvCMy6fwlo5yNUktCE=",
+    "h1:Uu/gjBc99GefdPdSrlBwU75DWU0ZcwGcrd3ZFyTeL0s=",
+    "h1:VZw0uN41PWRmNlhg7Ze0Eh7cdoklX1oZbfNAXNYnU1I=",
+    "h1:cMdV7ql6PsFa4qtb0EoZSctvTaTqV7yplBSDwcLRCLc=",
+    "h1:ePGvSurmlqOCkD761vkhRmz7bsK36/EnIvx2Xy8TdXo=",
+    "h1:fOYufF+1bzw2N3aHLpkLB6E8VbZ4ysXDODYQOlwhwd4=",
+    "h1:qe8RbnWq0T4xhqjn9QcbO6YW5YDx47P+eJ0NUMIfwCc=",
+    "h1:tRD2av6PafHDP/b9jDQsG5/aX+lHeKxpbIEHYYLBVUc=",
+    "h1:zyl6Gvx/CFpwYW8pFFDesfO8Lxv+a6CopyAsIMhp54s=",
+    "zh:04c0a49c2b23140b2f21cfd0d52f9798d70d3bdae3831613e156aabe519bbc6c",
+    "zh:185f21b4834ba63e8df1f84aa34639d8a7e126429a4007bb5f9ad82f2602a997",
+    "zh:234724f52cb4c0c3f7313d3b2697caef26d921d134f26ae14801e7afac522f7b",
+    "zh:38a56fcd1b3e40706af995611c977816543b53f1e55fe2720944aae2b6828fcb",
+    "zh:419938f5430fc78eff933470aefbf94a460a478f867cf7761a3dea177b4eb153",
+    "zh:4b46d92bfde1deab7de7ba1a6bbf4ba7c711e4fd925341ddf09d4cc28dae03d8",
+    "zh:537acd4a31c752f1bae305ba7190f60b71ad1a459f22d464f3f914336c9e919f",
+    "zh:5ff36b005aad07697dd0b30d4f0c35dbcdc30dc52b41722552060792fa87ce04",
+    "zh:635c5ee419daea098060f794d9d7d999275301181e49562c4e4c08f043076937",
+    "zh:859277c330d61f91abe9e799389467ca11b77131bf34bedbef52f8da68b2bb49",
     "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
-    "zh:9aeae8b3be4a577ced46987fd9159262c5b4c54a510f66592fbcdb40fef55b10",
-    "zh:a0479ef2d308c4a7894f1fe77467cd07e04c7b40d281088f4f204af1bdf94ac6",
-    "zh:a2bdc0c25130665af0b9559942b9813a1ba4889513e7185d4abc9c02e9bb99bd",
-    "zh:b10be9755fe80395ced6f0bbda38b8c8681714cf1eca1d895be239c75c2ffc2a",
-    "zh:ba3d55e722d9f48646574ce7c448f0084fe21fa884b5f8b6d6146a82a99c4baa",
-    "zh:ec1fd0ecaedc787a77d5342b51ae8dea8362a67f1e19123f6521a0e8e012d9e8",
-    "zh:ed49590e69faef14550179f965b4451b31415b8f6be6d33427ad48f65c76b6cf",
-    "zh:f4baa3a2dac719ad20dcfa525bc3f737ad95650b8d0de0c648dc9a87f993b2c3",
+    "zh:927dfdb8d9aef37ead03fceaa29e87ba076a3dd24e19b6cefdbb0efe9987ff8c",
+    "zh:bbf2226f07f6b1e721877328e69ded4b64f9c196634d2e2429e3cfabbe41e532",
+    "zh:daeed873d6f38604232b46ee4a5830c85d195b967f8dbcafe2fcffa98daf9c5f",
+    "zh:f8f2fc4646c1ba44085612fa7f4dbb7cbcead43b4e661f2b98ddfb4f68afc758",
   ]
 }
diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf
index f06c083bb0..c5397ea410 100644
--- a/deployment/modules/cloudflare/docs/config.tf
+++ b/deployment/modules/cloudflare/docs/config.tf
@@ -5,7 +5,7 @@ terraform {
   required_providers {
     cloudflare = {
       source = "cloudflare/cloudflare"
-      version = "4.46.0"
+      version = "4.48.0"
     }
   }
 }

From 39732f3371e8a92e46c22b332b7c73013510d5f2 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 12 Dec 2024 16:28:27 +0000
Subject: [PATCH 25/25] chore(deps): update base-image to v20241210 (major)
 (#14670)

chore(deps): update base-image to v20241210

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
 server/Dockerfile | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/server/Dockerfile b/server/Dockerfile
index 37b80ff1ee..9b510b72cc 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,5 +1,5 @@
 # dev build
-FROM ghcr.io/immich-app/base-server-dev:20241119@sha256:fef1bead6a594ebd6fa54712c3dc4db050173657738db0c21bb91b00f8b56320 AS dev
+FROM ghcr.io/immich-app/base-server-dev:20241210@sha256:35c28404b508fc0741fffb39a9de17e3a6acdff0b623bb7b6cdf4a427462a0e2 AS dev
 
 RUN apt-get install --no-install-recommends -yqq tini
 WORKDIR /usr/src/app
@@ -42,7 +42,7 @@ RUN npm run build
 
 
 # prod build
-FROM ghcr.io/immich-app/base-server-prod:20241119@sha256:0ab6c3d0d41924fba45f92c383bcf405abda338602d1140d151963bbbb088759
+FROM ghcr.io/immich-app/base-server-prod:20241210@sha256:076d002070385bc6dc7454ef9419f44341c074bcfc49be5deddbdb4108ae0060
 
 WORKDIR /usr/src/app
 ENV NODE_ENV=production \