mirror of
https://github.com/immich-app/immich.git
synced 2025-12-24 01:11:32 +03:00
feat: asset copy (#23172)
This commit is contained in:
@@ -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[]> {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
13
server/src/queries/shared.link.asset.repository.sql
Normal file
13
server/src/queries/shared.link.asset.repository.sql
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user