From f44669447fc2e84202b4eaeefa642b0c81de10a2 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 02:58:27 +0000 Subject: [PATCH 01/26] chore: version v1.125.6 --- 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 9044ab7171ab1..4e956fdfde0fc 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.46", + "version": "2.2.47", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.46", + "version": "2.2.47", "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.125.5", + "version": "1.125.6", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 24da3c87ac9c9..f356c7fbe70a6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.46", + "version": "2.2.47", "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 177f445f53b4d..829935d60b4fc 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.125.6", + "url": "https://v1.125.6.archive.immich.app" + }, { "label": "v1.125.5", "url": "https://v1.125.5.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 5fd97d8f98f47..76314d99cc04d 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.125.5", + "version": "1.125.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.125.5", + "version": "1.125.6", "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.46", + "version": "2.2.47", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.5", + "version": "1.125.6", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index a65545ddb393c..87193b55a0ce4 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.125.5", + "version": "1.125.6", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 14be0f1947eb1..c644caac71ca0 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.125.5" +version = "1.125.6" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index c943ca8f209be..b1dcfcb93856c 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" => 181, - "android.injected.version.name" => "1.125.5", + "android.injected.version.code" => 182, + "android.injected.version.name" => "1.125.6", } ) 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 82694c98356ad..52f6bc0fbe66c 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.125.5" + version_number: "1.125.6" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d448416a180af..01ced65598c80 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.125.5 +- API version: 1.125.6 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3bd660dac2fae..9741ddfa3e722 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.125.5+181 +version: 1.125.6+182 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 30fa7316ec9b8..fc62b58290b43 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7454,7 +7454,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.125.5", + "version": "1.125.6", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 8ceb4077987b6..62fe913e707a5 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.125.5", + "version": "1.125.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.125.5", + "version": "1.125.6", "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 88f35e2bfe7b6..81352dc721de8 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.125.5", + "version": "1.125.6", "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 93c72a477b2bd..088e30f9d8b4f 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.125.5 + * 1.125.6 * 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 9a3587fa3ae4a..ab9c5f91a0c34 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.125.5", + "version": "1.125.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.125.5", + "version": "1.125.6", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.0", diff --git a/server/package.json b/server/package.json index f9b55a1ac42d0..974256c8e30f2 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.125.5", + "version": "1.125.6", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index d57126bb80f7a..c445a58b97be4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.125.5", + "version": "1.125.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.125.5", + "version": "1.125.6", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -75,7 +75,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.5", + "version": "1.125.6", "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 9e9a6bf7e2765..de60c948873b3 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.125.5", + "version": "1.125.6", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From fe1e09e51f565b0e521862b5f3e18a8b5b1f588a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=BCndig?= Date: Tue, 28 Jan 2025 04:54:29 +0100 Subject: [PATCH 02/26] fix(server): Allow negative rating (for rejected images) (#15699) Allow negative rating (for rejected images) --- e2e/src/api/specs/asset.e2e-spec.ts | 14 ++++++++++++++ .../openapi/lib/model/asset_bulk_update_dto.dart | 2 +- mobile/openapi/lib/model/update_asset_dto.dart | 2 +- open-api/immich-openapi-specs.json | 4 ++-- server/src/dtos/asset.dto.ts | 2 +- server/src/services/metadata.service.spec.ts | 11 +++++++++++ server/src/services/metadata.service.ts | 2 +- 7 files changed, 31 insertions(+), 6 deletions(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 32cbdd6df812a..1b644454aab0b 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -701,6 +701,20 @@ describe('/asset', () => { expect(status).toEqual(200); }); + it('should set the negative rating', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ rating: -1 }); + expect(body).toMatchObject({ + id: user1Assets[0].id, + exifInfo: expect.objectContaining({ + rating: -1, + }), + }); + expect(status).toEqual(200); + }); + it('should reject invalid rating', async () => { for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) { const { status, body } = await request(app) diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index da23d2f09d2e0..0b5a2c30d913b 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -67,7 +67,7 @@ class AssetBulkUpdateDto { /// num? longitude; - /// Minimum value: 0 + /// Minimum value: -1 /// Maximum value: 5 /// /// Please note: This property should have been non-nullable! Since the specification file diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 9ebce5fd9232b..c6ae6d8e07d3d 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -73,7 +73,7 @@ class UpdateAssetDto { /// num? longitude; - /// Minimum value: 0 + /// Minimum value: -1 /// Maximum value: 5 /// /// Please note: This property should have been non-nullable! Since the specification file diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fc62b58290b43..3067b25449482 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7951,7 +7951,7 @@ }, "rating": { "maximum": 5, - "minimum": 0, + "minimum": -1, "type": "number" } }, @@ -12780,7 +12780,7 @@ }, "rating": { "maximum": 5, - "minimum": 0, + "minimum": -1, "type": "number" } }, diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 42d6d7d7451eb..8aa63f2f6924c 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -52,7 +52,7 @@ export class UpdateAssetBase { @Optional() @IsInt() @Max(5) - @Min(0) + @Min(-1) rating?: number; } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 8cc6e014d2038..99ca1e7ed3120 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1162,6 +1162,17 @@ describe(MetadataService.name, () => { }), ); }); + it('should handle valid negative rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ Rating: -1 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: -1, + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index d5b7e6e4e4e8b..db3af9fca09e3 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -204,7 +204,7 @@ export class MetadataService extends BaseService { // comments description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), profileDescription: exifTags.ProfileDescription || null, - rating: validateRange(exifTags.Rating, 0, 5), + rating: validateRange(exifTags.Rating, -1, 5), // grouping livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, From 92dff839d091ac837cca903fb521fd0dae4ee22d Mon Sep 17 00:00:00 2001 From: RiggiG <44820045+RiggiG@users.noreply.github.com> Date: Mon, 27 Jan 2025 22:54:56 -0500 Subject: [PATCH 03/26] fix(web): do not throw error when hash fails (#15740) change: do not throw error when hash fails --- web/src/lib/utils/file-uploader.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 407501e6229f1..04d4d788100e9 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -157,7 +157,6 @@ async function fileUploader( } } catch (error) { console.error(`Error calculating sha1 file=${assetFile.name})`, error); - throw error; } } From 08db77db231420849ef5f4c082d705838d04e19a Mon Sep 17 00:00:00 2001 From: PastLeo Date: Tue, 28 Jan 2025 23:09:40 +0800 Subject: [PATCH 04/26] =?UTF-8?q?feat:=20resolution=20selection=20and=20de?= =?UTF-8?q?fault=20preview=20playback=20for=20360=C2=B0=20panorama=20video?= =?UTF-8?q?s=20(#15747)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * original/preview switching in photo-sphere-viewer 1. default to preview in photo-sphere-viewer video mode 2. install and integrate @photo-sphere-viewer/settings-plugin & @photo-sphere-viewer/resolution-plugin * fix lint errors --- web/package-lock.json | 21 ++++++++++ web/package.json | 2 + .../asset-viewer/image-panorama-viewer.svelte | 2 +- .../photo-sphere-viewer-adapter.svelte | 42 +++++++++++++++---- .../asset-viewer/video-panorama-viewer.svelte | 10 ++++- 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index c445a58b97be4..b6454c2caae99 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,8 @@ "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5", + "@photo-sphere-viewer/resolution-plugin": "^5.11.5", + "@photo-sphere-viewer/settings-plugin": "^5.11.5", "@photo-sphere-viewer/video-plugin": "^5.11.5", "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", @@ -1669,6 +1671,25 @@ "@photo-sphere-viewer/video-plugin": "5.11.5" } }, + "node_modules/@photo-sphere-viewer/resolution-plugin": { + "version": "5.11.5", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/resolution-plugin/-/resolution-plugin-5.11.5.tgz", + "integrity": "sha512-Dbvp5bBtozD3IWt1Q0wORVaZBcB1bV9xUeoOS9A7F7b3EkQ2pkC5/jot/1AyM4wtU5wJ63NWHskQ1d7m6WWazQ==", + "license": "MIT", + "peerDependencies": { + "@photo-sphere-viewer/core": "5.11.5", + "@photo-sphere-viewer/settings-plugin": "5.11.5" + } + }, + "node_modules/@photo-sphere-viewer/settings-plugin": { + "version": "5.11.5", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/settings-plugin/-/settings-plugin-5.11.5.tgz", + "integrity": "sha512-ZgYaWjiBMhsoRH5ddW3h+v4J4LPmofsT7BBRq5UCssWw2Fsrvv7mFFRi4UbZ1qzeKmvNUOr8BaFQgX1ZLvUWfQ==", + "license": "MIT", + "peerDependencies": { + "@photo-sphere-viewer/core": "5.11.5" + } + }, "node_modules/@photo-sphere-viewer/video-plugin": { "version": "5.11.5", "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.11.5.tgz", diff --git a/web/package.json b/web/package.json index de60c948873b3..9c9bfc680c896 100644 --- a/web/package.json +++ b/web/package.json @@ -72,6 +72,8 @@ "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5", + "@photo-sphere-viewer/resolution-plugin": "^5.11.5", + "@photo-sphere-viewer/settings-plugin": "^5.11.5", "@photo-sphere-viewer/video-plugin": "^5.11.5", "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", diff --git a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte index 6da8cc33d3bc9..7b9fd85b4a2dc 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -24,7 +24,7 @@ {:then [data, { default: PhotoSphereViewer }]} {:catch} {$t('errors.failed_to_load_asset')} diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index 0c8f76a01ed7a..517e630dc91d3 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -7,18 +7,21 @@ type AdapterConstructor, type PluginConstructor, } from '@photo-sphere-viewer/core'; + import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin'; + import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin'; import '@photo-sphere-viewer/core/index.css'; + import '@photo-sphere-viewer/settings-plugin/index.css'; import { onDestroy, onMount } from 'svelte'; interface Props { panorama: string | { source: string }; - originalImageUrl?: string; + originalPanorama?: string | { source: string }; adapter?: AdapterConstructor | [AdapterConstructor, unknown]; plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; navbar?: boolean; } - let { panorama, originalImageUrl, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); + let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); let container: HTMLDivElement | undefined = $state(); let viewer: Viewer; @@ -30,9 +33,33 @@ viewer = new Viewer({ adapter, - plugins, + plugins: [ + SettingsPlugin, + [ + ResolutionPlugin, + { + defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default', + resolutions: [ + { + id: 'default', + label: 'Default', + panorama, + }, + ...(originalPanorama + ? [ + { + id: 'original', + label: 'Original', + panorama: originalPanorama, + }, + ] + : []), + ], + }, + ], + ...plugins, + ], container, - panorama, touchmoveTwoFingers: false, mousewheelCtrlKey: false, navbar, @@ -40,15 +67,14 @@ maxFov: 120, fisheye: false, }); + const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin; - if (originalImageUrl && !$alwaysLoadOriginalFile) { + if (originalPanorama && !$alwaysLoadOriginalFile) { const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { // zoomLevel range: [0, 100] if (Math.round(zoomLevel) >= 75) { // Replace the preview with the original - viewer.setPanorama(originalImageUrl, { showLoader: false, speed: 150 }).catch(() => { - viewer.setPanorama(panorama, { showLoader: false, speed: 0 }).catch(() => {}); - }); + void resolutionPlugin.setResolution('original'); viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); } }; diff --git a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte index 73315d661ed49..a205ffce3cf5a 100644 --- a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte @@ -1,5 +1,5 @@ - {getDateRange(startDate, endDate)} + {getAlbumDateRange(album)} {$t('items_count', { values: { count: album.assetCount } })} diff --git a/web/src/lib/utils/date-time.spec.ts b/web/src/lib/utils/date-time.spec.ts index cbb08418c0cbf..90db980e2a01a 100644 --- a/web/src/lib/utils/date-time.spec.ts +++ b/web/src/lib/utils/date-time.spec.ts @@ -1,4 +1,5 @@ -import { timeToSeconds } from './date-time'; +import { writable } from 'svelte/store'; +import { getAlbumDateRange, timeToSeconds } from './date-time'; describe('converting time to seconds', () => { it('parses hh:mm:ss correctly', () => { @@ -21,3 +22,30 @@ describe('converting time to seconds', () => { expect(timeToSeconds('01:02:03.456.123456')).toBeCloseTo(3723.456); }); }); + +describe('getAlbumDate', () => { + beforeAll(() => { + process.env.TZ = 'UTC'; + + vitest.mock('$lib/stores/preferences.store', () => ({ + locale: writable('en'), + })); + }); + + it('should work with only a start date', () => { + expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00Z' })).toEqual('Jan 1, 2021'); + }); + + it('should work with a start and end date', () => { + expect( + getAlbumDateRange({ + startDate: '2021-01-01T00:00:00Z', + endDate: '2021-01-05T00:00:00Z', + }), + ).toEqual('Jan 1, 2021 - Jan 5, 2021'); + }); + + it('should work with the new date format', () => { + expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021'); + }); +}); diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index d5482f153ff06..ba22503c7086e 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -1,3 +1,4 @@ +import { dateFormats } from '$lib/constants'; import { locale } from '$lib/stores/preferences.store'; import { DateTime, Duration } from 'luxon'; import { get } from 'svelte/store'; @@ -51,3 +52,28 @@ export const getShortDateRange = (startDate: string | Date, endDate: string | Da return `${startDateLocalized} - ${endDateLocalized}`; } }; + +const formatDate = (date?: string) => { + if (!date) { + return; + } + + // without timezone + const localDate = date.replace(/Z$/, '').replace(/\+.+$/, ''); + return localDate ? new Date(localDate).toLocaleDateString(get(locale), dateFormats.album) : undefined; +}; + +export const getAlbumDateRange = (album: { startDate?: string; endDate?: string }) => { + const start = formatDate(album.startDate); + const end = formatDate(album.endDate); + + if (start && end && start !== end) { + return `${start} - ${end}`; + } + + if (start) { + return start; + } + + return ''; +}; From 4fccc09fc128e88c0485691b6b544b1a4d027ada Mon Sep 17 00:00:00 2001 From: Felix Eckhofer Date: Fri, 31 Jan 2025 03:34:12 +0100 Subject: [PATCH 21/26] chore: fix typo in libraries.md (#15800) Fix typo in libraries.md --- docs/docs/features/libraries.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index 6a1dba9ebaab2..796337f37c429 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -58,7 +58,7 @@ If your photos are on a network drive, automatic file watching likely won't work #### Troubleshooting -If you encounter an `ENOSPC` error, you need to increase your file watcher limit. In sysctl, this key is called `fs.inotify.max_user_watched` and has a default value of 8192. Increase this number to a suitable value greater than the number of files you will be watching. Note that Immich has to watch all files in your import paths including any ignored files. +If you encounter an `ENOSPC` error, you need to increase your file watcher limit. In sysctl, this key is called `fs.inotify.max_user_watches` and has a default value of 8192. Increase this number to a suitable value greater than the number of files you will be watching. Note that Immich has to watch all files in your import paths including any ignored files. ``` ERROR [LibraryService] Library watcher for library c69faf55-f96d-4aa0-b83b-2d80cbc27d98 encountered error: Error: ENOSPC: System limit for number of file watchers reached, watch '/media/photo.jpg' From 098bab7c9b8162438a74fdbd8329c1b8501a14c3 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 30 Jan 2025 21:12:57 -0600 Subject: [PATCH 22/26] fix(mobile): search page issues (#15804) * fix: don't repeat search * fix: show snackbar for no result * fix: do not search on empty filter * chore: syling --- mobile/assets/i18n/en-US.json | 2 + .../lib/interfaces/person_api.interface.dart | 81 ++++++++++++++++++- .../models/search/search_filter.model.dart | 17 ++++ mobile/lib/pages/search/search.page.dart | 57 +++++++++++-- .../search/paginated_search.provider.dart | 12 ++- mobile/lib/services/search.service.dart | 2 +- .../search/search_filter/people_picker.dart | 2 +- 7 files changed, 159 insertions(+), 14 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index c14aa6d748914..61c4621346846 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,4 +1,6 @@ { + "search_no_result": "No results found, try a different search term or combination", + "search_no_more_result": "No more results", "action_common_back": "Back", "action_common_cancel": "Cancel", "action_common_clear": "Clear", diff --git a/mobile/lib/interfaces/person_api.interface.dart b/mobile/lib/interfaces/person_api.interface.dart index b2fa28df8cc19..9d127ad7653b8 100644 --- a/mobile/lib/interfaces/person_api.interface.dart +++ b/mobile/lib/interfaces/person_api.interface.dart @@ -1,3 +1,6 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + abstract interface class IPersonApiRepository { Future> getAll(); Future update(String id, {String? name}); @@ -6,10 +9,10 @@ abstract interface class IPersonApiRepository { class Person { Person({ required this.id, + this.birthDate, required this.isHidden, required this.name, required this.thumbnailPath, - this.birthDate, this.updatedAt, }); @@ -19,4 +22,80 @@ class Person { final String name; final String thumbnailPath; final DateTime? updatedAt; + + @override + String toString() { + return 'Person(id: $id, birthDate: $birthDate, isHidden: $isHidden, name: $name, thumbnailPath: $thumbnailPath, updatedAt: $updatedAt)'; + } + + Person copyWith({ + String? id, + DateTime? birthDate, + bool? isHidden, + String? name, + String? thumbnailPath, + DateTime? updatedAt, + }) { + return Person( + id: id ?? this.id, + birthDate: birthDate ?? this.birthDate, + isHidden: isHidden ?? this.isHidden, + name: name ?? this.name, + thumbnailPath: thumbnailPath ?? this.thumbnailPath, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + Map toMap() { + return { + 'id': id, + 'birthDate': birthDate?.millisecondsSinceEpoch, + 'isHidden': isHidden, + 'name': name, + 'thumbnailPath': thumbnailPath, + 'updatedAt': updatedAt?.millisecondsSinceEpoch, + }; + } + + factory Person.fromMap(Map map) { + return Person( + id: map['id'] as String, + birthDate: map['birthDate'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int) + : null, + isHidden: map['isHidden'] as bool, + name: map['name'] as String, + thumbnailPath: map['thumbnailPath'] as String, + updatedAt: map['updatedAt'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int) + : null, + ); + } + + String toJson() => json.encode(toMap()); + + factory Person.fromJson(String source) => + Person.fromMap(json.decode(source) as Map); + + @override + bool operator ==(covariant Person other) { + if (identical(this, other)) return true; + + return other.id == id && + other.birthDate == birthDate && + other.isHidden == isHidden && + other.name == name && + other.thumbnailPath == thumbnailPath && + other.updatedAt == updatedAt; + } + + @override + int get hashCode { + return id.hashCode ^ + birthDate.hashCode ^ + isHidden.hashCode ^ + name.hashCode ^ + thumbnailPath.hashCode ^ + updatedAt.hashCode; + } } diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 297a819b6a335..0df64b6924415 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -255,6 +255,23 @@ class SearchFilter { required this.mediaType, }); + bool get isEmpty { + return (context == null || (context != null && context!.isEmpty)) && + (filename == null || (filename!.isEmpty)) && + people.isEmpty && + location.country == null && + location.state == null && + location.city == null && + camera.make == null && + camera.model == null && + date.takenBefore == null && + date.takenAfter == null && + display.isNotInAlbum == false && + display.isArchive == false && + display.isFavorite == false && + mediaType == AssetType.other; + } + SearchFilter copyWith({ String? context, String? filename, diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 88cc56a14590d..385da9dbcbaf2 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -49,7 +49,7 @@ class SearchPage extends HookConsumerWidget { ), ); - final previousFilter = useState(filter.value); + final previousFilter = useState(null); final peopleCurrentFilterWidget = useState(null); final dateRangeCurrentFilterWidget = useState(null); @@ -60,19 +60,55 @@ class SearchPage extends HookConsumerWidget { final isSearching = useState(false); + SnackBar searchInfoSnackBar(String message) { + return SnackBar( + content: Text( + message, + style: context.textTheme.labelLarge, + ), + showCloseIcon: true, + behavior: SnackBarBehavior.fixed, + closeIconColor: context.colorScheme.onSurface, + ); + } + search() async { - if (prefilter == null && filter.value == previousFilter.value) return; + if (filter.value.isEmpty) { + return; + } + + if (prefilter == null && filter.value == previousFilter.value) { + return; + } isSearching.value = true; ref.watch(paginatedSearchProvider.notifier).clear(); - await ref.watch(paginatedSearchProvider.notifier).search(filter.value); + final hasResult = await ref + .watch(paginatedSearchProvider.notifier) + .search(filter.value); + + if (!hasResult) { + context.showSnackBar( + searchInfoSnackBar('search_no_result'.tr()), + ); + } + previousFilter.value = filter.value; isSearching.value = false; } loadMoreSearchResult() async { isSearching.value = true; - await ref.watch(paginatedSearchProvider.notifier).search(filter.value); + final hasResult = await ref + .watch(paginatedSearchProvider.notifier) + .search(filter.value); + + if (!hasResult) { + context.showSnackBar( + searchInfoSnackBar('search_no_more_result'.tr()), + ); + } + isSearching.value = false; } @@ -596,10 +632,15 @@ class SearchPage extends HookConsumerWidget { ), ), ), - SearchResultGrid( - onScrollEnd: loadMoreSearchResult, - isSearching: isSearching.value, - ), + if (isSearching.value) + const Expanded( + child: Center(child: CircularProgressIndicator.adaptive()), + ) + else + SearchResultGrid( + onScrollEnd: loadMoreSearchResult, + isSearching: isSearching.value, + ), ], ), ); diff --git a/mobile/lib/providers/search/paginated_search.provider.dart b/mobile/lib/providers/search/paginated_search.provider.dart index 270f1148e8fb6..60264947b28bc 100644 --- a/mobile/lib/providers/search/paginated_search.provider.dart +++ b/mobile/lib/providers/search/paginated_search.provider.dart @@ -19,17 +19,23 @@ class PaginatedSearchNotifier extends StateNotifier { PaginatedSearchNotifier(this._searchService) : super(SearchResult(assets: [], nextPage: 1)); - search(SearchFilter filter) async { - if (state.nextPage == null) return; + Future search(SearchFilter filter) async { + if (state.nextPage == null) { + return false; + } final result = await _searchService.search(filter, state.nextPage!); - if (result == null) return; + if (result == null) { + return false; + } state = SearchResult( assets: [...state.assets, ...result.assets], nextPage: result.nextPage, ); + + return true; } clear() { diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index ba46848cddce0..fe8f7393c23e0 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -101,7 +101,7 @@ class SearchService { ); } - if (response == null) { + if (response == null || response.assets.items.isEmpty) { return null; } diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index 9cc74bf93983d..04f9538875e42 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -20,7 +20,7 @@ class PeoplePicker extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final formFocus = useFocusNode(); - final imageSize = 75.0; + final imageSize = 60.0; final searchQuery = useState(''); final people = ref.watch(getAllPeopleProvider); final headers = ApiService.getRequestHeaders(); From 1b141d5ca9d68b7224f6c85d8dbdf3012cfc1c56 Mon Sep 17 00:00:00 2001 From: David Wolff Date: Fri, 31 Jan 2025 16:06:45 +0100 Subject: [PATCH 23/26] refactor(server): filter assets by people using a subquery instead of a cte (#15768) --- server/src/entities/asset.entity.ts | 31 ++++++++++----------- server/src/repositories/asset.repository.ts | 13 ++++----- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index e9dbe67a2fffc..879c2c51699cd 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -238,24 +238,20 @@ export function withFacesAndPeople(eb: ExpressionBuilder) { .as('faces'); } -/** Adds a `has_people` CTE that can be inner joined on to filter out assets */ -export function hasPeopleCte(db: Kysely, personIds: string[]) { - return db.with('has_people', (qb) => - qb - .selectFrom('asset_faces') - .select('assetId') - .where('personId', '=', anyUuid(personIds!)) - .groupBy('assetId') - .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length), +export function hasPeople(qb: SelectQueryBuilder, personIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('asset_faces') + .select('assetId') + .where('personId', '=', anyUuid(personIds!)) + .groupBy('assetId') + .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) + .as('has_people'), + (join) => join.onRef('has_people.assetId', '=', 'assets.id'), ); } -export function hasPeople(db: Kysely, personIds?: string[]) { - return personIds && personIds.length > 0 - ? hasPeopleCte(db, personIds).selectFrom('assets').innerJoin('has_people', 'has_people.assetId', 'assets.id') - : db.selectFrom('assets'); -} - export function withOwner(eb: ExpressionBuilder) { return jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner'); } @@ -326,8 +322,11 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { options.isArchived ??= options.withArchived ? undefined : false; options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore); - return hasPeople(kysely.withPlugin(joinDeduplicationPlugin), options.personIds) + return kysely + .withPlugin(joinDeduplicationPlugin) + .selectFrom('assets') .selectAll('assets') + .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!)) .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!)) .$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore!)) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 1f9f8f997f83b..b306b1a694473 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -8,7 +8,6 @@ import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetEntity, hasPeople, - hasPeopleCte, searchAssetBuilder, truncatedDate, withAlbums, @@ -576,7 +575,7 @@ export class AssetRepository implements IAssetRepository { @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) async getTimeBuckets(options: TimeBucketOptions): Promise { return ( - ((options.personId ? hasPeopleCte(this.db, [options.personId]) : this.db) as Kysely) + this.db .with('assets', (qb) => qb .selectFrom('assets') @@ -589,11 +588,7 @@ export class AssetRepository implements IAssetRepository { .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') .where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)), ) - .$if(!!options.personId, (qb) => - qb.innerJoin(sql.table('has_people').as('has_people'), (join) => - join.onRef(sql`has_people."assetId"`, '=', 'assets.id'), - ), - ) + .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.withStacked, (qb) => qb .leftJoin('asset_stack', (join) => @@ -628,10 +623,12 @@ export class AssetRepository implements IAssetRepository { @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] }) async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { - return hasPeople(this.db, options.personId ? [options.personId] : undefined) + return this.db + .selectFrom('assets') .selectAll('assets') .$call(withExif) .$if(!!options.albumId, (qb) => withAlbums(qb, { albumId: options.albumId })) + .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) From 221e1976330a02d974d38a95e4865b50d0c75d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mangat=20Singh=20Toor=20=7C=20=E0=A8=AE=E0=A9=B0=E0=A8=97?= =?UTF-8?q?=E0=A8=A4=20=E0=A8=B8=E0=A8=BF=E0=A9=B0=E0=A8=98=20=E0=A8=A4?= =?UTF-8?q?=E0=A9=82=E0=A8=B0?= Date: Fri, 31 Jan 2025 07:24:53 -0800 Subject: [PATCH 24/26] fix(mobile): retain edited title when album updates (#15806) * fix(album-viewer): retain edited title when album updates ensure `AlbumViewerEditableTitle` keeps user input while editing, even when the album updates from another provider. fall back to `albumName` only when not in edit mode. * linting --------- Co-authored-by: Alex --- .../lib/widgets/album/album_viewer_editable_title.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mobile/lib/widgets/album/album_viewer_editable_title.dart b/mobile/lib/widgets/album/album_viewer_editable_title.dart index 7547dff932f03..72fdfe070d886 100644 --- a/mobile/lib/widgets/album/album_viewer_editable_title.dart +++ b/mobile/lib/widgets/album/album_viewer_editable_title.dart @@ -16,7 +16,14 @@ class AlbumViewerEditableTitle extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final titleTextEditController = useTextEditingController(text: albumName); + final albumViewerState = ref.watch(albumViewerProvider); + + final titleTextEditController = useTextEditingController( + text: albumViewerState.isEditAlbum && + albumViewerState.editTitleText.isNotEmpty + ? albumViewerState.editTitleText + : albumName, + ); void onFocusModeChange() { if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) { From 9ac95d6845825d0d188eb9fb7b2971df3695ee92 Mon Sep 17 00:00:00 2001 From: David Wolff Date: Fri, 31 Jan 2025 22:37:22 +0100 Subject: [PATCH 25/26] feat: add searching by tags (#15395) * feat: add searching by tags * fix: fix merge --------- Co-authored-by: Alex --- .../lib/model/metadata_search_dto.dart | 11 ++- .../openapi/lib/model/random_search_dto.dart | 11 ++- .../openapi/lib/model/smart_search_dto.dart | 11 ++- open-api/immich-openapi-specs.json | 21 +++++ open-api/typescript-sdk/src/fetch-client.ts | 3 + server/src/dtos/search.dto.ts | 3 + server/src/entities/asset.entity.ts | 16 ++++ server/src/interfaces/search.interface.ts | 10 ++- .../search-bar/search-filter-modal.svelte | 8 ++ .../search-bar/search-tags-section.svelte | 80 +++++++++++++++++++ .../[[assetId=id]]/+page.svelte | 18 +++++ 11 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/components/shared-components/search-bar/search-tags-section.svelte diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 654883b38afc3..5f9e3f8e15e00 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -41,6 +41,7 @@ class MetadataSearchDto { this.previewPath, this.size, this.state, + this.tagIds = const [], this.takenAfter, this.takenBefore, this.thumbnailPath, @@ -235,6 +236,8 @@ class MetadataSearchDto { String? state; + List tagIds; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -363,6 +366,7 @@ class MetadataSearchDto { other.previewPath == previewPath && other.size == size && other.state == state && + _deepEquality.equals(other.tagIds, tagIds) && other.takenAfter == takenAfter && other.takenBefore == takenBefore && other.thumbnailPath == thumbnailPath && @@ -408,6 +412,7 @@ class MetadataSearchDto { (previewPath == null ? 0 : previewPath!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + + (tagIds.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (thumbnailPath == null ? 0 : thumbnailPath!.hashCode) + @@ -423,7 +428,7 @@ class MetadataSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -559,6 +564,7 @@ class MetadataSearchDto { } else { // json[r'state'] = null; } + json[r'tagIds'] = this.tagIds; if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { @@ -662,6 +668,9 @@ class MetadataSearchDto { previewPath: mapValueOfType(json, r'previewPath'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), thumbnailPath: mapValueOfType(json, r'thumbnailPath'), diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 3fcab05bbb275..c63d7e82f611d 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -32,6 +32,7 @@ class RandomSearchDto { this.personIds = const [], this.size, this.state, + this.tagIds = const [], this.takenAfter, this.takenBefore, this.trashedAfter, @@ -158,6 +159,8 @@ class RandomSearchDto { String? state; + List tagIds; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -269,6 +272,7 @@ class RandomSearchDto { _deepEquality.equals(other.personIds, personIds) && other.size == size && other.state == state && + _deepEquality.equals(other.tagIds, tagIds) && other.takenAfter == takenAfter && other.takenBefore == takenBefore && other.trashedAfter == trashedAfter && @@ -304,6 +308,7 @@ class RandomSearchDto { (personIds.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + + (tagIds.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + @@ -318,7 +323,7 @@ class RandomSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -413,6 +418,7 @@ class RandomSearchDto { } else { // json[r'state'] = null; } + json[r'tagIds'] = this.tagIds; if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { @@ -502,6 +508,9 @@ class RandomSearchDto { : const [], size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), trashedAfter: mapDateTime(json, r'trashedAfter', r''), diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 4e1408cafa737..c81e1519b4eda 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -34,6 +34,7 @@ class SmartSearchDto { required this.query, this.size, this.state, + this.tagIds = const [], this.takenAfter, this.takenBefore, this.trashedAfter, @@ -169,6 +170,8 @@ class SmartSearchDto { String? state; + List tagIds; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -266,6 +269,7 @@ class SmartSearchDto { other.query == query && other.size == size && other.state == state && + _deepEquality.equals(other.tagIds, tagIds) && other.takenAfter == takenAfter && other.takenBefore == takenBefore && other.trashedAfter == trashedAfter && @@ -301,6 +305,7 @@ class SmartSearchDto { (query.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + + (tagIds.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + @@ -313,7 +318,7 @@ class SmartSearchDto { (withExif == null ? 0 : withExif!.hashCode); @override - String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; + String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; Map toJson() { final json = {}; @@ -414,6 +419,7 @@ class SmartSearchDto { } else { // json[r'state'] = null; } + json[r'tagIds'] = this.tagIds; if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { @@ -495,6 +501,9 @@ class SmartSearchDto { query: mapValueOfType(json, r'query')!, size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), trashedAfter: mapDateTime(json, r'trashedAfter', r''), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0bb00103ba6cb..090a3267d49db 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10036,6 +10036,13 @@ "nullable": true, "type": "string" }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "takenAfter": { "format": "date-time", "type": "string" @@ -10649,6 +10656,13 @@ "nullable": true, "type": "string" }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "takenAfter": { "format": "date-time", "type": "string" @@ -11564,6 +11578,13 @@ "nullable": true, "type": "string" }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "takenAfter": { "format": "date-time", "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0c6ed43249783..bbd41c3ecbfbb 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -792,6 +792,7 @@ export type MetadataSearchDto = { previewPath?: string; size?: number; state?: string | null; + tagIds?: string[]; takenAfter?: string; takenBefore?: string; thumbnailPath?: string; @@ -858,6 +859,7 @@ export type RandomSearchDto = { personIds?: string[]; size?: number; state?: string | null; + tagIds?: string[]; takenAfter?: string; takenBefore?: string; trashedAfter?: string; @@ -893,6 +895,7 @@ export type SmartSearchDto = { query: string; size?: number; state?: string | null; + tagIds?: string[]; takenAfter?: string; takenBefore?: string; trashedAfter?: string; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index f3f45af44dc60..9dabfff25fc10 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -111,6 +111,9 @@ class BaseSearchDto { @ValidateUUID({ each: true, optional: true }) personIds?: string[]; + + @ValidateUUID({ each: true, optional: true }) + tagIds?: string[]; } export class RandomSearchDto extends BaseSearchDto { diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 879c2c51699cd..605fbb04560ce 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -252,6 +252,21 @@ export function hasPeople(qb: SelectQueryBuilder, personIds: ); } +export function hasTags(qb: SelectQueryBuilder, tagIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('tag_asset') + .select('assetsId') + .innerJoin('tags_closure', 'tag_asset.tagsId', 'tags_closure.id_descendant') + .where('tags_closure.id_ancestor', '=', anyUuid(tagIds)) + .groupBy('assetsId') + .having((eb) => eb.fn.count('tags_closure.id_ancestor').distinct(), '>=', tagIds.length) + .as('has_tags'), + (join) => join.onRef('has_tags.assetsId', '=', 'assets.id'), + ); +} + export function withOwner(eb: ExpressionBuilder) { return jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner'); } @@ -326,6 +341,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .withPlugin(joinDeduplicationPlugin) .selectFrom('assets') .selectAll('assets') + .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!)) .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!)) diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index bb76ff7b1fd04..e6f9acbd212d0 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -112,6 +112,10 @@ export interface SearchPeopleOptions { personIds?: string[]; } +export interface SearchTagOptions { + tagIds?: string[]; +} + export interface SearchOrderOptions { orderDirection?: 'asc' | 'desc'; } @@ -128,7 +132,8 @@ type BaseAssetSearchOptions = SearchDateOptions & SearchPathOptions & SearchStatusOptions & SearchUserIdOptions & - SearchPeopleOptions; + SearchPeopleOptions & + SearchTagOptions; export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; @@ -142,7 +147,8 @@ export type SmartSearchOptions = SearchDateOptions & SearchOneToOneRelationOptions & SearchStatusOptions & SearchUserIdOptions & - SearchPeopleOptions; + SearchPeopleOptions & + SearchTagOptions; export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { hasPerson?: boolean; 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 c367d001f2eea..7653ad341311b 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 @@ -8,6 +8,7 @@ query: string; queryType: 'smart' | 'metadata'; personIds: SvelteSet; + tagIds: SvelteSet; location: SearchLocationFilter; camera: SearchCameraFilter; date: SearchDateFilter; @@ -20,6 +21,7 @@ import { Button } from '@immich/ui'; import { AssetTypeEnum, type SmartSearchDto, type MetadataSearchDto } from '@immich/sdk'; import SearchPeopleSection from './search-people-section.svelte'; + import SearchTagsSection from './search-tags-section.svelte'; import SearchLocationSection from './search-location-section.svelte'; import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte'; import SearchDateSection from './search-date-section.svelte'; @@ -54,6 +56,7 @@ query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '', queryType: 'query' in searchQuery ? 'smart' : 'metadata', personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), + tagIds: new SvelteSet('tagIds' in searchQuery ? searchQuery.tagIds : []), location: { country: withNullAsUndefined(searchQuery.country), state: withNullAsUndefined(searchQuery.state), @@ -85,6 +88,7 @@ query: '', queryType: 'smart', personIds: new SvelteSet(), + tagIds: new SvelteSet(), location: {}, camera: {}, date: {}, @@ -117,6 +121,7 @@ isFavorite: filter.display.isFavorite || undefined, isNotInAlbum: filter.display.isNotInAlbum || undefined, personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined, + tagIds: filter.tagIds.size > 0 ? [...filter.tagIds] : undefined, type, }; @@ -143,6 +148,9 @@ + + + diff --git a/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte new file mode 100644 index 0000000000000..6071da1460266 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte @@ -0,0 +1,80 @@ + + +{#if $preferences?.tags?.enabled} +
+
+
+ ({ id: tag.id, label: tag.value, value: tag.id }))} + bind:selectedOption + placeholder={$t('search_tags')} + /> +
+
+ +
+ {#each selectedTags as tagId (tagId)} + {@const tag = tagMap[tagId]} + {#if tag} +
+ +

+ {tag.value} +

+
+ + +
+ {/if} + {/each} +
+
+{/if} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 97d0cacdce572..c416226c41d1b 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -29,6 +29,7 @@ type SmartSearchDto, type MetadataSearchDto, type AlbumResponseDto, + getTagById, } from '@immich/sdk'; import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import type { Viewport } from '$lib/stores/assets.store'; @@ -194,6 +195,7 @@ model: $t('camera_model'), lensModel: $t('lens_model'), personIds: $t('people'), + tagIds: $t('tags'), originalFileName: $t('file_name'), }; return keyMap[key] || key; @@ -215,6 +217,18 @@ return personNames.join(', '); } + async function getTagNames(tagIds: string[]) { + const tagNames = await Promise.all( + tagIds.map(async (tagId) => { + const tag = await getTagById({ id: tagId }); + + return tag.value; + }), + ); + + return tagNames.join(', '); + } + const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets); const onAddToAlbum = (assetIds: string[]) => { @@ -299,6 +313,10 @@ {#await getPersonName(value) then personName} {personName} {/await} + {:else if key === 'tagIds' && Array.isArray(value)} + {#await getTagNames(value) then tagNames} + {tagNames} + {/await} {:else if value === null || value === ''} {$t('unknown')} {:else} From 2b41b5efe159921bef8ebb4928eed592c5f93923 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Sat, 1 Feb 2025 23:26:23 +0000 Subject: [PATCH 26/26] feat: merch links (#15843) --- docs/docusaurus.config.js | 4 ++-- docs/src/pages/index.tsx | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 16d654b46bc58..7166611a2e3dc 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -110,9 +110,9 @@ const config = { label: 'API', }, { - to: '/blog', + href: 'https://immich.store', position: 'right', - label: 'Blog', + label: 'Merch', }, { href: 'https://github.com/immich-app/immich', diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index b3cf10b810f59..8ea8e1220ddb3 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -50,6 +50,13 @@ function HomepageHeader() { > Demo + + + Buy Merch +