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 354 additions and 11 deletions

View File

@ -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",

View File

@ -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);

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
isOffline: mapValueOfType<bool>(json, r'isOffline')!,
isTrashed: mapValueOfType<bool>(json, r'isTrashed')!,
@ -413,6 +420,7 @@ class AssetResponseDto {
'height',
'id',
'isArchived',
'isEdited',
'isFavorite',
'isOffline',
'isTrashed',

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'checksum')!,
deletedAt: mapDateTime(json, r'deletedAt', r''),
duration: mapValueOfType<String>(json, r'duration'),
editCount: mapValueOfType<int>(json, r'editCount')!,
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''),
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''),
height: mapValueOfType<int>(json, r'height'),
@ -259,6 +266,7 @@ class SyncAssetV1 {
'checksum',
'deletedAt',
'duration',
'editCount',
'fileCreatedAt',
'fileModifiedAt',
'height',

View File

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

View File

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

View File

@ -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",

View File

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

View File

@ -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'],

View File

@ -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,
};
}

View File

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

View File

@ -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"

View File

@ -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
`,
});

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 { 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<T extends AssetEditAction = AssetEditAction> {
@PrimaryGeneratedColumn()
id!: Generated<string>;

View File

@ -143,4 +143,7 @@ export class AssetTable {
@Column({ type: 'integer', nullable: true })
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,
width: asset.width,
height: asset.height,
editCount: asset.editCount,
},
exif: {
assetId: exif.assetId,

View File

@ -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,
}),
};

View File

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

View File

@ -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 = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case AlbumUserRepository:
case ActivityRepository:
case AssetRepository:
case AssetEditRepository:
case AssetJobRepository:
case MemoryRepository:
case NotificationRepository:
@ -535,6 +537,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
fileModifiedAt: now,
localDateTime: now,
visibility: AssetVisibility.Timeline,
editCount: 0,
};
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,
width: asset.width,
height: asset.height,
editCount: asset.editCount,
},
type: SyncEntityType.AlbumAssetCreateV1,
},

View File

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

View File

@ -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,

View File

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

View File

@ -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}
<ActionMenuItem action={Download} />
<ActionMenuItem action={DownloadOriginal} />
{#if !isLocked}
{#if asset.isTrashed}

View File

@ -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;
}

View File

@ -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 } }));
}

View File

@ -30,6 +30,7 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
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<TimelineAsset>({