From fdff591a11f39ce99f621d2fb2a96e5ef350a8a3 Mon Sep 17 00:00:00 2001 From: Savely Krasovsky Date: Fri, 16 Jan 2026 15:42:55 +0100 Subject: [PATCH 01/32] feat: update intel compute driver (#25259) --- machine-learning/Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 32b2bc6db0..dfc217c118 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -92,14 +92,14 @@ FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64 RUN apt-get update && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ - wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-core-2_2.24.8+20344_amd64.deb && \ - wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-opencl-2_2.24.8+20344_amd64.deb && \ - wget -nv https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/intel-opencl-icd_25.48.36300.8-0_amd64.deb && \ + wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \ + wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-opencl-2_2.27.10+20617_amd64.deb && \ + wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/intel-opencl-icd_26.01.36711.4-0_amd64.deb && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \ wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \ # TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file - wget -nv https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/libigdgmm12_22.8.2_amd64.deb && \ + wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \ dpkg -i *.deb && \ rm *.deb && \ apt-get remove wget -yqq && \ From a2b03f7650c946db9e518b81cc81cd325eb6cf39 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 16 Jan 2026 11:17:35 -0500 Subject: [PATCH 02/32] refactor(web): user sidebar (#25292) --- pnpm-lock.yaml | 10 +- web/package.json | 2 +- .../side-bar/side-bar-link.svelte | 76 ------------ .../side-bar/user-sidebar.svelte | 109 ++++++------------ web/src/lib/constants.ts | 1 - 5 files changed, 40 insertions(+), 158 deletions(-) delete mode 100644 web/src/lib/components/shared-components/side-bar/side-bar-link.svelte diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e74dc09990..5892dcf601 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,8 +741,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.58.4 - version: 0.58.4(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4) + specifier: ^0.59.0 + version: 0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -3128,8 +3128,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.58.4': - resolution: {integrity: sha512-/Y+TRA9E8VQ+yH0aqrkEnQTQi4j02dNgahil9NbJe3hSnakfDHZUgJR5xevGZbKqlnBV4O3mjbwmzr6j9wlP7w==} + '@immich/ui@0.59.0': + resolution: {integrity: sha512-7yxvyhhd99T0AHhjMakp7c/U4n0jGAmRO5xpncsRASRvqZve/LAibjr6N5FJc5IAd222DROTMLn6imsxVfqfvg==} peerDependencies: svelte: ^5.0.0 @@ -15604,7 +15604,7 @@ snapshots: dependencies: svelte: 5.46.4 - '@immich/ui@0.58.4(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)': + '@immich/ui@0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.3(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)': dependencies: '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.4) '@internationalized/date': 3.10.0 diff --git a/web/package.json b/web/package.json index d0971ac3fc..e078612e3c 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.58.4", + "@immich/ui": "^0.59.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte deleted file mode 100644 index c5f9080a13..0000000000 --- a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - -
- {#if hasDropdown} - - {/if} - - -
- - {title} -
-
-
-
- -{#if hasDropdown && dropdownOpen} - {@render hasDropdown?.()} -{/if} diff --git a/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte b/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte index cfc712d989..de8876f002 100644 --- a/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte +++ b/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte @@ -1,11 +1,12 @@ - + href={AppRoute.PHOTOS} + icon={mdiImageMultipleOutline} + activeIcon={mdiImageMultiple} + /> {#if featureFlagsManager.value.search} - + {/if} {#if featureFlagsManager.value.map} - + {/if} {#if $preferences.people.enabled && $preferences.people.sidebarWeb} - + {/if} {#if $preferences.sharedLinks.enabled && $preferences.sharedLinks.sidebarWeb} - + {/if} - + href={AppRoute.SHARING} + icon={mdiAccountMultipleOutline} + activeIcon={mdiAccountMultiple} + /> -

{$t('library')}

+ - + - - {#snippet dropDownContent()} + {#snippet items()} {/snippet} - + {#if $preferences.tags.enabled && $preferences.tags.sidebarWeb} - + {/if} {#if $preferences.folders.enabled && $preferences.folders.sidebarWeb} - + {/if} - + - + href={AppRoute.ARCHIVE} + icon={mdiArchiveArrowDownOutline} + activeIcon={mdiArchiveArrowDown} + /> - + {#if featureFlagsManager.value.trash} - + {/if} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index fca52c301f..626273dc14 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -30,7 +30,6 @@ export enum AppRoute { ADMIN_REPAIR = '/admin/repair', ALBUMS = '/albums', - LIBRARIES = '/libraries', ARCHIVE = '/archive', FAVORITES = '/favorites', PEOPLE = '/people', From 07675a2de44c4df4bf0e5a3e965d07e739446bee Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:05:13 -0600 Subject: [PATCH 03/32] feat: download original asset (#25302) Co-authored-by: bwees --- i18n/en.json | 1 + mobile/lib/utils/openapi_patching.dart | 5 + .../openapi/lib/model/asset_response_dto.dart | 10 +- mobile/openapi/lib/model/sync_asset_v1.dart | 10 +- .../sync_stream_repository_test.dart | 1 + mobile/test/fixtures/sync_stream.stub.dart | 1 + open-api/immich-openapi-specs.json | 19 +++ open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/database.ts | 1 + server/src/dtos/asset-response.dto.ts | 4 + server/src/dtos/sync.dto.ts | 2 + server/src/queries/sync.repository.sql | 8 +- server/src/schema/functions.ts | 28 +++++ .../1768587436457-AddEditCountToAsset.ts | 53 +++++++++ server/src/schema/tables/asset-edit.table.ts | 19 ++- server/src/schema/tables/asset.table.ts | 3 + server/src/services/job.service.ts | 1 + server/test/fixtures/asset.stub.ts | 28 +++++ server/test/fixtures/shared-link.stub.ts | 1 + server/test/medium.factory.ts | 3 + .../asset-edit.repository.spec.ts | 111 ++++++++++++++++++ .../specs/sync/sync-album-asset.spec.ts | 1 + .../test/medium/specs/sync/sync-asset.spec.ts | 1 + .../specs/sync/sync-partner-asset.spec.ts | 1 + server/test/small.factory.ts | 1 + .../asset-viewer/asset-viewer-nav-bar.svelte | 15 ++- .../timeline/actions/DownloadAction.svelte | 2 +- web/src/lib/services/asset.service.ts | 33 +++++- web/src/test-data/factories/asset-factory.ts | 1 + 29 files changed, 354 insertions(+), 11 deletions(-) create mode 100644 server/src/schema/migrations/1768587436457-AddEditCountToAsset.ts create mode 100644 server/test/medium/specs/repositories/asset-edit.repository.spec.ts diff --git a/i18n/en.json b/i18n/en.json index ac5a0a8cc0..c62ad97e7d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -928,6 +928,7 @@ "download_include_embedded_motion_videos": "Embedded videos", "download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file", "download_notfound": "Download not found", + "download_original": "Download original", "download_paused": "Download paused", "download_settings": "Download", "download_settings_description": "Manage settings related to asset download", diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 0c1f03086f..02ff265104 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -29,6 +29,7 @@ dynamic upgradeDto(dynamic value, String targetType) { if (value is Map) { addDefault(value, 'visibility', 'timeline'); addDefault(value, 'createdAt', DateTime.now().toIso8601String()); + addDefault(value, 'isEdited', false); } break; case 'UserAdminResponseDto': @@ -46,6 +47,10 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); addDefault(value, 'hasProfileImage', false); } + case 'SyncAssetV1': + if (value is Map) { + addDefault(value, 'editCount', 0); + } case 'ServerFeaturesDto': if (value is Map) { addDefault(value, 'ocr', false); diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index c9581b19dd..27aa3b98f3 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -26,6 +26,7 @@ class AssetResponseDto { required this.height, required this.id, required this.isArchived, + required this.isEdited, required this.isFavorite, required this.isOffline, required this.isTrashed, @@ -85,6 +86,8 @@ class AssetResponseDto { bool isArchived; + bool isEdited; + bool isFavorite; bool isOffline; @@ -162,6 +165,7 @@ class AssetResponseDto { other.height == height && other.id == id && other.isArchived == isArchived && + other.isEdited == isEdited && other.isFavorite == isFavorite && other.isOffline == isOffline && other.isTrashed == isTrashed && @@ -200,6 +204,7 @@ class AssetResponseDto { (height == null ? 0 : height!.hashCode) + (id.hashCode) + (isArchived.hashCode) + + (isEdited.hashCode) + (isFavorite.hashCode) + (isOffline.hashCode) + (isTrashed.hashCode) + @@ -223,7 +228,7 @@ class AssetResponseDto { (width == null ? 0 : width!.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]'; + String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]'; Map toJson() { final json = {}; @@ -252,6 +257,7 @@ class AssetResponseDto { } json[r'id'] = this.id; json[r'isArchived'] = this.isArchived; + json[r'isEdited'] = this.isEdited; json[r'isFavorite'] = this.isFavorite; json[r'isOffline'] = this.isOffline; json[r'isTrashed'] = this.isTrashed; @@ -332,6 +338,7 @@ class AssetResponseDto { : num.parse('${json[r'height']}'), id: mapValueOfType(json, r'id')!, isArchived: mapValueOfType(json, r'isArchived')!, + isEdited: mapValueOfType(json, r'isEdited')!, isFavorite: mapValueOfType(json, r'isFavorite')!, isOffline: mapValueOfType(json, r'isOffline')!, isTrashed: mapValueOfType(json, r'isTrashed')!, @@ -413,6 +420,7 @@ class AssetResponseDto { 'height', 'id', 'isArchived', + 'isEdited', 'isFavorite', 'isOffline', 'isTrashed', diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart index a2c89eb5c1..6e9fa95df0 100644 --- a/mobile/openapi/lib/model/sync_asset_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_v1.dart @@ -16,6 +16,7 @@ class SyncAssetV1 { required this.checksum, required this.deletedAt, required this.duration, + required this.editCount, required this.fileCreatedAt, required this.fileModifiedAt, required this.height, @@ -39,6 +40,8 @@ class SyncAssetV1 { String? duration; + int editCount; + DateTime? fileCreatedAt; DateTime? fileModifiedAt; @@ -74,6 +77,7 @@ class SyncAssetV1 { other.checksum == checksum && other.deletedAt == deletedAt && other.duration == duration && + other.editCount == editCount && other.fileCreatedAt == fileCreatedAt && other.fileModifiedAt == fileModifiedAt && other.height == height && @@ -96,6 +100,7 @@ class SyncAssetV1 { (checksum.hashCode) + (deletedAt == null ? 0 : deletedAt!.hashCode) + (duration == null ? 0 : duration!.hashCode) + + (editCount.hashCode) + (fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) + (fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) + (height == null ? 0 : height!.hashCode) + @@ -113,7 +118,7 @@ class SyncAssetV1 { (width == null ? 0 : width!.hashCode); @override - String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]'; + String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, editCount=$editCount, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]'; Map toJson() { final json = {}; @@ -128,6 +133,7 @@ class SyncAssetV1 { } else { // json[r'duration'] = null; } + json[r'editCount'] = this.editCount; if (this.fileCreatedAt != null) { json[r'fileCreatedAt'] = this.fileCreatedAt!.toUtc().toIso8601String(); } else { @@ -194,6 +200,7 @@ class SyncAssetV1 { checksum: mapValueOfType(json, r'checksum')!, deletedAt: mapDateTime(json, r'deletedAt', r''), duration: mapValueOfType(json, r'duration'), + editCount: mapValueOfType(json, r'editCount')!, fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''), fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''), height: mapValueOfType(json, r'height'), @@ -259,6 +266,7 @@ class SyncAssetV1 { 'checksum', 'deletedAt', 'duration', + 'editCount', 'fileCreatedAt', 'fileModifiedAt', 'height', diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index d39446ada3..5f139df401 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -44,6 +44,7 @@ SyncAssetV1 _createAsset({ livePhotoVideoId: null, stackId: null, thumbhash: null, + editCount: 0, ); } diff --git a/mobile/test/fixtures/sync_stream.stub.dart b/mobile/test/fixtures/sync_stream.stub.dart index 69f6c1753f..9ab6a5685d 100644 --- a/mobile/test/fixtures/sync_stream.stub.dart +++ b/mobile/test/fixtures/sync_stream.stub.dart @@ -128,6 +128,7 @@ abstract final class SyncStreamStub { visibility: AssetVisibility.timeline, width: null, height: null, + editCount: 0, ), ack: ack, ); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2f160e6bed..1535b509cc 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16276,6 +16276,20 @@ "isArchived": { "type": "boolean" }, + "isEdited": { + "type": "boolean", + "x-immich-history": [ + { + "version": "v2.5.0", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-state": "Beta" + }, "isFavorite": { "type": "boolean" }, @@ -16408,6 +16422,7 @@ "height", "id", "isArchived", + "isEdited", "isFavorite", "isOffline", "isTrashed", @@ -21276,6 +21291,9 @@ "nullable": true, "type": "string" }, + "editCount": { + "type": "integer" + }, "fileCreatedAt": { "format": "date-time", "nullable": true, @@ -21346,6 +21364,7 @@ "checksum", "deletedAt", "duration", + "editCount", "fileCreatedAt", "fileModifiedAt", "height", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 496e6906a2..8708d32bba 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -352,6 +352,7 @@ export type AssetResponseDto = { height: number | null; id: string; isArchived: boolean; + isEdited: boolean; isFavorite: boolean; isOffline: boolean; isTrashed: boolean; diff --git a/server/src/database.ts b/server/src/database.ts index 95bc98bae4..61a08df14b 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -395,6 +395,7 @@ export const columns = { 'asset.libraryId', 'asset.width', 'asset.height', + 'asset.editCount', ], syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 1607c15085..5d66c0c08b 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -98,6 +98,8 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { @Property({ history: new HistoryBuilder().added('v1').deprecated('v1.113.0') }) resized?: boolean; + @Property({ history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0') }) + isEdited!: boolean; } export type MapAsset = { @@ -137,6 +139,7 @@ export type MapAsset = { type: AssetType; width: number | null; height: number | null; + editCount: number; }; export class AssetStackResponseDto { @@ -245,5 +248,6 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset resized: true, width: entity.width, height: entity.height, + isEdited: entity.editCount > 0, }; } diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 6baf3c8ac7..f775a22116 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -121,6 +121,8 @@ export class SyncAssetV1 { width!: number | null; @ApiProperty({ type: 'integer' }) height!: number | null; + @ApiProperty({ type: 'integer' }) + editCount!: number; } @ExtraModel() diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index e7595b3d1e..c57530050d 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -71,6 +71,7 @@ select "asset"."libraryId", "asset"."width", "asset"."height", + "asset"."editCount", "album_asset"."updateId" from "album_asset" as "album_asset" @@ -103,6 +104,7 @@ select "asset"."libraryId", "asset"."width", "asset"."height", + "asset"."editCount", "asset"."updateId" from "asset" as "asset" @@ -140,7 +142,8 @@ select "asset"."stackId", "asset"."libraryId", "asset"."width", - "asset"."height" + "asset"."height", + "asset"."editCount" from "album_asset" as "album_asset" inner join "asset" on "asset"."id" = "album_asset"."assetId" @@ -456,6 +459,7 @@ select "asset"."libraryId", "asset"."width", "asset"."height", + "asset"."editCount", "asset"."updateId" from "asset" as "asset" @@ -751,6 +755,7 @@ select "asset"."libraryId", "asset"."width", "asset"."height", + "asset"."editCount", "asset"."updateId" from "asset" as "asset" @@ -802,6 +807,7 @@ select "asset"."libraryId", "asset"."width", "asset"."height", + "asset"."editCount", "asset"."updateId" from "asset" as "asset" diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 385db37cf8..8988bf38d2 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -255,3 +255,31 @@ export const asset_face_audit = registerFunction({ RETURN NULL; END`, }); + +export const asset_edit_insert = registerFunction({ + name: 'asset_edit_insert', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + UPDATE asset + SET "editCount" = "editCount" + 1 + WHERE "id" = NEW."assetId"; + RETURN NULL; + END + `, +}); + +export const asset_edit_delete = registerFunction({ + name: 'asset_edit_delete', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + UPDATE asset + SET "editCount" = "editCount" - 1 + WHERE "id" = OLD."assetId"; + RETURN NULL; + END + `, +}); diff --git a/server/src/schema/migrations/1768587436457-AddEditCountToAsset.ts b/server/src/schema/migrations/1768587436457-AddEditCountToAsset.ts new file mode 100644 index 0000000000..3dd60ccda0 --- /dev/null +++ b/server/src/schema/migrations/1768587436457-AddEditCountToAsset.ts @@ -0,0 +1,53 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION asset_edit_insert() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + UPDATE asset + SET "editCount" = "editCount" + 1 + WHERE "id" = NEW."assetId"; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE FUNCTION asset_edit_delete() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + UPDATE asset + SET "editCount" = "editCount" - 1 + WHERE "id" = OLD."assetId"; + RETURN NULL; + END + $$;`.execute(db); + await sql`ALTER TABLE "asset" ADD "editCount" integer NOT NULL DEFAULT 0;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_edit_delete" + AFTER DELETE ON "asset_edit" + REFERENCING OLD TABLE AS "old" + FOR EACH ROW + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION asset_edit_delete();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_edit_insert" + AFTER INSERT ON "asset_edit" + FOR EACH ROW + EXECUTE FUNCTION asset_edit_insert();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_edit_insert', '{"type":"function","name":"asset_edit_insert","sql":"CREATE OR REPLACE FUNCTION asset_edit_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"editCount\\" = \\"editCount\\" + 1\\n WHERE \\"id\\" = NEW.\\"assetId\\";\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_edit_delete', '{"type":"function","name":"asset_edit_delete","sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"editCount\\" = \\"editCount\\" - 1\\n WHERE \\"id\\" = OLD.\\"assetId\\";\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_edit_delete', '{"type":"trigger","name":"asset_edit_delete","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_delete\\"\\n AFTER DELETE ON \\"asset_edit\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH ROW\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_edit_delete();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_edit_insert', '{"type":"trigger","name":"asset_edit_insert","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_insert\\"\\n AFTER INSERT ON \\"asset_edit\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION asset_edit_insert();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "asset_edit_delete" ON "asset_edit";`.execute(db); + await sql`DROP TRIGGER "asset_edit_insert" ON "asset_edit";`.execute(db); + await sql`ALTER TABLE "asset" DROP COLUMN "editCount";`.execute(db); + await sql`DROP FUNCTION asset_edit_insert;`.execute(db); + await sql`DROP FUNCTION asset_edit_delete;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_edit_insert';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_edit_delete';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_edit_delete';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_edit_insert';`.execute(db); +} diff --git a/server/src/schema/tables/asset-edit.table.ts b/server/src/schema/tables/asset-edit.table.ts index 84d95ca3c9..4c4bf45cf2 100644 --- a/server/src/schema/tables/asset-edit.table.ts +++ b/server/src/schema/tables/asset-edit.table.ts @@ -1,7 +1,24 @@ import { AssetEditAction, AssetEditActionParameter } from 'src/dtos/editing.dto'; +import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn } from 'src/sql-tools'; +import { + AfterDeleteTrigger, + AfterInsertTrigger, + Column, + ForeignKeyColumn, + Generated, + PrimaryGeneratedColumn, + Table, +} from 'src/sql-tools'; +@Table('asset_edit') +@AfterInsertTrigger({ scope: 'row', function: asset_edit_insert }) +@AfterDeleteTrigger({ + scope: 'row', + function: asset_edit_delete, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) export class AssetEditTable { @PrimaryGeneratedColumn() id!: Generated; diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 96ea0a98d8..fb21b67afd 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -143,4 +143,7 @@ export class AssetTable { @Column({ type: 'integer', nullable: true }) height!: number | null; + + @Column({ type: 'integer', default: 0 }) + editCount!: Generated; } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index c47d75dc2a..5cca0a8f8e 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -153,6 +153,7 @@ export class JobService extends BaseService { libraryId: asset.libraryId, width: asset.width, height: asset.height, + editCount: asset.editCount, }, exif: { assetId: exif.assetId, diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 3478e31fe9..21ffbda599 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -86,6 +86,7 @@ export const assetStub = { make: 'FUJIFILM', model: 'X-T50', lensModel: 'XF27mm F2.8 R WR', + editCount: 0, ...asset, }), noResizePath: Object.freeze({ @@ -125,6 +126,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), noWebpPath: Object.freeze({ @@ -166,6 +168,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), noThumbhash: Object.freeze({ @@ -204,6 +207,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), primaryImage: Object.freeze({ @@ -252,6 +256,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), image: Object.freeze({ @@ -298,6 +303,7 @@ export const assetStub = { width: null, visibility: AssetVisibility.Timeline, edits: [], + editCount: 0, }), trashed: Object.freeze({ @@ -341,6 +347,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), trashedOffline: Object.freeze({ @@ -384,6 +391,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), archived: Object.freeze({ id: 'asset-id', @@ -426,6 +434,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), external: Object.freeze({ @@ -468,6 +477,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), image1: Object.freeze({ @@ -510,6 +520,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), imageFrom2015: Object.freeze({ @@ -551,6 +562,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), video: Object.freeze({ @@ -594,6 +606,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), livePhotoMotionAsset: Object.freeze({ @@ -614,6 +627,7 @@ export const assetStub = { width: null, height: null, edits: [] as AssetEditActionItem[], + editCount: 0, } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }), livePhotoStillAsset: Object.freeze({ @@ -635,6 +649,7 @@ export const assetStub = { width: null, height: null, edits: [] as AssetEditActionItem[], + editCount: 0, } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), livePhotoWithOriginalFileName: Object.freeze({ @@ -658,6 +673,7 @@ export const assetStub = { width: null, height: null, edits: [] as AssetEditActionItem[], + editCount: 0, } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), withLocation: Object.freeze({ @@ -705,6 +721,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), sidecar: Object.freeze({ @@ -743,6 +760,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), sidecarWithoutExt: Object.freeze({ @@ -778,6 +796,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), hasEncodedVideo: Object.freeze({ @@ -820,6 +839,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), hasFileExtension: Object.freeze({ @@ -859,6 +879,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), imageDng: Object.freeze({ @@ -902,6 +923,7 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), imageHif: Object.freeze({ @@ -945,7 +967,9 @@ export const assetStub = { width: null, height: null, edits: [], + editCount: 0, }), + panoramaTif: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, @@ -988,6 +1012,7 @@ export const assetStub = { height: null, edits: [], }), + withCropEdit: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, @@ -1043,7 +1068,9 @@ export const assetStub = { }, }, ] as AssetEditActionItem[], + editCount: 1, }), + withoutEdits: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, @@ -1089,5 +1116,6 @@ export const assetStub = { width: 2160, visibility: AssetVisibility.Timeline, edits: [], + editCount: 0, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 6aa76dd4dc..a080b505d4 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -159,6 +159,7 @@ export const sharedLinkStub = { visibility: AssetVisibility.Timeline, width: 500, height: 500, + editCount: 0, }, ], albumId: null, diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 17b0e232b6..acca3092c1 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -19,6 +19,7 @@ import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -384,6 +385,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case AlbumUserRepository: case ActivityRepository: case AssetRepository: + case AssetEditRepository: case AssetJobRepository: case MemoryRepository: case NotificationRepository: @@ -535,6 +537,7 @@ const assetInsert = (asset: Partial> = {}) => { fileModifiedAt: now, localDateTime: now, visibility: AssetVisibility.Timeline, + editCount: 0, }; return { diff --git a/server/test/medium/specs/repositories/asset-edit.repository.spec.ts b/server/test/medium/specs/repositories/asset-edit.repository.spec.ts new file mode 100644 index 0000000000..da025299f5 --- /dev/null +++ b/server/test/medium/specs/repositories/asset-edit.repository.spec.ts @@ -0,0 +1,111 @@ +import { Kysely } from 'kysely'; +import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { DB } from 'src/schema'; +import { BaseService } from 'src/services/base.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { ctx } = newMediumService(BaseService, { + database: db || defaultDatabase, + real: [], + mock: [LoggingRepository], + }); + return { ctx, sut: ctx.get(AssetEditRepository) }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AssetEditRepository.name, () => { + describe('replaceAll', () => { + it('should increment editCount on insert', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await expect( + ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ editCount: 0 }); + + await sut.replaceAll(asset.id, [ + { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, + ]); + + await expect( + ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ editCount: 1 }); + }); + + it('should increment editCount when inserting multiple edits', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await expect( + ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ editCount: 0 }); + + await sut.replaceAll(asset.id, [ + { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]); + + await expect( + ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ editCount: 3 }); + }); + + it('should decrement editCount', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await expect( + ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ editCount: 0 }); + + await sut.replaceAll(asset.id, [ + { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]); + + await sut.replaceAll(asset.id, [ + { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, + ]); + + await expect( + ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ editCount: 1 }); + }); + + it('should set editCount to 0 if all edits are deleted', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await expect( + ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ editCount: 0 }); + + await sut.replaceAll(asset.id, [ + { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]); + + await sut.replaceAll(asset.id, []); + + await expect( + ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ editCount: 0 }); + }); + }); +}); diff --git a/server/test/medium/specs/sync/sync-album-asset.spec.ts b/server/test/medium/specs/sync/sync-album-asset.spec.ts index 6c094c1121..b271956dc5 100644 --- a/server/test/medium/specs/sync/sync-album-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset.spec.ts @@ -83,6 +83,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { libraryId: asset.libraryId, width: asset.width, height: asset.height, + editCount: asset.editCount, }, type: SyncEntityType.AlbumAssetCreateV1, }, diff --git a/server/test/medium/specs/sync/sync-asset.spec.ts b/server/test/medium/specs/sync/sync-asset.spec.ts index acba274b4f..839923ce14 100644 --- a/server/test/medium/specs/sync/sync-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-asset.spec.ts @@ -64,6 +64,7 @@ describe(SyncEntityType.AssetV1, () => { libraryId: asset.libraryId, width: asset.width, height: asset.height, + editCount: asset.editCount, }, type: 'AssetV1', }, diff --git a/server/test/medium/specs/sync/sync-partner-asset.spec.ts b/server/test/medium/specs/sync/sync-partner-asset.spec.ts index 421423a741..af38160545 100644 --- a/server/test/medium/specs/sync/sync-partner-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-partner-asset.spec.ts @@ -63,6 +63,7 @@ describe(SyncRequestType.PartnerAssetsV1, () => { type: asset.type, visibility: asset.visibility, duration: asset.duration, + editCount: asset.editCount, stackId: null, livePhotoVideoId: null, libraryId: asset.libraryId, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 65ee7be07d..9d998f5ae4 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -253,6 +253,7 @@ const assetFactory = (asset: Partial = {}) => ({ visibility: AssetVisibility.Timeline, width: null, height: null, + editCount: 0, ...asset, }); diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 319d382706..84a02c4735 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -112,8 +112,18 @@ const { Cast } = $derived(getGlobalActions($t)); - const { Share, Download, SharedLinkDownload, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } = - $derived(getAssetActions($t, asset)); + const { + Share, + Download, + DownloadOriginal, + SharedLinkDownload, + Offline, + Favorite, + Unfavorite, + PlayMotionPhoto, + StopMotionPhoto, + Info, + } = $derived(getAssetActions($t, asset)); const sharedLink = getSharedLink(); // TODO: Enable when edits are ready for release @@ -195,6 +205,7 @@ {/if} + {#if !isLocked} {#if asset.isTrashed} diff --git a/web/src/lib/components/timeline/actions/DownloadAction.svelte b/web/src/lib/components/timeline/actions/DownloadAction.svelte index b1b1640798..758ac26f07 100644 --- a/web/src/lib/components/timeline/actions/DownloadAction.svelte +++ b/web/src/lib/components/timeline/actions/DownloadAction.svelte @@ -25,7 +25,7 @@ if (assets.length === 1) { clearSelect(); let asset = await getAssetInfo({ ...authManager.params, id: assets[0].id }); - await handleDownloadAsset(asset); + await handleDownloadAsset(asset, { edited: true }); return; } diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index 81b74e51e2..0feab709c0 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -22,6 +22,7 @@ import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { mdiAlertOutline, mdiDownload, + mdiDownloadBox, mdiHeart, mdiHeartOutline, mdiInformationOutline, @@ -51,7 +52,15 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = shortcuts: { key: 'd', shift: true }, type: $t('assets'), $if: () => !!currentAuthUser, - onAction: () => handleDownloadAsset(asset), + onAction: () => handleDownloadAsset(asset, { edited: true }), + }; + + const DownloadOriginal: ActionItem = { + title: $t('download_original'), + icon: mdiDownloadBox, + type: $t('assets'), + $if: () => !!currentAuthUser && asset.isEdited, + onAction: () => handleDownloadAsset(asset, { edited: false }), }; const SharedLinkDownload: ActionItem = { @@ -115,10 +124,21 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = shortcuts: [{ key: 'i' }], }; - return { Share, Download, SharedLinkDownload, Offline, Info, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto }; + return { + Share, + Download, + DownloadOriginal, + SharedLinkDownload, + Offline, + Info, + Favorite, + Unfavorite, + PlayMotionPhoto, + StopMotionPhoto, + }; }; -export const handleDownloadAsset = async (asset: AssetResponseDto) => { +export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: { edited: boolean }) => { const $t = await getFormatter(); const assets = [ @@ -154,7 +174,12 @@ export const handleDownloadAsset = async (asset: AssetResponseDto) => { try { toastManager.success($t('downloading_asset_filename', { values: { filename: asset.originalFileName } })); - downloadUrl(getBaseUrl() + `/assets/${id}/original` + (queryParams ? `?${queryParams}` : ''), filename); + downloadUrl( + getBaseUrl() + + `/assets/${id}/original` + + (queryParams ? `?${queryParams}&edited=${edited}` : `?edited=${edited}`), + filename, + ); } catch (error) { handleError(error, $t('errors.error_downloading', { values: { filename } })); } diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index a5a59261cd..00dd588243 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -30,6 +30,7 @@ export const assetFactory = Sync.makeFactory({ visibility: AssetVisibility.Timeline, width: faker.number.int({ min: 100, max: 1000 }), height: faker.number.int({ min: 100, max: 1000 }), + isEdited: false, }); export const timelineAssetFactory = Sync.makeFactory({ From 8196bd9bbd6fcc00592f62324825e31e40e2c2f9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 16 Jan 2026 16:11:09 -0500 Subject: [PATCH 04/32] refactor(web): routes (#25313) --- web/src/lib/actions/scroll-memory.ts | 6 +- web/src/lib/components/QueueCard.svelte | 7 +- .../QueueStorageMigrationDescription.svelte | 8 +- .../StorageTemplateSettings.svelte | 8 +- .../album-page/album-card-group.svelte | 5 +- .../album-page/albums-table-row.svelte | 6 +- .../asset-viewer/activity-viewer.svelte | 11 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 8 +- .../asset-viewer/asset-viewer.svelte | 5 +- .../asset-viewer/detail-panel.svelte | 18 ++- .../components/layouts/AdminPageLayout.svelte | 12 +- .../memory-page/memory-viewer.svelte | 11 +- .../places-page/places-card-group.svelte | 5 +- .../individual-shared-viewer.svelte | 7 +- .../gallery-viewer/gallery-viewer.svelte | 7 +- .../navigation-bar/account-info-panel.svelte | 6 +- .../navigation-bar/navigation-bar.svelte | 6 +- .../search-bar/search-bar.svelte | 9 +- .../side-bar/purchase-info.svelte | 5 +- .../side-bar/recent-albums.svelte | 3 +- .../side-bar/user-sidebar.svelte | 26 ++--- .../upload-asset-preview.svelte | 10 +- .../sharedlinks-page/SharedLinkCard.svelte | 4 +- .../actions/TimelineKeyboardActions.svelte | 4 +- .../user-settings-list.svelte | 10 +- .../utilities-page/utilities-menu.svelte | 10 +- web/src/lib/constants.ts | 45 +------- web/src/lib/managers/auth-manager.svelte.ts | 4 +- web/src/lib/route.spec.ts | 36 ++++++ web/src/lib/route.ts | 105 ++++++++++++++++++ web/src/lib/services/album.service.ts | 6 +- web/src/lib/services/library.service.ts | 10 +- web/src/lib/services/queue.service.ts | 24 +--- web/src/lib/services/shared-link.service.ts | 4 +- web/src/lib/services/user-admin.service.ts | 6 +- web/src/lib/services/workflow.service.ts | 10 +- web/src/lib/utils/album-utils.ts | 4 +- web/src/lib/utils/asset-utils.ts | 4 +- web/src/lib/utils/auth.ts | 6 +- web/src/lib/utils/metadata-search.ts | 9 -- web/src/lib/utils/navigation.ts | 6 +- web/src/routes/(user)/albums/+page.svelte | 4 +- .../[[assetId=id]]/+page.svelte | 15 +-- web/src/routes/(user)/buy/+page.svelte | 4 +- web/src/routes/(user)/explore/+page.svelte | 10 +- .../[[assetId=id]]/+page.svelte | 5 +- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 4 +- .../[[assetId=id]]/+page.svelte | 5 +- .../[[assetId=id]]/+page.svelte | 4 +- .../[[assetId=id]]/+page.svelte | 3 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 5 +- .../[[assetId=id]]/+page.svelte | 9 +- .../(user)/shared-links/(list)/+layout.svelte | 18 +-- .../shared-links/(list)/[id]/+layout.ts | 7 +- .../(list)/[id]/edit/+page.svelte | 4 +- web/src/routes/(user)/sharing/+page.svelte | 6 +- .../(user)/sharing/sharedlinks/+page.ts | 6 +- .../[[assetId=id]]/+page.svelte | 4 +- .../[[assetId=id]]/+page.svelte | 33 ++---- .../geolocation/photos/[photoId]/+page.ts | 7 +- .../workflows/[workflowId]/+page.svelte | 4 +- web/src/routes/+layout.svelte | 11 +- web/src/routes/+page.svelte | 4 +- web/src/routes/+page.ts | 5 +- web/src/routes/admin/+page.ts | 6 +- web/src/routes/admin/jobs-status/+page.ts | 4 +- .../library-management/(list)/+layout.svelte | 10 +- .../(list)/new/+page.svelte | 6 +- .../library-management/[id]/+layout.svelte | 6 +- .../admin/library-management/[id]/+layout.ts | 4 +- .../library-management/[id]/edit/+page.svelte | 4 +- .../routes/admin/queues/[name]/+page.svelte | 4 +- web/src/routes/admin/queues/[name]/+page.ts | 5 +- web/src/routes/admin/user-management/+page.ts | 4 +- .../admin/users/(list)/new/+page.svelte | 6 +- .../routes/admin/users/[id]/+layout.svelte | 6 +- web/src/routes/admin/users/[id]/+layout.ts | 7 +- .../routes/admin/users/[id]/edit/+page.svelte | 6 +- web/src/routes/auth/change-password/+page.ts | 4 +- web/src/routes/auth/login/+page.svelte | 8 +- web/src/routes/auth/login/+page.ts | 6 +- web/src/routes/auth/onboarding/+page.svelte | 12 +- web/src/routes/auth/pin-prompt/+page.svelte | 4 +- web/src/routes/auth/pin-prompt/+page.ts | 4 +- web/src/routes/auth/register/+page.svelte | 4 +- web/src/routes/auth/register/+page.ts | 4 +- web/src/routes/link/+page.ts | 11 +- 87 files changed, 425 insertions(+), 383 deletions(-) create mode 100644 web/src/lib/route.spec.ts create mode 100644 web/src/lib/route.ts delete mode 100644 web/src/lib/utils/metadata-search.ts diff --git a/web/src/lib/actions/scroll-memory.ts b/web/src/lib/actions/scroll-memory.ts index 1c19fdd8ab..9953bf00fb 100644 --- a/web/src/lib/actions/scroll-memory.ts +++ b/web/src/lib/actions/scroll-memory.ts @@ -1,14 +1,12 @@ import { navigating } from '$app/stores'; -import { AppRoute, SessionStorageKey } from '$lib/constants'; +import { SessionStorageKey } from '$lib/constants'; import { handlePromiseError } from '$lib/utils'; interface Options { /** - * {@link AppRoute} for subpages that scroll state should be kept while visiting. - * * This must be kept the same in all subpages of this route for the scroll memory clearer to work. */ - routeStartsWith: AppRoute; + routeStartsWith: string; /** * Function to clear additional data/state before scrolling (ex infinite scroll). */ diff --git a/web/src/lib/components/QueueCard.svelte b/web/src/lib/components/QueueCard.svelte index f57fb984a2..b98c732348 100644 --- a/web/src/lib/components/QueueCard.svelte +++ b/web/src/lib/components/QueueCard.svelte @@ -2,7 +2,8 @@ import QueueCardBadge from '$lib/components/QueueCardBadge.svelte'; import QueueCardButton from '$lib/components/QueueCardButton.svelte'; import Badge from '$lib/elements/Badge.svelte'; - import { asQueueItem, getQueueDetailUrl } from '$lib/services/queue.service'; + import { Route } from '$lib/route'; + import { asQueueItem } from '$lib/services/queue.service'; import { locale } from '$lib/stores/preferences.store'; import { QueueCommand, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk'; import { Icon, IconButton, Link } from '@immich/ui'; @@ -50,7 +51,7 @@ {/if}
- +