feat: download original asset (#25302)

Co-authored-by: bwees <brandonwees@gmail.com>
This commit is contained in:
Daniel Dietzler 2026-01-16 13:05:13 -06:00 committed by GitHub
parent a2b03f7650
commit 07675a2de4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 336 additions and 9 deletions

View file

@ -928,6 +928,7 @@
"download_include_embedded_motion_videos": "Embedded videos", "download_include_embedded_motion_videos": "Embedded videos",
"download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file", "download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file",
"download_notfound": "Download not found", "download_notfound": "Download not found",
"download_original": "Download original",
"download_paused": "Download paused", "download_paused": "Download paused",
"download_settings": "Download", "download_settings": "Download",
"download_settings_description": "Manage settings related to asset download", "download_settings_description": "Manage settings related to asset download",

View file

@ -29,6 +29,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
if (value is Map) { if (value is Map) {
addDefault(value, 'visibility', 'timeline'); addDefault(value, 'visibility', 'timeline');
addDefault(value, 'createdAt', DateTime.now().toIso8601String()); addDefault(value, 'createdAt', DateTime.now().toIso8601String());
addDefault(value, 'isEdited', false);
} }
break; break;
case 'UserAdminResponseDto': case 'UserAdminResponseDto':
@ -46,6 +47,10 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
addDefault(value, 'hasProfileImage', false); addDefault(value, 'hasProfileImage', false);
} }
case 'SyncAssetV1':
if (value is Map) {
addDefault(value, 'editCount', 0);
}
case 'ServerFeaturesDto': case 'ServerFeaturesDto':
if (value is Map) { if (value is Map) {
addDefault(value, 'ocr', false); addDefault(value, 'ocr', false);

Binary file not shown.

Binary file not shown.

View file

@ -44,6 +44,7 @@ SyncAssetV1 _createAsset({
livePhotoVideoId: null, livePhotoVideoId: null,
stackId: null, stackId: null,
thumbhash: null, thumbhash: null,
editCount: 0,
); );
} }

View file

@ -128,6 +128,7 @@ abstract final class SyncStreamStub {
visibility: AssetVisibility.timeline, visibility: AssetVisibility.timeline,
width: null, width: null,
height: null, height: null,
editCount: 0,
), ),
ack: ack, ack: ack,
); );

View file

