From 5053130e351f691b7aef5410805e8e881e0ac237 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Wed, 26 Feb 2025 14:35:51 +0000 Subject: [PATCH 001/104] fix: sync set ack validation (#16320) --- server/src/services/sync.service.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index b94e8cfcbf..98e4d5fb09 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, Injectable } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { Insertable } from 'kysely'; import { DateTime } from 'luxon'; import { Writable } from 'node:stream'; @@ -43,8 +43,6 @@ export class SyncService extends BaseService { } async setAcks(auth: AuthDto, dto: SyncAckSetDto) { - // TODO ack validation - const sessionId = auth.session?.id; if (!sessionId) { return throwSessionRequired(); @@ -53,6 +51,10 @@ export class SyncService extends BaseService { const checkpoints: Insertable[] = []; for (const ack of dto.acks) { const { type } = fromAck(ack); + // TODO proper ack validation via class validator + if (!Object.values(SyncEntityType).includes(type)) { + throw new BadRequestException(`Invalid ack type: ${type}`); + } checkpoints.push({ sessionId, type, ack }); } From 5f7f88ff17ec4869a052fa992033f62a80152389 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:18:50 +0000 Subject: [PATCH 002/104] chore: version v1.127.0 --- 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 | 2 +- mobile/openapi/devtools_options.yaml | 3 --- 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 +- 19 files changed, 31 insertions(+), 30 deletions(-) delete mode 100644 mobile/openapi/devtools_options.yaml diff --git a/cli/package-lock.json b/cli/package-lock.json index 3d8cf35459..e84738e519 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.50", + "version": "2.2.51", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.50", + "version": "2.2.51", "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.126.1", + "version": "1.127.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 2f0ddde3e4..f902ff0089 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.50", + "version": "2.2.51", "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 516a978f50..4a3a1a6b60 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.127.0", + "url": "https://v1.127.0.archive.immich.app" + }, { "label": "v1.126.1", "url": "https://v1.126.1.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index c32beef97f..df4e01b4d9 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.126.1", + "version": "1.127.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.126.1", + "version": "1.127.0", "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.50", + "version": "2.2.51", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.126.1", + "version": "1.127.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index c0d9ec424e..b1f4b79137 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.126.1", + "version": "1.127.0", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 7446435388..032ced81b1 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.126.1" +version = "1.127.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index f46a0dd4ec..ecda716fe2 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" => 184, - "android.injected.version.name" => "1.126.1", + "android.injected.version.code" => 185, + "android.injected.version.name" => "1.127.0", } ) 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 2f5616deb8..43dc346284 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.126.1" + version_number: "1.127.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 80d85bac9a..6e11640f4f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.126.1 +- API version: 1.127.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/openapi/devtools_options.yaml b/mobile/openapi/devtools_options.yaml deleted file mode 100644 index fa0b357c4f..0000000000 --- a/mobile/openapi/devtools_options.yaml +++ /dev/null @@ -1,3 +0,0 @@ -description: This file stores settings for Dart & Flutter DevTools. -documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states -extensions: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index dd046a60eb..1191612363 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.126.1+184 +version: 1.127.0+185 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 e8bdfa7405..6a57001085 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7655,7 +7655,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.126.1", + "version": "1.127.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 9cdb5f991f..0db9c31fff 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.126.1", + "version": "1.127.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.126.1", + "version": "1.127.0", "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 be06fbdc4d..c76165fae9 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.126.1", + "version": "1.127.0", "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 bb97dbaf78..7786c09d9a 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.126.1 + * 1.127.0 * 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 ce39195f22..50ef7943f3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.126.1", + "version": "1.127.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.126.1", + "version": "1.127.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.1", diff --git a/server/package.json b/server/package.json index fa0be7d4e4..9f13976d10 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.126.1", + "version": "1.127.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 5b3373628a..cb84fb2cea 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.126.1", + "version": "1.127.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.126.1", + "version": "1.127.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -78,7 +78,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.126.1", + "version": "1.127.0", "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 c5016640de..a5774bbbe7 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.126.1", + "version": "1.127.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From c055e1aefecd0cfe15e2ff8f9423de0037722340 Mon Sep 17 00:00:00 2001 From: luzpaz Date: Wed, 26 Feb 2025 12:21:27 -0500 Subject: [PATCH 003/104] docs: fix typos (#16352) Found via `codespell -q 3 -S "./i18n,./docs/package-lock.json,./readme_i18n,./mobile/assets/i18n" -L afterall,nd,renderd` --- docs/docs/FAQ.mdx | 4 ++-- docs/docs/install/docker-compose.mdx | 2 +- docs/docs/install/truenas.md | 2 +- docs/tailwind.config.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 4cd3717e84..23b2b9b30f 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -97,7 +97,7 @@ Make sure to [set your reverse proxy](/docs/administration/reverse-proxy/) to al Also, check the disk space of your reverse proxy. In some cases, proxies cache requests to disk before passing them on, and if disk space runs out, the request fails. -If you are using Cloudflare Tunnel, please know that they set a maxiumum filesize of 100 MB that cannot be changed. +If you are using Cloudflare Tunnel, please know that they set a maximum filesize of 100 MB that cannot be changed. At times, files larger than this may work, potentially up to 1 GB. However, the official limit is 100 MB. If you are having issues, we recommend switching to a different network deployment. @@ -170,7 +170,7 @@ If you aren't able to or prefer not to mount Samba on the host (such as Windows Below is an example in the `docker-compose.yml`. Change your username, password, local IP, and share name, and see below where the line `- originals:/usr/src/app/originals`, -corrolates to the section where the volume `originals` was created. You can call this whatever you like, and map it to the docker container as you like. +correlates to the section where the volume `originals` was created. You can call this whatever you like, and map it to the docker container as you like. For example you could change `originals:` to `Photos:`, and change `- originals:/usr/src/app/originals` to `Photos:/usr/src/app/photos`. ```diff diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index 3593cf19ee..99a29397fa 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -37,7 +37,7 @@ You can alternatively download these two files from your browser and move them t - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. It should be a new directory on the server with enough free space. -- Consider changing `DB_PASSWORD` to a custom value. Postgres is not publically exposed, so this password is only used for local authentication. +- Consider changing `DB_PASSWORD` to a custom value. Postgres is not publicly exposed, so this password is only used for local authentication. To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. `pwgen` is a handy utility for this. - Set your timezone by uncommenting the `TZ=` line. - Populate custom database information if necessary. diff --git a/docs/docs/install/truenas.md b/docs/docs/install/truenas.md index 049af1250e..31b007a47d 100644 --- a/docs/docs/install/truenas.md +++ b/docs/docs/install/truenas.md @@ -198,7 +198,7 @@ The **CPU** value was specified in a different format with a default of `4000m` The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000` ::: -Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passtrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough) +Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passthrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough) ### Install diff --git a/docs/tailwind.config.js b/docs/tailwind.config.js index 98f69bcd59..5ed28c737d 100644 --- a/docs/tailwind.config.js +++ b/docs/tailwind.config.js @@ -5,7 +5,7 @@ module.exports = { preflight: false, // disable Tailwind's reset }, content: ['./src/**/*.{js,jsx,ts,tsx}', './{docs,blog}/**/*.{md,mdx}'], // my markdown stuff is in ../docs, not /src - darkMode: ['class', '[data-theme="dark"]'], // hooks into docusaurus' dark mode settigns + darkMode: ['class', '[data-theme="dark"]'], // hooks into docusaurus' dark mode settings theme: { extend: { colors: { From 2969e25ff71c6a1e7c298ce877195a2f39bcf5f5 Mon Sep 17 00:00:00 2001 From: Adam O'neill <78691968+AdamT20054@users.noreply.github.com> Date: Wed, 26 Feb 2025 17:48:18 +0000 Subject: [PATCH 004/104] fix: websockets calling on_new_release across all sessions upon new websocket connection. (#16339) * Implemented possible fix for the new_release window re-appearing across all active sessions when a new websocket connection is established. * Reverted websocket.ts Changes not needed to websocket.ts - was bouncing between ideas, current implementation doesn't need this to change. * Prettier test format. * Spelling (Aknowledged --> Acknowledged) --- .../shared-components/version-announcement-box.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/version-announcement-box.svelte b/web/src/lib/components/shared-components/version-announcement-box.svelte index 62e9baf779..9bfebe317e 100644 --- a/web/src/lib/components/shared-components/version-announcement-box.svelte +++ b/web/src/lib/components/shared-components/version-announcement-box.svelte @@ -14,6 +14,7 @@ const onAcknowledge = () => { localStorage.setItem('appVersion', releaseVersion); + sessionStorage.setItem('modalAcknowledged', 'true'); showModal = false; }; @@ -31,7 +32,7 @@ let releaseVersion = $derived($release && semverToName($release.releaseVersion)); let serverVersion = $derived($release && semverToName($release.serverVersion)); $effect(() => { - if ($release?.isAvailable) { + if ($release?.isAvailable && !sessionStorage.getItem('modalAcknowledged')) { handleRelease(); } }); From c778516ce2064e41160a5d5b92a3e0200dc651c8 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 26 Feb 2025 12:55:32 -0600 Subject: [PATCH 005/104] fix(web): tag people in video (#16351) --- .../face-editor/face-editor.svelte | 83 +++++++++++++------ .../asset-viewer/photo-viewer.svelte | 2 +- .../asset-viewer/video-native-viewer.svelte | 22 ++++- 3 files changed, 79 insertions(+), 28 deletions(-) diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index fdf42000f0..afe45331e4 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -12,13 +12,13 @@ import { handleError } from '$lib/utils/handle-error'; interface Props { - imgElement: HTMLImageElement; + htmlElement: HTMLImageElement | HTMLVideoElement; containerWidth: number; containerHeight: number; assetId: string; } - let { imgElement, containerWidth, containerHeight, assetId }: Props = $props(); + let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props(); let canvasEl: HTMLCanvasElement | undefined = $state(); let canvas: Canvas | undefined = $state(); @@ -39,7 +39,7 @@ }; const setupCanvas = () => { - if (!canvasEl || !imgElement) { + if (!canvasEl || !htmlElement) { return; } @@ -68,7 +68,7 @@ }); $effect(() => { - const { actualWidth, actualHeight } = getContainedSize(imgElement); + const { actualWidth, actualHeight } = getContainedSize(htmlElement); const offsetArea = { width: (containerWidth - actualWidth) / 2, height: (containerHeight - actualHeight) / 2, @@ -103,15 +103,30 @@ positionFaceSelector(); }); - const getContainedSize = (img: HTMLImageElement): { actualWidth: number; actualHeight: number } => { - const ratio = img.naturalWidth / img.naturalHeight; - let actualWidth = img.height * ratio; - let actualHeight = img.height; - if (actualWidth > img.width) { - actualWidth = img.width; - actualHeight = img.width / ratio; + const getContainedSize = ( + img: HTMLImageElement | HTMLVideoElement, + ): { actualWidth: number; actualHeight: number } => { + if (img instanceof HTMLImageElement) { + const ratio = img.naturalWidth / img.naturalHeight; + let actualWidth = img.height * ratio; + let actualHeight = img.height; + if (actualWidth > img.width) { + actualWidth = img.width; + actualHeight = img.width / ratio; + } + return { actualWidth, actualHeight }; + } else if (img instanceof HTMLVideoElement) { + const ratio = img.videoWidth / img.videoHeight; + let actualWidth = img.clientHeight * ratio; + let actualHeight = img.clientHeight; + if (actualWidth > img.clientWidth) { + actualWidth = img.clientWidth; + actualHeight = img.clientWidth / ratio; + } + return { actualWidth, actualHeight }; } - return { actualWidth, actualHeight }; + + return { actualWidth: 0, actualHeight: 0 }; }; const cancel = () => { @@ -202,12 +217,12 @@ }); const getFaceCroppedCoordinates = () => { - if (!faceRect || !imgElement) { + if (!faceRect || !htmlElement) { return; } const { left, top, width, height } = faceRect.getBoundingRect(); - const { actualWidth, actualHeight } = getContainedSize(imgElement); + const { actualWidth, actualHeight } = getContainedSize(htmlElement); const offsetArea = { width: (containerWidth - actualWidth) / 2, @@ -220,19 +235,35 @@ const y2Coeff = (top + height - offsetArea.height) / actualHeight; // transpose to the natural image location - const x1 = x1Coeff * imgElement.naturalWidth; - const y1 = y1Coeff * imgElement.naturalHeight; - const x2 = x2Coeff * imgElement.naturalWidth; - const y2 = y2Coeff * imgElement.naturalHeight; + if (htmlElement instanceof HTMLImageElement) { + const x1 = x1Coeff * htmlElement.naturalWidth; + const y1 = y1Coeff * htmlElement.naturalHeight; + const x2 = x2Coeff * htmlElement.naturalWidth; + const y2 = y2Coeff * htmlElement.naturalHeight; - return { - imageWidth: imgElement.naturalWidth, - imageHeight: imgElement.naturalHeight, - x: Math.floor(x1), - y: Math.floor(y1), - width: Math.floor(x2 - x1), - height: Math.floor(y2 - y1), - }; + return { + imageWidth: htmlElement.naturalWidth, + imageHeight: htmlElement.naturalHeight, + x: Math.floor(x1), + y: Math.floor(y1), + width: Math.floor(x2 - x1), + height: Math.floor(y2 - y1), + }; + } else if (htmlElement instanceof HTMLVideoElement) { + const x1 = x1Coeff * htmlElement.videoWidth; + const y1 = y1Coeff * htmlElement.videoHeight; + const x2 = x2Coeff * htmlElement.videoWidth; + const y2 = y2Coeff * htmlElement.videoHeight; + + return { + imageWidth: htmlElement.videoWidth, + imageHeight: htmlElement.videoHeight, + x: Math.floor(x1), + y: Math.floor(y1), + width: Math.floor(x2 - x1), + height: Math.floor(y2 - y1), + }; + } }; const tagFace = async (person: PersonResponseDto) => { diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 582f56fab3..0d79b9e5fc 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -234,7 +234,7 @@ {#if isFaceEditMode.value} - + {/if} {/if} diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index ea75ea069b..46a0301306 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -9,6 +9,8 @@ import type { SwipeCustomEvent } from 'svelte-gestures'; import { fade } from 'svelte/transition'; import { t } from 'svelte-i18n'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; + import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; interface Props { assetId: string; @@ -84,9 +86,23 @@ onPreviousAsset(); } }; + + let containerWidth = $state(0); + let containerHeight = $state(0); + + $effect(() => { + if (isFaceEditMode.value) { + videoPlayer?.pause(); + } + }); -
+
{/if} + + {#if isFaceEditMode.value} + + {/if}
From 8fbd65048321d3b618a6a42fe65d406dbc49ed4a Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 26 Feb 2025 17:04:43 -0600 Subject: [PATCH 006/104] refactor(mobile): refactor user provider (#16358) --- mobile/analysis_options.yaml | 4 ++-- mobile/lib/interfaces/user.interface.dart | 4 ++++ mobile/lib/pages/photos/photos.page.dart | 2 +- mobile/lib/providers/partner.provider.dart | 18 ++++++++++++---- mobile/lib/providers/timeline.provider.dart | 6 +++++- mobile/lib/providers/user.provider.dart | 22 +++++++------------- mobile/lib/repositories/user.repository.dart | 22 ++++++++++++++++++++ mobile/lib/services/user.service.dart | 10 +++++++++ 8 files changed, 65 insertions(+), 23 deletions(-) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 6794f39b81..dd081be64e 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -79,14 +79,14 @@ custom_lint: - lib/widgets/asset_grid/asset_grid_data_structure.dart - test/**.dart # refactor the remaining providers - - lib/providers/{db,user}.provider.dart + - lib/providers/db.provider.dart - lib/providers/backup/backup.provider.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories restrict: package:openapi allowed: - # requried / wanted + # required / wanted - lib/repositories/*_api.repository.dart # acceptable exceptions for the time being - lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index d099e0e50b..17918ac170 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -22,6 +22,10 @@ abstract interface class IUserRepository implements IDatabaseRepository { Future me(); Future clearTable(); + + Future> getTimelineUserIds(int id); + + Stream> watchTimelineUsers(int id); } enum UserSort { id } diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 7910d45e13..b3bfa366f2 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -110,7 +110,7 @@ class PhotosPage extends HookConsumerWidget { : const SizedBox(), renderListProvider: timelineUsers.length > 1 ? multiUsersTimelineProvider(timelineUsers) - : singleUserTimelineProvider(currentUser!.isarId), + : singleUserTimelineProvider(currentUser?.isarId), buildLoadingIndicator: buildLoadingIndicator, onRefresh: refreshAssets, stackEnabled: true, diff --git a/mobile/lib/providers/partner.provider.dart b/mobile/lib/providers/partner.provider.dart index 38b449e96a..282e779432 100644 --- a/mobile/lib/providers/partner.provider.dart +++ b/mobile/lib/providers/partner.provider.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/user.entity.dart'; class PartnerSharedWithNotifier extends StateNotifier> { final PartnerService _partnerService; + late final StreamSubscription> streamSub; PartnerSharedWithNotifier(this._partnerService) : super([]) { Function eq = const ListEquality().equals; @@ -16,7 +17,7 @@ class PartnerSharedWithNotifier extends StateNotifier> { state = partners; } }).then((_) { - _partnerService.watchSharedWith().listen((partners) { + streamSub = _partnerService.watchSharedWith().listen((partners) { if (!eq(state, partners)) { state = partners; } @@ -27,6 +28,14 @@ class PartnerSharedWithNotifier extends StateNotifier> { Future updatePartner(User partner, {required bool inTimeline}) { return _partnerService.updatePartner(partner, inTimeline: inTimeline); } + + @override + void dispose() { + if (mounted) { + streamSub.cancel(); + } + super.dispose(); + } } final partnerSharedWithProvider = @@ -38,6 +47,7 @@ final partnerSharedWithProvider = class PartnerSharedByNotifier extends StateNotifier> { final PartnerService _partnerService; + late final StreamSubscription> streamSub; PartnerSharedByNotifier(this._partnerService) : super([]) { Function eq = const ListEquality().equals; @@ -54,11 +64,11 @@ class PartnerSharedByNotifier extends StateNotifier> { }); } - late final StreamSubscription> streamSub; - @override void dispose() { - streamSub.cancel(); + if (mounted) { + streamSub.cancel(); + } super.dispose(); } } diff --git a/mobile/lib/providers/timeline.provider.dart b/mobile/lib/providers/timeline.provider.dart index b0e9482b81..97d5698c4c 100644 --- a/mobile/lib/providers/timeline.provider.dart +++ b/mobile/lib/providers/timeline.provider.dart @@ -5,8 +5,12 @@ import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -final singleUserTimelineProvider = StreamProvider.family( +final singleUserTimelineProvider = StreamProvider.family( (ref, userId) { + if (userId == null) { + return const Stream.empty(); + } + ref.watch(localeProvider); final timelineService = ref.watch(timelineServiceProvider); return timelineService.watchHomeTimeline(userId); diff --git a/mobile/lib/providers/user.provider.dart b/mobile/lib/providers/user.provider.dart index c69245ea98..c143086a15 100644 --- a/mobile/lib/providers/user.provider.dart +++ b/mobile/lib/providers/user.provider.dart @@ -5,9 +5,8 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/services/user.service.dart'; class CurrentUserProvider extends StateNotifier { CurrentUserProvider(this._apiService) : super(null) { @@ -47,18 +46,14 @@ final currentUserProvider = }); class TimelineUserIdsProvider extends StateNotifier> { - TimelineUserIdsProvider(Isar db, User? currentUser) : super([]) { - final query = db.users - .filter() - .inTimelineEqualTo(true) - .or() - .isarIdEqualTo(currentUser?.isarId ?? Isar.autoIncrement) - .isarIdProperty(); - query.findAll().then((users) => state = users); - streamSub = query.watch().listen((users) => state = users); + TimelineUserIdsProvider(this._userService) : super([]) { + _userService.getTimelineUserIds().then((users) => state = users); + streamSub = + _userService.watchTimelineUserIds().listen((users) => state = users); } late final StreamSubscription> streamSub; + final UserService _userService; @override void dispose() { @@ -69,8 +64,5 @@ class TimelineUserIdsProvider extends StateNotifier> { final timelineUsersIdsProvider = StateNotifierProvider>((ref) { - return TimelineUserIdsProvider( - ref.watch(dbProvider), - ref.watch(currentUserProvider), - ); + return TimelineUserIdsProvider(ref.watch(userServiceProvider)); }); diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index ea67b30e0d..190fb780c8 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -70,4 +70,26 @@ class UserRepository extends DatabaseRepository implements IUserRepository { await db.users.clear(); }); } + + @override + Future> getTimelineUserIds(int id) { + return db.users + .filter() + .inTimelineEqualTo(true) + .or() + .isarIdEqualTo(id) + .isarIdProperty() + .findAll(); + } + + @override + Stream> watchTimelineUsers(int id) { + return db.users + .filter() + .inTimelineEqualTo(true) + .or() + .isarIdEqualTo(id) + .isarIdProperty() + .watch(); + } } diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 935a751e2a..a14b1c08f2 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -107,4 +107,14 @@ class UserService { Future clearTable() { return _userRepository.clearTable(); } + + Future> getTimelineUserIds() async { + final me = await _userRepository.me(); + return _userRepository.getTimelineUserIds(me.isarId); + } + + Stream> watchTimelineUserIds() async* { + final me = await _userRepository.me(); + yield* _userRepository.watchTimelineUsers(me.isarId); + } } From 4b55888d16a16fdc0fcdc3de143b59e7a3b0580d Mon Sep 17 00:00:00 2001 From: David Bourgault Date: Wed, 26 Feb 2025 21:53:21 -0500 Subject: [PATCH 007/104] fix: ensure manually tagged faces have proper source type (#16364) immich-app/immich#16062 added manual face tagging and deletion, but did not add a new 'SourceType'. The create faces would default to 'machine-learning' which is incorrect, and has the annoying downside that they will be wiped when the 'Refresh Faces' job is run. Handling of non-machine-learning faces was previously added in immich-app/immich#6455. This PR simply extends it to the new manually tagged faces. --- .../lib/model/asset_face_create_dto.dart | 10 ++++++- mobile/openapi/lib/model/source_type.dart | 3 +++ open-api/immich-openapi-specs.json | 12 ++++++++- open-api/typescript-sdk/src/fetch-client.ts | 4 ++- server/src/db.d.ts | 2 +- server/src/dtos/person.dto.ts | 6 ++++- server/src/enum.ts | 1 + .../1740619600996-AddManualSourceType.ts | 27 +++++++++++++++++++ server/src/services/person.service.ts | 1 + .../face-editor/face-editor.svelte | 3 ++- 10 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 server/src/migrations/1740619600996-AddManualSourceType.ts diff --git a/mobile/openapi/lib/model/asset_face_create_dto.dart b/mobile/openapi/lib/model/asset_face_create_dto.dart index 29e8244a96..d25a5d8b82 100644 --- a/mobile/openapi/lib/model/asset_face_create_dto.dart +++ b/mobile/openapi/lib/model/asset_face_create_dto.dart @@ -18,6 +18,7 @@ class AssetFaceCreateDto { required this.imageHeight, required this.imageWidth, required this.personId, + this.sourceType = SourceType.manual, required this.width, required this.x, required this.y, @@ -33,6 +34,8 @@ class AssetFaceCreateDto { String personId; + SourceType sourceType; + int width; int x; @@ -46,6 +49,7 @@ class AssetFaceCreateDto { other.imageHeight == imageHeight && other.imageWidth == imageWidth && other.personId == personId && + other.sourceType == sourceType && other.width == width && other.x == x && other.y == y; @@ -58,12 +62,13 @@ class AssetFaceCreateDto { (imageHeight.hashCode) + (imageWidth.hashCode) + (personId.hashCode) + + (sourceType.hashCode) + (width.hashCode) + (x.hashCode) + (y.hashCode); @override - String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, width=$width, x=$x, y=$y]'; + String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, sourceType=$sourceType, width=$width, x=$x, y=$y]'; Map toJson() { final json = {}; @@ -72,6 +77,7 @@ class AssetFaceCreateDto { json[r'imageHeight'] = this.imageHeight; json[r'imageWidth'] = this.imageWidth; json[r'personId'] = this.personId; + json[r'sourceType'] = this.sourceType; json[r'width'] = this.width; json[r'x'] = this.x; json[r'y'] = this.y; @@ -92,6 +98,7 @@ class AssetFaceCreateDto { imageHeight: mapValueOfType(json, r'imageHeight')!, imageWidth: mapValueOfType(json, r'imageWidth')!, personId: mapValueOfType(json, r'personId')!, + sourceType: SourceType.fromJson(json[r'sourceType'])!, width: mapValueOfType(json, r'width')!, x: mapValueOfType(json, r'x')!, y: mapValueOfType(json, r'y')!, @@ -147,6 +154,7 @@ class AssetFaceCreateDto { 'imageHeight', 'imageWidth', 'personId', + 'sourceType', 'width', 'x', 'y', diff --git a/mobile/openapi/lib/model/source_type.dart b/mobile/openapi/lib/model/source_type.dart index 13c450b010..4da5aba495 100644 --- a/mobile/openapi/lib/model/source_type.dart +++ b/mobile/openapi/lib/model/source_type.dart @@ -25,11 +25,13 @@ class SourceType { static const machineLearning = SourceType._(r'machine-learning'); static const exif = SourceType._(r'exif'); + static const manual = SourceType._(r'manual'); /// List of all possible values in this [enum][SourceType]. static const values = [ machineLearning, exif, + manual, ]; static SourceType? fromJson(dynamic value) => SourceTypeTypeTransformer().decode(value); @@ -70,6 +72,7 @@ class SourceTypeTypeTransformer { switch (data) { case r'machine-learning': return SourceType.machineLearning; case r'exif': return SourceType.exif; + case r'manual': return SourceType.manual; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6a57001085..aeafc27ee6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8301,6 +8301,14 @@ "format": "uuid", "type": "string" }, + "sourceType": { + "allOf": [ + { + "$ref": "#/components/schemas/SourceType" + } + ], + "default": "manual" + }, "width": { "type": "integer" }, @@ -8317,6 +8325,7 @@ "imageHeight", "imageWidth", "personId", + "sourceType", "width", "x", "y" @@ -11952,7 +11961,8 @@ "SourceType": { "enum": [ "machine-learning", - "exif" + "exif", + "manual" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7786c09d9a..7237e0aac3 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -529,6 +529,7 @@ export type AssetFaceCreateDto = { imageHeight: number; imageWidth: number; personId: string; + sourceType: SourceType; width: number; x: number; y: number; @@ -3453,7 +3454,8 @@ export enum AlbumUserRole { } export enum SourceType { MachineLearning = "machine-learning", - Exif = "exif" + Exif = "exif", + Manual = "manual" } export enum AssetTypeEnum { Image = "IMAGE", diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 4a2adc917f..bc88d7de3c 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -29,7 +29,7 @@ export type JsonPrimitive = boolean | number | string | null; export type JsonValue = JsonArray | JsonObject | JsonPrimitive; -export type Sourcetype = 'exif' | 'machine-learning'; +export type Sourcetype = 'exif' | 'machine-learning' | 'manual'; export type Timestamp = ColumnType; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 0778c35b8f..c4d3018be2 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsEnum, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; import { DateTime } from 'luxon'; import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -194,6 +194,10 @@ export class AssetFaceCreateDto extends AssetFaceUpdateItem { @IsNotEmpty() @IsNumber() height!: number; + + @ApiProperty({ type: 'string', enum: SourceType, enumName: 'SourceType' }) + @IsEnum(SourceType) + sourceType: SourceType = SourceType.MANUAL; } export class AssetFaceDeleteDto { diff --git a/server/src/enum.ts b/server/src/enum.ts index 7bf4ca3dcf..676e1d27db 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -228,6 +228,7 @@ export enum AssetStatus { export enum SourceType { MACHINE_LEARNING = 'machine-learning', EXIF = 'exif', + MANUAL = 'manual', } export enum ManualJobName { diff --git a/server/src/migrations/1740619600996-AddManualSourceType.ts b/server/src/migrations/1740619600996-AddManualSourceType.ts new file mode 100644 index 0000000000..dd53312ad7 --- /dev/null +++ b/server/src/migrations/1740619600996-AddManualSourceType.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddManualSourceType1740619600996 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE sourceType ADD VALUE 'manual'`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Prior to this migration, manually tagged pictures had the 'machine-learning' type + await queryRunner.query( + `UPDATE "asset_faces" SET "sourceType" = 'machine-learning' WHERE "sourceType" = 'manual';`, + ); + + // Postgres doesn't allow removing values from enums, we have to recreate the type + await queryRunner.query(`ALTER TYPE sourceType RENAME TO oldSourceType`); + await queryRunner.query(`CREATE TYPE sourceType AS ENUM ('machine-learning', 'exif');`); + + await queryRunner.query(`ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" DROP DEFAULT;`); + await queryRunner.query( + `ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" TYPE sourceType USING "sourceType"::text::sourceType;`, + ); + await queryRunner.query( + `ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" SET DEFAULT 'machine-learning'::sourceType;`, + ); + await queryRunner.query(`DROP TYPE oldSourceType;`); + } +} diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index dd998cc0fe..62bf55a78c 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -736,6 +736,7 @@ export class PersonService extends BaseService { boundingBoxX2: dto.x + dto.width, boundingBoxY1: dto.y, boundingBoxY2: dto.y + dto.height, + sourceType: dto.sourceType, }); } diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index afe45331e4..bcc9ee6875 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -4,7 +4,7 @@ import { notificationController } from '$lib/components/shared-components/notification/notification'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; - import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk'; + import { getAllPeople, createFace, type PersonResponseDto, SourceType } from '@immich/sdk'; import { Button } from '@immich/ui'; import { Canvas, InteractiveFabricObject, Rect } from 'fabric'; import { onMount } from 'svelte'; @@ -288,6 +288,7 @@ assetFaceCreateDto: { assetId, personId: person.id, + sourceType: SourceType.Manual, ...data, }, }); From 8b6911492419594f8e671bef7a041347de27bbf4 Mon Sep 17 00:00:00 2001 From: David Bourgault Date: Wed, 26 Feb 2025 22:01:29 -0500 Subject: [PATCH 008/104] feat(web): remember last chosen map location when editing (#16366) Uses a global store to remember the last location chosen by a user when editing asset locations. This fixes an annoyance when adding location data to multiple assets in a row and having to zoom in the same area everytime. --- .../shared-components/change-location.svelte | 27 ++++++++++++------- web/src/lib/stores/asset-editor.store.ts | 1 + 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 5970b91160..d2dbeb5488 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -2,6 +2,7 @@ import ConfirmDialog from './dialog/confirm-dialog.svelte'; import { timeDebounceOnSearch } from '$lib/constants'; import { handleError } from '$lib/utils/handle-error'; + import { lastChosenLocation } from '$lib/stores/asset-editor.store'; import { clickOutside } from '$lib/actions/click-outside'; import LoadingSpinner from './loading-spinner.svelte'; @@ -13,6 +14,7 @@ import { t } from 'svelte-i18n'; import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte'; import Map from '$lib/components/shared-components/map/map.svelte'; + import { get } from 'svelte/store'; interface Point { lng: number; @@ -36,9 +38,15 @@ let hideSuggestion = $state(false); let mapElement = $state>(); - let lat = $derived(asset?.exifInfo?.latitude ?? undefined); - let lng = $derived(asset?.exifInfo?.longitude ?? undefined); - let zoom = $derived(lat !== undefined && lng !== undefined ? 12.5 : 1); + let previousLocation = get(lastChosenLocation); + + let assetLat = $derived(asset?.exifInfo?.latitude ?? undefined); + let assetLng = $derived(asset?.exifInfo?.longitude ?? undefined); + + let mapLat = $derived(assetLat ?? previousLocation?.lat ?? undefined); + let mapLng = $derived(assetLng ?? previousLocation?.lng ?? undefined); + + let zoom = $derived(mapLat !== undefined && mapLng !== undefined ? 12.5 : 1); $effect(() => { if (places) { @@ -53,6 +61,7 @@ const handleConfirm = () => { if (point) { + lastChosenLocation.set(point); onConfirm(point); } else { onCancel(); @@ -160,12 +169,12 @@ {:then { default: Map }} (point = selected)} @@ -183,8 +192,8 @@
{ point = { lat, lng }; mapElement?.addClipMapMarker(lng, lat); diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts index 4d2f8977ee..ec06c2cef5 100644 --- a/web/src/lib/stores/asset-editor.store.ts +++ b/web/src/lib/stores/asset-editor.store.ts @@ -17,6 +17,7 @@ export const normaizedRorateDegrees = derived(rotateDegrees, (v) => { export const changedOriention = derived(normaizedRorateDegrees, () => get(normaizedRorateDegrees) % 180 > 0); //-----other export const showCancelConfirmDialog = writable(false); +export const lastChosenLocation = writable<{ lng: number; lat: number } | null>(null); export const editTypes = [ { From 128d653fc619be4ead3483b9d40ce6043ff21df6 Mon Sep 17 00:00:00 2001 From: Curtis Lowder Date: Wed, 26 Feb 2025 21:06:41 -0600 Subject: [PATCH 009/104] fix(web): update search modal to not jump around (#16308) * fix(web): update search modal to not jump around Search People selection will change size while loading. This causes the search modal to jump around as the people load in. * loading spinner size * remove unsued code --------- Co-authored-by: cwlowder Co-authored-by: Alex Tran --- .../search-bar/search-people-section.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte index d06c4dc5c0..ed17d78af3 100644 --- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte @@ -10,6 +10,7 @@ import { t } from 'svelte-i18n'; import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte'; import type { SvelteSet } from 'svelte/reactivity'; + import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; interface Props { selectedPeople: SvelteSet; @@ -52,13 +53,17 @@ }; -{#await peoplePromise then people} +{#await peoplePromise} +
+ +
+{:then people} {#if people && people.length > 0} {@const peopleList = showAllPeople ? filterPeople(people, name) : filterPeople(people, name).slice(0, numberOfPeople)} -
+

{$t('people').toUpperCase()}

From 967c69317bef6193f0f1fc1210e34a8c355bc7b4 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 27 Feb 2025 12:55:22 +0000 Subject: [PATCH 010/104] feat: updateId uuidv7 column for all entities with updatedAt (#16353) --- server/src/db.d.ts | 13 ++ server/src/entities/activity.entity.ts | 4 + server/src/entities/album.entity.ts | 5 + server/src/entities/api-key.entity.ts | 6 +- server/src/entities/asset-files.entity.ts | 4 + server/src/entities/asset.entity.ts | 4 + server/src/entities/library.entity.ts | 5 + server/src/entities/memory.entity.ts | 5 + server/src/entities/partner.entity.ts | 15 +- server/src/entities/person.entity.ts | 5 + server/src/entities/session.entity.ts | 6 +- server/src/entities/sync-checkpoint.entity.ts | 6 +- server/src/entities/tag.entity.ts | 5 + server/src/entities/user.entity.ts | 4 + .../1740586617223-AddUpdateIdColumns.ts | 134 ++++++++++++++++++ server/src/services/session.service.spec.ts | 1 + server/test/fixtures/activity.stub.ts | 2 + server/test/fixtures/session.stub.ts | 2 + 18 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 server/src/migrations/1740586617223-AddUpdateIdColumns.ts diff --git a/server/src/db.d.ts b/server/src/db.d.ts index bc88d7de3c..ff4cb4a1d2 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -41,6 +41,7 @@ export interface Activity { id: Generated; isLiked: Generated; updatedAt: Generated; + updateId: Generated; userId: string; } @@ -58,6 +59,7 @@ export interface Albums { order: Generated; ownerId: string; updatedAt: Generated; + updateId: Generated; } export interface AlbumsAssetsAssets { @@ -79,6 +81,7 @@ export interface ApiKeys { name: string; permissions: Permission[]; updatedAt: Generated; + updateId: Generated; userId: string; } @@ -103,6 +106,7 @@ export interface AssetFiles { path: string; type: string; updatedAt: Generated; + updateId: Generated; } export interface AssetJobStatus { @@ -143,6 +147,7 @@ export interface Assets { thumbhash: Buffer | null; type: string; updatedAt: Generated; + updateId: Generated; } export interface AssetStack { @@ -221,6 +226,7 @@ export interface Libraries { ownerId: string; refreshedAt: Timestamp | null; updatedAt: Generated; + updateId: Generated; } export interface Memories { @@ -236,6 +242,7 @@ export interface Memories { showAt: Timestamp | null; type: string; updatedAt: Generated; + updateId: Generated; } export interface MemoriesAssetsAssets { @@ -271,6 +278,7 @@ export interface Partners { sharedById: string; sharedWithId: string; updatedAt: Generated; + updateId: Generated; } export interface Person { @@ -285,6 +293,7 @@ export interface Person { ownerId: string; thumbnailPath: Generated; updatedAt: Generated; + updateId: Generated; } export interface Sessions { @@ -294,6 +303,7 @@ export interface Sessions { id: Generated; token: string; updatedAt: Generated; + updateId: Generated; userId: string; } @@ -303,6 +313,7 @@ export interface SessionSyncCheckpoints { sessionId: string; type: SyncEntityType; updatedAt: Generated; + updateId: Generated; } @@ -358,6 +369,7 @@ export interface Tags { id: Generated; parentId: string | null; updatedAt: Generated; + updateId: Generated; userId: string; value: string; } @@ -399,6 +411,7 @@ export interface Users { status: Generated; storageLabel: string | null; updatedAt: Generated; + updateId: Generated; } export interface UsersAudit { diff --git a/server/src/entities/activity.entity.ts b/server/src/entities/activity.entity.ts index 8de76ac894..dabb371977 100644 --- a/server/src/entities/activity.entity.ts +++ b/server/src/entities/activity.entity.ts @@ -25,6 +25,10 @@ export class ActivityEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_activity_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column() albumId!: string; diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts index 5aec5a0f47..4cd7c82394 100644 --- a/server/src/entities/album.entity.ts +++ b/server/src/entities/album.entity.ts @@ -8,6 +8,7 @@ import { CreateDateColumn, DeleteDateColumn, Entity, + Index, JoinTable, ManyToMany, ManyToOne, @@ -39,6 +40,10 @@ export class AlbumEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_albums_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz' }) deletedAt!: Date | null; diff --git a/server/src/entities/api-key.entity.ts b/server/src/entities/api-key.entity.ts index 998ee4f8ef..f59bf0d918 100644 --- a/server/src/entities/api-key.entity.ts +++ b/server/src/entities/api-key.entity.ts @@ -1,6 +1,6 @@ import { UserEntity } from 'src/entities/user.entity'; import { Permission } from 'src/enum'; -import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; @Entity('api_keys') export class APIKeyEntity { @@ -27,4 +27,8 @@ export class APIKeyEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + + @Index('IDX_api_keys_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; } diff --git a/server/src/entities/asset-files.entity.ts b/server/src/entities/asset-files.entity.ts index a8a6ddfee1..09f96e849d 100644 --- a/server/src/entities/asset-files.entity.ts +++ b/server/src/entities/asset-files.entity.ts @@ -30,6 +30,10 @@ export class AssetFileEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_asset_files_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column() type!: AssetFileType; diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index a325febce7..7345d9a2e6 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -96,6 +96,10 @@ export class AssetEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_assets_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz', nullable: true }) deletedAt!: Date | null; diff --git a/server/src/entities/library.entity.ts b/server/src/entities/library.entity.ts index a6053e4213..a594fd83ad 100644 --- a/server/src/entities/library.entity.ts +++ b/server/src/entities/library.entity.ts @@ -5,6 +5,7 @@ import { CreateDateColumn, DeleteDateColumn, Entity, + Index, JoinTable, ManyToOne, OneToMany, @@ -42,6 +43,10 @@ export class LibraryEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_libraries_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz' }) deletedAt?: Date; diff --git a/server/src/entities/memory.entity.ts b/server/src/entities/memory.entity.ts index 1f53d7a5c1..dafd7eb21c 100644 --- a/server/src/entities/memory.entity.ts +++ b/server/src/entities/memory.entity.ts @@ -6,6 +6,7 @@ import { CreateDateColumn, DeleteDateColumn, Entity, + Index, JoinTable, ManyToMany, ManyToOne, @@ -30,6 +31,10 @@ export class MemoryEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_memories_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz' }) deletedAt?: Date; diff --git a/server/src/entities/partner.entity.ts b/server/src/entities/partner.entity.ts index 189f6f51a7..877330a8e7 100644 --- a/server/src/entities/partner.entity.ts +++ b/server/src/entities/partner.entity.ts @@ -1,5 +1,14 @@ import { UserEntity } from 'src/entities/user.entity'; -import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('partners') export class PartnerEntity { @@ -23,6 +32,10 @@ export class PartnerEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_partners_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column({ type: 'boolean', default: false }) inTimeline!: boolean; } diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts index 3785e1985e..5ca74c12d2 100644 --- a/server/src/entities/person.entity.ts +++ b/server/src/entities/person.entity.ts @@ -5,6 +5,7 @@ import { Column, CreateDateColumn, Entity, + Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, @@ -23,6 +24,10 @@ export class PersonEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_person_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column() ownerId!: string; diff --git a/server/src/entities/session.entity.ts b/server/src/entities/session.entity.ts index e21c6d52ba..cb208c958e 100644 --- a/server/src/entities/session.entity.ts +++ b/server/src/entities/session.entity.ts @@ -1,7 +1,7 @@ import { ExpressionBuilder } from 'kysely'; import { DB } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; -import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; @Entity('sessions') export class SessionEntity { @@ -23,6 +23,10 @@ export class SessionEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_sessions_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId!: string; + @Column({ default: '' }) deviceType!: string; diff --git a/server/src/entities/sync-checkpoint.entity.ts b/server/src/entities/sync-checkpoint.entity.ts index 2a91d2386c..7c6818aba0 100644 --- a/server/src/entities/sync-checkpoint.entity.ts +++ b/server/src/entities/sync-checkpoint.entity.ts @@ -1,6 +1,6 @@ import { SessionEntity } from 'src/entities/session.entity'; import { SyncEntityType } from 'src/enum'; -import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; @Entity('session_sync_checkpoints') export class SessionSyncCheckpointEntity { @@ -19,6 +19,10 @@ export class SessionSyncCheckpointEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_session_sync_checkpoints_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column() ack!: string; } diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts index ebcc6853c9..fcbde6c779 100644 --- a/server/src/entities/tag.entity.ts +++ b/server/src/entities/tag.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, Entity, + Index, ManyToMany, ManyToOne, PrimaryGeneratedColumn, @@ -30,6 +31,10 @@ export class TagEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_tags_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column({ type: 'varchar', nullable: true, default: null }) color!: string | null; diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index b597d15cf9..5758e29098 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -58,6 +58,10 @@ export class UserEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_users_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @OneToMany(() => TagEntity, (tag) => tag.user) tags!: TagEntity[]; diff --git a/server/src/migrations/1740586617223-AddUpdateIdColumns.ts b/server/src/migrations/1740586617223-AddUpdateIdColumns.ts new file mode 100644 index 0000000000..02d680ddf6 --- /dev/null +++ b/server/src/migrations/1740586617223-AddUpdateIdColumns.ts @@ -0,0 +1,134 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUpdateIdColumns1740586617223 implements MigrationInterface { + name = 'AddUpdateIdColumns1740586617223' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + create or replace function immich_uuid_v7(p_timestamp timestamp with time zone default clock_timestamp()) + returns uuid + as $$ + select encode( + set_bit( + set_bit( + overlay(uuid_send(gen_random_uuid()) + placing substring(int8send(floor(extract(epoch from p_timestamp) * 1000)::bigint) from 3) + from 1 for 6 + ), + 52, 1 + ), + 53, 1 + ), + 'hex')::uuid; + $$ + language SQL + volatile; + `) + await queryRunner.query(` + CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER + LANGUAGE plpgsql + as $$ + BEGIN + return new; + END; + $$; + `) + await queryRunner.query(`ALTER TABLE "person" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "asset_files" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "libraries" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "tags" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "assets" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "users" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "albums" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "partners" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "memories" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "api_keys" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "activity" ADD "updateId" uuid`); + + await queryRunner.query(`UPDATE "person" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "asset_files" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "libraries" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "tags" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "assets" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "users" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "albums" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "sessions" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "session_sync_checkpoints" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "partners" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "memories" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "api_keys" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "activity" SET "updateId" = immich_uuid_v7("updatedAt")`); + + await queryRunner.query(`ALTER TABLE "person" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "asset_files" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "libraries" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "tags" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "albums" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "partners" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "memories" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "api_keys" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "activity" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + + await queryRunner.query(`CREATE INDEX "IDX_person_update_id" ON "person" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_asset_files_update_id" ON "asset_files" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_libraries_update_id" ON "libraries" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_tags_update_id" ON "tags" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_assets_update_id" ON "assets" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_users_update_id" ON "users" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_albums_update_id" ON "albums" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_sessions_update_id" ON "sessions" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_session_sync_checkpoints_update_id" ON "session_sync_checkpoints" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_partners_update_id" ON "partners" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_memories_update_id" ON "memories" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_api_keys_update_id" ON "api_keys" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_activity_update_id" ON "activity" ("updateId")`); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER + LANGUAGE plpgsql + as $$ + DECLARE + clock_timestamp TIMESTAMP := clock_timestamp(); + BEGIN + new."updatedAt" = clock_timestamp; + new."updateId" = immich_uuid_v7(clock_timestamp); + return new; + END; + $$; + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "activity" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "api_keys" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "partners" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "libraries" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "asset_files" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "updateId"`); + await queryRunner.query(`DROP FUNCTION immich_uuid_v7`); + await queryRunner.query(` + CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER + LANGUAGE plpgsql + as $$ + BEGIN + new."updatedAt" = now(); + return new; + END; + $$; + `) + } + +} diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 8c22abb7f0..96a1dacf64 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -33,6 +33,7 @@ describe('SessionService', () => { id: '123', token: '420', userId: '42', + updateId: 'uuid-v7', }, ]); diff --git a/server/test/fixtures/activity.stub.ts b/server/test/fixtures/activity.stub.ts index 9578bcd4a1..a81fd51ca8 100644 --- a/server/test/fixtures/activity.stub.ts +++ b/server/test/fixtures/activity.stub.ts @@ -19,6 +19,7 @@ export const activityStub = { albumId: albumStub.oneAsset.id, createdAt: new Date(), updatedAt: new Date(), + updateId: 'uuid-v7', }), liked: Object.freeze({ id: 'activity-2', @@ -36,5 +37,6 @@ export const activityStub = { albumId: albumStub.oneAsset.id, createdAt: new Date(), updatedAt: new Date(), + updateId: 'uuid-v7', }), }; diff --git a/server/test/fixtures/session.stub.ts b/server/test/fixtures/session.stub.ts index cdf499c8d1..af06237473 100644 --- a/server/test/fixtures/session.stub.ts +++ b/server/test/fixtures/session.stub.ts @@ -11,6 +11,7 @@ export const sessionStub = { updatedAt: new Date(), deviceType: '', deviceOS: '', + updateId: 'uuid-v7', }), inactive: Object.freeze({ id: 'not_active', @@ -21,5 +22,6 @@ export const sessionStub = { updatedAt: new Date('2021-01-01'), deviceType: 'Mobile', deviceOS: 'Android', + updateId: 'uuid-v7', }), }; From 7d6cfd09e6ef7b5f5132ee53045a08c654ef0f6f Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 27 Feb 2025 17:17:07 +0300 Subject: [PATCH 011/104] fix(server): don't expose source types in face creation api (#16381) * don't expose source types in face creation api * update open-api * remove source type reference from web --- mobile/openapi/lib/model/asset_face_create_dto.dart | 10 +--------- open-api/immich-openapi-specs.json | 9 --------- open-api/typescript-sdk/src/fetch-client.ts | 1 - server/src/dtos/person.dto.ts | 6 +----- server/src/services/person.service.ts | 2 +- .../asset-viewer/face-editor/face-editor.svelte | 3 +-- 6 files changed, 4 insertions(+), 27 deletions(-) diff --git a/mobile/openapi/lib/model/asset_face_create_dto.dart b/mobile/openapi/lib/model/asset_face_create_dto.dart index d25a5d8b82..29e8244a96 100644 --- a/mobile/openapi/lib/model/asset_face_create_dto.dart +++ b/mobile/openapi/lib/model/asset_face_create_dto.dart @@ -18,7 +18,6 @@ class AssetFaceCreateDto { required this.imageHeight, required this.imageWidth, required this.personId, - this.sourceType = SourceType.manual, required this.width, required this.x, required this.y, @@ -34,8 +33,6 @@ class AssetFaceCreateDto { String personId; - SourceType sourceType; - int width; int x; @@ -49,7 +46,6 @@ class AssetFaceCreateDto { other.imageHeight == imageHeight && other.imageWidth == imageWidth && other.personId == personId && - other.sourceType == sourceType && other.width == width && other.x == x && other.y == y; @@ -62,13 +58,12 @@ class AssetFaceCreateDto { (imageHeight.hashCode) + (imageWidth.hashCode) + (personId.hashCode) + - (sourceType.hashCode) + (width.hashCode) + (x.hashCode) + (y.hashCode); @override - String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, sourceType=$sourceType, width=$width, x=$x, y=$y]'; + String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, width=$width, x=$x, y=$y]'; Map toJson() { final json = {}; @@ -77,7 +72,6 @@ class AssetFaceCreateDto { json[r'imageHeight'] = this.imageHeight; json[r'imageWidth'] = this.imageWidth; json[r'personId'] = this.personId; - json[r'sourceType'] = this.sourceType; json[r'width'] = this.width; json[r'x'] = this.x; json[r'y'] = this.y; @@ -98,7 +92,6 @@ class AssetFaceCreateDto { imageHeight: mapValueOfType(json, r'imageHeight')!, imageWidth: mapValueOfType(json, r'imageWidth')!, personId: mapValueOfType(json, r'personId')!, - sourceType: SourceType.fromJson(json[r'sourceType'])!, width: mapValueOfType(json, r'width')!, x: mapValueOfType(json, r'x')!, y: mapValueOfType(json, r'y')!, @@ -154,7 +147,6 @@ class AssetFaceCreateDto { 'imageHeight', 'imageWidth', 'personId', - 'sourceType', 'width', 'x', 'y', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index aeafc27ee6..5730e41578 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8301,14 +8301,6 @@ "format": "uuid", "type": "string" }, - "sourceType": { - "allOf": [ - { - "$ref": "#/components/schemas/SourceType" - } - ], - "default": "manual" - }, "width": { "type": "integer" }, @@ -8325,7 +8317,6 @@ "imageHeight", "imageWidth", "personId", - "sourceType", "width", "x", "y" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7237e0aac3..b2895f6f1d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -529,7 +529,6 @@ export type AssetFaceCreateDto = { imageHeight: number; imageWidth: number; personId: string; - sourceType: SourceType; width: number; x: number; y: number; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index c4d3018be2..0778c35b8f 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsEnum, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; import { DateTime } from 'luxon'; import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -194,10 +194,6 @@ export class AssetFaceCreateDto extends AssetFaceUpdateItem { @IsNotEmpty() @IsNumber() height!: number; - - @ApiProperty({ type: 'string', enum: SourceType, enumName: 'SourceType' }) - @IsEnum(SourceType) - sourceType: SourceType = SourceType.MANUAL; } export class AssetFaceDeleteDto { diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 62bf55a78c..e297910a95 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -736,7 +736,7 @@ export class PersonService extends BaseService { boundingBoxX2: dto.x + dto.width, boundingBoxY1: dto.y, boundingBoxY2: dto.y + dto.height, - sourceType: dto.sourceType, + sourceType: SourceType.MANUAL, }); } diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index bcc9ee6875..afe45331e4 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -4,7 +4,7 @@ import { notificationController } from '$lib/components/shared-components/notification/notification'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; - import { getAllPeople, createFace, type PersonResponseDto, SourceType } from '@immich/sdk'; + import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk'; import { Button } from '@immich/ui'; import { Canvas, InteractiveFabricObject, Rect } from 'fabric'; import { onMount } from 'svelte'; @@ -288,7 +288,6 @@ assetFaceCreateDto: { assetId, personId: person.id, - sourceType: SourceType.Manual, ...data, }, }); From fb907d707de8102ca29d1603d7f9b325cf5a8d2f Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 27 Feb 2025 14:22:02 +0000 Subject: [PATCH 012/104] refactor: use new updateId column for user CUD sync (#16384) --- server/src/db.d.ts | 1 + server/src/entities/user-audit.entity.ts | 10 +++--- ...740595460866-UsersAuditUuidv7PrimaryKey.ts | 26 ++++++++++++++ server/src/repositories/sync.repository.ts | 35 ++++--------------- server/src/services/sync.service.ts | 8 ++--- server/src/types.ts | 3 +- server/src/utils/sync.ts | 14 ++++---- 7 files changed, 50 insertions(+), 47 deletions(-) create mode 100644 server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts diff --git a/server/src/db.d.ts b/server/src/db.d.ts index ff4cb4a1d2..7fb073d8ce 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -415,6 +415,7 @@ export interface Users { } export interface UsersAudit { + id: Generated; userId: string; deletedAt: Generated; } diff --git a/server/src/entities/user-audit.entity.ts b/server/src/entities/user-audit.entity.ts index 305994a6d6..c29bc94d97 100644 --- a/server/src/entities/user-audit.entity.ts +++ b/server/src/entities/user-audit.entity.ts @@ -1,14 +1,14 @@ -import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; @Entity('users_audit') -@Index('IDX_users_audit_deleted_at_asc_user_id_asc', ['deletedAt', 'userId']) export class UserAuditEntity { - @PrimaryGeneratedColumn('increment') - id!: number; + @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + id!: string; @Column({ type: 'uuid' }) userId!: string; - @CreateDateColumn({ type: 'timestamptz' }) + @Index('IDX_users_audit_deleted_at') + @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) deletedAt!: Date; } diff --git a/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts b/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts new file mode 100644 index 0000000000..997f718fd9 --- /dev/null +++ b/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UsersAuditUuidv7PrimaryKey1740595460866 implements MigrationInterface { + name = 'UsersAuditUuidv7PrimaryKey1740595460866' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_users_audit_deleted_at_asc_user_id_asc"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" uuid NOT NULL DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180" PRIMARY KEY ("id")`); + await queryRunner.query(`ALTER TABLE "users_audit" ALTER COLUMN "deletedAt" SET DEFAULT clock_timestamp()`) + await queryRunner.query(`CREATE INDEX "IDX_users_audit_deleted_at" ON "users_audit" ("deletedAt")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_users_audit_deleted_at"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" SERIAL NOT NULL`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180" PRIMARY KEY ("id")`); + await queryRunner.query(`ALTER TABLE "users_audit" ALTER COLUMN "deletedAt" SET DEFAULT now()`); + await queryRunner.query(`CREATE INDEX "IDX_users_audit_deleted_at_asc_user_id_asc" ON "users_audit" ("userId", "deletedAt") `); + } + +} diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 4023bf890e..d1d0e9b8ee 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; -import { columns } from 'src/database'; import { DB, SessionSyncCheckpoints } from 'src/db'; import { SyncEntityType } from 'src/enum'; import { SyncAck } from 'src/types'; @@ -41,39 +40,19 @@ export class SyncRepository { getUserUpserts(ack?: SyncAck) { return this.db .selectFrom('users') - .select(['id', 'name', 'email', 'deletedAt']) - .select(columns.ackEpoch('updatedAt')) - .$if(!!ack, (qb) => - qb.where((eb) => - eb.or([ - eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<', eb.ref('updatedAt')), - eb.and([ - eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<=', eb.ref('updatedAt')), - eb('id', '>', ack!.ids[0]), - ]), - ]), - ), - ) - .orderBy(['updatedAt asc', 'id asc']) + .select(['id', 'name', 'email', 'deletedAt', 'updateId']) + .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) + .where('updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .orderBy(['updateId asc']) .stream(); } getUserDeletes(ack?: SyncAck) { return this.db .selectFrom('users_audit') - .select(['userId']) - .select(columns.ackEpoch('deletedAt')) - .$if(!!ack, (qb) => - qb.where((eb) => - eb.or([ - eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<', eb.ref('deletedAt')), - eb.and([ - eb(eb.fn('to_timestamp', [sql.val(ack!.ackEpoch)]), '<=', eb.ref('deletedAt')), - eb('userId', '>', ack!.ids[0]), - ]), - ]), - ), - ) + .select(['id', 'userId']) + .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) + .where('deletedAt', '<', sql.raw("now() - interval '1 millisecond'")) .orderBy(['deletedAt asc', 'userId asc']) .stream(); } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 98e4d5fb09..b756c11ef4 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -87,13 +87,13 @@ export class SyncService extends BaseService { switch (type) { case SyncRequestType.UsersV1: { const deletes = this.syncRepository.getUserDeletes(checkpointMap[SyncEntityType.UserDeleteV1]); - for await (const { ackEpoch, ...data } of deletes) { - response.write(serialize({ type: SyncEntityType.UserDeleteV1, ackEpoch, ids: [data.userId], data })); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.UserDeleteV1, updateId: id, data })); } const upserts = this.syncRepository.getUserUpserts(checkpointMap[SyncEntityType.UserV1]); - for await (const { ackEpoch, ...data } of upserts) { - response.write(serialize({ type: SyncEntityType.UserV1, ackEpoch, ids: [data.id], data })); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.UserV1, updateId, data })); } break; diff --git a/server/src/types.ts b/server/src/types.ts index 3aa7a14add..5360e519bd 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -421,6 +421,5 @@ export interface IBulkAsset { export type SyncAck = { type: SyncEntityType; - ackEpoch: string; - ids: string[]; + updateId: string; }; diff --git a/server/src/utils/sync.ts b/server/src/utils/sync.ts index 8e426ab860..cfb6660bdc 100644 --- a/server/src/utils/sync.ts +++ b/server/src/utils/sync.ts @@ -9,22 +9,20 @@ type Impossible = { type Exact = U & Impossible>; export const fromAck = (ack: string): SyncAck => { - const [type, timestamp, ...ids] = ack.split('|'); - return { type: type as SyncEntityType, ackEpoch: timestamp, ids }; + const [type, updateId] = ack.split('|'); + return { type: type as SyncEntityType, updateId }; }; -export const toAck = ({ type, ackEpoch, ids }: SyncAck) => [type, ackEpoch, ...ids].join('|'); +export const toAck = ({ type, updateId }: SyncAck) => [type, updateId].join('|'); export const mapJsonLine = (object: unknown) => JSON.stringify(object) + '\n'; export const serialize = ({ type, - ackEpoch, - ids, + updateId, data, }: { type: T; - ackEpoch: string; - ids: string[]; + updateId: string; data: Exact; -}) => mapJsonLine({ type, data, ack: toAck({ type, ackEpoch, ids }) }); +}) => mapJsonLine({ type, data, ack: toAck({ type, updateId }) }); From 6050485ad899e295bee203d8e39d9047927a2039 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 27 Feb 2025 17:24:40 +0300 Subject: [PATCH 013/104] feat(server): set exiftool process count (#16388) exiftool concurrency control --- .../src/repositories/metadata.repository.ts | 4 ++++ server/src/services/metadata.service.spec.ts | 22 +++++++++++++++++++ server/src/services/metadata.service.ts | 10 +++++++++ .../repositories/metadata.repository.mock.ts | 1 + 4 files changed, 37 insertions(+) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 3f297d709b..5df37a5ea7 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -85,6 +85,10 @@ export class MetadataRepository { this.logger.setContext(MetadataRepository.name); } + setMaxConcurrency(concurrency: number) { + this.exiftool.batchCluster.setMaxProcs(concurrency); + } + async teardown() { await this.exiftool.end(); } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index f5b10aa379..74f0231f5d 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -2,6 +2,7 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; +import { defaults } from 'src/config'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; @@ -54,6 +55,27 @@ describe(MetadataService.name, () => { }); }); + describe('onConfigInit', () => { + it('should update metadata processing concurrency', () => { + sut.onConfigInit({ newConfig: defaults }); + + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledWith(defaults.job.metadataExtraction.concurrency); + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledTimes(1); + }); + }); + + describe('onConfigUpdate', () => { + it('should update metadata processing concurrency', () => { + const newConfig = structuredClone(defaults); + newConfig.job.metadataExtraction.concurrency = 10; + + sut.onConfigUpdate({ oldConfig: defaults, newConfig }); + + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledWith(newConfig.job.metadataExtraction.concurrency); + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledTimes(1); + }); + }); + describe('handleLivePhotoLinking', () => { it('should handle an asset that could not be found', async () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 78ea8089e6..592e0b836d 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -89,6 +89,16 @@ export class MetadataService extends BaseService { await this.metadataRepository.teardown(); } + @OnEvent({ name: 'config.init', workers: [ImmichWorker.MICROSERVICES] }) + onConfigInit({ newConfig }: ArgOf<'config.init'>) { + this.metadataRepository.setMaxConcurrency(newConfig.job.metadataExtraction.concurrency); + } + + @OnEvent({ name: 'config.update', workers: [ImmichWorker.MICROSERVICES], server: true }) + onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { + this.metadataRepository.setMaxConcurrency(newConfig.job.metadataExtraction.concurrency); + } + private async init() { this.logger.log('Initializing metadata service'); diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index 47a0471b22..854f13b841 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -4,6 +4,7 @@ import { Mocked, vitest } from 'vitest'; export const newMetadataRepositoryMock = (): Mocked> => { return { + setMaxConcurrency: vitest.fn(), teardown: vitest.fn(), readTags: vitest.fn(), writeTags: vitest.fn(), From 9d705097e8d4cccf5ebd4726697e052f5e274e8a Mon Sep 17 00:00:00 2001 From: "immich-tofu[bot]" <171590969+immich-tofu[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 14:28:08 +0000 Subject: [PATCH 014/104] chore: modify .github/FUNDING.yml --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c7519a4684..acbb7c785b 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -custom: ['https://buy.immich.app'] +custom: ['https://buy.immich.app', 'https://immich.store'] From 9a098b46585e540f5d4f414d7e65a9d25629450f Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 09:46:20 -0600 Subject: [PATCH 015/104] fix(web): storage template incorrect example (#16367) --- .../settings/storage-template/storage-template-settings.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 74d240a4a6..8fca558976 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -203,7 +203,7 @@

UPLOAD_LOCATION/{$user.storageLabel || $user.id}UPLOAD_LOCATION/library/{$user.storageLabel || $user.id}/{parsedTemplate()}.jpg

From 082471dfd91234f61e8f0fb73fc47379660df98e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 09:46:34 -0600 Subject: [PATCH 016/104] chore(mobile): post release task (#16349) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index ab0a629ad4..90caae97fe 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +685,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +715,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +748,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -791,7 +791,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -831,7 +831,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 3051768e53..c08b4082cb 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.126.1 + 1.127.0 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 194 + 195 FLTEnableImpeller ITSAppUsesNonExemptEncryption From c70c9067b070fd650df0a6411f3dbb4c205ad076 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 09:56:23 -0600 Subject: [PATCH 017/104] refactor(mobile): backup provider (#16360) * refactor(mobile): backup provider * refactor(mobile): backup provider --- ...rface.dart => backup_album.interface.dart} | 2 +- .../lib/providers/backup/backup.provider.dart | 109 ++++++++---------- .../backup/manual_upload.provider.dart | 14 +-- .../lib/repositories/backup.repository.dart | 11 +- mobile/lib/services/album.service.dart | 6 +- mobile/lib/services/asset.service.dart | 6 +- mobile/lib/services/background.service.dart | 4 +- mobile/lib/services/backup_album.service.dart | 34 ++++++ mobile/test/repository.mocks.dart | 4 +- 9 files changed, 109 insertions(+), 81 deletions(-) rename mobile/lib/interfaces/{backup.interface.dart => backup_album.interface.dart} (85%) create mode 100644 mobile/lib/services/backup_album.service.dart diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup_album.interface.dart similarity index 85% rename from mobile/lib/interfaces/backup.interface.dart rename to mobile/lib/interfaces/backup_album.interface.dart index c32199a58f..f98adb6821 100644 --- a/mobile/lib/interfaces/backup.interface.dart +++ b/mobile/lib/interfaces/backup_album.interface.dart @@ -1,7 +1,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IBackupRepository implements IDatabaseRepository { +abstract interface class IBackupAlbumRepository implements IDatabaseRepository { Future> getAll({BackupAlbumSort? sort}); Future> getIdsBySelection(BackupSelection backup); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 3b0f724411..a4f4fea45c 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; @@ -23,21 +23,34 @@ import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; -import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; +import 'package:immich_mobile/services/backup_album.service.dart'; import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; +final backupProvider = + StateNotifierProvider((ref) { + return BackupNotifier( + ref.watch(backupServiceProvider), + ref.watch(serverInfoServiceProvider), + ref.watch(authProvider), + ref.watch(backgroundServiceProvider), + ref.watch(galleryPermissionNotifier.notifier), + ref.watch(albumMediaRepositoryProvider), + ref.watch(fileMediaRepositoryProvider), + ref.watch(backupAlbumServiceProvider), + ref, + ); +}); + class BackupNotifier extends StateNotifier { BackupNotifier( this._backupService, @@ -45,10 +58,9 @@ class BackupNotifier extends StateNotifier { this._authState, this._backgroundService, this._galleryPermissionNotifier, - this._db, this._albumMediaRepository, this._fileMediaRepository, - this._backupRepository, + this._backupAlbumService, this.ref, ) : super( BackUpState( @@ -96,10 +108,9 @@ class BackupNotifier extends StateNotifier { final AuthState _authState; final BackgroundService _backgroundService; final GalleryPermissionNotifier _galleryPermissionNotifier; - final Isar _db; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; - final IBackupRepository _backupRepository; + final BackupAlbumService _backupAlbumService; final Ref ref; /// @@ -260,9 +271,9 @@ class BackupNotifier extends StateNotifier { state = state.copyWith(availableAlbums: availableAlbums); final List excludedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.exclude); + await _backupAlbumService.getAllBySelection(BackupSelection.exclude); final List selectedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.select); + await _backupAlbumService.getAllBySelection(BackupSelection.select); final Set selectedAlbums = {}; for (final BackupAlbum ba in selectedBackupAlbums) { @@ -439,7 +450,7 @@ class BackupNotifier extends StateNotifier { } /// Save user selection of selected albums and excluded albums to database - Future _updatePersistentAlbumsSelection() { + Future _updatePersistentAlbumsSelection() async { final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); final selected = state.selectedBackupAlbums.map( (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select), @@ -447,29 +458,30 @@ class BackupNotifier extends StateNotifier { final excluded = state.excludedBackupAlbums.map( (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude), ); - final backupAlbums = selected.followedBy(excluded).toList(); - backupAlbums.sortBy((e) => e.id); - return _db.writeTxn(() async { - final dbAlbums = await _db.backupAlbums.where().sortById().findAll(); - final List toDelete = []; - final List toUpsert = []; - // stores the most recent `lastBackup` per album but always keeps the `selection` the user just made - diffSortedListsSync( - dbAlbums, - backupAlbums, - compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), - both: (BackupAlbum a, BackupAlbum b) { - b.lastBackup = - a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup; - toUpsert.add(b); - return true; - }, - onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId), - onlySecond: (BackupAlbum b) => toUpsert.add(b), - ); - await _db.backupAlbums.deleteAll(toDelete); - await _db.backupAlbums.putAll(toUpsert); - }); + final candidates = selected.followedBy(excluded).toList(); + candidates.sortBy((e) => e.id); + + final savedBackupAlbums = + await _backupAlbumService.getAll(sort: BackupAlbumSort.id); + final List toDelete = []; + final List toUpsert = []; + + diffSortedListsSync( + savedBackupAlbums, + candidates, + compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), + both: (BackupAlbum a, BackupAlbum b) { + b.lastBackup = + a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup; + toUpsert.add(b); + return true; + }, + onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId), + onlySecond: (BackupAlbum b) => toUpsert.add(b), + ); + + await _backupAlbumService.deleteAll(toDelete); + await _backupAlbumService.updateAll(toUpsert); } /// Invoke backup process @@ -686,14 +698,10 @@ class BackupNotifier extends StateNotifier { } Future resumeBackup() async { - final List selectedBackupAlbums = await _db.backupAlbums - .filter() - .selectionEqualTo(BackupSelection.select) - .findAll(); - final List excludedBackupAlbums = await _db.backupAlbums - .filter() - .selectionEqualTo(BackupSelection.exclude) - .findAll(); + final List selectedBackupAlbums = + await _backupAlbumService.getAllBySelection(BackupSelection.select); + final List excludedBackupAlbums = + await _backupAlbumService.getAllBySelection(BackupSelection.exclude); Set selectedAlbums = state.selectedBackupAlbums; Set excludedAlbums = state.excludedBackupAlbums; if (selectedAlbums.isNotEmpty) { @@ -756,23 +764,8 @@ class BackupNotifier extends StateNotifier { } BackUpProgressEnum get backupProgress => state.backupProgress; + void updateBackupProgress(BackUpProgressEnum backupProgress) { state = state.copyWith(backupProgress: backupProgress); } } - -final backupProvider = - StateNotifierProvider((ref) { - return BackupNotifier( - ref.watch(backupServiceProvider), - ref.watch(serverInfoServiceProvider), - ref.watch(authProvider), - ref.watch(backgroundServiceProvider), - ref.watch(galleryPermissionNotifier.notifier), - ref.watch(dbProvider), - ref.watch(albumMediaRepositoryProvider), - ref.watch(fileMediaRepositoryProvider), - ref.watch(backupRepositoryProvider), - ref, - ); -}); diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 192126f085..6eaf0f7226 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -9,7 +9,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; -import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; @@ -24,6 +23,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/services/backup_album.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; @@ -37,7 +37,7 @@ final manualUploadProvider = ref.watch(localNotificationService), ref.watch(backupProvider.notifier), ref.watch(backupServiceProvider), - ref.watch(backupRepositoryProvider), + ref.watch(backupAlbumServiceProvider), ref, ); }); @@ -47,14 +47,14 @@ class ManualUploadNotifier extends StateNotifier { final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; final BackupService _backupService; - final BackupRepository _backupRepository; + final BackupAlbumService _backupAlbumService; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, this._backupService, - this._backupRepository, + this._backupAlbumService, this.ref, ) : super( ManualUploadState( @@ -210,9 +210,9 @@ class ManualUploadNotifier extends StateNotifier { } final selectedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.select); - final excludedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.exclude); + await _backupAlbumService.getAllBySelection(BackupSelection.select); + final excludedBackupAlbums = await _backupAlbumService + .getAllBySelection(BackupSelection.exclude); // Get candidates from selected albums and excluded albums Set candidates = diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart index ed3a9c27e4..f7f3051f46 100644 --- a/mobile/lib/repositories/backup.repository.dart +++ b/mobile/lib/repositories/backup.repository.dart @@ -1,15 +1,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; -final backupRepositoryProvider = - Provider((ref) => BackupRepository(ref.watch(dbProvider))); +final backupAlbumRepositoryProvider = + Provider((ref) => BackupAlbumRepository(ref.watch(dbProvider))); -class BackupRepository extends DatabaseRepository implements IBackupRepository { - BackupRepository(super.db); +class BackupAlbumRepository extends DatabaseRepository + implements IBackupAlbumRepository { + BackupAlbumRepository(super.db); @override Future> getAll({BackupAlbumSort? sort}) { diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 142ac48193..9c1f25f0a5 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -16,7 +16,7 @@ import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; @@ -36,7 +36,7 @@ final albumServiceProvider = Provider( ref.watch(entityServiceProvider), ref.watch(albumRepositoryProvider), ref.watch(assetRepositoryProvider), - ref.watch(backupRepositoryProvider), + ref.watch(backupAlbumRepositoryProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(albumApiRepositoryProvider), ), @@ -48,7 +48,7 @@ class AlbumService { final EntityService _entityService; final IAlbumRepository _albumRepository; final IAssetRepository _assetRepository; - final IBackupRepository _backupAlbumRepository; + final IBackupAlbumRepository _backupAlbumRepository; final IAlbumMediaRepository _albumMediaRepository; final IAlbumApiRepository _albumApiRepository; final Logger _log = Logger('AlbumService'); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index b4a2c097b7..a4e77c216d 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; @@ -39,7 +39,7 @@ final assetServiceProvider = Provider( ref.watch(exifInfoRepositoryProvider), ref.watch(userRepositoryProvider), ref.watch(etagRepositoryProvider), - ref.watch(backupRepositoryProvider), + ref.watch(backupAlbumRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), @@ -55,7 +55,7 @@ class AssetService { final IExifInfoRepository _exifInfoRepository; final IUserRepository _userRepository; final IETagRepository _etagRepository; - final IBackupRepository _backupRepository; + final IBackupAlbumRepository _backupRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 81619bdca1..2a7bfb2bb4 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -14,7 +14,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -377,7 +377,7 @@ class BackgroundService { AppSettingsService settingsService = AppSettingsService(); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); - BackupRepository backupRepository = BackupRepository(db); + BackupAlbumRepository backupRepository = BackupAlbumRepository(db); ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); ETagRepository eTagRepository = ETagRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); diff --git a/mobile/lib/services/backup_album.service.dart b/mobile/lib/services/backup_album.service.dart new file mode 100644 index 0000000000..8030d66937 --- /dev/null +++ b/mobile/lib/services/backup_album.service.dart @@ -0,0 +1,34 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; + +final backupAlbumServiceProvider = Provider((ref) { + return BackupAlbumService(ref.watch(backupAlbumRepositoryProvider)); +}); + +class BackupAlbumService { + final IBackupAlbumRepository _backupAlbumRepository; + + BackupAlbumService(this._backupAlbumRepository); + + Future> getAll({BackupAlbumSort? sort}) { + return _backupAlbumRepository.getAll(sort: sort); + } + + Future> getIdsBySelection(BackupSelection backup) { + return _backupAlbumRepository.getIdsBySelection(backup); + } + + Future> getAllBySelection(BackupSelection backup) { + return _backupAlbumRepository.getAllBySelection(backup); + } + + Future deleteAll(List ids) { + return _backupAlbumRepository.deleteAll(ids); + } + + Future updateAll(List backupAlbums) { + return _backupAlbumRepository.updateAll(backupAlbums); + } +} diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 3dda932cac..bad7d3ebab 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart'; import 'package:immich_mobile/interfaces/auth_api.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; @@ -18,7 +18,7 @@ class MockAssetRepository extends Mock implements IAssetRepository {} class MockUserRepository extends Mock implements IUserRepository {} -class MockBackupRepository extends Mock implements IBackupRepository {} +class MockBackupRepository extends Mock implements IBackupAlbumRepository {} class MockExifInfoRepository extends Mock implements IExifInfoRepository {} From a808b8610e762eab17038a0b25c2ca84d7e1e691 Mon Sep 17 00:00:00 2001 From: Tom Graham Date: Fri, 28 Feb 2025 03:14:09 +1100 Subject: [PATCH 018/104] fix(server): Fix delay with multiple ml servers (#16284) * Prospective fix for ensuring that known active ML servers are used to reduce search delay. * Added some logging and renamed backoff const. * Fix lint issues. * Update to use env vars for timeouts and updated documentation and strings. * Fix docs. * Make counter logic clearer. * Minor readability improvements. * Extract skipUrl logic per feedback, and change log to verbose. * Make code harder to read. --- docs/docs/administration/system-settings.md | 8 +++ docs/docs/install/environment-variables.md | 2 + i18n/en.json | 2 +- server/src/constants.ts | 5 ++ .../machine-learning.repository.ts | 66 +++++++++++++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/docs/docs/administration/system-settings.md b/docs/docs/administration/system-settings.md index 92b910a01b..f241050136 100644 --- a/docs/docs/administration/system-settings.md +++ b/docs/docs/administration/system-settings.md @@ -98,6 +98,14 @@ The default Immich log level is `Log` (commonly known as `Info`). The Immich adm Through this setting, you can manage all the settings related to machine learning in Immich, from the setting of remote machine learning to the model and its parameters You can choose to disable a certain type of machine learning, for example smart search or facial recognition. +### URL + +The built in (`http://immich-machine-learning:3003`) machine learning server will be configured by default, but you can change this or add additional servers. + +Hosting the `immich-machine-learning` container on a machine with a more powerful GPU can be helpful to for processing a large number of photos (such as during batch import) or for faster search. + +If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online. + ### Smart Search The [smart search](/docs/features/searching) settings allow you to change the [CLIP model](https://openai.com/research/clip). Larger models will typically provide [more accurate search results](https://github.com/immich-app/immich/discussions/11862) but consume more processing power and RAM. When [changing the CLIP model](/docs/FAQ#can-i-use-a-custom-clip-model) it is mandatory to re-run the Smart Search job on all images to fully apply the change. diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index a57eef540d..16f05b6338 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -168,6 +168,8 @@ Redis (Sentinel) URL example JSON before encoding: | `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | | `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning | | `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning | +| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server | +| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server | \*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones. diff --git a/i18n/en.json b/i18n/en.json index 1bf118976e..e35f1906c4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -131,7 +131,7 @@ "machine_learning_smart_search_description": "Search for images semantically using CLIP embeddings", "machine_learning_smart_search_enabled": "Enable smart search", "machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.", - "machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last.", + "machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.", "manage_concurrency": "Manage Concurrency", "manage_log_settings": "Manage log settings", "map_dark_style": "Dark style", diff --git a/server/src/constants.ts b/server/src/constants.ts index 889ce81620..20ce7dd497 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -38,6 +38,11 @@ export const ONE_HOUR = Duration.fromObject({ hours: 1 }); export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; +export const MACHINE_LEARNING_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000); +export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number( + process.env.MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME || 30_000, +); + export const citiesFile = 'cities500.txt'; export const MOBILE_REDIRECT = 'app.immich:///oauth-callback'; diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index 8145bf3154..5e916c71f3 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { readFile } from 'node:fs/promises'; +import { MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME, MACHINE_LEARNING_PING_TIMEOUT } from 'src/constants'; import { CLIPConfig } from 'src/dtos/model-config.dto'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -55,16 +56,80 @@ export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | Fa @Injectable() export class MachineLearningRepository { + // Note that deleted URL's are not removed from this map (ie: they're leaked) + // Cleaning them up is low priority since there should be very few over a + // typical server uptime cycle + private urlAvailability: { + [url: string]: + | { + active: boolean; + lastChecked: number; + } + | undefined; + }; + constructor(private logger: LoggingRepository) { this.logger.setContext(MachineLearningRepository.name); + this.urlAvailability = {}; + } + + private setUrlAvailability(url: string, active: boolean) { + const current = this.urlAvailability[url]; + if (current?.active !== active) { + this.logger.verbose(`Setting ${url} ML server to ${active ? 'active' : 'inactive'}.`); + } + this.urlAvailability[url] = { + active, + lastChecked: Date.now(), + }; + } + + private async checkAvailability(url: string) { + let active = false; + try { + const response = await fetch(new URL('/ping', url), { + signal: AbortSignal.timeout(MACHINE_LEARNING_PING_TIMEOUT), + }); + active = response.ok; + } catch {} + this.setUrlAvailability(url, active); + return active; + } + + private async shouldSkipUrl(url: string) { + const availability = this.urlAvailability[url]; + if (availability === undefined) { + // If this is a new endpoint, then check inline and skip if it fails + if (!(await this.checkAvailability(url))) { + return true; + } + return false; + } + if (!availability.active && Date.now() - availability.lastChecked < MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME) { + // If this is an old inactive endpoint that hasn't been checked in a + // while then check but don't wait for the result, just skip it + // This avoids delays on every search whilst allowing higher priority + // ML servers to recover over time. + void this.checkAvailability(url); + return true; + } + return false; } private async predict(urls: string[], payload: ModelPayload, config: MachineLearningRequest): Promise { const formData = await this.getFormData(payload, config); + let urlCounter = 0; for (const url of urls) { + urlCounter++; + const isLast = urlCounter >= urls.length; + if (!isLast && (await this.shouldSkipUrl(url))) { + continue; + } + try { const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); if (response.ok) { + this.setUrlAvailability(url, true); return response.json(); } @@ -76,6 +141,7 @@ export class MachineLearningRepository { `Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`, ); } + this.setUrlAvailability(url, false); } throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`); From a708649504f9ecd952f34346ebfb21ddfb5ccb50 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 27 Feb 2025 19:16:13 +0300 Subject: [PATCH 019/104] fix(server): skip stacked assets in duplicate detection (#16380) * skip stacked assets in duplicate detection * update sql * handle stacking after duplicate detection runs --- ...480319-UnsetStackedAssetsFromDuplicateStatus.ts | 14 ++++++++++++++ server/src/queries/asset.repository.sql | 1 + server/src/queries/search.repository.sql | 1 + server/src/repositories/asset.repository.ts | 1 + server/src/repositories/search.repository.ts | 1 + server/src/services/duplicate.service.spec.ts | 10 ++++++++++ server/src/services/duplicate.service.ts | 5 +++++ server/test/fixtures/asset.stub.ts | 1 + 8 files changed, 34 insertions(+) create mode 100644 server/src/migrations/1740654480319-UnsetStackedAssetsFromDuplicateStatus.ts diff --git a/server/src/migrations/1740654480319-UnsetStackedAssetsFromDuplicateStatus.ts b/server/src/migrations/1740654480319-UnsetStackedAssetsFromDuplicateStatus.ts new file mode 100644 index 0000000000..5c735a60bb --- /dev/null +++ b/server/src/migrations/1740654480319-UnsetStackedAssetsFromDuplicateStatus.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UnsetStackedAssetsFromDuplicateStatus1740654480319 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update assets + set "duplicateId" = null + where "stackId" is not null`); + } + + public async down(): Promise { + // No need to revert this migration + } +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 879152dc77..ce53bd1791 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -333,6 +333,7 @@ with and "assets"."duplicateId" is not null and "assets"."deletedAt" is null and "assets"."isVisible" = $2 + and "assets"."stackId" is null group by "assets"."duplicateId" ), diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 9400700e56..06590dc817 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -112,6 +112,7 @@ with and "assets"."isVisible" = $3 and "assets"."type" = $4 and "assets"."id" != $5::uuid + and "assets"."stackId" is null order by smart_search.embedding <=> $6 limit diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 139e652f03..daefacef09 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -794,6 +794,7 @@ export class AssetRepository { .where('assets.duplicateId', 'is not', null) .where('assets.deletedAt', 'is', null) .where('assets.isVisible', '=', true) + .where('assets.stackId', 'is', null) .groupBy('assets.duplicateId'), ) .with('unique', (qb) => diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 2f313aa083..46f38db55f 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -318,6 +318,7 @@ export class SearchRepository { .where('assets.isVisible', '=', true) .where('assets.type', '=', type) .where('assets.id', '!=', asUuid(assetId)) + .where('assets.stackId', 'is', null) .orderBy(sql`smart_search.embedding <=> ${embedding}`) .limit(64), ) diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 30b8cd3451..8be943eaf0 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -173,6 +173,16 @@ describe(SearchService.name, () => { expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); }); + it('should skip if asset is part of stack', async () => { + const id = assetStub.primaryImage.id; + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); + + const result = await sut.handleSearchDuplicates({ id }); + + expect(result).toBe(JobStatus.SKIPPED); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is part of a stack, skipping`); + }); + it('should skip if asset is not visible', async () => { const id = assetStub.livePhotoMotionAsset.id; mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 5600033b47..74b86f8e4e 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -59,6 +59,11 @@ export class DuplicateService extends BaseService { return JobStatus.FAILED; } + if (asset.stackId) { + this.logger.debug(`Asset ${id} is part of a stack, skipping`); + return JobStatus.SKIPPED; + } + if (!asset.isVisible) { this.logger.debug(`Asset ${id} is not visible, skipping`); return JobStatus.SKIPPED; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 6c20a765c7..a0619f1a10 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -184,6 +184,7 @@ export const assetStub = { exifImageHeight: 1000, exifImageWidth: 1000, } as ExifEntity, + stackId: 'stack-1', stack: stackStub('stack-1', [ { id: 'primary-asset-id' } as AssetEntity, { id: 'stack-child-asset-1' } as AssetEntity, From d20e2e268ad7a48930b5b11a6ab69396d04c1e41 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 27 Feb 2025 17:45:16 +0100 Subject: [PATCH 020/104] fix(server): don't reimport files more than once (#16375) * fix(server) don't reimport files more than once * fix: test --------- Co-authored-by: Alex Tran --- e2e/src/api/specs/library.e2e-spec.ts | 41 ++++++++++++++++++++ server/src/services/metadata.service.spec.ts | 6 --- server/src/services/metadata.service.ts | 2 +- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 19160fec88..4b340a2da5 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -526,6 +526,47 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); + it('should not reimport a modified file more than once', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/reimport`], + }); + + utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); + + await utils.scan(admin.accessToken, library.id); + + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001); + + await utils.scan(admin.accessToken, library.id); + + cpSync(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001); + + await utils.scan(admin.accessToken, library.id); + + const { assets } = await utils.searchAssets(admin.accessToken, { + libraryId: library.id, + }); + + expect(assets.count).toEqual(1); + + const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + + expect(asset).toEqual( + expect.objectContaining({ + originalFileName: 'asset.jpg', + exifInfo: expect.objectContaining({ + model: 'NIKON D750', + }), + }), + ); + + utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); + }); + it('should set an asset offline if its file is missing', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 74f0231f5d..5fb4ba9a4f 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -249,7 +249,6 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: sidecarDate, - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: sidecarDate, }); }); @@ -269,7 +268,6 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: fileModifiedAt, - fileModifiedAt, localDateTime: fileModifiedAt, }); }); @@ -287,7 +285,6 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt, - fileModifiedAt, localDateTime: fileCreatedAt, }); }); @@ -322,7 +319,6 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: assetStub.image.fileCreatedAt, - fileModifiedAt: assetStub.image.fileModifiedAt, localDateTime: assetStub.image.fileCreatedAt, }); }); @@ -345,7 +341,6 @@ describe(MetadataService.name, () => { id: assetStub.withLocation.id, duration: null, fileCreatedAt: assetStub.withLocation.createdAt, - fileModifiedAt: assetStub.withLocation.createdAt, localDateTime: new Date('2023-02-22T05:06:29.716Z'), }); }); @@ -867,7 +862,6 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: dateForTest, - fileModifiedAt: dateForTest, localDateTime: dateForTest, }); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 592e0b836d..b0422c28c0 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -248,7 +248,7 @@ export class MetadataService extends BaseService { duration: exifTags.Duration?.toString() ?? null, localDateTime, fileCreatedAt: exifData.dateTimeOriginal ?? undefined, - fileModifiedAt: exifData.modifyDate ?? undefined, + fileModifiedAt: stats.mtime, }); await this.assetRepository.upsertJobStatus({ From 5503bf7a6062e64452acbc0e7fcdf9822dca7e2b Mon Sep 17 00:00:00 2001 From: Etienne <51496600+Etienne-bdt@users.noreply.github.com> Date: Thu, 27 Feb 2025 18:20:03 +0100 Subject: [PATCH 021/104] fix: improve contrast on disabled input field in light mode (#16368) (#16382) --- web/src/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app.css b/web/src/app.css index 1127b60624..9bc1695a8f 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -127,7 +127,7 @@ input:focus-visible { @layer utilities { .immich-form-input { - @apply rounded-xl bg-slate-200 px-3 py-3 text-sm focus:border-immich-primary disabled:cursor-not-allowed disabled:bg-gray-400 disabled:text-gray-200 dark:bg-gray-600 dark:text-immich-dark-fg dark:disabled:bg-gray-800; + @apply rounded-xl bg-slate-200 px-3 py-3 text-sm focus:border-immich-primary disabled:cursor-not-allowed disabled:bg-gray-400 disabled:text-gray-400 dark:bg-gray-600 dark:text-immich-dark-fg dark:disabled:bg-gray-800 dark:disabled:text-gray-200; } .immich-form-label { From 362feb1e6246144a77e4aac122d09f35c372e136 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 11:49:07 -0600 Subject: [PATCH 022/104] feat(web): face tagging dialog enhancement (#16395) --- .../face-editor/face-editor.svelte | 75 ++++++++++++------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index afe45331e4..e7709cd9cc 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -5,7 +5,7 @@ import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk'; - import { Button } from '@immich/ui'; + import { Button, Input } from '@immich/ui'; import { Canvas, InteractiveFabricObject, Rect } from 'fabric'; import { onMount } from 'svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; @@ -24,6 +24,16 @@ let canvas: Canvas | undefined = $state(); let faceRect: Rect | undefined = $state(); let faceSelectorEl: HTMLDivElement | undefined = $state(); + let page = $state(1); + let candidates = $state([]); + + let searchTerm = $state(''); + + let filteredCandidates = $derived( + searchTerm + ? candidates.filter((person) => person.name.toLowerCase().includes(searchTerm.toLowerCase())) + : candidates, + ); const configureControlStyle = () => { InteractiveFabricObject.ownDefaults = { @@ -133,11 +143,8 @@ isFaceEditMode.value = false; }; - let page = $state(1); - let candidates = $state([]); - const getPeople = async () => { - const { hasNextPage, people, total } = await getAllPeople({ page, size: 250, withHidden: false }); + const { hasNextPage, people, total } = await getAllPeople({ page, size: 1000, withHidden: false }); if (candidates.length === total) { return; @@ -307,33 +314,43 @@

Select a person to tag

-
-
- {#each candidates as person} - - {/each} -
+
+ +
+ +
+ {#if filteredCandidates.length > 0} +
+ {#each filteredCandidates as person} + + {/each} +
+ {:else} +
+

No matching people found

+
+ {/if}
From 4a9d80298b62dab8dc4061496d520e8405d34765 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 27 Feb 2025 23:31:36 +0530 Subject: [PATCH 023/104] fix(mobile): bootstrap store inside isolates (#16392) fix: bootstrap store inside isolates Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- mobile/analysis_options.yaml | 1 + .../test_utils/general_helper.dart | 5 +- mobile/lib/main.dart | 42 ++-------------- mobile/lib/services/background.service.dart | 5 +- .../services/backup_verification.service.dart | 3 ++ mobile/lib/utils/bootstrap.dart | 50 +++++++++++++++++++ 6 files changed, 63 insertions(+), 43 deletions(-) create mode 100644 mobile/lib/utils/bootstrap.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index dd081be64e..c8ed225ce5 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -76,6 +76,7 @@ custom_lint: - lib/routing/router.dart - lib/services/immich_logger.service.dart # not really a service... more a util - lib/utils/{db,migration}.dart + - lib/utils/bootstrap.dart - lib/widgets/asset_grid/asset_grid_data_structure.dart - test/**.dart # refactor the remaining providers diff --git a/mobile/integration_test/test_utils/general_helper.dart b/mobile/integration_test/test_utils/general_helper.dart index a3db2d49a8..8e17bae9d3 100644 --- a/mobile/integration_test/test_utils/general_helper.dart +++ b/mobile/integration_test/test_utils/general_helper.dart @@ -7,8 +7,8 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/main.dart' as app; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:isar/isar.dart'; // ignore: depend_on_referenced_packages import 'package:meta/meta.dart'; @@ -39,7 +39,8 @@ class ImmichTestHelper { static Future loadApp(WidgetTester tester) async { await EasyLocalization.ensureInitialized(); // Clear all data from Isar (reuse existing instance if available) - final db = Isar.getInstance() ?? await app.loadDb(); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); await Store.clear(); await db.writeTxn(() => db.clear()); // Load main Widget diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 822d772278..da84a8cff6 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -10,20 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/locales.dart'; -import 'package:immich_mobile/domain/services/store.service.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'; -import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; -import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; -import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -37,19 +24,19 @@ import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/download.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:intl/date_symbol_data_local.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:timezone/data/latest.dart'; void main() async { ImmichWidgetsBinding(); - final db = await loadDb(); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); await initApp(); await migrateDatabaseIfNeeded(db); HttpOverrides.global = HttpSSLCertOverride(); @@ -122,29 +109,6 @@ Future initApp() async { await FileDownloader().trackTasks(); } -Future loadDb() async { - final dir = await getApplicationDocumentsDirectory(); - Isar db = await Isar.open( - [ - StoreValueSchema, - ExifInfoSchema, - AssetSchema, - AlbumSchema, - UserSchema, - BackupAlbumSchema, - DuplicatedAssetSchema, - LoggerMessageSchema, - ETagSchema, - if (Platform.isAndroid) AndroidDeviceAssetSchema, - if (Platform.isIOS) IOSDeviceAssetSchema, - ], - directory: dir.path, - maxSizeMiB: 1024, - ); - await StoreService.init(storeRepository: IsarStoreRepository(db)); - return db; -} - class ImmichApp extends ConsumerStatefulWidget { const ImmichApp({super.key}); diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 2a7bfb2bb4..4e83d1f0ac 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -15,7 +15,6 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart'; -import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; @@ -48,6 +47,7 @@ import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:network_info_plus/network_info_plus.dart'; @@ -369,7 +369,8 @@ class BackgroundService { } Future _onAssetsChanged() async { - final db = await loadDb(); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 5938cd7813..0d47d1a111 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/diff.dart'; /// Finds duplicates originating from missing EXIF information @@ -123,6 +124,8 @@ class BackupVerificationService { assert(tuple.deleteCandidates.length == tuple.originals.length); final List result = []; BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); await tuple.fileMediaRepository.enableBackgroundAccess(); final ApiService apiService = ApiService(); apiService.setEndpoint(tuple.endpoint); diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart new file mode 100644 index 0000000000..32bdac42d5 --- /dev/null +++ b/mobile/lib/utils/bootstrap.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/services/store.service.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'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; +import 'package:immich_mobile/entities/logger_message.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:isar/isar.dart'; +import 'package:path_provider/path_provider.dart'; + +abstract final class Bootstrap { + static Future initIsar() async { + if (Isar.getInstance() != null) { + return Isar.getInstance()!; + } + + final dir = await getApplicationDocumentsDirectory(); + return await Isar.open( + [ + StoreValueSchema, + ExifInfoSchema, + AssetSchema, + AlbumSchema, + UserSchema, + BackupAlbumSchema, + DuplicatedAssetSchema, + LoggerMessageSchema, + ETagSchema, + if (Platform.isAndroid) AndroidDeviceAssetSchema, + if (Platform.isIOS) IOSDeviceAssetSchema, + ], + directory: dir.path, + maxSizeMiB: 1024, + inspector: kDebugMode, + ); + } + + static Future initDomain(Isar db) async { + await StoreService.init(storeRepository: IsarStoreRepository(db)); + } +} From 1c862930355b0ca159f659a5009eb7994fb67efe Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 12:35:28 -0600 Subject: [PATCH 024/104] chore(mobile): update analysis option (#16396) chore-update-analysis-option --- mobile/analysis_options.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index c8ed225ce5..5cf21e1dd6 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -81,7 +81,6 @@ custom_lint: - test/**.dart # refactor the remaining providers - lib/providers/db.provider.dart - - lib/providers/backup/backup.provider.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories From fbd85a89e05213c4968b7cd995097600b08a3cf6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 27 Feb 2025 14:59:50 -0500 Subject: [PATCH 025/104] refactor: logger (#16393) --- server/src/bin/sync-sql.ts | 4 +- .../repositories/logging.repository.spec.ts | 30 ++-- server/src/repositories/logging.repository.ts | 140 ++++++++++++++---- server/src/repositories/map.repository.ts | 11 +- .../notification.repository.spec.ts | 9 +- .../repositories/storage.repository.spec.ts | 8 +- server/src/services/storage.service.ts | 2 +- .../medium/specs/metadata.service.spec.ts | 7 +- .../repositories/logger.repository.mock.ts | 22 +-- server/test/utils.ts | 6 +- 10 files changed, 153 insertions(+), 86 deletions(-) diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index 4765993643..a9f5d72ec9 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -6,6 +6,7 @@ import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ClassConstructor } from 'class-transformer'; import { PostgresJSDialect } from 'kysely-postgres-js'; +import { ClsModule } from 'nestjs-cls'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; import { mkdir, rm, writeFile } from 'node:fs/promises'; @@ -77,7 +78,7 @@ class SqlGenerator { await mkdir(this.options.targetDir); process.env.DB_HOSTNAME = 'localhost'; - const { database, otel } = new ConfigRepository().getEnv(); + const { database, cls, otel } = new ConfigRepository().getEnv(); const moduleFixture = await Test.createTestingModule({ imports: [ @@ -92,6 +93,7 @@ class SqlGenerator { } }, }), + ClsModule.forRoot(cls.config), TypeOrmModule.forRoot({ ...database.config.typeorm, entities, diff --git a/server/src/repositories/logging.repository.spec.ts b/server/src/repositories/logging.repository.spec.ts index 10c1a6516c..393eeb9496 100644 --- a/server/src/repositories/logging.repository.spec.ts +++ b/server/src/repositories/logging.repository.spec.ts @@ -1,8 +1,8 @@ import { ClsService } from 'nestjs-cls'; import { ImmichWorker } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; -import { LoggingRepository } from 'src/repositories/logging.repository'; -import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; +import { LoggingRepository, MyConsoleLogger } from 'src/repositories/logging.repository'; +import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { Mocked } from 'vitest'; describe(LoggingRepository.name, () => { @@ -18,23 +18,25 @@ describe(LoggingRepository.name, () => { } as unknown as Mocked; }); - describe('formatContext', () => { - it('should use colors', () => { - configMock.getEnv.mockReturnValue(mockEnvData({ noColor: false })); + describe(MyConsoleLogger.name, () => { + describe('formatContext', () => { + it('should use colors', () => { + sut = new LoggingRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); - sut = new LoggingRepository(clsMock, configMock); - sut.setAppName(ImmichWorker.API); + const logger = new MyConsoleLogger(clsMock, { color: true }); - expect(sut['formatContext']('context')).toBe('\u001B[33m[Api:context]\u001B[39m '); - }); + expect(logger.formatContext('context')).toBe('\u001B[33m[Api:context]\u001B[39m '); + }); - it('should not use colors when noColor is true', () => { - configMock.getEnv.mockReturnValue(mockEnvData({ noColor: true })); + it('should not use colors when color is false', () => { + sut = new LoggingRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); - sut = new LoggingRepository(clsMock, configMock); - sut.setAppName(ImmichWorker.API); + const logger = new MyConsoleLogger(clsMock, { color: false }); - expect(sut['formatContext']('context')).toBe('[Api:context] '); + expect(logger.formatContext('context')).toBe('[Api:context] '); + }); }); }); }); diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 7ddae26a9d..801f467a6d 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -5,6 +5,9 @@ import { Telemetry } from 'src/decorators'; import { LogLevel } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; +type LogDetails = any[]; +type LogFunction = () => string; + const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; enum LogColor { @@ -16,38 +19,22 @@ enum LogColor { CYAN_BRIGHT = 96, } -@Injectable({ scope: Scope.TRANSIENT }) -@Telemetry({ enabled: false }) -export class LoggingRepository extends ConsoleLogger { - private static logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; - private noColor: boolean; +let appName: string | undefined; +let logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; + +export class MyConsoleLogger extends ConsoleLogger { + private isColorEnabled: boolean; constructor( private cls: ClsService, - configRepository: ConfigRepository, + options?: { color?: boolean; context?: string }, ) { - super(LoggingRepository.name); - - const { noColor } = configRepository.getEnv(); - this.noColor = noColor; + super(options?.context || MyConsoleLogger.name); + this.isColorEnabled = options?.color || false; } - private static appName?: string = undefined; - - setAppName(name: string): void { - LoggingRepository.appName = name.charAt(0).toUpperCase() + name.slice(1); - } - - isLevelEnabled(level: LogLevel) { - return isLogLevelEnabled(level, LoggingRepository.logLevels); - } - - setLogLevel(level: LogLevel | false): void { - LoggingRepository.logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; - } - - protected formatContext(context: string): string { - let prefix = LoggingRepository.appName || ''; + formatContext(context: string): string { + let prefix = appName || ''; if (context) { prefix += (prefix ? ':' : '') + context; } @@ -74,6 +61,105 @@ export class LoggingRepository extends ConsoleLogger { }; private withColor(text: string, color: LogColor) { - return this.noColor ? text : `\u001B[${color}m${text}\u001B[39m`; + return this.isColorEnabled ? `\u001B[${color}m${text}\u001B[39m` : text; + } +} + +@Injectable({ scope: Scope.TRANSIENT }) +@Telemetry({ enabled: false }) +export class LoggingRepository { + private logger: MyConsoleLogger; + + constructor(cls: ClsService, configRepository: ConfigRepository) { + const { noColor } = configRepository.getEnv(); + this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor }); + } + + setAppName(name: string): void { + appName = name.charAt(0).toUpperCase() + name.slice(1); + } + + setContext(context: string) { + this.logger.setContext(context); + } + + isLevelEnabled(level: LogLevel) { + return isLogLevelEnabled(level, logLevels); + } + + setLogLevel(level: LogLevel | false): void { + logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; + } + + verbose(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.VERBOSE, message, details); + } + + verboseFn(message: LogFunction, ...details: LogDetails) { + this.handleFunction(LogLevel.VERBOSE, message, details); + } + + debug(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.DEBUG, message, details); + } + + debugFn(message: LogFunction, ...details: LogDetails) { + this.handleFunction(LogLevel.DEBUG, message, details); + } + + log(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.LOG, message, details); + } + + warn(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.WARN, message, details); + } + + error(message: string | Error, ...details: LogDetails) { + this.handleMessage(LogLevel.ERROR, message, details); + } + + fatal(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.FATAL, message, details); + } + + private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) { + if (this.isLevelEnabled(level)) { + this.handleMessage(level, message(), details); + } + } + + private handleMessage(level: LogLevel, message: string | Error, details: LogDetails[]) { + switch (level) { + case LogLevel.VERBOSE: { + this.logger.verbose(message, ...details); + break; + } + + case LogLevel.DEBUG: { + this.logger.debug(message, ...details); + break; + } + + case LogLevel.LOG: { + this.logger.log(message, ...details); + break; + } + + case LogLevel.WARN: { + this.logger.warn(message, ...details); + break; + } + + case LogLevel.ERROR: { + this.logger.error(message, ...details); + break; + } + + case LogLevel.FATAL: { + this.logger.fatal(message, ...details); + break; + } + } } } diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 965e7ffd13..442225f7c8 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -10,7 +10,7 @@ import { citiesFile } from 'src/constants'; import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity'; -import { LogLevel, SystemMetadataKey } from 'src/enum'; +import { SystemMetadataKey } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; @@ -137,9 +137,7 @@ export class MapRepository { .executeTakeFirst(); if (response) { - if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { - this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`); - } + this.logger.verboseFn(() => `Raw: ${JSON.stringify(response, null, 2)}`); const { countryCode, name: city, admin1Name } = response; const country = getName(countryCode, 'en') ?? null; @@ -167,9 +165,8 @@ export class MapRepository { return { country: null, state: null, city: null }; } - if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { - this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); - } + this.logger.verboseFn(() => `Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); + const { admin_a3 } = ne_response; const country = getName(admin_a3, 'en') ?? null; const state = null; diff --git a/server/src/repositories/notification.repository.spec.ts b/server/src/repositories/notification.repository.spec.ts index 7707069dd9..6253697087 100644 --- a/server/src/repositories/notification.repository.spec.ts +++ b/server/src/repositories/notification.repository.spec.ts @@ -1,16 +1,11 @@ -import { LoggingRepository } from 'src/repositories/logging.repository'; import { EmailRenderRequest, EmailTemplate, NotificationRepository } from 'src/repositories/notification.repository'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { Mocked } from 'vitest'; +import { newFakeLoggingRepository } from 'test/repositories/logger.repository.mock'; describe(NotificationRepository.name, () => { let sut: NotificationRepository; - let loggerMock: Mocked; beforeEach(() => { - loggerMock = newLoggingRepositoryMock() as ILoggingRepository as Mocked; - - sut = new NotificationRepository(loggerMock as LoggingRepository); + sut = new NotificationRepository(newFakeLoggingRepository()); }); describe('renderEmail', () => { diff --git a/server/src/repositories/storage.repository.spec.ts b/server/src/repositories/storage.repository.spec.ts index 3ab9e615ec..93b21a7f9b 100644 --- a/server/src/repositories/storage.repository.spec.ts +++ b/server/src/repositories/storage.repository.spec.ts @@ -1,9 +1,7 @@ import mockfs from 'mock-fs'; import { CrawlOptionsDto } from 'src/dtos/library.dto'; -import { LoggingRepository } from 'src/repositories/logging.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { Mocked } from 'vitest'; +import { newFakeLoggingRepository } from 'test/repositories/logger.repository.mock'; interface Test { test: string; @@ -182,11 +180,9 @@ const tests: Test[] = [ describe(StorageRepository.name, () => { let sut: StorageRepository; - let logger: Mocked; beforeEach(() => { - logger = newLoggingRepositoryMock(); - sut = new StorageRepository(logger as ILoggingRepository as LoggingRepository); + sut = new StorageRepository(newFakeLoggingRepository()); }); afterEach(() => { diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index c0c7a00ae7..ca1d9e7921 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -54,7 +54,7 @@ export class StorageService extends BaseService { this.logger.log('Successfully verified system mount folder checks'); } catch (error) { if (envData.storage.ignoreMountCheckErrors) { - this.logger.error(error); + this.logger.error(error as Error); this.logger.warn('Ignoring mount folder errors'); } else { throw error; diff --git a/server/test/medium/specs/metadata.service.spec.ts b/server/test/medium/specs/metadata.service.spec.ts index 4c89ce4e37..275d3f1bda 100644 --- a/server/test/medium/specs/metadata.service.spec.ts +++ b/server/test/medium/specs/metadata.service.spec.ts @@ -3,15 +3,12 @@ import { writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AssetEntity } from 'src/entities/asset.entity'; -import { LoggingRepository } from 'src/repositories/logging.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newFakeLoggingRepository } from 'test/repositories/logger.repository.mock'; import { newRandomImage, newTestService, ServiceMocks } from 'test/utils'; -const metadataRepository = new MetadataRepository( - newLoggingRepositoryMock() as ILoggingRepository as LoggingRepository, -); +const metadataRepository = new MetadataRepository(newFakeLoggingRepository()); const createTestFile = async (exifData: Record) => { const data = newRandomImage(); diff --git a/server/test/repositories/logger.repository.mock.ts b/server/test/repositories/logger.repository.mock.ts index 46a81c8965..7257d375f1 100644 --- a/server/test/repositories/logger.repository.mock.ts +++ b/server/test/repositories/logger.repository.mock.ts @@ -1,31 +1,23 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export type ILoggingRepository = Pick< - LoggingRepository, - | 'verbose' - | 'log' - | 'debug' - | 'warn' - | 'error' - | 'fatal' - | 'isLevelEnabled' - | 'setLogLevel' - | 'setContext' - | 'setAppName' ->; - -export const newLoggingRepositoryMock = (): Mocked => { +export const newLoggingRepositoryMock = (): Mocked> => { return { setLogLevel: vitest.fn(), setContext: vitest.fn(), setAppName: vitest.fn(), isLevelEnabled: vitest.fn(), verbose: vitest.fn(), + verboseFn: vitest.fn(), debug: vitest.fn(), + debugFn: vitest.fn(), log: vitest.fn(), warn: vitest.fn(), error: vitest.fn(), fatal: vitest.fn(), }; }; + +export const newFakeLoggingRepository = () => + newLoggingRepositoryMock() as RepositoryInterface as LoggingRepository; diff --git a/server/test/utils.ts b/server/test/utils.ts index 8f65ec614d..8b3798b8b1 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -63,7 +63,7 @@ import { newDatabaseRepositoryMock } from 'test/repositories/database.repository import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; @@ -120,7 +120,7 @@ export type ServiceMocks = { event: Mocked>; job: Mocked>; library: Mocked>; - logger: Mocked; + logger: Mocked>; machineLearning: Mocked>; map: Mocked>; media: Mocked>; @@ -197,7 +197,7 @@ export const newTestService = ( const viewMock = newViewRepositoryMock(); const sut = new Service( - loggerMock as ILoggingRepository as LoggingRepository, + loggerMock as RepositoryInterface as LoggingRepository, accessMock as IAccessRepository as AccessRepository, activityMock as RepositoryInterface as ActivityRepository, auditMock as RepositoryInterface as AuditRepository, From 28c664c76966299d876e351edcc17c3a9d6db6a7 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 28 Feb 2025 01:48:49 +0530 Subject: [PATCH 026/104] refactor(mobile): log service (#16383) refactor: log service Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/analysis_options.yaml | 2 +- mobile/lib/constants/constants.dart | 3 + .../lib/domain/interfaces/log.interface.dart | 16 ++ mobile/lib/domain/models/log.model.dart | 69 +++++++ mobile/lib/domain/services/log.service.dart | 153 ++++++++++++++ .../lib/entities/logger_message.entity.dart | 50 ----- .../infrastructure/entities/log.entity.dart | 52 +++++ .../entities/log.entity.g.dart} | 2 +- .../repositories/log.repository.dart | 53 +++++ mobile/lib/main.dart | 4 - mobile/lib/pages/common/app_log.page.dart | 20 +- .../lib/pages/common/app_log_detail.page.dart | 20 +- .../providers/app_life_cycle.provider.dart | 16 +- mobile/lib/routing/router.dart | 36 ++-- mobile/lib/routing/router.gr.dart | 4 +- .../lib/services/immich_logger.service.dart | 97 +-------- mobile/lib/utils/bootstrap.dart | 8 +- .../widgets/settings/advanced_settings.dart | 16 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../domain/services/log_service_test.dart | 186 ++++++++++++++++++ .../test/infrastructure/repository.mock.dart | 3 + .../modules/shared/sync_service_test.dart | 8 +- mobile/test/test_utils.dart | 36 +++- 24 files changed, 656 insertions(+), 201 deletions(-) create mode 100644 mobile/lib/domain/interfaces/log.interface.dart create mode 100644 mobile/lib/domain/models/log.model.dart create mode 100644 mobile/lib/domain/services/log.service.dart delete mode 100644 mobile/lib/entities/logger_message.entity.dart create mode 100644 mobile/lib/infrastructure/entities/log.entity.dart rename mobile/lib/{entities/logger_message.entity.g.dart => infrastructure/entities/log.entity.g.dart} (99%) create mode 100644 mobile/lib/infrastructure/repositories/log.repository.dart create mode 100644 mobile/test/domain/services/log_service_test.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 5cf21e1dd6..2e74f6153a 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -67,7 +67,7 @@ custom_lint: - lib/entities/*.entity.dart - lib/repositories/{album,asset,backup,database,etag,exif_info,user,timeline,partner}.repository.dart - lib/infrastructure/entities/*.entity.dart - - lib/infrastructure/repositories/{store,db}.repository.dart + - lib/infrastructure/repositories/{store,db,log}.repository.dart - lib/providers/infrastructure/db.provider.dart # acceptable exceptions for the time being (until Isar is fully replaced) - integration_test/test_utils/general_helper.dart diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index cc0e7ca215..868b036d1b 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -1,3 +1,6 @@ const int noDbId = -9223372036854775808; // from Isar const double downloadCompleted = -1; const double downloadFailed = -2; + +// Number of log entries to retain on app start +const int kLogTruncateLimit = 250; diff --git a/mobile/lib/domain/interfaces/log.interface.dart b/mobile/lib/domain/interfaces/log.interface.dart new file mode 100644 index 0000000000..f1cbc977dd --- /dev/null +++ b/mobile/lib/domain/interfaces/log.interface.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/models/log.model.dart'; + +abstract interface class ILogRepository { + Future insert(LogMessage log); + + Future insertAll(Iterable logs); + + Future> getAll(); + + Future deleteAll(); + + /// Truncates the logs to the most recent [limit]. Defaults to recent 250 logs + Future truncate({int limit = 250}); +} diff --git a/mobile/lib/domain/models/log.model.dart b/mobile/lib/domain/models/log.model.dart new file mode 100644 index 0000000000..51f816df01 --- /dev/null +++ b/mobile/lib/domain/models/log.model.dart @@ -0,0 +1,69 @@ +// ignore_for_file: constant_identifier_names + +import 'package:logging/logging.dart'; + +/// Log levels according to dart logging [Level] +enum LogLevel { + ALL, + FINEST, + FINER, + FINE, + CONFIG, + INFO, + WARNING, + SEVERE, + SHOUT, + OFF, +} + +class LogMessage { + final String message; + final LogLevel level; + final DateTime createdAt; + final String? logger; + final String? error; + final String? stack; + + const LogMessage({ + required this.message, + required this.level, + required this.createdAt, + this.logger, + this.error, + this.stack, + }); + + @override + bool operator ==(covariant LogMessage other) { + if (identical(this, other)) return true; + + return other.message == message && + other.level == level && + other.createdAt == createdAt && + other.logger == logger && + other.error == error && + other.stack == stack; + } + + @override + int get hashCode { + return message.hashCode ^ + level.hashCode ^ + createdAt.hashCode ^ + logger.hashCode ^ + error.hashCode ^ + stack.hashCode; + } + + @override + String toString() { + return '''LogMessage: { +message: $message, +level: $level, +createdAt: $createdAt, +logger: ${logger ?? ''}, +error: ${error ?? ''}, +stack: ${stack ?? ''}, +}'''; + } +} diff --git a/mobile/lib/domain/services/log.service.dart b/mobile/lib/domain/services/log.service.dart new file mode 100644 index 0000000000..61b5638e78 --- /dev/null +++ b/mobile/lib/domain/services/log.service.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:logging/logging.dart'; + +class LogService { + final ILogRepository _logRepository; + final IStoreRepository _storeRepository; + + final List _msgBuffer = []; + + /// Whether to buffer logs in memory before writing to the database. + /// This is useful when logging in quick succession, as it increases performance + /// and reduces NAND wear. However, it may cause the logs to be lost in case of a crash / in isolates. + final bool _shouldBuffer; + Timer? _flushTimer; + + late final StreamSubscription _logSubscription; + + LogService._( + this._logRepository, + this._storeRepository, + this._shouldBuffer, + ) { + // Listen to log messages and write them to the database + _logSubscription = Logger.root.onRecord.listen(_writeLogToDatabase); + } + + static LogService? _instance; + static LogService get I { + if (_instance == null) { + throw const LoggerUnInitializedException(); + } + return _instance!; + } + + static Future init({ + required ILogRepository logRepo, + required IStoreRepository storeRepo, + bool shouldBuffer = true, + }) async { + if (_instance != null) { + return _instance!; + } + _instance = await create( + logRepo: logRepo, + storeRepo: storeRepo, + shouldBuffer: shouldBuffer, + ); + return _instance!; + } + + static Future create({ + required ILogRepository logRepo, + required IStoreRepository storeRepo, + bool shouldBuffer = true, + }) async { + final instance = LogService._(logRepo, storeRepo, shouldBuffer); + // Truncate logs to 250 + await logRepo.truncate(limit: kLogTruncateLimit); + // Get log level from store + final level = await instance._storeRepository.tryGet(StoreKey.logLevel); + if (level != null) { + Logger.root.level = Level.LEVELS.elementAtOrNull(level) ?? Level.INFO; + } + return instance; + } + + Future setlogLevel(LogLevel level) async { + await _storeRepository.insert(StoreKey.logLevel, level.index); + Logger.root.level = level.toLevel(); + } + + Future> getMessages() async { + final logsFromDb = await _logRepository.getAll(); + if (_msgBuffer.isNotEmpty) { + return [..._msgBuffer.reversed, ...logsFromDb]; + } + return logsFromDb; + } + + Future clearLogs() async { + _flushTimer?.cancel(); + _flushTimer = null; + _msgBuffer.clear(); + await _logRepository.deleteAll(); + } + + /// Flush pending log messages to persistent storage + Future flush() async { + if (_flushTimer == null) { + return; + } + _flushTimer!.cancel(); + await _flushBufferToDatabase(); + } + + Future dispose() { + _flushTimer?.cancel(); + _logSubscription.cancel(); + return _flushBufferToDatabase(); + } + + void _writeLogToDatabase(LogRecord r) { + final record = LogMessage( + message: r.message, + level: r.level.toLogLevel(), + createdAt: r.time, + logger: r.loggerName, + error: r.error?.toString(), + stack: r.stackTrace?.toString(), + ); + + if (_shouldBuffer) { + _msgBuffer.add(record); + _flushTimer ??= Timer( + const Duration(seconds: 5), + () => unawaited(_flushBufferToDatabase()), + ); + } else { + unawaited(_logRepository.insert(record)); + } + } + + Future _flushBufferToDatabase() async { + _flushTimer = null; + final buffer = [..._msgBuffer]; + _msgBuffer.clear(); + await _logRepository.insertAll(buffer); + } +} + +class LoggerUnInitializedException implements Exception { + const LoggerUnInitializedException(); + + @override + String toString() => 'Logger is not initialized. Call init()'; +} + +/// Log levels according to dart logging [Level] +extension LevelDomainToInfraExtension on Level { + LogLevel toLogLevel() => + LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ?? + LogLevel.INFO; +} + +extension on LogLevel { + Level toLevel() => Level.LEVELS.elementAtOrNull(index) ?? Level.INFO; +} diff --git a/mobile/lib/entities/logger_message.entity.dart b/mobile/lib/entities/logger_message.entity.dart deleted file mode 100644 index d904e19e7a..0000000000 --- a/mobile/lib/entities/logger_message.entity.dart +++ /dev/null @@ -1,50 +0,0 @@ -// ignore_for_file: constant_identifier_names - -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; - -part 'logger_message.entity.g.dart'; - -@Collection(inheritance: false) -class LoggerMessage { - Id id = Isar.autoIncrement; - String message; - String? details; - @Enumerated(EnumType.ordinal) - LogLevel level = LogLevel.INFO; - DateTime createdAt; - String? context1; - String? context2; - - LoggerMessage({ - required this.message, - required this.details, - required this.level, - required this.createdAt, - required this.context1, - required this.context2, - }); - - @override - String toString() { - return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)'; - } -} - -/// Log levels according to dart logging [Level] -enum LogLevel { - ALL, - FINEST, - FINER, - FINE, - CONFIG, - INFO, - WARNING, - SEVERE, - SHOUT, - OFF, -} - -extension LevelExtension on Level { - LogLevel toLogLevel() => LogLevel.values[Level.LEVELS.indexOf(this)]; -} diff --git a/mobile/lib/infrastructure/entities/log.entity.dart b/mobile/lib/infrastructure/entities/log.entity.dart new file mode 100644 index 0000000000..6c55f17989 --- /dev/null +++ b/mobile/lib/infrastructure/entities/log.entity.dart @@ -0,0 +1,52 @@ +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:isar/isar.dart'; + +part 'log.entity.g.dart'; + +@Collection(inheritance: false) +class LoggerMessage { + Id id = Isar.autoIncrement; + String message; + String? details; + @Enumerated(EnumType.ordinal) + LogLevel level = LogLevel.INFO; + DateTime createdAt; + String? context1; + String? context2; + + LoggerMessage({ + required this.message, + required this.details, + required this.level, + required this.createdAt, + required this.context1, + required this.context2, + }); + + @override + String toString() { + return 'LoggerMessage(message: $message, level: $level, createdAt: $createdAt)'; + } + + LogMessage toDto() { + return LogMessage( + message: message, + level: level, + createdAt: createdAt, + logger: context1, + error: details, + stack: context2, + ); + } + + static LoggerMessage fromDto(LogMessage log) { + return LoggerMessage( + message: log.message, + details: log.error, + level: log.level, + createdAt: log.createdAt, + context1: log.logger, + context2: log.stack, + ); + } +} diff --git a/mobile/lib/entities/logger_message.entity.g.dart b/mobile/lib/infrastructure/entities/log.entity.g.dart similarity index 99% rename from mobile/lib/entities/logger_message.entity.g.dart rename to mobile/lib/infrastructure/entities/log.entity.g.dart index e292e7173a..f3ee284aa4 100644 --- a/mobile/lib/entities/logger_message.entity.g.dart +++ b/mobile/lib/infrastructure/entities/log.entity.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'logger_message.entity.dart'; +part of 'log.entity.dart'; // ************************************************************************** // IsarCollectionGenerator diff --git a/mobile/lib/infrastructure/repositories/log.repository.dart b/mobile/lib/infrastructure/repositories/log.repository.dart new file mode 100644 index 0000000000..6ff128f93b --- /dev/null +++ b/mobile/lib/infrastructure/repositories/log.repository.dart @@ -0,0 +1,53 @@ +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:isar/isar.dart'; + +class IsarLogRepository extends IsarDatabaseRepository + implements ILogRepository { + final Isar _db; + const IsarLogRepository(super.db) : _db = db; + + @override + Future deleteAll() async { + await transaction(() async => await _db.loggerMessages.clear()); + return true; + } + + @override + Future> getAll() async { + final logs = + await _db.loggerMessages.where().sortByCreatedAtDesc().findAll(); + return logs.map((l) => l.toDto()).toList(); + } + + @override + Future insert(LogMessage log) async { + final logEntity = LoggerMessage.fromDto(log); + await transaction(() async { + await _db.loggerMessages.put(logEntity); + }); + return true; + } + + @override + Future insertAll(Iterable logs) async { + await transaction(() async { + final logEntities = + logs.map((log) => LoggerMessage.fromDto(log)).toList(); + await _db.loggerMessages.putAll(logEntities); + }); + return true; + } + + @override + Future truncate({int limit = 250}) async { + await transaction(() async { + final count = await _db.loggerMessages.count(); + if (count <= limit) return; + final toRemove = count - limit; + await _db.loggerMessages.where().limit(toRemove).deleteAll(); + }); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index da84a8cff6..407ea86d59 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -20,7 +20,6 @@ import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.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/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; @@ -67,9 +66,6 @@ Future initApp() async { await DynamicTheme.fetchSystemPalette(); - // Initialize Immich Logger Service - ImmichLogger(); - final log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index 226d380a28..3bd2e0111f 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -2,10 +2,11 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:intl/intl.dart'; @@ -17,8 +18,11 @@ class AppLogPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final immichLogger = ImmichLogger(); - final logMessages = useState(immichLogger.messages); + final immichLogger = LogService.I; + final shouldReload = useState(false); + final logMessages = useFuture( + useMemoized(() => immichLogger.getMessages(), [shouldReload.value]), + ); Widget colorStatusIndicator(Color color) { return Column( @@ -71,7 +75,7 @@ class AppLogPage extends HookConsumerWidget { ), onPressed: () { immichLogger.clearLogs(); - logMessages.value = []; + shouldReload.value = !shouldReload.value; }, ), Builder( @@ -84,7 +88,7 @@ class AppLogPage extends HookConsumerWidget { size: 20.0, ), onPressed: () { - immichLogger.shareLogs(iconContext); + ImmichLogger.shareLogs(iconContext); }, ); }, @@ -105,9 +109,9 @@ class AppLogPage extends HookConsumerWidget { separatorBuilder: (context, index) { return const Divider(height: 0); }, - itemCount: logMessages.value.length, + itemCount: logMessages.data?.length ?? 0, itemBuilder: (context, index) { - var logMessage = logMessages.value[index]; + var logMessage = logMessages.data![index]; return ListTile( onTap: () => context.pushRoute( AppLogDetailRoute( @@ -128,7 +132,7 @@ class AppLogPage extends HookConsumerWidget { ), ), subtitle: Text( - "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}", + "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.logger}", style: TextStyle( fontSize: 12.0, color: context.colorScheme.onSurfaceSecondary, diff --git a/mobile/lib/pages/common/app_log_detail.page.dart b/mobile/lib/pages/common/app_log_detail.page.dart index dd6af81728..1bfea44ba1 100644 --- a/mobile/lib/pages/common/app_log_detail.page.dart +++ b/mobile/lib/pages/common/app_log_detail.page.dart @@ -1,15 +1,15 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; @RoutePage() class AppLogDetailPage extends HookConsumerWidget { const AppLogDetailPage({super.key, required this.logMessage}); - final LoggerMessage logMessage; + final LogMessage logMessage; @override Widget build(BuildContext context, WidgetRef ref) { @@ -126,14 +126,14 @@ class AppLogDetailPage extends HookConsumerWidget { child: ListView( children: [ buildTextWithCopyButton("MESSAGE", logMessage.message), - if (logMessage.details != null) - buildTextWithCopyButton("DETAILS", logMessage.details.toString()), - if (logMessage.context1 != null) - buildLogContext1(logMessage.context1.toString()), - if (logMessage.context2 != null) + if (logMessage.error != null) + buildTextWithCopyButton("DETAILS", logMessage.error.toString()), + if (logMessage.logger != null) + buildLogContext1(logMessage.logger.toString()), + if (logMessage.stack != null) buildTextWithCopyButton( "STACK TRACE", - logMessage.context2.toString(), + logMessage.stack.toString(), ), ], ), diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 780e22b818..92c199ab76 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -1,20 +1,22 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/services/background.service.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; +import 'package:immich_mobile/services/background.service.dart'; import 'package:permission_handler/permission_handler.dart'; enum AppLifeCycleEnum { @@ -112,7 +114,7 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(websocketProvider.notifier).disconnect(); } - ImmichLogger().flush(); + unawaited(LogService.I.flush()); } void handleAppDetached() { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 66a65f559e..ae5419b712 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -1,44 +1,48 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; -import 'package:immich_mobile/pages/backup/album_preview.page.dart'; -import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; -import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; -import 'package:immich_mobile/pages/backup/backup_options.page.dart'; -import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; -import 'package:immich_mobile/pages/albums/albums.page.dart'; -import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; -import 'package:immich_mobile/pages/library/local_albums.page.dart'; -import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; -import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; -import 'package:immich_mobile/pages/library/library.page.dart'; -import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/album/album_additional_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_asset_selection.page.dart'; import 'package:immich_mobile/pages/album/album_options.page.dart'; import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_viewer.page.dart'; +import 'package:immich_mobile/pages/albums/albums.page.dart'; +import 'package:immich_mobile/pages/backup/album_preview.page.dart'; +import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; +import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; +import 'package:immich_mobile/pages/backup/backup_options.page.dart'; +import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; +import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/common/app_log.page.dart'; import 'package:immich_mobile/pages/common/app_log_detail.page.dart'; import 'package:immich_mobile/pages/common/create_album.page.dart'; import 'package:immich_mobile/pages/common/gallery_viewer.page.dart'; import 'package:immich_mobile/pages/common/headers_settings.page.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/pages/common/settings.page.dart'; import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/pages/common/tab_controller.page.dart'; -import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/crop.page.dart'; +import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/filter.page.dart'; import 'package:immich_mobile/pages/library/archive.page.dart'; import 'package:immich_mobile/pages/library/favorite.page.dart'; +import 'package:immich_mobile/pages/library/library.page.dart'; +import 'package:immich_mobile/pages/library/local_albums.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; +import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; +import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/pages/library/trash.page.dart'; import 'package:immich_mobile/pages/login/change_password.page.dart'; import 'package:immich_mobile/pages/login/login.page.dart'; @@ -54,10 +58,6 @@ import 'package:immich_mobile/pages/search/map/map_location_picker.page.dart'; import 'package:immich_mobile/pages/search/person_result.page.dart'; import 'package:immich_mobile/pages/search/recently_added.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; -import 'package:immich_mobile/pages/library/partner/partner.page.dart'; -import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; -import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; -import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index e4f1190510..299c8a602f 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -386,7 +386,7 @@ class AllVideosRoute extends PageRouteInfo { class AppLogDetailRoute extends PageRouteInfo { AppLogDetailRoute({ Key? key, - required LoggerMessage logMessage, + required LogMessage logMessage, List? children, }) : super( AppLogDetailRoute.name, @@ -419,7 +419,7 @@ class AppLogDetailRouteArgs { final Key? key; - final LoggerMessage logMessage; + final LogMessage logMessage; @override String toString() { diff --git a/mobile/lib/services/immich_logger.service.dart b/mobile/lib/services/immich_logger.service.dart index 952e8b191e..fab4b9966a 100644 --- a/mobile/lib/services/immich_logger.service.dart +++ b/mobile/lib/services/immich_logger.service.dart @@ -2,11 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/widgets.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -18,75 +14,10 @@ import 'package:share_plus/share_plus.dart'; /// /// Logs can be shared by calling the `shareLogs` method, which will open a share dialog /// and generate a csv file. -class ImmichLogger { - static final ImmichLogger _instance = ImmichLogger._internal(); - final maxLogEntries = 500; - final Isar _db = Isar.getInstance()!; - List _msgBuffer = []; - Timer? _timer; +abstract final class ImmichLogger { + const ImmichLogger(); - factory ImmichLogger() => _instance; - - ImmichLogger._internal() { - _removeOverflowMessages(); - final int levelId = Store.get(StoreKey.logLevel, 5); // 5 is INFO - Logger.root.level = Level.LEVELS[levelId]; - Logger.root.onRecord.listen(_writeLogToDatabase); - } - - set level(Level level) => Logger.root.level = level; - - List get messages { - final inDb = - _db.loggerMessages.where(sort: Sort.desc).anyId().findAllSync(); - return _msgBuffer.isEmpty ? inDb : _msgBuffer.reversed.toList() + inDb; - } - - void _removeOverflowMessages() { - final msgCount = _db.loggerMessages.countSync(); - if (msgCount > maxLogEntries) { - final numberOfEntryToBeDeleted = msgCount - maxLogEntries; - _db.writeTxn( - () => _db.loggerMessages - .where() - .limit(numberOfEntryToBeDeleted) - .deleteAll(), - ); - } - } - - void _writeLogToDatabase(LogRecord record) { - debugPrint('[${record.level.name}] [${record.time}] ${record.message}'); - final lm = LoggerMessage( - message: record.message, - details: record.error?.toString(), - level: record.level.toLogLevel(), - createdAt: record.time, - context1: record.loggerName, - context2: record.stackTrace?.toString(), - ); - _msgBuffer.add(lm); - - // delayed batch writing to database: increases performance when logging - // messages in quick succession and reduces NAND wear - _timer ??= Timer(const Duration(seconds: 5), _flushBufferToDatabase); - } - - void _flushBufferToDatabase() { - _timer = null; - final buffer = _msgBuffer; - _msgBuffer = []; - _db.writeTxn(() => _db.loggerMessages.putAll(buffer)); - } - - void clearLogs() { - _timer?.cancel(); - _timer = null; - _msgBuffer.clear(); - _db.writeTxn(() => _db.loggerMessages.clear()); - } - - Future shareLogs(BuildContext context) async { + static Future shareLogs(BuildContext context) async { final tempDir = await getTemporaryDirectory(); final dateTime = DateTime.now().toIso8601String(); final filePath = '${tempDir.path}/Immich_log_$dateTime.log'; @@ -94,13 +25,13 @@ class ImmichLogger { final io = logFile.openWrite(); try { // Write messages - for (final m in messages) { + for (final m in await LogService.I.getMessages()) { final created = m.createdAt; final level = m.level.name.padRight(8); - final logger = (m.context1 ?? "").padRight(20); + final logger = (m.logger ?? "").padRight(20); final message = m.message; - final error = m.details != null ? " ${m.details} |" : ""; - final stack = m.context2 != null ? "\n${m.context2!}" : ""; + final error = m.error == null ? "" : " ${m.error} |"; + final stack = m.stack == null ? "" : "\n${m.stack!}"; io.write('$created | $level | $logger | $message |$error$stack\n'); } } finally { @@ -115,16 +46,6 @@ class ImmichLogger { [XFile(filePath)], subject: "Immich logs $dateTime", sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ).then( - (value) => logFile.delete(), - ); - } - - /// Flush pending log messages to persistent storage - void flush() { - if (_timer != null) { - _timer!.cancel(); - _db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer)); - } + ).then((value) => logFile.delete()); } } diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 32bdac42d5..5b9a41f28d 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; @@ -10,9 +11,10 @@ import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; @@ -46,5 +48,9 @@ abstract final class Bootstrap { static Future initDomain(Isar db) async { await StoreService.init(storeRepository: IsarStoreRepository(db)); + await LogService.init( + logRepo: IsarLogRepository(db), + storeRepo: IsarStoreRepository(db), + ); } } diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index ec1ab79cf7..4e399e8aec 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -1,18 +1,19 @@ import 'dart:io'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.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:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart'; import 'package:logging/logging.dart'; @@ -33,7 +34,8 @@ class AdvancedSettings extends HookConsumerWidget { useValueChanged( levelId.value, - (_, __) => ImmichLogger().level = Level.LEVELS[levelId.value], + (_, __) => + LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()), ); final advancedSettings = [ diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5a15bf5f5e..08c71e36f8 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -407,7 +407,7 @@ packages: source: hosted version: "0.0.2" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1191612363..89e7b09ca4 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -113,6 +113,7 @@ dev_dependencies: mocktail: ^1.0.3 immich_mobile_immich_lint: path: './immich_lint' + fake_async: ^1.3.1 flutter: uses-material-design: true diff --git a/mobile/test/domain/services/log_service_test.dart b/mobile/test/domain/services/log_service_test.dart new file mode 100644 index 0000000000..cbceb0d165 --- /dev/null +++ b/mobile/test/domain/services/log_service_test.dart @@ -0,0 +1,186 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:logging/logging.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../infrastructure/repository.mock.dart'; +import '../../test_utils.dart'; + +final _kInfoLog = LogMessage( + message: '#Info Message', + level: LogLevel.INFO, + createdAt: DateTime(2025, 2, 26), + logger: 'Info Logger', +); + +final _kWarnLog = LogMessage( + message: '#Warn Message', + level: LogLevel.WARNING, + createdAt: DateTime(2025, 2, 27), + logger: 'Warn Logger', +); + +void main() { + late LogService sut; + late ILogRepository mockLogRepo; + late IStoreRepository mockStoreRepo; + + setUp(() async { + mockLogRepo = MockLogRepository(); + mockStoreRepo = MockStoreRepository(); + + registerFallbackValue(_kInfoLog); + + when(() => mockLogRepo.truncate(limit: any(named: 'limit'))) + .thenAnswer((_) async => {}); + when(() => mockStoreRepo.tryGet(StoreKey.logLevel)) + .thenAnswer((_) async => LogLevel.FINE.index); + when(() => mockLogRepo.getAll()).thenAnswer((_) async => []); + when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true); + when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true); + + sut = + await LogService.create(logRepo: mockLogRepo, storeRepo: mockStoreRepo); + }); + + tearDown(() async { + await sut.dispose(); + }); + + group("Log Service Init:", () { + test('Truncates the existing logs on init', () { + final limit = + verify(() => mockLogRepo.truncate(limit: captureAny(named: 'limit'))) + .captured + .firstOrNull as int?; + expect(limit, kLogTruncateLimit); + }); + + test('Sets log level based on the store setting', () { + verify(() => mockStoreRepo.tryGet(StoreKey.logLevel)).called(1); + expect(Logger.root.level, Level.FINE); + }); + }); + + group("Log Service Set Level:", () { + setUp(() async { + when(() => mockStoreRepo.insert(StoreKey.logLevel, any())) + .thenAnswer((_) async => true); + await sut.setlogLevel(LogLevel.SHOUT); + }); + + test('Updates the log level in store', () { + final index = verify( + () => mockStoreRepo.insert(StoreKey.logLevel, captureAny()), + ).captured.firstOrNull; + expect(index, LogLevel.SHOUT.index); + }); + + test('Sets log level on logger', () { + expect(Logger.root.level, Level.SHOUT); + }); + }); + + group("Log Service Buffer:", () { + test('Buffers logs until timer elapses', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + expect(await sut.getMessages(), hasLength(1)); + logger.warning(_kWarnLog.message); + expect(await sut.getMessages(), hasLength(2)); + time.elapse(const Duration(seconds: 6)); + expect(await sut.getMessages(), isEmpty); + }); + }); + + test('Batch inserts all logs on timer', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + time.elapse(const Duration(seconds: 6)); + final insert = verify(() => mockLogRepo.insertAll(captureAny())); + insert.called(1); + // ignore: prefer-correct-json-casts + final captured = insert.captured.firstOrNull as List; + expect(captured.firstOrNull?.message, _kInfoLog.message); + expect(captured.firstOrNull?.logger, _kInfoLog.logger); + + verifyNever(() => mockLogRepo.insert(captureAny())); + }); + }); + + test('Does not buffer when off', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: false, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + // Ensure nothing gets buffer. This works because we mock log repo getAll to return nothing + expect(await sut.getMessages(), isEmpty); + + final insert = verify(() => mockLogRepo.insert(captureAny())); + insert.called(1); + final captured = insert.captured.firstOrNull as LogMessage; + expect(captured.message, _kInfoLog.message); + expect(captured.logger, _kInfoLog.logger); + + verifyNever(() => mockLogRepo.insertAll(captureAny())); + }); + }); + }); + + group("Log Service Get messages:", () { + setUp(() { + when(() => mockLogRepo.getAll()).thenAnswer((_) async => [_kInfoLog]); + }); + + test('Fetches result from DB', () async { + expect(await sut.getMessages(), hasLength(1)); + verify(() => mockLogRepo.getAll()).called(1); + }); + + test('Combines result from both DB + Buffer', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kWarnLog.logger!); + logger.warning(_kWarnLog.message); + expect(await sut.getMessages(), hasLength(2)); // 1 - DB, 1 - Buff + + final messages = await sut.getMessages(); + // Logged time is assigned in the service for messages in the buffer, so compare manually + expect(messages.firstOrNull?.message, _kWarnLog.message); + expect(messages.firstOrNull?.logger, _kWarnLog.logger); + + expect(messages.elementAtOrNull(1), _kInfoLog); + }); + }); + }); +} diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index ff25bdac9d..3e33fdac0a 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -1,4 +1,7 @@ +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:mocktail/mocktail.dart'; class MockStoreRepository extends Mock implements IStoreRepository {} + +class MockLogRepository extends Mock implements ILogRepository {} diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 464dafc82b..e37b5ec7bc 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,15 +1,16 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -70,7 +71,10 @@ void main() { db.writeTxnSync(() => db.clearSync()); await StoreService.init(storeRepository: IsarStoreRepository(db)); await Store.put(StoreKey.currentUser, owner); - ImmichLogger(); + await LogService.init( + logRepo: IsarLogRepository(db), + storeRepo: IsarStoreRepository(db), + ); }); final List initialAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index 35ab1fb0aa..825d77190b 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:io'; import 'package:easy_localization/easy_localization.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -11,8 +13,8 @@ import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; @@ -88,4 +90,36 @@ abstract final class TestUtils { WidgetController.hitTestWarningShouldBeFatal = true; HttpOverrides.global = MockHttpOverrides(); } + + // Workaround till the following issue is resolved + // https://github.com/dart-lang/test/issues/2307 + static T fakeAsync( + Future Function(FakeAsync _) callback, { + DateTime? initialTime, + }) { + late final T result; + Object? error; + StackTrace? stack; + FakeAsync(initialTime: initialTime).run((FakeAsync async) { + bool shouldPump = true; + unawaited( + callback(async).then( + (value) => result = value, + onError: (e, s) { + error = e; + stack = s; + }, + ).whenComplete(() => shouldPump = false), + ); + + while (shouldPump) { + async.flushMicrotasks(); + } + }); + + if (error != null) { + Error.throwWithStackTrace(error!, stack!); + } + return result; + } } From 5c879acd5bd0b67699d65da7af1ac89d9cb6af1a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 17:02:00 -0600 Subject: [PATCH 027/104] fix(server): don't show assets that no longer associate with a face (#16404) --- server/src/entities/asset.entity.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 7345d9a2e6..c01410adc9 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -260,6 +260,7 @@ export function hasPeople(qb: SelectQueryBuilder, personIds: .selectFrom('asset_faces') .select('assetId') .where('personId', '=', anyUuid(personIds!)) + .where('deletedAt', 'is', null) .groupBy('assetId') .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) .as('has_people'), From f2be9f7ad1132369de32e0f0e58264033bc5ac9c Mon Sep 17 00:00:00 2001 From: Calum Dingwall <29152895+caburum@users.noreply.github.com> Date: Thu, 27 Feb 2025 23:15:37 -0500 Subject: [PATCH 028/104] fix(web): person favorite icon bad placement (#16412) move favorite person icon to top left fixes #16003 Co-authored-by: Calum Dingwall --- web/src/lib/components/faces-page/people-card.svelte | 2 +- web/src/routes/(user)/explore/+page.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 494dd94666..4e341c5743 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -65,7 +65,7 @@ widthStyle="100%" /> {#if person.isFavorite} -
+
{/if} diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index 40a02f7425..ec62d5e869 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -64,7 +64,7 @@ widthStyle="100%" /> {#if person.isFavorite} -
+
{/if} From a185e0639987c1ba3e8ebd4435a9acae6edfbda2 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 27 Feb 2025 23:35:48 -0600 Subject: [PATCH 029/104] fix(server): follow logs level setting (#16415) --- server/src/repositories/logging.repository.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 801f467a6d..aaf21a3d7c 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -33,6 +33,10 @@ export class MyConsoleLogger extends ConsoleLogger { this.isColorEnabled = options?.color || false; } + isLevelEnabled(level: LogLevel) { + return isLogLevelEnabled(level, logLevels); + } + formatContext(context: string): string { let prefix = appName || ''; if (context) { @@ -84,7 +88,7 @@ export class LoggingRepository { } isLevelEnabled(level: LogLevel) { - return isLogLevelEnabled(level, logLevels); + return this.logger.isLevelEnabled(level); } setLogLevel(level: LogLevel | false): void { @@ -124,7 +128,7 @@ export class LoggingRepository { } private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) { - if (this.isLevelEnabled(level)) { + if (this.logger.isLevelEnabled(level)) { this.handleMessage(level, message(), details); } } From 9a98712db7e32d48bf8b7dea72f4a052e8e8d865 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 28 Feb 2025 19:08:51 +0530 Subject: [PATCH 030/104] fix(mobile): background backup failing due to store (#16418) fix: background backup failing due to store Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/services/background.service.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 4e83d1f0ac..f4597831d2 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -720,7 +720,6 @@ enum IosBackgroundTask { fetch, processing } /// entry point called by Kotlin/Java code; needs to be a top-level function @pragma('vm:entry-point') void _nativeEntry() { - HttpOverrides.global = HttpSSLCertOverride(); WidgetsFlutterBinding.ensureInitialized(); DartPluginRegistrant.ensureInitialized(); BackgroundService backgroundService = BackgroundService(); From 819e56d9caff01dd0db4a55c421206b4ab4d9c03 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 28 Feb 2025 15:22:36 +0000 Subject: [PATCH 031/104] fix: user delete sync query sort by id (#16420) --- server/src/repositories/sync.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index d1d0e9b8ee..bde4b9f10f 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -53,7 +53,7 @@ export class SyncRepository { .select(['id', 'userId']) .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) .where('deletedAt', '<', sql.raw("now() - interval '1 millisecond'")) - .orderBy(['deletedAt asc', 'userId asc']) + .orderBy(['id asc']) .stream(); } } From b3b15e9b61396fbb3fb67c426d8e2f6ab4cc151e Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 28 Feb 2025 18:23:18 +0300 Subject: [PATCH 032/104] fix(server): include deleted assets if searching offline assets (#16417) include deleted assets if searching for offline assets --- server/src/entities/asset.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index c01410adc9..b2589e1231 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -352,7 +352,7 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { options.isArchived ??= options.withArchived ? undefined : false; - options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore); + options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); return kysely .withPlugin(joinDeduplicationPlugin) .selectFrom('assets') From bfcde05b1c8911d0c9ac5bdedd8eaf8ac6fd17b3 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Fri, 28 Feb 2025 18:45:30 +0100 Subject: [PATCH 033/104] chore(server): trash e2e cleanup (#16423) --- e2e/src/api/specs/trash.e2e-spec.ts | 31 ++++++++++++----------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 15b915ef2a..7a1a61f946 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -1,4 +1,4 @@ -import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk'; +import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk'; import { existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { errorDto } from 'src/responses'; @@ -6,8 +6,6 @@ import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'sr import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); - describe('/trash', () => { let admin: LoginResponseDto; let ws: Socket; @@ -81,8 +79,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assets.items.length).toBe(1); @@ -90,8 +87,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); @@ -116,8 +112,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assets.items.length).toBe(1); @@ -125,8 +120,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); @@ -180,8 +174,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assets.count).toBe(1); @@ -189,9 +182,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); @@ -201,6 +192,8 @@ describe('/trash', () => { const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); }); }); @@ -238,7 +231,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); + await utils.scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -247,7 +240,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); + await utils.scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const before = await utils.getAssetInfo(admin.accessToken, assetId); @@ -261,6 +254,8 @@ describe('/trash', () => { const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(true); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); }); }); }); From 84cf0d1670a25f2b0f04f1a1571a1ff30b9f059c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 28 Feb 2025 12:49:29 -0500 Subject: [PATCH 034/104] fix: duplicate memories (#16432) --- server/src/services/memory.service.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 10b8cee2fe..be4d6dfc76 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -30,20 +30,18 @@ export class MemoryService extends BaseService { const start = DateTime.utc().startOf('day').minus({ days: DAYS }); const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE); - let lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state?.lastOnThisDayDate) : start; + const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start; // generate a memory +/- X days from today - for (let i = 0; i <= DAYS * 2 + 1; i++) { + for (let i = 0; i <= DAYS * 2; i++) { const target = start.plus({ days: i }); - if (lastOnThisDayDate > target) { + if (lastOnThisDayDate >= target) { continue; } const showAt = target.startOf('day').toISO(); const hideAt = target.endOf('day').toISO(); - this.logger.log(`Creating memories for month=${target.month}, day=${target.day}`); - for (const [userId, userIds] of Object.entries(userMap)) { const memories = await this.assetRepository.getByDayOfYear(userIds, target); @@ -67,8 +65,6 @@ export class MemoryService extends BaseService { ...state, lastOnThisDayDate: target.toISO(), }); - - lastOnThisDayDate = target; } } From 5c0538e52ca15a28d6cba3a34c3618fe3c39724c Mon Sep 17 00:00:00 2001 From: Desmond Cox Date: Fri, 28 Feb 2025 18:50:00 +0100 Subject: [PATCH 035/104] fix(server): stringify error log parameter to ensure correct overload (#16422) * fix(server): stringify error log parameter to ensure correct overload The intended error(message, stack, context) overload is only selected if context is a string. * formatter --- server/src/services/job.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 95ff1ad303..22408c33de 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -195,7 +195,11 @@ export class JobService extends BaseService { await this.onDone(job); } } catch (error: Error | any) { - this.logger.error(`Unable to run job handler (${queueName}/${job.name}): ${error}`, error?.stack, job.data); + this.logger.error( + `Unable to run job handler (${queueName}/${job.name}): ${error}`, + error?.stack, + JSON.stringify(job.data), + ); } finally { this.telemetryRepository.jobs.addToGauge(queueMetric, -1); } From e684062569c9bd0f5ec0e447d3daf698705f2fe9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 28 Feb 2025 13:51:28 -0500 Subject: [PATCH 036/104] fix: memories off by one (#16434) --- server/src/queries/asset.repository.sql | 7 ++++--- server/src/repositories/asset.repository.ts | 10 +++------- server/src/services/asset.service.spec.ts | 8 ++++---- server/src/services/asset.service.ts | 15 +++++++++------ server/src/services/memory.service.ts | 8 ++++---- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index ce53bd1791..c0b778bb50 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -55,9 +55,10 @@ with inner join "exif" on "a"."id" = "exif"."assetId" ) select - ( - (now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date - ) / 365 as "yearsAgo", + date_part( + 'year', + ("localDateTime" at time zone 'UTC')::date + )::int as "year", json_agg("res") as "assets" from "res" diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index daefacef09..91597ed720 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -192,7 +192,7 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) - getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { + getByDayOfYear(ownerIds: string[], { day, month }: MonthDay) { return this.db .with('res', (qb) => qb @@ -239,16 +239,12 @@ export class AssetRepository { .select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')), ) .selectFrom('res') - .select( - sql`((now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date) / 365`.as( - 'yearsAgo', - ), - ) + .select(sql`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year')) .select((eb) => eb.fn.jsonAgg(eb.table('res')).as('assets')) .groupBy(sql`("localDateTime" at time zone 'UTC')::date`) .orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc') .limit(10) - .execute() as any as Promise; + .execute(); } @GenerateSql({ params: [[DummyValue.UUID]] }) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 336c3ac8f0..f91f600bb1 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -64,18 +64,18 @@ describe(AssetService.name, () => { mocks.partner.getAll.mockResolvedValue([]); mocks.asset.getByDayOfYear.mockResolvedValue([ { - yearsAgo: 1, + year: 2023, assets: [image1, image2], }, { - yearsAgo: 9, + year: 2015, assets: [image3], }, { - yearsAgo: 15, + year: 2009, assets: [image4], }, - ]); + ] as any); 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 a9b723c9f9..df66d405b7 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -38,12 +38,15 @@ export class AssetService extends BaseService { const userIds = [auth.user.id, ...partnerIds]; 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 })), - })); + return groups.map(({ year, assets }) => { + const yearsAgo = DateTime.utc().year - year; + return { + yearsAgo, + // TODO move this to clients + title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, + assets: assets.map((asset) => mapAsset(asset as AssetEntity, { auth })), + }; + }); } async getStatistics(auth: AuthDto, dto: AssetStatsDto) { diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index be4d6dfc76..8a46b289c3 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -45,18 +45,18 @@ export class MemoryService extends BaseService { for (const [userId, userIds] of Object.entries(userMap)) { const memories = await this.assetRepository.getByDayOfYear(userIds, target); - for (const memory of memories) { - const data: OnThisDayData = { year: target.year - memory.yearsAgo }; + for (const { year, assets } of memories) { + const data: OnThisDayData = { year }; await this.memoryRepository.create( { ownerId: userId, type: MemoryType.ON_THIS_DAY, data, - memoryAt: target.minus({ years: memory.yearsAgo }).toISO(), + memoryAt: target.set({ year }).toISO(), showAt, hideAt, }, - new Set(memory.assets.map(({ id }) => id)), + new Set(assets.map(({ id }) => id)), ); } } From dc143046e3633455413424ab3d343d279183f23e Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 18:54:08 +0000 Subject: [PATCH 037/104] chore: version v1.128.0 --- 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 | 2 +- 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, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index e84738e519..24d159ef7b 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.51", + "version": "2.2.52", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.51", + "version": "2.2.52", "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.127.0", + "version": "1.128.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index f902ff0089..d684ab3b41 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.51", + "version": "2.2.52", "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 4a3a1a6b60..13ab48d766 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.128.0", + "url": "https://v1.128.0.archive.immich.app" + }, { "label": "v1.127.0", "url": "https://v1.127.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index df4e01b4d9..c8526f5ba0 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.127.0", + "version": "1.128.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.127.0", + "version": "1.128.0", "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.51", + "version": "2.2.52", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.127.0", + "version": "1.128.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index b1f4b79137..775247f19c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.127.0", + "version": "1.128.0", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 032ced81b1..71174e2158 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.127.0" +version = "1.128.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index ecda716fe2..74ee2d415b 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" => 185, - "android.injected.version.name" => "1.127.0", + "android.injected.version.code" => 186, + "android.injected.version.name" => "1.128.0", } ) 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 43dc346284..fa3cac0d22 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.127.0" + version_number: "1.128.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 6e11640f4f..66c264cd76 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.127.0 +- API version: 1.128.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 89e7b09ca4..fe6defe362 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.127.0+185 +version: 1.128.0+186 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 5730e41578..2728fb9c91 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7655,7 +7655,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.127.0", + "version": "1.128.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 0db9c31fff..6c343e33e2 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.127.0", + "version": "1.128.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.127.0", + "version": "1.128.0", "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 c76165fae9..2345652519 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.127.0", + "version": "1.128.0", "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 b2895f6f1d..7e6164099b 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.127.0 + * 1.128.0 * 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 50ef7943f3..4be6f957ff 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.127.0", + "version": "1.128.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.127.0", + "version": "1.128.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.1", diff --git a/server/package.json b/server/package.json index 9f13976d10..651a04eb0f 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.127.0", + "version": "1.128.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index cb84fb2cea..054a39ec51 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.127.0", + "version": "1.128.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.127.0", + "version": "1.128.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -78,7 +78,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.127.0", + "version": "1.128.0", "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 a5774bbbe7..8180d08e9a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.127.0", + "version": "1.128.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From efcf773ea061789447781e62f5356787a91fe0e1 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Fri, 28 Feb 2025 16:04:34 -0500 Subject: [PATCH 038/104] feat(server): Shortened asset ID in storage template (#16433) * Update storage-template.service.ts * Update supported-variables-panel.svelte * docs example * Update storage-template-settings.svelte --- docs/docs/guides/database-queries.md | 4 ++++ server/src/services/storage-template.service.ts | 1 + .../storage-template/storage-template-settings.svelte | 1 + .../storage-template/supported-variables-panel.svelte | 1 + 4 files changed, 7 insertions(+) diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index 2017689984..89a4f07bc0 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -31,6 +31,10 @@ SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%'; SELECT * FROM "assets" WHERE "id" = '9f94e60f-65b6-47b7-ae44-a4df7b57f0e9'; ``` +```sql title="Find by partial ID" +SELECT * FROM "assets" WHERE "id"::text LIKE '%ab431d3a%'; +``` + :::note You can calculate the checksum for a particular file by using the command `sha1sum `. ::: diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 24a9fcd459..6a1548ff20 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -304,6 +304,7 @@ export class StorageTemplateService extends BaseService { filetype: asset.type == AssetType.IMAGE ? 'IMG' : 'VID', filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', assetId: asset.id, + assetIdShort: asset.id.slice(-12), //just throw into the root if it doesn't belong to an album album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '', }; diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 8fca558976..2a40156eac 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -73,6 +73,7 @@ filetype: 'IMG', filetypefull: 'IMAGE', assetId: 'a8312960-e277-447d-b4ea-56717ccba856', + assetIdShort: '56717ccba856', album: $t('album_name'), }; diff --git a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte index 515f2e48f0..fc8f913281 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte @@ -27,6 +27,7 @@

{$t('other').toUpperCase()}

  • {`{{assetId}}`} - Asset ID
  • +
  • {`{{assetIdShort}}`} - Asset ID (last 12 characters)
  • {`{{album}}`} - Album Name
From f11080cc2d72c832c3816e90219649d16c7577ee Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 28 Feb 2025 21:09:09 -0600 Subject: [PATCH 039/104] chore(mobile): post release task (#16437) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 90caae97fe..1bf67ac5f9 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +685,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +715,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +748,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -791,7 +791,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -831,7 +831,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index c08b4082cb..035b0ff642 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.127.0 + 1.128.0 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 195 + 196 FLTEnableImpeller ITSAppUsesNonExemptEncryption From 0cb3dc62119cb30c912f134f202fa0029368be27 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Sat, 1 Mar 2025 21:05:36 +0100 Subject: [PATCH 040/104] chore: add 'not duplicate' checkbox to issue template (#16462) --- .github/ISSUE_TEMPLATE/bug_report.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 346c6e60f2..86bef294fb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,6 +1,13 @@ name: Report an issue with Immich description: Report an issue with Immich body: + - type: checkboxes + attributes: + label: I have searched the existing issues to make sure this is not a duplicate report. + options: + - label: "Yes" + required: true + - type: markdown attributes: value: | From c8eef5ad4d12e58d7a991c52da8fd12be206d0d2 Mon Sep 17 00:00:00 2001 From: luzpaz Date: Sat, 1 Mar 2025 15:06:47 -0500 Subject: [PATCH 041/104] fix(mobile): fix typos (#16456) Found via codespell --- .../ios/Runner/BackgroundSync/BackgroundServicePlugin.swift | 6 +++--- mobile/lib/mixins/error_logger.mixin.dart | 2 +- mobile/lib/providers/asset_viewer/download.provider.dart | 2 +- .../asset_viewer/share_intent_upload.provider.dart | 2 +- mobile/lib/providers/auth.provider.dart | 2 +- mobile/lib/providers/gallery_permission.provider.dart | 2 +- mobile/lib/services/auth.service.dart | 4 ++-- mobile/lib/services/background.service.dart | 2 +- mobile/lib/services/sync.service.dart | 2 +- mobile/lib/utils/cache/custom_image_cache.dart | 2 +- .../lib/widgets/photo_view/src/utils/photo_view_utils.dart | 2 +- 11 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift index c84b037daf..cac9faab01 100644 --- a/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift +++ b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift @@ -160,7 +160,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { } } - // Called by the flutter code when enabled so that we can turn on the backround services + // Called by the flutter code when enabled so that we can turn on the background services // and save the callback information to communicate on this method channel public func handleBackgroundEnable(call: FlutterMethodCall, result: FlutterResult) { @@ -249,7 +249,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { result(true) } - // Returns the number of currently scheduled background processes to Flutter, striclty + // Returns the number of currently scheduled background processes to Flutter, strictly // for debugging func handleNumberOfProcesses(call: FlutterMethodCall, result: @escaping FlutterResult) { BGTaskScheduler.shared.getPendingTaskRequests { requests in @@ -355,7 +355,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { let isExpensive = wifiMonitor.currentPath.isExpensive if (isExpensive) { // The network is expensive and we have required Wi-Fi - // Therfore, we will simply complete the task without + // Therefore, we will simply complete the task without // running it task.setTaskCompleted(success: true) return diff --git a/mobile/lib/mixins/error_logger.mixin.dart b/mobile/lib/mixins/error_logger.mixin.dart index 9b2bc6f98e..466028c338 100644 --- a/mobile/lib/mixins/error_logger.mixin.dart +++ b/mobile/lib/mixins/error_logger.mixin.dart @@ -7,7 +7,7 @@ mixin ErrorLoggerMixin { abstract final Logger logger; /// Returns an AsyncValue if the future is successfully executed - /// Else, logs the error to the overrided logger and returns an AsyncError<> + /// Else, logs the error to the overridden logger and returns an AsyncError<> AsyncFuture guardError( Future Function() fn, { required String errorMessage, diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart index 68b120c38a..d699c7c763 100644 --- a/mobile/lib/providers/asset_viewer/download.provider.dart +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -104,7 +104,7 @@ class DownloadStateNotifier extends StateNotifier { } void _taskProgressCallback(TaskProgressUpdate update) { - // Ignore if the task is cancled or completed + // Ignore if the task is canceled or completed if (update.progress == -2 || update.progress == -1) { return; } diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart index a5a42ec796..ed2c485b13 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -117,7 +117,7 @@ class ShareIntentUploadStateNotifier } void _taskProgressCallback(TaskProgressUpdate update) { - // Ignore if the task is cancled or completed + // Ignore if the task is canceled or completed if (update.progress == downloadFailed || update.progress == downloadCompleted) { return; diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index e2b15753a9..e2939e89ce 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -47,7 +47,7 @@ class AuthNotifier extends StateNotifier { } /// Validating the url is the alternative connecting server url without - /// saving the infomation to the local database + /// saving the information to the local database Future validateAuxilaryServerUrl(String url) async { try { final validEndpoint = await _apiService.resolveEndpoint(url); diff --git a/mobile/lib/providers/gallery_permission.provider.dart b/mobile/lib/providers/gallery_permission.provider.dart index 8077ca99fe..07d9cca591 100644 --- a/mobile/lib/providers/gallery_permission.provider.dart +++ b/mobile/lib/providers/gallery_permission.provider.dart @@ -6,7 +6,7 @@ import 'package:permission_handler/permission_handler.dart'; class GalleryPermissionNotifier extends StateNotifier { GalleryPermissionNotifier() - : super(PermissionStatus.denied) // Denied is the intitial state + : super(PermissionStatus.denied) // Denied is the initial state { // Sets the initial state getGalleryPermissionStatus(); diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index be6c64bc43..20fa62dc4b 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -75,7 +75,7 @@ class AuthService { isValid = true; } } catch (error) { - _log.severe("Error validating auxilary endpoint", error); + _log.severe("Error validating auxiliary endpoint", error); } finally { httpclient.close(); } @@ -187,7 +187,7 @@ class AuthService { _log.severe("Cannot resolve endpoint", error); continue; } catch (_) { - _log.severe("Auxilary server is not valid"); + _log.severe("Auxiliary server is not valid"); continue; } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index f4597831d2..e457102d9f 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -329,7 +329,7 @@ class BackgroundService { try { _clearErrorNotifications(); - // iOS should time out after some threshhold so it doesn't wait + // iOS should time out after some threshold so it doesn't wait // indefinitely and can run later // Android is fine to wait here until the lock releases final waitForLock = Platform.isIOS diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index ddca266006..34df461866 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -639,7 +639,7 @@ class SyncService { } /// fast path for common case: only new assets were added to device album - /// returns `true` if successfull, else `false` + /// returns `true` if successful, else `false` Future _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async { if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) { return false; diff --git a/mobile/lib/utils/cache/custom_image_cache.dart b/mobile/lib/utils/cache/custom_image_cache.dart index a2a7839172..dcb8dacb0d 100644 --- a/mobile/lib/utils/cache/custom_image_cache.dart +++ b/mobile/lib/utils/cache/custom_image_cache.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart' import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; /// [ImageCache] that uses two caches for small and large images -/// so that a single large image does not evict all small iamges +/// so that a single large image does not evict all small images final class CustomImageCache implements ImageCache { final _small = ImageCache(); final _large = ImageCache()..maximumSize = 5; // Maximum 5 images diff --git a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart index 9c632df3bf..facd701725 100644 --- a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart +++ b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart @@ -26,7 +26,7 @@ double getScaleForScaleState( } /// Internal class to wraps custom scale boundaries (min, max and initial) -/// Also, stores values regarding the two sizes: the container and teh child. +/// Also, stores values regarding the two sizes: the container and the child. class ScaleBoundaries { const ScaleBoundaries( this._minScale, From 2510684bf7a5c8799dd36468f61454645c444cdb Mon Sep 17 00:00:00 2001 From: Thomas Laroche Date: Sat, 1 Mar 2025 21:07:19 +0100 Subject: [PATCH 042/104] fix(web): unable to download live photo as anonymous user (#16455) --- web/src/lib/utils/asset-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 70f5c5f8f2..fa9725aa24 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -229,7 +229,7 @@ export const downloadFile = async (asset: AssetResponseDto) => { if (asset.livePhotoVideoId) { const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() }); - if (!isAndroidMotionVideo(motionAsset) || get(preferences).download.includeEmbeddedVideos) { + if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) { assets.push({ filename: motionAsset.originalFileName, id: asset.livePhotoVideoId, From f13d13b2ea7221eb6234ab28695e8632fd5efaa5 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sat, 1 Mar 2025 21:34:57 +0100 Subject: [PATCH 043/104] fix(web): Fixed people list overflowing in advanced search (#16457) * Fixed people list overflowing in search * styling: better fix --------- Co-authored-by: Alex Tran --- .../search-bar/search-people-section.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte index ed17d78af3..bcf858d01a 100644 --- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte @@ -54,7 +54,7 @@ {#await peoplePromise} -
+
{:then people} @@ -63,14 +63,14 @@ ? filterPeople(people, name) : filterPeople(people, name).slice(0, numberOfPeople)} -
+

{$t('people').toUpperCase()}

{#each peopleList as person (person.id)} From 506d2d0f815a8100a4a4b267418909b969e5ef5b Mon Sep 17 00:00:00 2001 From: luzpaz Date: Sat, 1 Mar 2025 17:51:50 -0500 Subject: [PATCH 044/104] fix(web): fix typos (#16466) Found via codespell --- web/src/lib/actions/intersection-observer.ts | 2 +- web/src/lib/components/i18n/__test__/format-message.spec.ts | 2 +- .../components/shared-components/notification/notification.ts | 2 +- .../purchasing/individual-purchase-option-card.svelte | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts index edbc07e5c1..3a10074051 100644 --- a/web/src/lib/actions/intersection-observer.ts +++ b/web/src/lib/actions/intersection-observer.ts @@ -60,7 +60,7 @@ const observe = (key: HTMLElement | string, target: HTMLElement, properties: Int (entries: IntersectionObserverEntry[]) => { // This IntersectionObserver is limited to observing a single element, the one the // action is attached to. If there are multiple entries, it means that this - // observer is being notified of multiple events that have occured quickly together, + // observer is being notified of multiple events that have occurred quickly together, // and the latest element is the one we are interested in. entries.sort((a, b) => a.time - b.time); diff --git a/web/src/lib/components/i18n/__test__/format-message.spec.ts b/web/src/lib/components/i18n/__test__/format-message.spec.ts index a496237f90..e7b3cd6ab9 100644 --- a/web/src/lib/components/i18n/__test__/format-message.spec.ts +++ b/web/src/lib/components/i18n/__test__/format-message.spec.ts @@ -70,7 +70,7 @@ describe('FormatMessage component', () => { expect(getSanitizedHTML(container)).toBe('You have 1 item'); }); - it('protects agains XSS injection', () => { + it('protects against XSS injection', () => { render(FormatMessage, { key: 'xss' as Translations, }); diff --git a/web/src/lib/components/shared-components/notification/notification.ts b/web/src/lib/components/shared-components/notification/notification.ts index fd54768c04..79b1edd1a9 100644 --- a/web/src/lib/components/shared-components/notification/notification.ts +++ b/web/src/lib/components/shared-components/notification/notification.ts @@ -19,7 +19,7 @@ export type Notification = { /** The action to take when the notification is clicked */ action: NotificationAction; button?: NotificationButton; - /** Timeout in miliseconds */ + /** Timeout in milliseconds */ timeout: number; }; diff --git a/web/src/lib/components/shared-components/purchasing/individual-purchase-option-card.svelte b/web/src/lib/components/shared-components/purchasing/individual-purchase-option-card.svelte index f9de919025..c77a9dac96 100644 --- a/web/src/lib/components/shared-components/purchasing/individual-purchase-option-card.svelte +++ b/web/src/lib/components/shared-components/purchasing/individual-purchase-option-card.svelte @@ -7,7 +7,7 @@ import { t } from 'svelte-i18n'; - +
From 6cc1978b2dad52b923fcda14d3c0b320dc6bb423 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sun, 2 Mar 2025 00:02:56 +0100 Subject: [PATCH 045/104] fix(web): Open huggingface.co link on settings page in new tab (#16470) fix(web): Open huggingface on settings page in new tab --- .../machine-learning-settings/machine-learning-settings.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index 90131d7238..80c8904376 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -110,7 +110,7 @@

{#snippet children({ message })} - {message} + {message} {/snippet}

From d8d87bb5656cfe9a82ea830e6dd9762fd0a0506a Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Sun, 2 Mar 2025 18:00:48 +0530 Subject: [PATCH 046/104] chore(mobile): rename log enum to lowercase (#16476) * chore(mobile): rename log enum to lowercase * chore(mobile): do not abbreviate --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/models/log.model.dart | 24 ++++----- mobile/lib/domain/services/log.service.dart | 18 +++---- .../infrastructure/entities/log.entity.dart | 23 ++++----- .../infrastructure/entities/log.entity.g.dart | 49 +++++++++---------- .../infrastructure/entities/store.entity.dart | 3 +- mobile/lib/pages/common/app_log.page.dart | 12 ++--- mobile/lib/utils/bootstrap.dart | 4 +- .../domain/services/log_service_test.dart | 32 ++++++------ .../modules/shared/sync_service_test.dart | 4 +- 9 files changed, 80 insertions(+), 89 deletions(-) diff --git a/mobile/lib/domain/models/log.model.dart b/mobile/lib/domain/models/log.model.dart index 51f816df01..dffd1cccda 100644 --- a/mobile/lib/domain/models/log.model.dart +++ b/mobile/lib/domain/models/log.model.dart @@ -1,19 +1,15 @@ -// ignore_for_file: constant_identifier_names - -import 'package:logging/logging.dart'; - /// Log levels according to dart logging [Level] enum LogLevel { - ALL, - FINEST, - FINER, - FINE, - CONFIG, - INFO, - WARNING, - SEVERE, - SHOUT, - OFF, + all, + finest, + finer, + fine, + config, + info, + warning, + severe, + shout, + off, } class LogMessage { diff --git a/mobile/lib/domain/services/log.service.dart b/mobile/lib/domain/services/log.service.dart index 61b5638e78..59de5c2e94 100644 --- a/mobile/lib/domain/services/log.service.dart +++ b/mobile/lib/domain/services/log.service.dart @@ -39,29 +39,29 @@ class LogService { } static Future init({ - required ILogRepository logRepo, - required IStoreRepository storeRepo, + required ILogRepository logRepository, + required IStoreRepository storeRepository, bool shouldBuffer = true, }) async { if (_instance != null) { return _instance!; } _instance = await create( - logRepo: logRepo, - storeRepo: storeRepo, + logRepository: logRepository, + storeRepository: storeRepository, shouldBuffer: shouldBuffer, ); return _instance!; } static Future create({ - required ILogRepository logRepo, - required IStoreRepository storeRepo, + required ILogRepository logRepository, + required IStoreRepository storeRepository, bool shouldBuffer = true, }) async { - final instance = LogService._(logRepo, storeRepo, shouldBuffer); + final instance = LogService._(logRepository, storeRepository, shouldBuffer); // Truncate logs to 250 - await logRepo.truncate(limit: kLogTruncateLimit); + await logRepository.truncate(limit: kLogTruncateLimit); // Get log level from store final level = await instance._storeRepository.tryGet(StoreKey.logLevel); if (level != null) { @@ -145,7 +145,7 @@ class LoggerUnInitializedException implements Exception { extension LevelDomainToInfraExtension on Level { LogLevel toLogLevel() => LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ?? - LogLevel.INFO; + LogLevel.info; } extension on LogLevel { diff --git a/mobile/lib/infrastructure/entities/log.entity.dart b/mobile/lib/infrastructure/entities/log.entity.dart index 6c55f17989..6a38924e24 100644 --- a/mobile/lib/infrastructure/entities/log.entity.dart +++ b/mobile/lib/infrastructure/entities/log.entity.dart @@ -5,29 +5,24 @@ part 'log.entity.g.dart'; @Collection(inheritance: false) class LoggerMessage { - Id id = Isar.autoIncrement; - String message; - String? details; + final Id id = Isar.autoIncrement; + final String message; + final String? details; @Enumerated(EnumType.ordinal) - LogLevel level = LogLevel.INFO; - DateTime createdAt; - String? context1; - String? context2; + final LogLevel level; + final DateTime createdAt; + final String? context1; + final String? context2; - LoggerMessage({ + const LoggerMessage({ required this.message, required this.details, - required this.level, + this.level = LogLevel.info, required this.createdAt, required this.context1, required this.context2, }); - @override - String toString() { - return 'LoggerMessage(message: $message, level: $level, createdAt: $createdAt)'; - } - LogMessage toDto() { return LogMessage( message: message, diff --git a/mobile/lib/infrastructure/entities/log.entity.g.dart b/mobile/lib/infrastructure/entities/log.entity.g.dart index f3ee284aa4..9300cf15c5 100644 --- a/mobile/lib/infrastructure/entities/log.entity.g.dart +++ b/mobile/lib/infrastructure/entities/log.entity.g.dart @@ -117,10 +117,9 @@ LoggerMessage _loggerMessageDeserialize( createdAt: reader.readDateTime(offsets[2]), details: reader.readStringOrNull(offsets[3]), level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[4])] ?? - LogLevel.ALL, + LogLevel.info, message: reader.readString(offsets[5]), ); - object.id = id; return object; } @@ -141,7 +140,7 @@ P _loggerMessageDeserializeProp

( return (reader.readStringOrNull(offset)) as P; case 4: return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ?? - LogLevel.ALL) as P; + LogLevel.info) as P; case 5: return (reader.readString(offset)) as P; default: @@ -150,28 +149,28 @@ P _loggerMessageDeserializeProp

( } const _LoggerMessagelevelEnumValueMap = { - 'ALL': 0, - 'FINEST': 1, - 'FINER': 2, - 'FINE': 3, - 'CONFIG': 4, - 'INFO': 5, - 'WARNING': 6, - 'SEVERE': 7, - 'SHOUT': 8, - 'OFF': 9, + 'all': 0, + 'finest': 1, + 'finer': 2, + 'fine': 3, + 'config': 4, + 'info': 5, + 'warning': 6, + 'severe': 7, + 'shout': 8, + 'off': 9, }; const _LoggerMessagelevelValueEnumMap = { - 0: LogLevel.ALL, - 1: LogLevel.FINEST, - 2: LogLevel.FINER, - 3: LogLevel.FINE, - 4: LogLevel.CONFIG, - 5: LogLevel.INFO, - 6: LogLevel.WARNING, - 7: LogLevel.SEVERE, - 8: LogLevel.SHOUT, - 9: LogLevel.OFF, + 0: LogLevel.all, + 1: LogLevel.finest, + 2: LogLevel.finer, + 3: LogLevel.fine, + 4: LogLevel.config, + 5: LogLevel.info, + 6: LogLevel.warning, + 7: LogLevel.severe, + 8: LogLevel.shout, + 9: LogLevel.off, }; Id _loggerMessageGetId(LoggerMessage object) { @@ -183,9 +182,7 @@ List> _loggerMessageGetLinks(LoggerMessage object) { } void _loggerMessageAttach( - IsarCollection col, Id id, LoggerMessage object) { - object.id = id; -} + IsarCollection col, Id id, LoggerMessage object) {} extension LoggerMessageQueryWhereSort on QueryBuilder { diff --git a/mobile/lib/infrastructure/entities/store.entity.dart b/mobile/lib/infrastructure/entities/store.entity.dart index ef47af8f52..8d6d9a7d16 100644 --- a/mobile/lib/infrastructure/entities/store.entity.dart +++ b/mobile/lib/infrastructure/entities/store.entity.dart @@ -5,8 +5,9 @@ part 'store.entity.g.dart'; /// Internal class for `Store`, do not use elsewhere. @Collection(inheritance: false) class StoreValue { - const StoreValue(this.id, {this.intValue, this.strValue}); final Id id; final int? intValue; final String? strValue; + + const StoreValue(this.id, {this.intValue, this.strValue}); } diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index 3bd2e0111f..56c32327dd 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -41,16 +41,16 @@ class AppLogPage extends HookConsumerWidget { } Widget buildLeadingIcon(LogLevel level) => switch (level) { - LogLevel.INFO => colorStatusIndicator(context.primaryColor), - LogLevel.SEVERE => colorStatusIndicator(Colors.redAccent), - LogLevel.WARNING => colorStatusIndicator(Colors.orangeAccent), + LogLevel.info => colorStatusIndicator(context.primaryColor), + LogLevel.severe => colorStatusIndicator(Colors.redAccent), + LogLevel.warning => colorStatusIndicator(Colors.orangeAccent), _ => colorStatusIndicator(Colors.grey), }; Color getTileColor(LogLevel level) => switch (level) { - LogLevel.INFO => Colors.transparent, - LogLevel.SEVERE => Colors.redAccent.withOpacity(0.25), - LogLevel.WARNING => Colors.orangeAccent.withOpacity(0.25), + LogLevel.info => Colors.transparent, + LogLevel.severe => Colors.redAccent.withOpacity(0.25), + LogLevel.warning => Colors.orangeAccent.withOpacity(0.25), _ => context.primaryColor.withOpacity(0.1), }; diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 5b9a41f28d..4a9ce1a5e1 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -49,8 +49,8 @@ abstract final class Bootstrap { static Future initDomain(Isar db) async { await StoreService.init(storeRepository: IsarStoreRepository(db)); await LogService.init( - logRepo: IsarLogRepository(db), - storeRepo: IsarStoreRepository(db), + logRepository: IsarLogRepository(db), + storeRepository: IsarStoreRepository(db), ); } } diff --git a/mobile/test/domain/services/log_service_test.dart b/mobile/test/domain/services/log_service_test.dart index cbceb0d165..5811a8c430 100644 --- a/mobile/test/domain/services/log_service_test.dart +++ b/mobile/test/domain/services/log_service_test.dart @@ -14,14 +14,14 @@ import '../../test_utils.dart'; final _kInfoLog = LogMessage( message: '#Info Message', - level: LogLevel.INFO, + level: LogLevel.info, createdAt: DateTime(2025, 2, 26), logger: 'Info Logger', ); final _kWarnLog = LogMessage( message: '#Warn Message', - level: LogLevel.WARNING, + level: LogLevel.warning, createdAt: DateTime(2025, 2, 27), logger: 'Warn Logger', ); @@ -40,13 +40,15 @@ void main() { when(() => mockLogRepo.truncate(limit: any(named: 'limit'))) .thenAnswer((_) async => {}); when(() => mockStoreRepo.tryGet(StoreKey.logLevel)) - .thenAnswer((_) async => LogLevel.FINE.index); + .thenAnswer((_) async => LogLevel.fine.index); when(() => mockLogRepo.getAll()).thenAnswer((_) async => []); when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true); when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true); - sut = - await LogService.create(logRepo: mockLogRepo, storeRepo: mockStoreRepo); + sut = await LogService.create( + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, + ); }); tearDown(() async { @@ -72,14 +74,14 @@ void main() { setUp(() async { when(() => mockStoreRepo.insert(StoreKey.logLevel, any())) .thenAnswer((_) async => true); - await sut.setlogLevel(LogLevel.SHOUT); + await sut.setlogLevel(LogLevel.shout); }); test('Updates the log level in store', () { final index = verify( () => mockStoreRepo.insert(StoreKey.logLevel, captureAny()), ).captured.firstOrNull; - expect(index, LogLevel.SHOUT.index); + expect(index, LogLevel.shout.index); }); test('Sets log level on logger', () { @@ -91,8 +93,8 @@ void main() { test('Buffers logs until timer elapses', () { TestUtils.fakeAsync((time) async { sut = await LogService.create( - logRepo: mockLogRepo, - storeRepo: mockStoreRepo, + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, shouldBuffer: true, ); @@ -109,8 +111,8 @@ void main() { test('Batch inserts all logs on timer', () { TestUtils.fakeAsync((time) async { sut = await LogService.create( - logRepo: mockLogRepo, - storeRepo: mockStoreRepo, + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, shouldBuffer: true, ); @@ -131,8 +133,8 @@ void main() { test('Does not buffer when off', () { TestUtils.fakeAsync((time) async { sut = await LogService.create( - logRepo: mockLogRepo, - storeRepo: mockStoreRepo, + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, shouldBuffer: false, ); @@ -165,8 +167,8 @@ void main() { test('Combines result from both DB + Buffer', () { TestUtils.fakeAsync((time) async { sut = await LogService.create( - logRepo: mockLogRepo, - storeRepo: mockStoreRepo, + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, shouldBuffer: true, ); diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index e37b5ec7bc..a58de21613 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -72,8 +72,8 @@ void main() { await StoreService.init(storeRepository: IsarStoreRepository(db)); await Store.put(StoreKey.currentUser, owner); await LogService.init( - logRepo: IsarLogRepository(db), - storeRepo: IsarStoreRepository(db), + logRepository: IsarLogRepository(db), + storeRepository: IsarStoreRepository(db), ); }); final List initialAssets = [ From fd5e9316173ee6f3a89e9fbe978b01f63e9cd8f9 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sun, 2 Mar 2025 13:58:05 +0100 Subject: [PATCH 047/104] fix(mobile): Updated formatting of server address in networking (#16483) * Updated formatting of server address in networking * fallback for undefined endpoint --- .../networking_settings.dart | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart index 1089029947..587a0ce6d3 100644 --- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -2,13 +2,12 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/network.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @@ -18,7 +17,7 @@ class NetworkingSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentEndpoint = Store.get(StoreKey.serverEndpoint); + final currentEndpoint = getServerUrl(); final featureEnabled = useAppSettingsState(AppSettingsEnum.autoEndpointSwitching); @@ -102,7 +101,7 @@ class NetworkingSettings extends HookConsumerWidget { padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8), child: NetworkPreferenceTitle( title: "current_server_address".tr().toUpperCase(), - icon: currentEndpoint.startsWith('https') + icon: (currentEndpoint?.startsWith('https') ?? false) ? Icons.https_outlined : Icons.http_outlined, ), @@ -119,10 +118,16 @@ class NetworkingSettings extends HookConsumerWidget { ), ), child: ListTile( - leading: - const Icon(Icons.check_circle_rounded, color: Colors.green), + leading: currentEndpoint != null + ? const Icon( + Icons.check_circle_rounded, + color: Colors.green, + ) + : const Icon( + Icons.circle_outlined, + ), title: Text( - currentEndpoint, + currentEndpoint ?? "--", style: TextStyle( fontSize: 16, fontFamily: 'Inconsolata', From 366f23774a28424bcae5843e9c3d84b149808be6 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sun, 2 Mar 2025 14:06:15 +0100 Subject: [PATCH 048/104] fix(web): Default to context search on web (#16485) Default to context search on web --- .../shared-components/search-bar/search-filter-modal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index 4fc646b204..f37894a3e2 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -57,7 +57,7 @@ let filter: SearchFilter = $state({ query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '', - queryType: 'query' in searchQuery ? 'smart' : 'metadata', + queryType: 'smart', personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), tagIds: new SvelteSet('tagIds' in searchQuery ? searchQuery.tagIds : []), location: { From 6bf2e8dbcb5eecadb3c43a3fa5f6576a5ec07756 Mon Sep 17 00:00:00 2001 From: knechtandreas Date: Mon, 3 Mar 2025 00:15:00 +1100 Subject: [PATCH 049/104] feat: add album keyboard shortcuts (#16442) * 15712: Added keyboard shortcuts for opening add to album modal and highlighting/selecting an album to add to. * 15712: Re-factored logic from template code into script. Extracted new album button into separate cmponent. * 15712: Document new keyboard shortucts now that they work everywhere. * 15712: Extract some constants/helper functions. * 15712: Missing comma. * 15712: Pulled logic out into separate unit testable class. * 15712: Added a unit test. * 15712: Move the modal back up to keep the github PR happy. * 15712: PR feedback - renamed typescript files and switch to class bind directive. * 15712:Move selection modal into correct package. * 15712: Better naming of module and files. --- .../actions/add-to-album-action.svelte | 7 +- .../asset-viewer/album-list-item.svelte | 16 +- .../photos-page/actions/add-to-album.svelte | 2 +- .../album-selection-modal.svelte | 113 ------------ .../album-selection-modal.svelte | 126 +++++++++++++ .../album-selection-utils.spec.ts | 171 ++++++++++++++++++ .../album-selection/album-selection-utils.ts | 94 ++++++++++ .../new-album-list-item.svelte | 40 ++++ .../shared-components/show-shortcuts.svelte | 2 + 9 files changed, 455 insertions(+), 116 deletions(-) delete mode 100644 web/src/lib/components/shared-components/album-selection-modal.svelte create mode 100644 web/src/lib/components/shared-components/album-selection/album-selection-modal.svelte create mode 100644 web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts create mode 100644 web/src/lib/components/shared-components/album-selection/album-selection-utils.ts create mode 100644 web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte index ab0da059d0..202f0e4593 100644 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte @@ -1,6 +1,7 @@ + (showSelectionModal = true) }} +/> + void; } - let { album, searchQuery = '', onAlbumClick }: Props = $props(); + let { album, searchQuery = '', selected = false, onAlbumClick }: Props = $props(); + + const scrollIntoViewIfSelected: Action = (node) => { + $effect(() => { + if (selected) { + node.scrollIntoView(SCROLL_PROPERTIES); + } + }); + }; let albumNameArray: string[] = $state(['', '', '']); @@ -31,7 +42,10 @@ - {#if filteredAlbums.length > 0} - {#if !shared && search.length === 0} -

{$t('recent').toUpperCase()}

- {#each recentAlbums as album (album.id)} - onAlbumClick(album)} /> - {/each} - {/if} - - {#if !shared} -

- {(search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase()} -

- {/if} - {#each filteredAlbums as album (album.id)} - onAlbumClick(album)} /> - {/each} - {:else if albums.length > 0} -

{$t('no_albums_with_name_yet')}

- {:else} -

{$t('no_albums_yet')}

- {/if} -
- {/if} -
- diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-modal.svelte b/web/src/lib/components/shared-components/album-selection/album-selection-modal.svelte new file mode 100644 index 0000000000..49b697b62a --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/album-selection-modal.svelte @@ -0,0 +1,126 @@ + + + +
+ {#if loading} + {#each { length: 3 } as _} +
+
+
+ +
+ + +
+
+
+ {/each} + {:else} + +
+ {#each albumModalRows as row} + {#if row.type === AlbumModalRowType.NEW_ALBUM} + + {:else if row.type === AlbumModalRowType.SECTION} +

{row.text}

+ {:else if row.type === AlbumModalRowType.MESSAGE} +

{row.text}

+ {:else if row.type === AlbumModalRowType.ALBUM_ITEM && row.album} + + {/if} + {/each} +
+ {/if} +
+
diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts new file mode 100644 index 0000000000..242809d58f --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts @@ -0,0 +1,171 @@ +import { + type AlbumModalRow, + AlbumModalRowConverter, + AlbumModalRowType, +} from '$lib/components/shared-components/album-selection/album-selection-utils'; +import { AlbumSortBy, SortOrder } from '$lib/stores/preferences.store'; +import type { AlbumResponseDto } from '@immich/sdk'; +import { albumFactory } from '@test-data/factories/album-factory'; + +// Some helper functions to make tests below more readable +const createNewAlbumRow = (selected: boolean) => ({ + type: AlbumModalRowType.NEW_ALBUM, + selected, +}); +const createMessageRow = (message: string): AlbumModalRow => ({ + type: AlbumModalRowType.MESSAGE, + text: message, +}); +const createSectionRow = (message: string): AlbumModalRow => ({ + type: AlbumModalRowType.SECTION, + text: message, +}); +const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({ + type: AlbumModalRowType.ALBUM_ITEM, + album, + selected, +}); + +describe('Album Modal', () => { + it('non-shared with no albums configured yet shows message and new', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const modalRows = converter.toModalRows('', [], [], -1); + + expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]); + }); + + it('non-shared with no matching albums shows message and new', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const modalRows = converter.toModalRows('matches_nothing', [], [albumFactory.build({ albumName: 'Holidays' })], -1); + + expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]); + }); + + it('non-shared displays single albums', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const modalRows = converter.toModalRows('', [], [holidayAlbum], -1); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + ]); + }); + + it('non-shared displays multiple albums and recents', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); + const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); + const modalRows = converter.toModalRows( + '', + [holidayAlbum, constructionAlbum], + [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], + -1, + ); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + createAlbumRow(birthdayAlbum, false), + createAlbumRow(christmasAlbum, false), + ]); + }); + + it('shared only displays albums and no recents', () => { + const converter = new AlbumModalRowConverter(true, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); + const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); + const modalRows = converter.toModalRows( + '', + [holidayAlbum, constructionAlbum], + [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], + -1, + ); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + createAlbumRow(birthdayAlbum, false), + createAlbumRow(christmasAlbum, false), + ]); + }); + + it('search changes messaging and removes recent and non-matching albums', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); + const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); + const modalRows = converter.toModalRows( + 'Cons', + [holidayAlbum, constructionAlbum], + [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], + -1, + ); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('ALBUMS'), + createAlbumRow(constructionAlbum, false), + ]); + }); + + it('selection can select new album row', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(true), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + ]); + }); + + it('selection can select recent row', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, true), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + ]); + }); + + it('selection can select last row', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, true), + ]); + }); +}); diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts new file mode 100644 index 0000000000..73f289eb1d --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts @@ -0,0 +1,94 @@ +import { sortAlbums } from '$lib/utils/album-utils'; +import { normalizeSearchString } from '$lib/utils/string-utils'; +import type { AlbumResponseDto } from '@immich/sdk'; +import { t } from 'svelte-i18n'; +import { get } from 'svelte/store'; + +export const SCROLL_PROPERTIES: ScrollIntoViewOptions = { block: 'center', behavior: 'smooth' }; + +export enum AlbumModalRowType { + SECTION = 'section', + MESSAGE = 'message', + NEW_ALBUM = 'newAlbum', + ALBUM_ITEM = 'albumItem', +} + +export type AlbumModalRow = { + type: AlbumModalRowType; + selected?: boolean; + text?: string; + album?: AlbumResponseDto; +}; + +export const isSelectableRowType = (type: AlbumModalRowType) => + type === AlbumModalRowType.NEW_ALBUM || type === AlbumModalRowType.ALBUM_ITEM; + +const $t = get(t); + +export class AlbumModalRowConverter { + private readonly shared: boolean; + private readonly sortBy: string; + private readonly orderBy: string; + + constructor(shared: boolean, sortBy: string, orderBy: string) { + this.shared = shared; + this.sortBy = sortBy; + this.orderBy = orderBy; + } + + toModalRows( + search: string, + recentAlbums: AlbumResponseDto[], + albums: AlbumResponseDto[], + selectedRowIndex: number, + ): AlbumModalRow[] { + // only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal. + const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : []; + const rows: AlbumModalRow[] = []; + rows.push({ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }); + + const filteredAlbums = sortAlbums( + search.length > 0 && albums.length > 0 + ? albums.filter((album) => { + return normalizeSearchString(album.albumName).includes(normalizeSearchString(search)); + }) + : albums, + { sortBy: this.sortBy, orderBy: this.orderBy }, + ); + + if (filteredAlbums.length > 0) { + if (recentAlbumsToShow.length > 0) { + rows.push({ type: AlbumModalRowType.SECTION, text: $t('recent').toUpperCase() }); + const selectedOffsetDueToNewAlbumRow = 1; + for (const [i, album] of recentAlbums.entries()) { + rows.push({ + type: AlbumModalRowType.ALBUM_ITEM, + selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow, + album, + }); + } + } + + if (!this.shared) { + rows.push({ + type: AlbumModalRowType.SECTION, + text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(), + }); + } + + const selectedOffsetDueToNewAndRecents = 1 + recentAlbumsToShow.length; + for (const [i, album] of filteredAlbums.entries()) { + rows.push({ + type: AlbumModalRowType.ALBUM_ITEM, + selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents, + album, + }); + } + } else if (albums.length > 0) { + rows.push({ type: AlbumModalRowType.MESSAGE, text: $t('no_albums_with_name_yet') }); + } else { + rows.push({ type: AlbumModalRowType.MESSAGE, text: $t('no_albums_yet') }); + } + return rows; + } +} diff --git a/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte new file mode 100644 index 0000000000..d8be0e2a30 --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte @@ -0,0 +1,40 @@ + + + diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte index a3cfd83ad5..9ca35c927e 100644 --- a/web/src/lib/components/shared-components/show-shortcuts.svelte +++ b/web/src/lib/components/shared-components/show-shortcuts.svelte @@ -33,6 +33,8 @@ { key: ['f'], action: $t('favorite_or_unfavorite_photo') }, { key: ['i'], action: $t('show_or_hide_info') }, { key: ['s'], action: $t('stack_selected_photos') }, + { key: ['l'], action: $t('add_to_album') }, + { key: ['⇧', 'l'], action: $t('add_to_shared_album') }, { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') }, { key: ['⇧', 'd'], action: $t('download') }, { key: ['Space'], action: $t('play_or_pause_video') }, From 6e51c4ec71c2516035b393e1175c99daac1f6a81 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 3 Mar 2025 04:02:36 +0100 Subject: [PATCH 050/104] chore: add extra note to no-dupes checkbox (#16499) --- .github/DISCUSSION_TEMPLATE/feature-request.yaml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/DISCUSSION_TEMPLATE/feature-request.yaml b/.github/DISCUSSION_TEMPLATE/feature-request.yaml index 9aeee8004c..7a260188ea 100644 --- a/.github/DISCUSSION_TEMPLATE/feature-request.yaml +++ b/.github/DISCUSSION_TEMPLATE/feature-request.yaml @@ -11,7 +11,7 @@ body: - type: checkboxes attributes: - label: I have searched the existing feature requests to make sure this is not a duplicate request. + label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request. options: - label: "Yes" required: true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 86bef294fb..c4e1cc2bf1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -3,7 +3,7 @@ description: Report an issue with Immich body: - type: checkboxes attributes: - label: I have searched the existing issues to make sure this is not a duplicate report. + label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report. options: - label: "Yes" required: true From 8885e3105e3d915ae1d9f5ddd462ca9636ef66c7 Mon Sep 17 00:00:00 2001 From: Justin Cichra Date: Sun, 2 Mar 2025 22:27:20 -0500 Subject: [PATCH 051/104] chore: reword backup_manual_in_progress (#16513) fix(i18n): reword backup_manual_in_progress Split "sometime" into "some time". --- mobile/assets/i18n/ca-CA.json | 2 +- mobile/assets/i18n/en-US.json | 4 ++-- mobile/assets/i18n/ga.json | 4 ++-- mobile/assets/i18n/gl-ES.json | 2 +- mobile/assets/i18n/hi-IN.json | 4 ++-- mobile/assets/i18n/lt-LT.json | 4 ++-- mobile/assets/i18n/mn-MN.json | 4 ++-- mobile/assets/i18n/sr-Cyrl.json | 4 ++-- mobile/assets/i18n/sr-Latn.json | 4 ++-- mobile/assets/i18n/sv-FI.json | 4 ++-- 10 files changed, 18 insertions(+), 18 deletions(-) diff --git a/mobile/assets/i18n/ca-CA.json b/mobile/assets/i18n/ca-CA.json index 8366e01f93..cd7fd0e12b 100644 --- a/mobile/assets/i18n/ca-CA.json +++ b/mobile/assets/i18n/ca-CA.json @@ -108,7 +108,7 @@ "backup_info_card_assets": "elements", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index fd628d4692..38fdb8ac5e 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/ga.json b/mobile/assets/i18n/ga.json index fd628d4692..38fdb8ac5e 100644 --- a/mobile/assets/i18n/ga.json +++ b/mobile/assets/i18n/ga.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/gl-ES.json b/mobile/assets/i18n/gl-ES.json index 9450b4b44f..a5b43d7447 100644 --- a/mobile/assets/i18n/gl-ES.json +++ b/mobile/assets/i18n/gl-ES.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json index 5b64f8c674..0d58fbb42e 100644 --- a/mobile/assets/i18n/hi-IN.json +++ b/mobile/assets/i18n/hi-IN.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "स्टैक रद्द करें", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/lt-LT.json b/mobile/assets/i18n/lt-LT.json index fd628d4692..38fdb8ac5e 100644 --- a/mobile/assets/i18n/lt-LT.json +++ b/mobile/assets/i18n/lt-LT.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/mn-MN.json b/mobile/assets/i18n/mn-MN.json index 33c9ed0440..9fb9c5498f 100644 --- a/mobile/assets/i18n/mn-MN.json +++ b/mobile/assets/i18n/mn-MN.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json index fd628d4692..38fdb8ac5e 100644 --- a/mobile/assets/i18n/sr-Cyrl.json +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/sr-Latn.json b/mobile/assets/i18n/sr-Latn.json index e0f8edc97b..8d67c34792 100644 --- a/mobile/assets/i18n/sr-Latn.json +++ b/mobile/assets/i18n/sr-Latn.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "zapisi", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json index fd628d4692..38fdb8ac5e 100644 --- a/mobile/assets/i18n/sv-FI.json +++ b/mobile/assets/i18n/sv-FI.json @@ -133,7 +133,7 @@ "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_in_progress": "Upload already in progress. Try after some time", "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", @@ -678,4 +678,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} From 869839f64256927c8920a6cda2d410922603b150 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Mon, 3 Mar 2025 04:29:02 +0100 Subject: [PATCH 052/104] feat(server): library cleanup from ui (#16226) * feat(server,web): scan all libraries from frontend * feat(server,web): scan all libraries from frontend * Add button text --- docs/docs/features/libraries.md | 2 +- i18n/en.json | 4 ++- server/src/enum.ts | 2 +- server/src/services/job.service.ts | 2 +- server/src/services/library.service.spec.ts | 2 +- server/src/services/library.service.ts | 28 +++++++++++++------ server/src/types.ts | 2 +- .../admin-page/jobs/job-tile.svelte | 2 +- .../admin-page/jobs/jobs-panel.svelte | 7 ++--- web/src/lib/utils.ts | 2 +- .../admin/library-management/+page.svelte | 8 ++++-- 11 files changed, 37 insertions(+), 24 deletions(-) diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index a137980e00..3d4ab6a892 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -68,7 +68,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up. ### Nightly job -There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. +There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library managment page. ## Usage diff --git a/i18n/en.json b/i18n/en.json index e35f1906c4..4f84d140e0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -96,7 +96,7 @@ "library_scanning_enable_description": "Enable periodic library scanning", "library_settings": "External Library", "library_settings_description": "Manage external library settings", - "library_tasks_description": "Perform library tasks", + "library_tasks_description": "Scan external libraries for new and/or changed assets", "library_watching_enable_description": "Watch external libraries for file changes", "library_watching_settings": "Library watching (EXPERIMENTAL)", "library_watching_settings_description": "Automatically watch for changed files", @@ -336,6 +336,7 @@ "untracked_files": "Untracked Files", "untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug", "user_cleanup_job": "User cleanup", + "cleanup": "Cleanup", "user_delete_delay": "{user}'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Delete delay", "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.", @@ -1114,6 +1115,7 @@ "say_something": "Say something", "scan_all_libraries": "Scan All Libraries", "scan_library": "Scan", + "rescan": "Rescan", "scan_settings": "Scan Settings", "scanning_for_album": "Scanning for album...", "search": "Search", diff --git a/server/src/enum.ts b/server/src/enum.ts index 676e1d27db..95168b1754 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -473,7 +473,7 @@ export enum JobName { LIBRARY_SYNC_FILE = 'library-sync-file', LIBRARY_SYNC_ASSET = 'library-sync-asset', LIBRARY_DELETE = 'library-delete', - LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all', + LIBRARY_QUEUE_SCAN_ALL = 'library-queue-scan-all', LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', // cleanup diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 22408c33de..167c121706 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -170,7 +170,7 @@ export class JobService extends BaseService { } case QueueName.LIBRARY: { - return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, data: { force } }); + return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } }); } case QueueName.BACKUP_DATABASE: { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index ded7e0630a..c869f803f0 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1079,7 +1079,7 @@ describe(LibraryService.name, () => { it('should queue the refresh job', async () => { mocks.library.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueScanAll()).resolves.toBe(JobStatus.SUCCESS); expect(mocks.job.queue.mock.calls).toEqual([ [ diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 441d130c12..cdd6a3948f 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -47,7 +47,7 @@ export class LibraryService extends BaseService { name: 'libraryScan', expression: scan.cronExpression, onTick: () => - handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger), + handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL }), this.logger), start: scan.enabled, }); } @@ -210,11 +210,17 @@ export class LibraryService extends BaseService { @OnJob({ name: JobName.LIBRARY_QUEUE_CLEANUP, queue: QueueName.LIBRARY }) async handleQueueCleanup(): Promise { - this.logger.debug('Cleaning up any pending library deletions'); - const pendingDeletion = await this.libraryRepository.getAllDeleted(); - await this.jobRepository.queueAll( - pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), - ); + this.logger.log('Checking for any libraries pending deletion...'); + const pendingDeletions = await this.libraryRepository.getAllDeleted(); + if (pendingDeletions.length > 0) { + const libraryString = pendingDeletions.length === 1 ? 'library' : 'libraries'; + this.logger.log(`Found ${pendingDeletions.length} ${libraryString} pending deletion, cleaning up...`); + + await this.jobRepository.queueAll( + pendingDeletions.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), + ); + } + return JobStatus.SUCCESS; } @@ -442,9 +448,13 @@ export class LibraryService extends BaseService { await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } }); } - @OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, queue: QueueName.LIBRARY }) - async handleQueueSyncAll(): Promise { - this.logger.debug(`Refreshing all external libraries`); + async queueScanAll() { + await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: {} }); + } + + @OnJob({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, queue: QueueName.LIBRARY }) + async handleQueueScanAll(): Promise { + this.logger.log(`Refreshing all external libraries`); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); diff --git a/server/src/types.ts b/server/src/types.ts index 5360e519bd..902e13b9ea 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -351,7 +351,7 @@ export type JobItem = | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } | { name: JobName.LIBRARY_SYNC_ASSET; data: ILibraryAssetJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } + | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data?: IBaseJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } // Notification diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index 0e39647c75..80dd29e0be 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -185,7 +185,7 @@ {#if !disabled && !multipleButtons && isIdle} onCommand({ command: JobCommand.Start, force: false })}> - {$t('start').toUpperCase()} + {missingText} {/if}
diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 9b4f3ffdd6..4eb0bf6bb0 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -79,8 +79,7 @@ icon: mdiLibraryShelves, title: $getJobName(JobName.Library), subtitle: $t('admin.library_tasks_description'), - allText: $t('all'), - missingText: $t('refresh'), + missingText: $t('rescan'), }, [JobName.Sidecar]: { title: $getJobName(JobName.Sidecar), @@ -135,14 +134,14 @@ [JobName.StorageTemplateMigration]: { icon: mdiFolderMove, title: $getJobName(JobName.StorageTemplateMigration), - missingText: $t('missing'), + missingText: $t('start'), description: StorageMigrationDescription, }, [JobName.Migration]: { icon: mdiFolderMove, title: $getJobName(JobName.Migration), subtitle: $t('admin.migration_job_description'), - missingText: $t('missing'), + missingText: $t('start'), }, }; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index c87b623549..7d542a940a 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -146,7 +146,7 @@ export const getJobName = derived(t, ($t) => { [JobName.Migration]: $t('admin.migration_job'), [JobName.BackgroundTask]: $t('admin.background_task_job'), [JobName.Search]: $t('search'), - [JobName.Library]: $t('library'), + [JobName.Library]: $t('external_libraries'), [JobName.Notifications]: $t('notifications'), [JobName.BackupDatabase]: $t('admin.backup_database'), }; diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 04325f9fc2..c397fe6d3a 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -22,7 +22,10 @@ getAllLibraries, getLibraryStatistics, getUserAdmin, + JobCommand, + JobName, scanLibrary, + sendJobCommand, updateLibrary, type LibraryResponseDto, type LibraryStatsResponseDto, @@ -151,9 +154,8 @@ const handleScanAll = async () => { try { - for (const library of libraries) { - await scanLibrary({ id: library.id }); - } + await sendJobCommand({ id: JobName.Library, jobCommandDto: { command: JobCommand.Start } }); + notificationController.show({ message: $t('admin.refreshing_all_libraries'), type: NotificationType.Info, From fe702ba6d78ab828a86e3e48c951dd320590e2b1 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Mon, 3 Mar 2025 11:05:30 +0000 Subject: [PATCH 053/104] feat: partner sync (#16424) feat: partner CUD sync --- mobile/openapi/README.md | 2 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api_client.dart | 4 + .../openapi/lib/model/sync_entity_type.dart | 6 + .../lib/model/sync_partner_delete_v1.dart | 107 +++++++++++ mobile/openapi/lib/model/sync_partner_v1.dart | 115 ++++++++++++ .../openapi/lib/model/sync_request_type.dart | 3 + open-api/immich-openapi-specs.json | 41 +++- open-api/typescript-sdk/src/fetch-client.ts | 7 +- server/src/db.d.ts | 9 +- server/src/dtos/sync.dto.ts | 15 ++ server/src/entities/partner-audit.entity.ts | 19 ++ server/src/enum.ts | 3 + .../1740739778549-CreatePartnersAuditTable.ts | 38 ++++ server/src/repositories/sync.repository.ts | 22 +++ server/src/services/sync.service.ts | 20 +- server/test/factory.ts | 31 ++- server/test/medium/specs/sync.service.spec.ts | 176 ++++++++++++++++++ .../test/repositories/sync.repository.mock.ts | 2 + 19 files changed, 614 insertions(+), 8 deletions(-) create mode 100644 mobile/openapi/lib/model/sync_partner_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_partner_v1.dart create mode 100644 server/src/entities/partner-audit.entity.ts create mode 100644 server/src/migrations/1740739778549-CreatePartnersAuditTable.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 66c264cd76..3a3a3bc6ca 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -425,6 +425,8 @@ Class | Method | HTTP request | Description - [SyncAckDto](doc//SyncAckDto.md) - [SyncAckSetDto](doc//SyncAckSetDto.md) - [SyncEntityType](doc//SyncEntityType.md) + - [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md) + - [SyncPartnerV1](doc//SyncPartnerV1.md) - [SyncRequestType](doc//SyncRequestType.md) - [SyncStreamDto](doc//SyncStreamDto.md) - [SyncUserDeleteV1](doc//SyncUserDeleteV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 893587e7fc..04dc43f88c 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -232,6 +232,8 @@ part 'model/sync_ack_delete_dto.dart'; part 'model/sync_ack_dto.dart'; part 'model/sync_ack_set_dto.dart'; part 'model/sync_entity_type.dart'; +part 'model/sync_partner_delete_v1.dart'; +part 'model/sync_partner_v1.dart'; part 'model/sync_request_type.dart'; part 'model/sync_stream_dto.dart'; part 'model/sync_user_delete_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 7c2dc53455..4d837ccb9d 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -520,6 +520,10 @@ class ApiClient { return SyncAckSetDto.fromJson(value); case 'SyncEntityType': return SyncEntityTypeTypeTransformer().decode(value); + case 'SyncPartnerDeleteV1': + return SyncPartnerDeleteV1.fromJson(value); + case 'SyncPartnerV1': + return SyncPartnerV1.fromJson(value); case 'SyncRequestType': return SyncRequestTypeTypeTransformer().decode(value); case 'SyncStreamDto': diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index ed82205a37..5d130f7f93 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -25,11 +25,15 @@ class SyncEntityType { static const userV1 = SyncEntityType._(r'UserV1'); static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1'); + static const partnerV1 = SyncEntityType._(r'PartnerV1'); + static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1'); /// List of all possible values in this [enum][SyncEntityType]. static const values = [ userV1, userDeleteV1, + partnerV1, + partnerDeleteV1, ]; static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); @@ -70,6 +74,8 @@ class SyncEntityTypeTypeTransformer { switch (data) { case r'UserV1': return SyncEntityType.userV1; case r'UserDeleteV1': return SyncEntityType.userDeleteV1; + case r'PartnerV1': return SyncEntityType.partnerV1; + case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/sync_partner_delete_v1.dart b/mobile/openapi/lib/model/sync_partner_delete_v1.dart new file mode 100644 index 0000000000..f5e10d6576 --- /dev/null +++ b/mobile/openapi/lib/model/sync_partner_delete_v1.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncPartnerDeleteV1 { + /// Returns a new [SyncPartnerDeleteV1] instance. + SyncPartnerDeleteV1({ + required this.sharedById, + required this.sharedWithId, + }); + + String sharedById; + + String sharedWithId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPartnerDeleteV1 && + other.sharedById == sharedById && + other.sharedWithId == sharedWithId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (sharedById.hashCode) + + (sharedWithId.hashCode); + + @override + String toString() => 'SyncPartnerDeleteV1[sharedById=$sharedById, sharedWithId=$sharedWithId]'; + + Map toJson() { + final json = {}; + json[r'sharedById'] = this.sharedById; + json[r'sharedWithId'] = this.sharedWithId; + return json; + } + + /// Returns a new [SyncPartnerDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPartnerDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPartnerDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPartnerDeleteV1( + sharedById: mapValueOfType(json, r'sharedById')!, + sharedWithId: mapValueOfType(json, r'sharedWithId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncPartnerDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncPartnerDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPartnerDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncPartnerDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'sharedById', + 'sharedWithId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_partner_v1.dart b/mobile/openapi/lib/model/sync_partner_v1.dart new file mode 100644 index 0000000000..e551c4c83d --- /dev/null +++ b/mobile/openapi/lib/model/sync_partner_v1.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncPartnerV1 { + /// Returns a new [SyncPartnerV1] instance. + SyncPartnerV1({ + required this.inTimeline, + required this.sharedById, + required this.sharedWithId, + }); + + bool inTimeline; + + String sharedById; + + String sharedWithId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPartnerV1 && + other.inTimeline == inTimeline && + other.sharedById == sharedById && + other.sharedWithId == sharedWithId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (inTimeline.hashCode) + + (sharedById.hashCode) + + (sharedWithId.hashCode); + + @override + String toString() => 'SyncPartnerV1[inTimeline=$inTimeline, sharedById=$sharedById, sharedWithId=$sharedWithId]'; + + Map toJson() { + final json = {}; + json[r'inTimeline'] = this.inTimeline; + json[r'sharedById'] = this.sharedById; + json[r'sharedWithId'] = this.sharedWithId; + return json; + } + + /// Returns a new [SyncPartnerV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPartnerV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPartnerV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPartnerV1( + inTimeline: mapValueOfType(json, r'inTimeline')!, + sharedById: mapValueOfType(json, r'sharedById')!, + sharedWithId: mapValueOfType(json, r'sharedWithId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncPartnerV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncPartnerV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPartnerV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncPartnerV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'inTimeline', + 'sharedById', + 'sharedWithId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index d7f1bde54c..c35b17dea1 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -24,10 +24,12 @@ class SyncRequestType { String toJson() => value; static const usersV1 = SyncRequestType._(r'UsersV1'); + static const partnersV1 = SyncRequestType._(r'PartnersV1'); /// List of all possible values in this [enum][SyncRequestType]. static const values = [ usersV1, + partnersV1, ]; static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); @@ -67,6 +69,7 @@ class SyncRequestTypeTypeTransformer { if (data != null) { switch (data) { case r'UsersV1': return SyncRequestType.usersV1; + case r'PartnersV1': return SyncRequestType.partnersV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2728fb9c91..7212d3b7f7 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12052,13 +12052,50 @@ "SyncEntityType": { "enum": [ "UserV1", - "UserDeleteV1" + "UserDeleteV1", + "PartnerV1", + "PartnerDeleteV1" ], "type": "string" }, + "SyncPartnerDeleteV1": { + "properties": { + "sharedById": { + "type": "string" + }, + "sharedWithId": { + "type": "string" + } + }, + "required": [ + "sharedById", + "sharedWithId" + ], + "type": "object" + }, + "SyncPartnerV1": { + "properties": { + "inTimeline": { + "type": "boolean" + }, + "sharedById": { + "type": "string" + }, + "sharedWithId": { + "type": "string" + } + }, + "required": [ + "inTimeline", + "sharedById", + "sharedWithId" + ], + "type": "object" + }, "SyncRequestType": { "enum": [ - "UsersV1" + "UsersV1", + "PartnersV1" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7e6164099b..8aecbe9816 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3645,10 +3645,13 @@ export enum Error2 { } export enum SyncEntityType { UserV1 = "UserV1", - UserDeleteV1 = "UserDeleteV1" + UserDeleteV1 = "UserDeleteV1", + PartnerV1 = "PartnerV1", + PartnerDeleteV1 = "PartnerDeleteV1" } export enum SyncRequestType { - UsersV1 = "UsersV1" + UsersV1 = "UsersV1", + PartnersV1 = "PartnersV1" } export enum TranscodeHWAccel { Nvenc = "nvenc", diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 7fb073d8ce..4c75562ba1 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -272,6 +272,13 @@ export interface NaturalearthCountries { type: string; } +export interface PartnersAudit { + deletedAt: Generated; + id: Generated; + sharedById: string; + sharedWithId: string; +} + export interface Partners { createdAt: Generated; inTimeline: Generated; @@ -316,7 +323,6 @@ export interface SessionSyncCheckpoints { updateId: Generated; } - export interface SharedLinkAsset { assetsId: string; sharedLinksId: string; @@ -462,6 +468,7 @@ export interface DB { migrations: Migrations; move_history: MoveHistory; naturalearth_countries: NaturalearthCountries; + partners_audit: PartnersAudit; partners: Partners; person: Person; sessions: Sessions; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 0628a566cd..d191c82bb3 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -45,15 +45,30 @@ export class SyncUserDeleteV1 { userId!: string; } +export class SyncPartnerV1 { + sharedById!: string; + sharedWithId!: string; + inTimeline!: boolean; +} + +export class SyncPartnerDeleteV1 { + sharedById!: string; + sharedWithId!: string; +} + export type SyncItem = { [SyncEntityType.UserV1]: SyncUserV1; [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; + [SyncEntityType.PartnerV1]: SyncPartnerV1; + [SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1; }; const responseDtos = [ // SyncUserV1, SyncUserDeleteV1, + SyncPartnerV1, + SyncPartnerDeleteV1, ]; export const extraSyncModels = responseDtos; diff --git a/server/src/entities/partner-audit.entity.ts b/server/src/entities/partner-audit.entity.ts new file mode 100644 index 0000000000..a731e017dc --- /dev/null +++ b/server/src/entities/partner-audit.entity.ts @@ -0,0 +1,19 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity('partners_audit') +export class PartnerAuditEntity { + @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + id!: string; + + @Index('IDX_partners_audit_shared_by_id') + @Column({ type: 'uuid' }) + sharedById!: string; + + @Index('IDX_partners_audit_shared_with_id') + @Column({ type: 'uuid' }) + sharedWithId!: string; + + @Index('IDX_partners_audit_deleted_at') + @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) + deletedAt!: Date; +} diff --git a/server/src/enum.ts b/server/src/enum.ts index 95168b1754..483bae2fc8 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -548,9 +548,12 @@ export enum DatabaseLock { export enum SyncRequestType { UsersV1 = 'UsersV1', + PartnersV1 = 'PartnersV1', } export enum SyncEntityType { UserV1 = 'UserV1', UserDeleteV1 = 'UserDeleteV1', + PartnerV1 = 'PartnerV1', + PartnerDeleteV1 = 'PartnerDeleteV1', } diff --git a/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts b/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts new file mode 100644 index 0000000000..d9c9dc1949 --- /dev/null +++ b/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreatePartnersAuditTable1740739778549 implements MigrationInterface { + name = 'CreatePartnersAuditTable1740739778549' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "partners_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "sharedById" uuid NOT NULL, "sharedWithId" uuid NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp(), CONSTRAINT "PK_952b50217ff78198a7e380f0359" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_shared_by_id" ON "partners_audit" ("sharedById") `); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_shared_with_id" ON "partners_audit" ("sharedWithId") `); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_deleted_at" ON "partners_audit" ("deletedAt") `); + await queryRunner.query(`CREATE OR REPLACE FUNCTION partners_delete_audit() RETURNS TRIGGER AS + $$ + BEGIN + INSERT INTO partners_audit ("sharedById", "sharedWithId") + SELECT "sharedById", "sharedWithId" + FROM OLD; + RETURN NULL; + END; + $$ LANGUAGE plpgsql` + ); + await queryRunner.query(`CREATE OR REPLACE TRIGGER partners_delete_audit + AFTER DELETE ON partners + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION partners_delete_audit(); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_deleted_at"`); + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_shared_with_id"`); + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_shared_by_id"`); + await queryRunner.query(`DROP TRIGGER partners_delete_audit`); + await queryRunner.query(`DROP FUNCTION partners_delete_audit`); + await queryRunner.query(`DROP TABLE "partners_audit"`); + } + +} diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index bde4b9f10f..f2c5a1fc16 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -56,4 +56,26 @@ export class SyncRepository { .orderBy(['id asc']) .stream(); } + + getPartnerUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('partners') + .select(['sharedById', 'sharedWithId', 'inTimeline', 'updateId']) + .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) + .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)])) + .where('updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .orderBy(['updateId asc']) + .stream(); + } + + getPartnerDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('partners_audit') + .select(['id', 'sharedById', 'sharedWithId']) + .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) + .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)])) + .where('deletedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .orderBy(['id asc']) + .stream(); + } } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index b756c11ef4..45b1b7ff84 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -25,6 +25,7 @@ const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; const SYNC_TYPES_ORDER = [ // SyncRequestType.UsersV1, + SyncRequestType.PartnersV1, ]; const throwSessionRequired = () => { @@ -81,8 +82,6 @@ export class SyncService extends BaseService { checkpoints.map(({ type, ack }) => [type, fromAck(ack)]), ); - // TODO pre-filter/sort list based on optimal sync order - for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { switch (type) { case SyncRequestType.UsersV1: { @@ -99,6 +98,23 @@ export class SyncService extends BaseService { break; } + case SyncRequestType.PartnersV1: { + const deletes = this.syncRepository.getPartnerDeletes( + auth.user.id, + checkpointMap[SyncEntityType.PartnerDeleteV1], + ); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.PartnerDeleteV1, updateId: id, data })); + } + + const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[SyncEntityType.PartnerV1]); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.PartnerV1, updateId, data })); + } + + break; + } + default: { this.logger.warn(`Unsupported sync type: ${type}`); break; diff --git a/server/test/factory.ts b/server/test/factory.ts index 983b7cbb77..a682ad48f2 100644 --- a/server/test/factory.ts +++ b/server/test/factory.ts @@ -1,11 +1,12 @@ import { Insertable, Kysely } from 'kysely'; import { randomBytes, randomUUID } from 'node:crypto'; import { Writable } from 'node:stream'; -import { Assets, DB, Sessions, Users } from 'src/db'; +import { Assets, DB, Partners, Sessions, Users } from 'src/db'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetType } from 'src/enum'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; import { SessionRepository } from 'src/repositories/session.repository'; import { SyncRepository } from 'src/repositories/sync.repository'; import { UserRepository } from 'src/repositories/user.repository'; @@ -30,6 +31,7 @@ class CustomWritable extends Writable { type Asset = Insertable; type User = Partial>; type Session = Omit, 'token'> & { token?: string }; +type Partner = Insertable; export const newUuid = () => randomUUID() as string; @@ -37,6 +39,7 @@ export class TestFactory { private assets: Asset[] = []; private sessions: Session[] = []; private users: User[] = []; + private partners: Partner[] = []; private constructor(private context: TestContext) {} @@ -100,6 +103,17 @@ export class TestFactory { }; } + static partner(partner: Partner) { + const defaults = { + inTimeline: true, + }; + + return { + ...defaults, + ...partner, + }; + } + withAsset(asset: Asset) { this.assets.push(asset); return this; @@ -115,6 +129,11 @@ export class TestFactory { return this; } + withPartner(partner: Partner) { + this.partners.push(partner); + return this; + } + async create() { for (const asset of this.assets) { await this.context.createAsset(asset); @@ -124,6 +143,10 @@ export class TestFactory { await this.context.createUser(user); } + for (const partner of this.partners) { + await this.context.createPartner(partner); + } + for (const session of this.sessions) { await this.context.createSession(session); } @@ -138,6 +161,7 @@ export class TestContext { albumRepository: AlbumRepository; sessionRepository: SessionRepository; syncRepository: SyncRepository; + partnerRepository: PartnerRepository; private constructor(private db: Kysely) { this.userRepository = new UserRepository(this.db); @@ -145,6 +169,7 @@ export class TestContext { this.albumRepository = new AlbumRepository(this.db); this.sessionRepository = new SessionRepository(this.db); this.syncRepository = new SyncRepository(this.db); + this.partnerRepository = new PartnerRepository(this.db); } static from(db: Kysely) { @@ -159,6 +184,10 @@ export class TestContext { return this.userRepository.create(TestFactory.user(user)); } + createPartner(partner: Partner) { + return this.partnerRepository.create(TestFactory.partner(partner)); + } + createAsset(asset: Asset) { return this.assetRepository.create(TestFactory.asset(asset)); } diff --git a/server/test/medium/specs/sync.service.spec.ts b/server/test/medium/specs/sync.service.spec.ts index bab9794100..7cd849c6ff 100644 --- a/server/test/medium/specs/sync.service.spec.ts +++ b/server/test/medium/specs/sync.service.spec.ts @@ -17,6 +17,8 @@ const setup = async () => { const testSync = async (auth: AuthDto, types: SyncRequestType[]) => { const stream = TestFactory.stream(); + // Wait for 1ms to ensure all updates are available + await new Promise((resolve) => setTimeout(resolve, 1)); await sut.stream(auth, stream, { types }); return stream.getResponse(); @@ -186,4 +188,178 @@ describe(SyncService.name, () => { ); }); }); + + describe.concurrent('partners', () => { + it('should detect and sync the first partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: partner.inTimeline, + sharedById: partner.sharedById, + sharedWithId: partner.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + await context.partnerRepository.remove(partner); + + const response = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + sharedById: partner.sharedById, + sharedWithId: partner.sharedWithId, + }, + type: 'PartnerDeleteV1', + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a partner share both to and from another user', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner1 = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + const partner2 = await context.createPartner({ sharedById: user1.id, sharedWithId: user2.id }); + + const response = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(response).toHaveLength(2); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: partner1.inTimeline, + sharedById: partner1.sharedById, + sharedWithId: partner1.sharedWithId, + }, + type: 'PartnerV1', + }, + { + ack: expect.any(String), + data: { + inTimeline: partner2.inTimeline, + sharedById: partner2.sharedById, + sharedWithId: partner2.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + + await sut.setAcks(auth, { acks: [response[1].ack] }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should sync a partner and then an update to that same partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: partner.inTimeline, + sharedById: partner.sharedById, + sharedWithId: partner.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const updated = await context.partnerRepository.update( + { sharedById: partner.sharedById, sharedWithId: partner.sharedWithId }, + { inTimeline: true }, + ); + + const updatedSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(updatedSyncResponse).toHaveLength(1); + expect(updatedSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: updated.inTimeline, + sharedById: updated.sharedById, + sharedWithId: updated.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + }); + + it('should not sync a partner for an unrelated user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const user3 = await context.createUser(); + + await context.createPartner({ sharedById: user2.id, sharedWithId: user3.id }); + + const response = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(response).toHaveLength(0); + }); + }); }); diff --git a/server/test/repositories/sync.repository.mock.ts b/server/test/repositories/sync.repository.mock.ts index fbb8ec2f62..6d94f6e039 100644 --- a/server/test/repositories/sync.repository.mock.ts +++ b/server/test/repositories/sync.repository.mock.ts @@ -9,5 +9,7 @@ export const newSyncRepositoryMock = (): Mocked Date: Mon, 3 Mar 2025 12:39:53 +0100 Subject: [PATCH 054/104] feat: weblate checks workflow (#16251) --- .github/workflows/weblate-lock.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/weblate-lock.yml diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml new file mode 100644 index 0000000000..317dd7c33a --- /dev/null +++ b/.github/workflows/weblate-lock.yml @@ -0,0 +1,25 @@ +name: Weblate checks + +on: + pull_request: + branches: [main] + paths: + - 'i18n/**' + +jobs: + enforce-lock: + runs-on: ubuntu-latest + steps: + - name: Check weblate lock + run: | + if [[ "false" = $(curl https://hosted.weblate.org/api/components/immich/immich/lock/ | jq .locked) ]]; then + exit 1 + fi + - name: Find Pull Request + uses: juliangruber/find-pull-request-action@v1 + id: find-pr + with: + branch: chore/translations + - name: Fail if existing weblate PR + if: ${{ steps.find-pr.outputs.number }} + run: exit 1 From a2aab1f3736f27fe74e0b775a270381f2868e7f8 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 3 Mar 2025 05:40:14 -0600 Subject: [PATCH 055/104] fix: don't use public keyword in migration query (#16514) Co-authored-by: Zack Pollard --- .../migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts b/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts index 997f718fd9..59fc4dbd5b 100644 --- a/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts +++ b/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts @@ -4,7 +4,7 @@ export class UsersAuditUuidv7PrimaryKey1740595460866 implements MigrationInterfa name = 'UsersAuditUuidv7PrimaryKey1740595460866' public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_users_audit_deleted_at_asc_user_id_asc"`); + await queryRunner.query(`DROP INDEX "IDX_users_audit_deleted_at_asc_user_id_asc"`); await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`); await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`); await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" uuid NOT NULL DEFAULT immich_uuid_v7()`); @@ -14,7 +14,7 @@ export class UsersAuditUuidv7PrimaryKey1740595460866 implements MigrationInterfa } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_users_audit_deleted_at"`); + await queryRunner.query(`DROP INDEX "IDX_users_audit_deleted_at"`); await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`); await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`); await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" SERIAL NOT NULL`); From 5f6c16080bb232dd27745d853307c397ed96f242 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:51:13 +0000 Subject: [PATCH 056/104] chore(deps): update docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0 docker digest to 739cdd6 (#16528) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 08437e17c7..fd0edf9cb0 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -56,7 +56,7 @@ services: database: container_name: immich_postgres - image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 + image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USERNAME} From eed6465b418a4fc2c06d45bf7fe3387b8311e64b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:51:44 +0000 Subject: [PATCH 057/104] chore(deps): update grafana/grafana docker tag to v11.5.2 (#16301) 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 ffc5c85b1d..4b394f9e02 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -112,7 +112,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:11.5.1-ubuntu@sha256:9a4ab78cec1a2ec7d1ca5dfd5aacec6412706a1bc9e971fc7184e2f6696a63f5 + image: grafana/grafana:11.5.2-ubuntu@sha256:8b5858c447e06fd7a89006b562ba7bba7c4d5813600c7982374c41852adefaeb volumes: - grafana-data:/var/lib/grafana From 12ab56c8853b34866f21dffab7c1af20e54693cb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:52:22 +0000 Subject: [PATCH 058/104] chore(deps): update prom/prometheus docker digest to 6927e09 (#16529) 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 4b394f9e02..003367e21e 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -100,7 +100,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:5888c188cf09e3f7eebc97369c3b2ce713e844cdbd88ccf36f5047c958aea120 + image: prom/prometheus@sha256:6927e0919a144aa7616fd0137d4816816d42f6b816de3af269ab065250859a62 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus From 4b568dcbb3f1554ef66016128a8a9257fa3f285d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:57:46 +0000 Subject: [PATCH 059/104] chore(deps): update dependency black to v25 (#16033) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/poetry.lock | 46 ++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 6aaf8c2972..b16c33839f 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -75,33 +75,33 @@ trio = ["trio (>=0.23)"] [[package]] name = "black" -version = "24.10.0" +version = "25.1.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" files = [ - {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, - {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, - {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, - {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, - {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, - {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, - {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, - {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, - {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, - {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, - {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, - {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, - {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, - {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, - {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, - {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, - {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, - {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, - {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, - {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, - {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, - {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, + {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, + {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, + {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, + {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, + {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, + {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, + {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, + {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, + {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, + {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, + {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, + {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, + {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, + {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, + {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, + {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, + {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, + {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, + {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, + {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, + {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, + {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, ] [package.dependencies] From a99bd94717bd6ff3c58d099f0e69a4fa4543b036 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 12:01:40 +0000 Subject: [PATCH 060/104] fix(deps): update dependency ua-parser-js to v2 (#14301) * fix(deps): update dependency ua-parser-js to v2 * fix: breaking changes from ua-parsed-js major update --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Zack Pollard --- server/package-lock.json | 75 +++++++++++++++++-- server/package.json | 2 +- .../user-settings-page/device-card.svelte | 4 +- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 4be6f957ff..2f14a9482a 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -66,7 +66,7 @@ "tailwindcss-preset-email": "^1.3.2", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", - "ua-parser-js": "^1.0.35", + "ua-parser-js": "^2.0.0", "validator": "^13.12.0" }, "devDependencies": { @@ -8491,6 +8491,26 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -10785,6 +10805,26 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -15913,10 +15953,30 @@ "node": ">=14.17" } }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/ua-parser-js": { - "version": "1.0.40", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", - "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.1.tgz", + "integrity": "sha512-PgWLeyhIgff0Jomd3U2cYCdfp5iHbaCMlylG9NoV19tAlvXWUzM3bG2DIasLTI1PrbLtVutGr1CaezttVV2PeA==", "funding": [ { "type": "opencollective", @@ -15931,7 +15991,12 @@ "url": "https://github.com/sponsors/faisalman" } ], - "license": "MIT", + "license": "AGPL-3.0-or-later", + "dependencies": { + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "ua-is-frozen": "^0.1.2" + }, "bin": { "ua-parser-js": "script/cli.js" }, diff --git a/server/package.json b/server/package.json index 651a04eb0f..a4fb4c5f4c 100644 --- a/server/package.json +++ b/server/package.json @@ -92,7 +92,7 @@ "tailwindcss-preset-email": "^1.3.2", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", - "ua-parser-js": "^1.0.35", + "ua-parser-js": "^2.0.0", "validator": "^13.12.0" }, "devDependencies": { diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 5248a6d119..5b70b006be 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -34,7 +34,7 @@