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] 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({