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 | Bin 15374 -> 15617 bytes mobile/openapi/lib/model/sync_asset_v1.dart | Bin 8591 -> 8844 bytes .../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, 336 insertions(+), 9 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 ac5a0a8cc..c62ad97e7 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 0c1f03086..02ff26510 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 c9581b19ddfdd1e502409495e6d72cf258b6a06a..27aa3b98f35b648701fba8a8130b68d55eb942f9 100644 GIT binary patch delta 145 zcmeCHXsp`spNY*iC9@M0+ZH$R3IdD(`^HHd delta 51 zcmV-30L=f4MUO+U*a5Tk0r3H|_yZ~evqlE%0<%mB@B*_^4C?}uz77eqaSuBPv*{O# J3A2PD2mv>o6bk?V diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index d39446ada..5f139df40 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 69f6c1753..9ab6a5685 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 2f160e6be..1535b509c 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 496e6906a..8708d32bb 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 95bc98bae..61a08df14 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 1607c1508..5d66c0c08 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 6baf3c8ac..f775a2211 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 e7595b3d1..c57530050 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 385db37cf..8988bf38d 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 000000000..3dd60ccda --- /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 84d95ca3c..4c4bf45cf 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 96ea0a98d..fb21b67af 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 c47d75dc2..5cca0a8f8 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 3478e31fe..21ffbda59 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 6aa76dd4d..a080b505d 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 17b0e232b..acca3092c 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 000000000..da025299f --- /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 6c094c112..b271956dc 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 acba274b4..839923ce1 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 421423a74..af3816054 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 65ee7be07..9d998f5ae 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 319d38270..84a02c473 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 b1b164079..758ac26f0 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 81b74e51e..0feab709c 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 a5a59261c..00dd58824 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({