feat: asset copy (#23172)

This commit is contained in:
Daniel Dietzler 2025-10-29 14:43:47 +01:00 committed by GitHub
parent fdfb04d83c
commit 4ae7cadeae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 450 additions and 2 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1946,6 +1946,43 @@
"x-immich-permission": "asset.upload"
}
},
"/assets/copy": {
"put": {
"operationId": "copyAsset",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetCopyDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Assets"
],
"x-immich-permission": "asset.copy",
"description": "This endpoint requires the `asset.copy` permission."
}
},
"/assets/device/{deviceId}": {
"get": {
"description": "Get all asset of a device that are in the database, ID only.",
@ -10651,6 +10688,43 @@
],
"type": "object"
},
"AssetCopyDto": {
"properties": {
"albums": {
"default": true,
"type": "boolean"
},
"favorite": {
"default": true,
"type": "boolean"
},
"sharedLinks": {
"default": true,
"type": "boolean"
},
"sidecar": {
"default": true,
"type": "boolean"
},
"sourceId": {
"format": "uuid",
"type": "string"
},
"stack": {
"default": true,
"type": "boolean"
},
"targetId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"sourceId",
"targetId"
],
"type": "object"
},
"AssetDeltaSyncDto": {
"properties": {
"updatedAfter": {
@ -13398,6 +13472,7 @@
"asset.download",
"asset.upload",
"asset.replace",
"asset.copy",
"album.create",
"album.read",
"album.update",

View File

@ -517,6 +517,15 @@ export type AssetBulkUploadCheckResult = {
export type AssetBulkUploadCheckResponseDto = {
results: AssetBulkUploadCheckResult[];
};
export type AssetCopyDto = {
albums?: boolean;
favorite?: boolean;
sharedLinks?: boolean;
sidecar?: boolean;
sourceId: string;
stack?: boolean;
targetId: string;
};
export type CheckExistingAssetsDto = {
deviceAssetIds: string[];
deviceId: string;
@ -2256,6 +2265,18 @@ export function checkBulkUpload({ assetBulkUploadCheckDto }: {
body: assetBulkUploadCheckDto
})));
}
/**
* This endpoint requires the `asset.copy` permission.
*/
export function copyAsset({ assetCopyDto }: {
assetCopyDto: AssetCopyDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/assets/copy", oazapfts.json({
...opts,
method: "PUT",
body: assetCopyDto
})));
}
/**
* getAllUserAssetsByDeviceId
*/
@ -4796,6 +4817,7 @@ export enum Permission {
AssetDownload = "asset.download",
AssetUpload = "asset.upload",
AssetReplace = "asset.replace",
AssetCopy = "asset.copy",
AlbumCreate = "album.create",
AlbumRead = "album.read",
AlbumUpdate = "album.update",

View File

@ -5,6 +5,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import {
AssetBulkDeleteDto,
AssetBulkUpdateDto,
AssetCopyDto,
AssetJobsDto,
AssetMetadataResponseDto,
AssetMetadataRouteParams,
@ -90,6 +91,13 @@ export class AssetController {
return this.service.update(auth, id, dto);
}
@Put('copy')
@Authenticated({ permission: Permission.AssetCopy })
@HttpCode(HttpStatus.NO_CONTENT)
copyAsset(@Auth() auth: AuthDto, @Body() dto: AssetCopyDto): Promise<void> {
return this.service.copy(auth, dto);
}
@Get(':id/metadata')
@Authenticated({ permission: Permission.AssetRead })
getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetMetadataResponseDto[]> {

View File

@ -186,6 +186,29 @@ export class AssetMetadataResponseDto {
updatedAt!: Date;
}
export class AssetCopyDto {
@ValidateUUID()
sourceId!: string;
@ValidateUUID()
targetId!: string;
@ValidateBoolean({ optional: true, default: true })
sharedLinks?: boolean;
@ValidateBoolean({ optional: true, default: true })
albums?: boolean;
@ValidateBoolean({ optional: true, default: true })
sidecar?: boolean;
@ValidateBoolean({ optional: true, default: true })
stack?: boolean;
@ValidateBoolean({ optional: true, default: true })
favorite?: boolean;
}
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
return {
images: stats[AssetType.Image],

View File

@ -95,6 +95,7 @@ export enum Permission {
AssetDownload = 'asset.download',
AssetUpload = 'asset.upload',
AssetReplace = 'asset.replace',
AssetCopy = 'asset.copy',
AlbumCreate = 'album.create',
AlbumRead = 'album.read',

View File

@ -422,3 +422,15 @@ group by
"asset"."ownerId"
order by
"assetCount" desc
-- AlbumRepository.copyAlbums
insert into
"album_asset"
select
"album_asset"."albumsId",
$1 as "assetsId"
from
"album_asset"
where
"album_asset"."assetsId" = $2
on conflict do nothing

View File

@ -0,0 +1,13 @@
-- NOTE: This file is auto generated by ./sql-generator
-- SharedLinkAssetRepository.copySharedLinks
insert into
"shared_link_asset"
select
$1 as "assetsId",
"shared_link_asset"."sharedLinksId"
from
"shared_link_asset"
where
"shared_link_asset"."assetsId" = $2
on conflict do nothing

View File

@ -153,3 +153,10 @@ from
left join "stack" on "stack"."id" = "asset"."stackId"
where
"asset"."id" = $1
-- StackRepository.merge
update "asset"
set
"stackId" = $1
where
"asset"."stackId" = $2

View File

@ -397,4 +397,18 @@ export class AlbumRepository {
.orderBy('assetCount', 'desc')
.execute();
}
@GenerateSql({ params: [{ sourceAssetId: DummyValue.UUID, targetAssetId: DummyValue.UUID }] })
async copyAlbums({ sourceAssetId, targetAssetId }: { sourceAssetId: string; targetAssetId: string }) {
return this.db
.insertInto('album_asset')
.expression((eb) =>
eb
.selectFrom('album_asset')
.select((eb) => ['album_asset.albumsId', eb.val(targetAssetId).as('assetsId')])
.where('album_asset.assetsId', '=', sourceAssetId),
)
.onConflict((oc) => oc.doNothing())
.execute();
}
}

View File

@ -1,5 +1,6 @@
import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
export class SharedLinkAssetRepository {
@ -15,4 +16,18 @@ export class SharedLinkAssetRepository {
return deleted.map((row) => row.assetsId);
}
@GenerateSql({ params: [{ sourceAssetId: DummyValue.UUID, targetAssetId: DummyValue.UUID }] })
async copySharedLinks({ sourceAssetId, targetAssetId }: { sourceAssetId: string; targetAssetId: string }) {
return this.db
.insertInto('shared_link_asset')
.expression((eb) =>
eb
.selectFrom('shared_link_asset')
.select((eb) => [eb.val(targetAssetId).as('assetsId'), 'shared_link_asset.sharedLinksId'])
.where('shared_link_asset.assetsId', '=', sourceAssetId),
)
.onConflict((oc) => oc.doNothing())
.execute();
}
}

View File

@ -162,4 +162,9 @@ export class StackRepository {
.where('asset.id', '=', assetId)
.executeTakeFirst();
}
@GenerateSql({ params: [{ sourceId: DummyValue.UUID, targetId: DummyValue.UUID }] })
merge({ sourceId, targetId }: { sourceId: string; targetId: string }) {
return this.db.updateTable('asset').set({ stackId: targetId }).where('asset.stackId', '=', sourceId).execute();
}
}

View File

@ -7,6 +7,7 @@ import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from
import {
AssetBulkDeleteDto,
AssetBulkUpdateDto,
AssetCopyDto,
AssetJobName,
AssetJobsDto,
AssetMetadataResponseDto,
@ -183,6 +184,84 @@ export class AssetService extends BaseService {
}
}
async copy(
auth: AuthDto,
{
sourceId,
targetId,
albums = true,
sidecar = true,
sharedLinks = true,
stack = true,
favorite = true,
}: AssetCopyDto,
) {
await this.requireAccess({ auth, permission: Permission.AssetCopy, ids: [sourceId, targetId] });
const sourceAsset = await this.assetRepository.getById(sourceId);
const targetAsset = await this.assetRepository.getById(targetId);
if (!sourceAsset || !targetAsset) {
throw new BadRequestException('Both assets must exist');
}
if (sourceId === targetId) {
throw new BadRequestException('Source and target id must be distinct');
}
if (albums) {
await this.albumRepository.copyAlbums({ sourceAssetId: sourceId, targetAssetId: targetId });
}
if (sharedLinks) {
await this.sharedLinkAssetRepository.copySharedLinks({ sourceAssetId: sourceId, targetAssetId: targetId });
}
if (stack) {
await this.copyStack(sourceAsset, targetAsset);
}
if (favorite) {
await this.assetRepository.update({ id: targetId, isFavorite: sourceAsset.isFavorite });
}
if (sidecar) {
await this.copySidecar(sourceAsset, targetAsset);
}
}
private async copyStack(
sourceAsset: { id: string; stackId: string | null },
targetAsset: { id: string; stackId: string | null },
) {
if (!sourceAsset.stackId) {
return;
}
if (targetAsset.stackId) {
await this.stackRepository.merge({ sourceId: sourceAsset.stackId, targetId: targetAsset.stackId });
await this.stackRepository.delete(sourceAsset.stackId);
} else {
await this.assetRepository.update({ id: targetAsset.id, stackId: sourceAsset.stackId });
}
}
private async copySidecar(
targetAsset: { sidecarPath: string | null },
sourceAsset: { id: string; sidecarPath: string | null; originalPath: string },
) {
if (!targetAsset.sidecarPath) {
return;
}
if (sourceAsset.sidecarPath) {
await this.storageRepository.unlink(sourceAsset.sidecarPath);
}
await this.storageRepository.copyFile(targetAsset.sidecarPath, `${sourceAsset.originalPath}.xmp`);
await this.assetRepository.update({ id: sourceAsset.id, sidecarPath: `${sourceAsset.originalPath}.xmp` });
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: sourceAsset.id } });
}
@OnJob({ name: JobName.AssetDeleteCheck, queue: QueueName.BackgroundTask })
async handleAssetDeletionCheck(): Promise<JobStatus> {
const config = await this.getConfig({ withCache: false });

View File

@ -153,6 +153,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AssetCopy: {
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AlbumRead: {
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.album.checkSharedAlbumAccess(

View File

@ -1,6 +1,14 @@
import { Kysely } from 'kysely';
import { JobName, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StackRepository } from 'src/repositories/stack.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { DB } from 'src/schema';
import { AssetService } from 'src/services/asset.service';
import { newMediumService } from 'test/medium.factory';
@ -12,8 +20,8 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetService, {
database: db || defaultDatabase,
real: [AssetRepository],
mock: [LoggingRepository],
real: [AssetRepository, AlbumRepository, AccessRepository, SharedLinkAssetRepository, StackRepository],
mock: [LoggingRepository, JobRepository, StorageRepository],
});
};
@ -32,4 +40,166 @@ describe(AssetService.name, () => {
await expect(sut.getStatistics(auth, {})).resolves.toEqual({ images: 1, total: 1, videos: 0 });
});
});
describe('copy', () => {
it('should copy albums', async () => {
const { sut, ctx } = setup();
const albumRepo = ctx.get(AlbumRepository);
const { user } = await ctx.newUser();
const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id });
const { album } = await ctx.newAlbum({ ownerId: user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: oldAsset.id });
const auth = factory.auth({ user: { id: user.id } });
await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id });
await expect(albumRepo.getAssetIds(album.id, [oldAsset.id, newAsset.id])).resolves.toEqual(
new Set([oldAsset.id, newAsset.id]),
);
});
it('should copy shared links', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const { user } = await ctx.newUser();
const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: oldAsset.id, description: 'foo' });
await ctx.newExif({ assetId: newAsset.id, description: 'bar' });
const { id: sharedLinkId } = await sharedLinkRepo.create({
allowUpload: false,
key: Buffer.from('123'),
type: SharedLinkType.Individual,
userId: user.id,
assetIds: [oldAsset.id],
});
const auth = factory.auth({ user: { id: user.id } });
await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id });
await expect(sharedLinkRepo.get(user.id, sharedLinkId)).resolves.toEqual(
expect.objectContaining({
assets: [expect.objectContaining({ id: oldAsset.id }), expect.objectContaining({ id: newAsset.id })],
}),
);
});
it('should merge stacks', async () => {
const { sut, ctx } = setup();
const stackRepo = ctx.get(StackRepository);
const { user } = await ctx.newUser();
const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: oldAsset.id, description: 'foo' });
await ctx.newExif({ assetId: asset1.id, description: 'bar' });
await ctx.newExif({ assetId: newAsset.id, description: 'bar' });
await ctx.newExif({ assetId: asset2.id, description: 'foo' });
await ctx.newStack({ ownerId: user.id }, [oldAsset.id, asset1.id]);
const {
stack: { id: newStackId },
} = await ctx.newStack({ ownerId: user.id }, [newAsset.id, asset2.id]);
const auth = factory.auth({ user: { id: user.id } });
await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id });
await expect(stackRepo.getById(oldAsset.id)).resolves.toEqual(undefined);
const newStack = await stackRepo.getById(newStackId);
expect(newStack).toEqual(
expect.objectContaining({
primaryAssetId: newAsset.id,
assets: expect.arrayContaining([expect.objectContaining({ id: asset2.id })]),
}),
);
expect(newStack!.assets.length).toEqual(4);
});
it('should copy stack', async () => {
const { sut, ctx } = setup();
const stackRepo = ctx.get(StackRepository);
const { user } = await ctx.newUser();
const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: oldAsset.id, description: 'foo' });
await ctx.newExif({ assetId: asset1.id, description: 'bar' });
await ctx.newExif({ assetId: newAsset.id, description: 'bar' });
const {
stack: { id: stackId },
} = await ctx.newStack({ ownerId: user.id }, [oldAsset.id, asset1.id]);
const auth = factory.auth({ user: { id: user.id } });
await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id });
const stack = await stackRepo.getById(stackId);
expect(stack).toEqual(
expect.objectContaining({
primaryAssetId: oldAsset.id,
assets: expect.arrayContaining([expect.objectContaining({ id: newAsset.id })]),
}),
);
expect(stack!.assets.length).toEqual(3);
});
it('should copy favorite status', async () => {
const { sut, ctx } = setup();
const assetRepo = ctx.get(AssetRepository);
const { user } = await ctx.newUser();
const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: oldAsset.id, description: 'foo' });
await ctx.newExif({ assetId: newAsset.id, description: 'bar' });
const auth = factory.auth({ user: { id: user.id } });
await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id });
await expect(assetRepo.getById(newAsset.id)).resolves.toEqual(expect.objectContaining({ isFavorite: true }));
});
it('should copy sidecar file', async () => {
const { sut, ctx } = setup();
const storageRepo = ctx.getMock(StorageRepository);
const jobRepo = ctx.getMock(JobRepository);
storageRepo.copyFile.mockResolvedValue();
jobRepo.queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id, sidecarPath: '/path/to/my/sidecar.xmp' });
const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: oldAsset.id, description: 'foo' });
await ctx.newExif({ assetId: newAsset.id, description: 'bar' });
const auth = factory.auth({ user: { id: user.id } });
await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id });
expect(storageRepo.copyFile).toHaveBeenCalledWith('/path/to/my/sidecar.xmp', `${newAsset.originalPath}.xmp`);
expect(jobRepo.queue).toHaveBeenCalledWith({
name: JobName.AssetExtractMetadata,
data: { id: newAsset.id },
});
});
});
});