@ -16276,6 +16276,20 @@
"isArchived": { "isArchived": {
"type": "boolean" "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": { "isFavorite": {
"type": "boolean" "type": "boolean"
}, },
@ -16408,6 +16422,7 @@
"height", "height",
"id", "id",
"isArchived", "isArchived",
"isEdited",
"isFavorite", "isFavorite",
"isOffline", "isOffline",
"isTrashed", "isTrashed",
@ -21276,6 +21291,9 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"editCount": {
"type": "integer"
},
"fileCreatedAt": { "fileCreatedAt": {
"format": "date-time", "format": "date-time",
"nullable": true, "nullable": true,
@ -21346,6 +21364,7 @@
"checksum", "checksum",
"deletedAt", "deletedAt",
"duration", "duration",
"editCount",
"fileCreatedAt", "fileCreatedAt",
"fileModifiedAt", "fileModifiedAt",
"height", "height",

View file

@ -352,6 +352,7 @@ export type AssetResponseDto = {
height: number | null; height: number | null;
id: string; id: string;
isArchived: boolean; isArchived: boolean;
isEdited: boolean;
isFavorite: boolean; isFavorite: boolean;
isOffline: boolean; isOffline: boolean;
isTrashed: boolean; isTrashed: boolean;

View file

@ -395,6 +395,7 @@ export const columns = {
'asset.libraryId', 'asset.libraryId',
'asset.width', 'asset.width',
'asset.height', 'asset.height',
'asset.editCount',
], ],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], 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'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],

View file

@ -98,6 +98,8 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
@Property({ history: new HistoryBuilder().added('v1').deprecated('v1.113.0') }) @Property({ history: new HistoryBuilder().added('v1').deprecated('v1.113.0') })
resized?: boolean; resized?: boolean;
@Property({ history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0') })
isEdited!: boolean;
} }
export type MapAsset = { export type MapAsset = {
@ -137,6 +139,7 @@ export type MapAsset = {
type: AssetType; type: AssetType;
width: number | null; width: number | null;
height: number | null; height: number | null;
editCount: number;
}; };
export class AssetStackResponseDto { export class AssetStackResponseDto {
@ -245,5 +248,6 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
resized: true, resized: true,
width: entity.width, width: entity.width,
height: entity.height, height: entity.height,
isEdited: entity.editCount > 0,
}; };
} }

View file

@ -121,6 +121,8 @@ export class SyncAssetV1 {
width!: number | null; width!: number | null;
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
height!: number | null; height!: number | null;
@ApiProperty({ type: 'integer' })
editCount!: number;
} }
@ExtraModel() @ExtraModel()

View file

@ -71,6 +71,7 @@ select
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."editCount",
"album_asset"."updateId" "album_asset"."updateId"
from from
"album_asset" as "album_asset" "album_asset" as "album_asset"
@ -103,6 +104,7 @@ select
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."editCount",
"asset"."updateId" "asset"."updateId"
from from
"asset" as "asset" "asset" as "asset"
@ -140,7 +142,8 @@ select
"asset"."stackId", "asset"."stackId",
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height" "asset"."height",
"asset"."editCount"
from from
"album_asset" as "album_asset" "album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId" inner join "asset" on "asset"."id" = "album_asset"."assetId"
@ -456,6 +459,7 @@ select
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."editCount",
"asset"."updateId" "asset"."updateId"
from from
"asset" as "asset" "asset" as "asset"
@ -751,6 +755,7 @@ select
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."editCount",
"asset"."updateId" "asset"."updateId"
from from
"asset" as "asset" "asset" as "asset"
@ -802,6 +807,7 @@ select
"asset"."libraryId", "asset"."libraryId",
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."editCount",
"asset"."updateId" "asset"."updateId"
from from
"asset" as "asset" "asset" as "asset"

View file

@ -255,3 +255,31 @@ export const asset_face_audit = registerFunction({
RETURN NULL; RETURN NULL;
END`, 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
`,
});

View file

@ -0,0 +1,53 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}

View file

