mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat: manual stack assets (#4198)
This commit is contained in:
@@ -1673,6 +1673,41 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/stack/parent": {
|
||||
"put": {
|
||||
"operationId": "updateStackParent",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStackParentDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Asset"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/statistics": {
|
||||
"get": {
|
||||
"operationId": "getAssetStats",
|
||||
@@ -5696,6 +5731,13 @@
|
||||
},
|
||||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"removeParent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"stackParentId": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -5941,6 +5983,19 @@
|
||||
"smartInfo": {
|
||||
"$ref": "#/components/schemas/SmartInfoResponseDto"
|
||||
},
|
||||
"stack": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AssetResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"stackCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"stackParentId": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"tags": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TagResponseDto"
|
||||
@@ -5961,6 +6016,7 @@
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"stackCount",
|
||||
"deviceAssetId",
|
||||
"deviceId",
|
||||
"ownerId",
|
||||
@@ -8521,6 +8577,23 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UpdateStackParentDto": {
|
||||
"properties": {
|
||||
"newParentId": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"oldParentId": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"oldParentId",
|
||||
"newParentId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UpdateTagDto": {
|
||||
"properties": {
|
||||
"name": {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Readable } from 'stream';
|
||||
import { JobName } from '../job';
|
||||
import {
|
||||
AssetStats,
|
||||
CommunicationEvent,
|
||||
IAssetRepository,
|
||||
ICommunicationRepository,
|
||||
ICryptoRepository,
|
||||
@@ -636,10 +637,89 @@ describe(AssetService.name, () => {
|
||||
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
|
||||
});
|
||||
|
||||
/// Stack related
|
||||
|
||||
it('should require asset update access for parent', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.updateAll(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
stackParentId: 'parent',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should update parent asset when children are added', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
await sut.updateAll(authStub.user1, {
|
||||
ids: [],
|
||||
stackParentId: 'parent',
|
||||
}),
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { stackParentId: null });
|
||||
});
|
||||
|
||||
it('should update parent asset when children are removed', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]);
|
||||
|
||||
await sut.updateAll(authStub.user1, {
|
||||
ids: ['child-1'],
|
||||
removeParent: true,
|
||||
}),
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { stackParentId: null });
|
||||
});
|
||||
|
||||
it('update parentId for new children', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
await sut.updateAll(authStub.user1, {
|
||||
stackParentId: 'parent',
|
||||
ids: ['child-1', 'child-2'],
|
||||
});
|
||||
|
||||
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
|
||||
});
|
||||
|
||||
it('nullify parentId for remove children', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
await sut.updateAll(authStub.user1, {
|
||||
removeParent: true,
|
||||
ids: ['child-1', 'child-2'],
|
||||
});
|
||||
|
||||
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: null });
|
||||
});
|
||||
|
||||
it('merge stacks if new child has children', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
assetMock.getByIds.mockResolvedValue([
|
||||
{ id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity,
|
||||
]);
|
||||
|
||||
await sut.updateAll(authStub.user1, {
|
||||
ids: ['child-1'],
|
||||
stackParentId: 'parent',
|
||||
});
|
||||
|
||||
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
|
||||
});
|
||||
|
||||
it('should send ws asset update event', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
await sut.updateAll(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
stackParentId: 'parent',
|
||||
});
|
||||
|
||||
expect(communicationMock.send).toHaveBeenCalledWith(CommunicationEvent.ASSET_UPDATE, authStub.user1.id, [
|
||||
'asset-1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('should required asset delete access for all ids', async () => {
|
||||
it('should require asset delete access for all ids', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.deleteAll(authStub.user1, {
|
||||
@@ -677,7 +757,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
describe('restoreAll', () => {
|
||||
it('should required asset restore access for all ids', async () => {
|
||||
it('should require asset restore access for all ids', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.deleteAll(authStub.user1, {
|
||||
@@ -757,6 +837,21 @@ describe(AssetService.name, () => {
|
||||
expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
|
||||
});
|
||||
|
||||
it('should update stack parent if asset has stack children', async () => {
|
||||
when(assetMock.getById)
|
||||
.calledWith(assetStub.primaryImage.id)
|
||||
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
|
||||
|
||||
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id });
|
||||
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-2'], {
|
||||
stackParentId: 'stack-child-asset-1',
|
||||
});
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-1'], {
|
||||
stackParentId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not schedule delete-files job for readonly assets', async () => {
|
||||
when(assetMock.getById)
|
||||
.calledWith(assetStub.readOnly.id)
|
||||
@@ -854,4 +949,70 @@ describe(AssetService.name, () => {
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStackParent', () => {
|
||||
it('should require asset update access for new parent', async () => {
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true);
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.updateStackParent(authStub.user1, {
|
||||
oldParentId: 'old',
|
||||
newParentId: 'new',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should require asset read access for old parent', async () => {
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false);
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true);
|
||||
await expect(
|
||||
sut.updateStackParent(authStub.user1, {
|
||||
oldParentId: 'old',
|
||||
newParentId: 'new',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('make old parent the child of new parent', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
when(assetMock.getById)
|
||||
.calledWith(assetStub.image.id)
|
||||
.mockResolvedValue(assetStub.image as AssetEntity);
|
||||
|
||||
await sut.updateStackParent(authStub.user1, {
|
||||
oldParentId: assetStub.image.id,
|
||||
newParentId: 'new',
|
||||
});
|
||||
|
||||
expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id], { stackParentId: 'new' });
|
||||
});
|
||||
|
||||
it('remove stackParentId of new parent', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
await sut.updateStackParent(authStub.user1, {
|
||||
oldParentId: assetStub.primaryImage.id,
|
||||
newParentId: 'new',
|
||||
});
|
||||
|
||||
expect(assetMock.updateAll).toBeCalledWith(['new'], { stackParentId: null });
|
||||
});
|
||||
|
||||
it('update stackParentId of old parents children to new parent', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
when(assetMock.getById)
|
||||
.calledWith(assetStub.primaryImage.id)
|
||||
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
|
||||
|
||||
await sut.updateStackParent(authStub.user1, {
|
||||
oldParentId: assetStub.primaryImage.id,
|
||||
newParentId: 'new',
|
||||
});
|
||||
|
||||
expect(assetMock.updateAll).toBeCalledWith(
|
||||
[assetStub.primaryImage.id, 'stack-child-asset-1', 'stack-child-asset-2'],
|
||||
{ stackParentId: 'new' },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
TimeBucketDto,
|
||||
TrashAction,
|
||||
UpdateAssetDto,
|
||||
UpdateStackParentDto,
|
||||
mapStats,
|
||||
} from './dto';
|
||||
import {
|
||||
@@ -208,7 +209,7 @@ export class AssetService {
|
||||
if (authUser.isShowMetadata) {
|
||||
return assets.map((asset) => mapAsset(asset));
|
||||
} else {
|
||||
return assets.map((asset) => mapAsset(asset, true));
|
||||
return assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,10 +339,29 @@ export class AssetService {
|
||||
}
|
||||
|
||||
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
|
||||
const { ids, ...options } = dto;
|
||||
const { ids, removeParent, ...options } = dto;
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
|
||||
|
||||
if (removeParent) {
|
||||
(options as Partial<AssetEntity>).stackParentId = null;
|
||||
const assets = await this.assetRepository.getByIds(ids);
|
||||
// This updates the updatedAt column of the parents to indicate that one of its children is removed
|
||||
// All the unique parent's -> parent is set to null
|
||||
ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!)));
|
||||
} else if (options.stackParentId) {
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, options.stackParentId);
|
||||
// Merge stacks
|
||||
const assets = await this.assetRepository.getByIds(ids);
|
||||
const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0);
|
||||
ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id)));
|
||||
|
||||
// This updates the updatedAt column of the parent to indicate that a new child has been added
|
||||
await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null });
|
||||
}
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
|
||||
await this.assetRepository.updateAll(ids, options);
|
||||
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
|
||||
}
|
||||
|
||||
async handleAssetDeletionCheck() {
|
||||
@@ -384,6 +404,14 @@ export class AssetService {
|
||||
);
|
||||
}
|
||||
|
||||
// Replace the parent of the stack children with a new asset
|
||||
if (asset.stack && asset.stack.length != 0) {
|
||||
const stackIds = asset.stack.map((a) => a.id);
|
||||
const newParentId = stackIds[0];
|
||||
await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId });
|
||||
await this.assetRepository.updateAll([newParentId], { stackParentId: null });
|
||||
}
|
||||
|
||||
await this.assetRepository.remove(asset);
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
|
||||
this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id);
|
||||
@@ -454,6 +482,25 @@ export class AssetService {
|
||||
this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
|
||||
}
|
||||
|
||||
async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise<void> {
|
||||
const { oldParentId, newParentId } = dto;
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_READ, oldParentId);
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, newParentId);
|
||||
|
||||
const childIds: string[] = [];
|
||||
const oldParent = await this.assetRepository.getById(oldParentId);
|
||||
if (oldParent != null) {
|
||||
childIds.push(oldParent.id);
|
||||
// Get all children of old parent
|
||||
childIds.push(...(oldParent.stack?.map((a) => a.id) ?? []));
|
||||
}
|
||||
|
||||
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, [...childIds, newParentId]);
|
||||
await this.assetRepository.updateAll(childIds, { stackParentId: newParentId });
|
||||
// Remove ParentId of new parent if this was previously a child of some other asset
|
||||
return this.assetRepository.updateAll([newParentId], { stackParentId: null });
|
||||
}
|
||||
|
||||
async run(authUser: AuthUserDto, dto: AssetJobsDto) {
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
|
||||
|
||||
|
||||
9
server/src/domain/asset/dto/asset-stack.dto.ts
Normal file
9
server/src/domain/asset/dto/asset-stack.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ValidateUUID } from '../../domain.util';
|
||||
|
||||
export class UpdateStackParentDto {
|
||||
@ValidateUUID()
|
||||
oldParentId!: string;
|
||||
|
||||
@ValidateUUID()
|
||||
newParentId!: string;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
|
||||
import { Optional } from '../../domain.util';
|
||||
import { Optional, ValidateUUID } from '../../domain.util';
|
||||
import { BulkIdsDto } from '../response-dto';
|
||||
|
||||
export class AssetBulkUpdateDto extends BulkIdsDto {
|
||||
@@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
|
||||
@Optional()
|
||||
@IsBoolean()
|
||||
isArchived?: boolean;
|
||||
|
||||
@Optional()
|
||||
@ValidateUUID()
|
||||
stackParentId?: string;
|
||||
|
||||
@Optional()
|
||||
@IsBoolean()
|
||||
removeParent?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateAssetDto {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './asset-ids.dto';
|
||||
export * from './asset-stack.dto';
|
||||
export * from './asset-statistics.dto';
|
||||
export * from './asset.dto';
|
||||
export * from './download.dto';
|
||||
|
||||
@@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
people?: PersonResponseDto[];
|
||||
/**base64 encoded sha1 hash */
|
||||
checksum!: string;
|
||||
stackParentId?: string | null;
|
||||
stack?: AssetResponseDto[];
|
||||
@ApiProperty({ type: 'integer' })
|
||||
stackCount!: number;
|
||||
}
|
||||
|
||||
export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto {
|
||||
export type AssetMapOptions = {
|
||||
stripMetadata?: boolean;
|
||||
withStack?: boolean;
|
||||
};
|
||||
|
||||
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
|
||||
const { stripMetadata = false, withStack = false } = options;
|
||||
|
||||
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
|
||||
id: entity.id,
|
||||
type: entity.type,
|
||||
@@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo
|
||||
tags: entity.tags?.map(mapTag),
|
||||
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
|
||||
checksum: entity.checksum.toString('base64'),
|
||||
stackParentId: entity.stackParentId,
|
||||
stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,
|
||||
stackCount: entity.stack?.length ?? 0,
|
||||
isExternal: entity.isExternal,
|
||||
isOffline: entity.isOffline,
|
||||
isReadOnly: entity.isReadOnly,
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum CommunicationEvent {
|
||||
UPLOAD_SUCCESS = 'on_upload_success',
|
||||
ASSET_DELETE = 'on_asset_delete',
|
||||
ASSET_TRASH = 'on_asset_trash',
|
||||
ASSET_UPDATE = 'on_asset_update',
|
||||
ASSET_RESTORE = 'on_asset_restore',
|
||||
PERSON_THUMBNAIL = 'on_person_thumbnail',
|
||||
SERVER_VERSION = 'on_server_version',
|
||||
|
||||
@@ -58,7 +58,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
|
||||
type: sharedLink.type,
|
||||
createdAt: sharedLink.createdAt,
|
||||
expiresAt: sharedLink.expiresAt,
|
||||
assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[],
|
||||
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[],
|
||||
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
|
||||
@@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository {
|
||||
faces: {
|
||||
person: true,
|
||||
},
|
||||
stack: true,
|
||||
},
|
||||
// We are specifically asking for this asset. Return it even if it is soft deleted
|
||||
withDeleted: true,
|
||||
@@ -131,6 +132,7 @@ export class AssetRepository implements IAssetRepository {
|
||||
relations: {
|
||||
exifInfo: true,
|
||||
tags: true,
|
||||
stack: true,
|
||||
},
|
||||
skip: dto.skip || 0,
|
||||
order: {
|
||||
|
||||
@@ -196,7 +196,7 @@ export class AssetService {
|
||||
const includeMetadata = this.getExifPermission(authUser);
|
||||
const asset = await this._assetRepository.getById(assetId);
|
||||
if (includeMetadata) {
|
||||
const data = mapAsset(asset);
|
||||
const data = mapAsset(asset, { withStack: true });
|
||||
|
||||
if (data.ownerId !== authUser.id) {
|
||||
data.people = [];
|
||||
@@ -208,7 +208,7 @@ export class AssetService {
|
||||
|
||||
return data;
|
||||
} else {
|
||||
return mapAsset(asset, true);
|
||||
return mapAsset(asset, { stripMetadata: true, withStack: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
TimeBucketResponseDto,
|
||||
TrashAction,
|
||||
UpdateAssetDto as UpdateDto,
|
||||
UpdateStackParentDto,
|
||||
} from '@app/domain';
|
||||
import {
|
||||
Body,
|
||||
@@ -137,6 +138,12 @@ export class AssetController {
|
||||
return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL);
|
||||
}
|
||||
|
||||
@Put('stack/parent')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
updateStackParent(@AuthUser() authUser: AuthUserDto, @Body() dto: UpdateStackParentDto): Promise<void> {
|
||||
return this.service.updateStackParent(authUser, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
updateAsset(
|
||||
@AuthUser() authUser: AuthUserDto,
|
||||
|
||||
@@ -148,6 +148,16 @@ export class AssetEntity {
|
||||
|
||||
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
|
||||
faces!: AssetFaceEntity[];
|
||||
|
||||
@Column({ nullable: true })
|
||||
stackParentId?: string | null;
|
||||
|
||||
@ManyToOne(() => AssetEntity, (asset) => asset.stack, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
|
||||
@JoinColumn({ name: 'stackParentId' })
|
||||
stackParent?: AssetEntity | null;
|
||||
|
||||
@OneToMany(() => AssetEntity, (asset) => asset.stackParent)
|
||||
stack?: AssetEntity[];
|
||||
}
|
||||
|
||||
export enum AssetType {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddStackParentIdToAssets1695354433573 implements MigrationInterface {
|
||||
name = 'AddStackParentIdToAssets1695354433573'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "assets" ADD "stackParentId" uuid`);
|
||||
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_b463c8edb01364bf2beba08ef19" FOREIGN KEY ("stackParentId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`);
|
||||
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "stackParentId"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export class AssetRepository implements IAssetRepository {
|
||||
faces: {
|
||||
person: true,
|
||||
},
|
||||
stack: true,
|
||||
},
|
||||
withDeleted: true,
|
||||
});
|
||||
@@ -192,6 +193,7 @@ export class AssetRepository implements IAssetRepository {
|
||||
person: true,
|
||||
},
|
||||
library: true,
|
||||
stack: true,
|
||||
},
|
||||
// We are specifically asking for this asset. Return it even if it is soft deleted
|
||||
withDeleted: true,
|
||||
@@ -538,6 +540,12 @@ export class AssetRepository implements IAssetRepository {
|
||||
.andWhere('person.id = :personId', { personId });
|
||||
}
|
||||
|
||||
// Hide stack children only in main timeline
|
||||
// Uncomment after adding support for stacked assets in web client
|
||||
// if (!isArchived && !isFavorite && !personId && !albumId && !isTrashed) {
|
||||
// builder = builder.andWhere('asset.stackParent IS NULL');
|
||||
// }
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,4 +626,167 @@ describe(`${AssetController.name} (e2e)`, () => {
|
||||
expect(body).toEqual([expect.objectContaining({ id: asset2.id })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /asset', () => {
|
||||
beforeEach(async () => {
|
||||
const { status } = await request(server)
|
||||
.put('/asset')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
|
||||
|
||||
expect(status).toBe(204);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).put('/asset');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should require a valid parent id', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.put('/asset')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ stackParentId: uuidStub.invalid, ids: [asset1.id] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID']));
|
||||
});
|
||||
|
||||
it('should require access to the parent', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.put('/asset')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ stackParentId: asset4.id, ids: [asset1.id] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.noPermission);
|
||||
});
|
||||
|
||||
it('should add stack children', async () => {
|
||||
const parent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
|
||||
const child = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
|
||||
|
||||
const { status } = await request(server)
|
||||
.put('/asset')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ stackParentId: parent.id, ids: [child.id] });
|
||||
|
||||
expect(status).toBe(204);
|
||||
|
||||
const asset = await api.assetApi.get(server, user1.accessToken, parent.id);
|
||||
expect(asset.stack).not.toBeUndefined();
|
||||
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })]));
|
||||
});
|
||||
|
||||
it('should remove stack children', async () => {
|
||||
const { status } = await request(server)
|
||||
.put('/asset')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ removeParent: true, ids: [asset2.id] });
|
||||
|
||||
expect(status).toBe(204);
|
||||
|
||||
const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
|
||||
expect(asset.stack).not.toBeUndefined();
|
||||
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
|
||||
});
|
||||
|
||||
it('should remove all stack children', async () => {
|
||||
const { status } = await request(server)
|
||||
.put('/asset')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ removeParent: true, ids: [asset2.id, asset3.id] });
|
||||
|
||||
expect(status).toBe(204);
|
||||
|
||||
const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
|
||||
expect(asset.stack).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should merge stack children', async () => {
|
||||
const newParent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
|
||||
const { status } = await request(server)
|
||||
.put('/asset')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ stackParentId: newParent.id, ids: [asset1.id] });
|
||||
|
||||
expect(status).toBe(204);
|
||||
|
||||
const asset = await api.assetApi.get(server, user1.accessToken, newParent.id);
|
||||
expect(asset.stack).not.toBeUndefined();
|
||||
expect(asset.stack).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: asset1.id }),
|
||||
expect.objectContaining({ id: asset2.id }),
|
||||
expect.objectContaining({ id: asset3.id }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /asset/stack/parent', () => {
|
||||
beforeEach(async () => {
|
||||
const { status } = await request(server)
|
||||
.put('/asset')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
|
||||
|
||||
expect(status).toBe(204);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).put('/asset/stack/parent');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.put('/asset/stack/parent')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest());
|
||||
});
|
||||
|
||||
it('should require access', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.put('/asset/stack/parent')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ oldParentId: asset4.id, newParentId: asset1.id });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.noPermission);
|
||||
});
|
||||
|
||||
it('should make old parent child of new parent', async () => {
|
||||
const { status } = await request(server)
|
||||
.put('/asset/stack/parent')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ oldParentId: asset1.id, newParentId: asset2.id });
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
|
||||
expect(asset.stack).not.toBeUndefined();
|
||||
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })]));
|
||||
});
|
||||
|
||||
it('should make all childrens of old parent, a child of new parent', async () => {
|
||||
const { status } = await request(server)
|
||||
.put('/asset/stack/parent')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ oldParentId: asset1.id, newParentId: asset2.id });
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
|
||||
expect(asset.stack).not.toBeUndefined();
|
||||
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
44
server/test/fixtures/asset.stub.ts
vendored
44
server/test/fixtures/asset.stub.ts
vendored
@@ -41,6 +41,7 @@ export const assetStub = {
|
||||
libraryId: 'library-id',
|
||||
library: libraryStub.uploadLibrary1,
|
||||
}),
|
||||
|
||||
noWebpPath: Object.freeze<AssetEntity>({
|
||||
id: 'asset-id',
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@@ -80,6 +81,7 @@ export const assetStub = {
|
||||
} as ExifEntity,
|
||||
deletedAt: null,
|
||||
}),
|
||||
|
||||
noThumbhash: Object.freeze<AssetEntity>({
|
||||
id: 'asset-id',
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@@ -116,6 +118,7 @@ export const assetStub = {
|
||||
sidecarPath: null,
|
||||
deletedAt: null,
|
||||
}),
|
||||
|
||||
primaryImage: Object.freeze<AssetEntity>({
|
||||
id: 'asset-id',
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@@ -154,7 +157,9 @@ export const assetStub = {
|
||||
exifInfo: {
|
||||
fileSizeInByte: 5_000,
|
||||
} as ExifEntity,
|
||||
stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity],
|
||||
}),
|
||||
|
||||
image: Object.freeze<AssetEntity>({
|
||||
id: 'asset-id',
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@@ -194,6 +199,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 5_000,
|
||||
} as ExifEntity,
|
||||
}),
|
||||
|
||||
external: Object.freeze<AssetEntity>({
|
||||
id: 'asset-id',
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@@ -233,6 +239,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 5_000,
|
||||
} as ExifEntity,
|
||||
}),
|
||||
|
||||
offline: Object.freeze<AssetEntity>({
|
||||
id: 'asset-id',
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@@ -272,6 +279,7 @@ export const assetStub = {
|
||||
} as ExifEntity,
|
||||
deletedAt: null,
|
||||
}),
|
||||
|
||||
image1: Object.freeze<AssetEntity>({
|
||||
id: 'asset-id-1',
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@@ -311,6 +319,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 5_000,
|
||||
} as ExifEntity,
|
||||
}),
|
||||
|
||||
imageFrom2015: Object.freeze<AssetEntity>({
|
||||
id: 'asset-id-1',
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@@ -350,6 +359,7 @@ export const assetStub = {
|
||||
} as ExifEntity,
|
||||
deletedAt: null,
|
||||
}),
|
||||
|
||||
video: Object.freeze<AssetEntity>({
|
||||
id: 'asset-id',
|
||||
originalFileName: 'asset-id.ext',
|
||||
@@ -389,6 +399,7 @@ export const assetStub = {
|
||||
} as ExifEntity,
|
||||
deletedAt: null,
|
||||
}),
|
||||
|
||||
livePhotoMotionAsset: Object.freeze({
|
||||
id: 'live-photo-motion-asset',
|
||||
originalPath: fileStub.livePhotoMotion.originalPath,
|
||||
@@ -497,10 +508,41 @@ export const assetStub = {
|
||||
sidecarPath: '/original/path.ext.xmp',
|
||||
deletedAt: null,
|
||||
}),
|
||||
readOnly: Object.freeze({
|
||||
|
||||
readOnly: Object.freeze<AssetEntity>({
|
||||
id: 'read-only-asset',
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
owner: userStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/original/path.ext',
|
||||
resizePath: '/uploads/user-id/thumbs/path.ext',
|
||||
thumbhash: null,
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
type: AssetType.IMAGE,
|
||||
webpPath: null,
|
||||
encodedVideoPath: null,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||
isFavorite: true,
|
||||
isArchived: false,
|
||||
isReadOnly: true,
|
||||
isExternal: false,
|
||||
isOffline: false,
|
||||
libraryId: 'library-id',
|
||||
library: libraryStub.uploadLibrary1,
|
||||
duration: null,
|
||||
isVisible: true,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
tags: [],
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.ext',
|
||||
faces: [],
|
||||
sidecarPath: '/original/path.ext.xmp',
|
||||
deletedAt: null,
|
||||
}),
|
||||
};
|
||||
|
||||
1
server/test/fixtures/shared-link.stub.ts
vendored
1
server/test/fixtures/shared-link.stub.ts
vendored
@@ -72,6 +72,7 @@ const assetResponse: AssetResponseDto = {
|
||||
isTrashed: false,
|
||||
libraryId: 'library-id',
|
||||
hasMetadata: true,
|
||||
stackCount: 0,
|
||||
};
|
||||
|
||||
const assetResponseWithoutMetadata = {
|
||||
|
||||
Reference in New Issue
Block a user