mirror of
https://github.com/immich-app/immich.git
synced 2026-02-07 11:33:45 -05:00
feat: download original asset (#25302)
Co-authored-by: bwees <brandonwees@gmail.com>
This commit is contained in:
parent
a2b03f7650
commit
07675a2de4
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
10
mobile/openapi/lib/model/asset_response_dto.dart
generated
10
mobile/openapi/lib/model/asset_response_dto.dart
generated
@ -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',
|
||||
|
||||
10
mobile/openapi/lib/model/sync_asset_v1.dart
generated
10
mobile/openapi/lib/model/sync_asset_v1.dart
generated
@ -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',
|
||||
|
||||
@ -44,6 +44,7 @@ SyncAssetV1 _createAsset({
|
||||
livePhotoVideoId: null,
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
editCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
1
mobile/test/fixtures/sync_stream.stub.dart
vendored
1
mobile/test/fixtures/sync_stream.stub.dart
vendored
@ -128,6 +128,7 @@ abstract final class SyncStreamStub {
|
||||
visibility: AssetVisibility.timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
editCount: 0,
|
||||
),
|
||||
ack: ack,
|
||||
);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -352,6 +352,7 @@ export type AssetResponseDto = {
|
||||
height: number | null;
|
||||
id: string;
|
||||
isArchived: boolean;
|
||||
isEdited: boolean;
|
||||
isFavorite: boolean;
|
||||
isOffline: boolean;
|
||||
isTrashed: boolean;
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -121,6 +121,8 @@ export class SyncAssetV1 {
|
||||
width!: number | null;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
height!: number | null;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
editCount!: number;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
`,
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
@ -143,4 +143,7 @@ export class AssetTable {
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
height!: number | null;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
editCount!: Generated<number>;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
28
server/test/fixtures/asset.stub.ts
vendored
28
server/test/fixtures/asset.stub.ts
vendored
@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
1
server/test/fixtures/shared-link.stub.ts
vendored
1
server/test/fixtures/shared-link.stub.ts
vendored
@ -159,6 +159,7 @@ export const sharedLinkStub = {
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: 500,
|
||||
height: 500,
|
||||
editCount: 0,
|
||||
},
|
||||
],
|
||||
albumId: null,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -83,6 +83,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
libraryId: asset.libraryId,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
editCount: asset.editCount,
|
||||
},
|
||||
type: SyncEntityType.AlbumAssetCreateV1,
|
||||
},
|
||||
|
||||
@ -64,6 +64,7 @@ describe(SyncEntityType.AssetV1, () => {
|
||||
libraryId: asset.libraryId,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
editCount: asset.editCount,
|
||||
},
|
||||
type: 'AssetV1',
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -253,6 +253,7 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
editCount: 0,
|
||||
...asset,
|
||||
});
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 } }));
|
||||
}
|
||||
|
||||
@ -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>({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user