@ -1,7 +1,24 @@
import { AssetEditAction, AssetEditActionParameter } from 'src/dtos/editing.dto'; 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 { 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<T extends AssetEditAction = AssetEditAction> { export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: Generated<string>; id!: Generated<string>;

View file

@ -143,4 +143,7 @@ export class AssetTable {
@Column({ type: 'integer', nullable: true }) @Column({ type: 'integer', nullable: true })
height!: number | null; height!: number | null;
@Column({ type: 'integer', default: 0 })
editCount!: Generated<number>;
} }

View file

@ -153,6 +153,7 @@ export class JobService extends BaseService {
libraryId: asset.libraryId, libraryId: asset.libraryId,
width: asset.width, width: asset.width,
height: asset.height, height: asset.height,
editCount: asset.editCount,
}, },
exif: { exif: {
assetId: exif.assetId, assetId: exif.assetId,

View file

@ -86,6 +86,7 @@ export const assetStub = {
make: 'FUJIFILM', make: 'FUJIFILM',
model: 'X-T50', model: 'X-T50',
lensModel: 'XF27mm F2.8 R WR', lensModel: 'XF27mm F2.8 R WR',
editCount: 0,
...asset, ...asset,
}), }),
noResizePath: Object.freeze({ noResizePath: Object.freeze({
@ -125,6 +126,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
noWebpPath: Object.freeze({ noWebpPath: Object.freeze({
@ -166,6 +168,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
noThumbhash: Object.freeze({ noThumbhash: Object.freeze({
@ -204,6 +207,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
primaryImage: Object.freeze({ primaryImage: Object.freeze({
@ -252,6 +256,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
image: Object.freeze({ image: Object.freeze({
@ -298,6 +303,7 @@ export const assetStub = {
width: null, width: null,
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
edits: [], edits: [],
editCount: 0,
}), }),
trashed: Object.freeze({ trashed: Object.freeze({
@ -341,6 +347,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
trashedOffline: Object.freeze({ trashedOffline: Object.freeze({
@ -384,6 +391,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
archived: Object.freeze({ archived: Object.freeze({
id: 'asset-id', id: 'asset-id',
@ -426,6 +434,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
external: Object.freeze({ external: Object.freeze({
@ -468,6 +477,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
image1: Object.freeze({ image1: Object.freeze({
@ -510,6 +520,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
imageFrom2015: Object.freeze({ imageFrom2015: Object.freeze({
@ -551,6 +562,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
video: Object.freeze({ video: Object.freeze({
@ -594,6 +606,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
livePhotoMotionAsset: Object.freeze({ livePhotoMotionAsset: Object.freeze({
@ -614,6 +627,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [] as AssetEditActionItem[], edits: [] as AssetEditActionItem[],
editCount: 0,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }), } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }),
livePhotoStillAsset: Object.freeze({ livePhotoStillAsset: Object.freeze({
@ -635,6 +649,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [] as AssetEditActionItem[], edits: [] as AssetEditActionItem[],
editCount: 0,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
livePhotoWithOriginalFileName: Object.freeze({ livePhotoWithOriginalFileName: Object.freeze({
@ -658,6 +673,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [] as AssetEditActionItem[], edits: [] as AssetEditActionItem[],
editCount: 0,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
withLocation: Object.freeze({ withLocation: Object.freeze({
@ -705,6 +721,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
sidecar: Object.freeze({ sidecar: Object.freeze({
@ -743,6 +760,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
sidecarWithoutExt: Object.freeze({ sidecarWithoutExt: Object.freeze({
@ -778,6 +796,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
hasEncodedVideo: Object.freeze({ hasEncodedVideo: Object.freeze({
@ -820,6 +839,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
hasFileExtension: Object.freeze({ hasFileExtension: Object.freeze({
@ -859,6 +879,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
imageDng: Object.freeze({ imageDng: Object.freeze({
@ -902,6 +923,7 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
imageHif: Object.freeze({ imageHif: Object.freeze({
@ -945,7 +967,9 @@ export const assetStub = {
width: null, width: null,
height: null, height: null,
edits: [], edits: [],
editCount: 0,
}), }),
panoramaTif: Object.freeze({ panoramaTif: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.Active, status: AssetStatus.Active,
@ -988,6 +1012,7 @@ export const assetStub = {
height: null, height: null,
edits: [], edits: [],
}), }),
withCropEdit: Object.freeze({ withCropEdit: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.Active, status: AssetStatus.Active,
@ -1043,7 +1068,9 @@ export const assetStub = {
}, },
}, },
] as AssetEditActionItem[], ] as AssetEditActionItem[],
editCount: 1,
}), }),
withoutEdits: Object.freeze({ withoutEdits: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.Active, status: AssetStatus.Active,
@ -1089,5 +1116,6 @@ export const assetStub = {
width: 2160, width: 2160,
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
edits: [], edits: [],
editCount: 0,
}), }),
}; };

View file

@ -159,6 +159,7 @@ export const sharedLinkStub = {
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
width: 500, width: 500,
height: 500, height: 500,
editCount: 0,
}, },
], ],
albumId: null, albumId: null,

View file

@ -19,6 +19,7 @@ import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository'; import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.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 { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
@ -384,6 +385,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case AlbumUserRepository: case AlbumUserRepository:
case ActivityRepository: case ActivityRepository:
case AssetRepository: case AssetRepository:
case AssetEditRepository:
case AssetJobRepository: case AssetJobRepository:
case MemoryRepository: case MemoryRepository:
case NotificationRepository: case NotificationRepository:
@ -535,6 +537,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
fileModifiedAt: now, fileModifiedAt: now,
localDateTime: now, localDateTime: now,
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
editCount: 0,
}; };
return { return {

View file

@ -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<DB>;
const setup = (db?: Kysely<DB>) => {
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 });
});
});
});

View file

@ -83,6 +83,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
libraryId: asset.libraryId, libraryId: asset.libraryId,
width: asset.width, width: asset.width,
height: asset.height, height: asset.height,
editCount: asset.editCount,
}, },
type: SyncEntityType.AlbumAssetCreateV1, type: SyncEntityType.AlbumAssetCreateV1,
}, },

View file

@ -64,6 +64,7 @@ describe(SyncEntityType.AssetV1, () => {
libraryId: asset.libraryId, libraryId: asset.libraryId,
width: asset.width, width: asset.width,
height: asset.height, height: asset.height,
editCount: asset.editCount,
}, },
type: 'AssetV1', type: 'AssetV1',
}, },

View file

@ -63,6 +63,7 @@ describe(SyncRequestType.PartnerAssetsV1, () => {
type: asset.type, type: asset.type,
visibility: asset.visibility, visibility: asset.visibility,
duration: asset.duration, duration: asset.duration,
editCount: asset.editCount,
stackId: null, stackId: null,
livePhotoVideoId: null, livePhotoVideoId: null,
libraryId: asset.libraryId, libraryId: asset.libraryId,

View file

@ -253,6 +253,7 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
width: null, width: null,
height: null, height: null,
editCount: 0,
...asset, ...asset,
}); });

View file

@ -112,8 +112,18 @@
const { Cast } = $derived(getGlobalActions($t)); const { Cast } = $derived(getGlobalActions($t));
const { Share, Download, SharedLinkDownload, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } = const {
$derived(getAssetActions($t, asset)); Share,
Download,
DownloadOriginal,
SharedLinkDownload,
Offline,
Favorite,
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
Info,
} = $derived(getAssetActions($t, asset));
const sharedLink = getSharedLink(); const sharedLink = getSharedLink();
// TODO: Enable when edits are ready for release // TODO: Enable when edits are ready for release
@ -195,6 +205,7 @@
{/if} {/if}
<ActionMenuItem action={Download} /> <ActionMenuItem action={Download} />
<ActionMenuItem action={DownloadOriginal} />
{#if !isLocked} {#if !isLocked}
{#if asset.isTrashed} {#if asset.isTrashed}

View file

@ -25,7 +25,7 @@
if (assets.length === 1) { if (assets.length === 1) {
clearSelect(); clearSelect();
let asset = await getAssetInfo({ ...authManager.params, id: assets[0].id }); let asset = await getAssetInfo({ ...authManager.params, id: assets[0].id });
await handleDownloadAsset(asset); await handleDownloadAsset(asset, { edited: true });
return; return;
} }

View file

@ -22,6 +22,7 @@ import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { import {
mdiAlertOutline, mdiAlertOutline,
mdiDownload, mdiDownload,
mdiDownloadBox,
mdiHeart, mdiHeart,
mdiHeartOutline, mdiHeartOutline,
mdiInformationOutline, mdiInformationOutline,
@ -51,7 +52,15 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: { key: 'd', shift: true }, shortcuts: { key: 'd', shift: true },
type: $t('assets'), type: $t('assets'),
$if: () => !!currentAuthUser, $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 = { const SharedLinkDownload: ActionItem = {
@ -115,10 +124,21 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'i' }], 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 $t = await getFormatter();
const assets = [ const assets = [
@ -154,7 +174,12 @@ export const handleDownloadAsset = async (asset: AssetResponseDto) => {
try { try {
toastManager.success($t('downloading_asset_filename', { values: { filename: asset.originalFileName } })); 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) { } catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } })); handleError(error, $t('errors.error_downloading', { values: { filename } }));
} }

View file

@ -30,6 +30,7 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
width: faker.number.int({ min: 100, max: 1000 }), width: faker.number.int({ min: 100, max: 1000 }),
height: faker.number.int({ min: 100, max: 1000 }), height: faker.number.int({ min: 100, max: 1000 }),
isEdited: false,
}); });
export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({