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
29 changed files with 354 additions and 11 deletions

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