diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 02bb0bc0c0..c234aa4314 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -97,6 +97,7 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | *AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | checkBulkUpload *AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | checkExistingAssets +*AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | *AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | *AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | *AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | @@ -321,6 +322,7 @@ Class | Method | HTTP request | Description - [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md) - [AssetBulkUploadCheckResponseDto](doc//AssetBulkUploadCheckResponseDto.md) - [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md) + - [AssetCopyDto](doc//AssetCopyDto.md) - [AssetDeltaSyncDto](doc//AssetDeltaSyncDto.md) - [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md) - [AssetFaceCreateDto](doc//AssetFaceCreateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 503a71ecb8..ab88670bcd 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -90,6 +90,7 @@ part 'model/asset_bulk_upload_check_dto.dart'; part 'model/asset_bulk_upload_check_item.dart'; part 'model/asset_bulk_upload_check_response_dto.dart'; part 'model/asset_bulk_upload_check_result.dart'; +part 'model/asset_copy_dto.dart'; part 'model/asset_delta_sync_dto.dart'; part 'model/asset_delta_sync_response_dto.dart'; part 'model/asset_face_create_dto.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 384fe0d72a..7bae14bb58 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -128,6 +128,50 @@ class AssetsApi { return null; } + /// This endpoint requires the `asset.copy` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AssetCopyDto] assetCopyDto (required): + Future copyAssetWithHttpInfo(AssetCopyDto assetCopyDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/copy'; + + // ignore: prefer_final_locals + Object? postBody = assetCopyDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.copy` permission. + /// + /// Parameters: + /// + /// * [AssetCopyDto] assetCopyDto (required): + Future copyAsset(AssetCopyDto assetCopyDto,) async { + final response = await copyAssetWithHttpInfo(assetCopyDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// This endpoint requires the `asset.update` permission. /// /// Note: This method returns the HTTP [Response]. diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index b20c04a2bf..5139c5cf62 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -234,6 +234,8 @@ class ApiClient { return AssetBulkUploadCheckResponseDto.fromJson(value); case 'AssetBulkUploadCheckResult': return AssetBulkUploadCheckResult.fromJson(value); + case 'AssetCopyDto': + return AssetCopyDto.fromJson(value); case 'AssetDeltaSyncDto': return AssetDeltaSyncDto.fromJson(value); case 'AssetDeltaSyncResponseDto': diff --git a/mobile/openapi/lib/model/asset_copy_dto.dart b/mobile/openapi/lib/model/asset_copy_dto.dart new file mode 100644 index 0000000000..ba19cb1dbc --- /dev/null +++ b/mobile/openapi/lib/model/asset_copy_dto.dart @@ -0,0 +1,142 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetCopyDto { + /// Returns a new [AssetCopyDto] instance. + AssetCopyDto({ + this.albums = true, + this.favorite = true, + this.sharedLinks = true, + this.sidecar = true, + required this.sourceId, + this.stack = true, + required this.targetId, + }); + + bool albums; + + bool favorite; + + bool sharedLinks; + + bool sidecar; + + String sourceId; + + bool stack; + + String targetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetCopyDto && + other.albums == albums && + other.favorite == favorite && + other.sharedLinks == sharedLinks && + other.sidecar == sidecar && + other.sourceId == sourceId && + other.stack == stack && + other.targetId == targetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albums.hashCode) + + (favorite.hashCode) + + (sharedLinks.hashCode) + + (sidecar.hashCode) + + (sourceId.hashCode) + + (stack.hashCode) + + (targetId.hashCode); + + @override + String toString() => 'AssetCopyDto[albums=$albums, favorite=$favorite, sharedLinks=$sharedLinks, sidecar=$sidecar, sourceId=$sourceId, stack=$stack, targetId=$targetId]'; + + Map toJson() { + final json = {}; + json[r'albums'] = this.albums; + json[r'favorite'] = this.favorite; + json[r'sharedLinks'] = this.sharedLinks; + json[r'sidecar'] = this.sidecar; + json[r'sourceId'] = this.sourceId; + json[r'stack'] = this.stack; + json[r'targetId'] = this.targetId; + return json; + } + + /// Returns a new [AssetCopyDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetCopyDto? fromJson(dynamic value) { + upgradeDto(value, "AssetCopyDto"); + if (value is Map) { + final json = value.cast(); + + return AssetCopyDto( + albums: mapValueOfType(json, r'albums') ?? true, + favorite: mapValueOfType(json, r'favorite') ?? true, + sharedLinks: mapValueOfType(json, r'sharedLinks') ?? true, + sidecar: mapValueOfType(json, r'sidecar') ?? true, + sourceId: mapValueOfType(json, r'sourceId')!, + stack: mapValueOfType(json, r'stack') ?? true, + targetId: mapValueOfType(json, r'targetId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetCopyDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetCopyDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetCopyDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetCopyDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'sourceId', + 'targetId', + }; +} + diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 86011eb835..e05c3e84bc 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -42,6 +42,7 @@ class Permission { static const assetPeriodDownload = Permission._(r'asset.download'); static const assetPeriodUpload = Permission._(r'asset.upload'); static const assetPeriodReplace = Permission._(r'asset.replace'); + static const assetPeriodCopy = Permission._(r'asset.copy'); static const albumPeriodCreate = Permission._(r'album.create'); static const albumPeriodRead = Permission._(r'album.read'); static const albumPeriodUpdate = Permission._(r'album.update'); @@ -174,6 +175,7 @@ class Permission { assetPeriodDownload, assetPeriodUpload, assetPeriodReplace, + assetPeriodCopy, albumPeriodCreate, albumPeriodRead, albumPeriodUpdate, @@ -341,6 +343,7 @@ class PermissionTypeTransformer { case r'asset.download': return Permission.assetPeriodDownload; case r'asset.upload': return Permission.assetPeriodUpload; case r'asset.replace': return Permission.assetPeriodReplace; + case r'asset.copy': return Permission.assetPeriodCopy; case r'album.create': return Permission.albumPeriodCreate; case r'album.read': return Permission.albumPeriodRead; case r'album.update': return Permission.albumPeriodUpdate; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 29503b1ef0..129a82c5b5 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f4801a1922..c281979f7b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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", diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index c57dc4ed28..fb528a5830 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -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 { + return this.service.copy(auth, dto); + } + @Get(':id/metadata') @Authenticated({ permission: Permission.AssetRead }) getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 6a89b7e2cf..dc43a0200c 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -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], diff --git a/server/src/enum.ts b/server/src/enum.ts index 0fc8323c5d..0755f75f70 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -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', diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 0087217738..1f4eda96a1 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -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 diff --git a/server/src/queries/shared.link.asset.repository.sql b/server/src/queries/shared.link.asset.repository.sql new file mode 100644 index 0000000000..7acee50812 --- /dev/null +++ b/server/src/queries/shared.link.asset.repository.sql @@ -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 diff --git a/server/src/queries/stack.repository.sql b/server/src/queries/stack.repository.sql index 94a24f69e4..0bfb5df2fb 100644 --- a/server/src/queries/stack.repository.sql +++ b/server/src/queries/stack.repository.sql @@ -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 diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 00c1dfda7f..f5bfe44efe 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -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(); + } } diff --git a/server/src/repositories/shared-link-asset.repository.ts b/server/src/repositories/shared-link-asset.repository.ts index 45085c4a8d..ab164683ca 100644 --- a/server/src/repositories/shared-link-asset.repository.ts +++ b/server/src/repositories/shared-link-asset.repository.ts @@ -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(); + } } diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index ace9468177..44db6fbeb4 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -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(); + } } diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index eb66c326e1..c1c2fb53c8 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -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 { const config = await this.getConfig({ withCache: false }); diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 8427da6f1b..7a0f701f74 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -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( diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 11343bd6e1..b3fc481708 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -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; const setup = (db?: Kysely) => { 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 }, + }); + }); + }); });