diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index f315810464..e1b0fbc6a3 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -588,6 +588,58 @@ describe('/asset', () => { const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(true); }); + + it('should clean up live photos', async () => { + const { id: motionId } = await utils.createAsset(admin.accessToken, { + assetData: { filename: 'test.mp4', bytes: makeRandomImage() }, + }); + const { id: photoId } = await utils.createAsset(admin.accessToken, { livePhotoVideoId: motionId }); + + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: photoId }); + await utils.waitForWebsocketEvent({ event: 'assetHidden', id: motionId }); + + const asset = await utils.getAssetInfo(admin.accessToken, photoId); + expect(asset.livePhotoVideoId).toBe(motionId); + + const { status } = await request(app) + .delete('/assets') + .send({ ids: [photoId], force: true }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(204); + + await utils.waitForWebsocketEvent({ event: 'assetDelete', id: photoId }); + await utils.waitForWebsocketEvent({ event: 'assetDelete', id: motionId }); + }); + + it('should not delete a shared motion asset', async () => { + const { id: motionId } = await utils.createAsset(admin.accessToken, { + assetData: { filename: 'test.mp4', bytes: makeRandomImage() }, + }); + const { id: asset1 } = await utils.createAsset(admin.accessToken, { livePhotoVideoId: motionId }); + const { id: asset2 } = await utils.createAsset(admin.accessToken, { livePhotoVideoId: motionId }); + + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset1 }); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset2 }); + await utils.waitForWebsocketEvent({ event: 'assetHidden', id: motionId }); + + const asset = await utils.getAssetInfo(admin.accessToken, asset1); + expect(asset.livePhotoVideoId).toBe(motionId); + + const { status } = await request(app) + .delete('/assets') + .send({ ids: [asset1], force: true }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(204); + + await utils.waitForWebsocketEvent({ event: 'assetDelete', id: asset1 }); + await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); + + await expect(utils.getAssetInfo(admin.accessToken, motionId)).resolves.toMatchObject({ id: motionId }); + await expect(utils.getAssetInfo(admin.accessToken, asset2)).resolves.toMatchObject({ + id: asset2, + livePhotoVideoId: motionId, + }); + }); }); describe('GET /assets/:id/thumbnail', () => { diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index f3afdddf8b..3459e51376 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -47,7 +47,7 @@ import { makeRandomImage } from 'src/generators'; import request from 'supertest'; type CommandResponse = { stdout: string; stderr: string; exitCode: number | null }; -type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete'; +type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete' | 'assetHidden'; type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number }; type AdminSetupOptions = { onboarding?: boolean }; type AssetData = { bytes?: Buffer; filename: string }; @@ -92,6 +92,7 @@ const executeCommand = (command: string, args: string[]) => { let client: pg.Client | null = null; const events: Record> = { + assetHidden: new Set(), assetUpload: new Set(), assetUpdate: new Set(), assetDelete: new Set(), @@ -203,6 +204,7 @@ export const utils = { .on('connect', () => resolve(websocket)) .on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id })) .on('on_asset_update', (data: AssetResponseDto) => onEvent({ event: 'assetUpdate', id: data.id })) + .on('on_asset_hidden', (assetId: string) => onEvent({ event: 'assetHidden', id: assetId })) .on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId })) .on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId })) .connect(); diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 2189d5389a..52dba6befe 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -124,7 +124,7 @@ export class AssetEntity { @Column({ type: 'boolean', default: true }) isVisible!: boolean; - @OneToOne(() => AssetEntity, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' }) + @ManyToOne(() => AssetEntity, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' }) @JoinColumn() livePhotoVideo!: AssetEntity | null; diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index a9a74f711b..37115a6e3a 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -173,6 +173,7 @@ export interface IAssetRepository { deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; getAllByDeviceId(userId: string, deviceId: string): Promise; + getLivePhotoCount(motionId: string): Promise; updateAll(ids: string[], options: Partial): Promise; updateDuplicates(options: AssetUpdateDuplicateOptions): Promise; update(asset: AssetUpdateOptions): Promise; diff --git a/server/src/migrations/1719359859887-FixLivePhotoVideoRelation.ts b/server/src/migrations/1719359859887-FixLivePhotoVideoRelation.ts new file mode 100644 index 0000000000..9bf2a2b8d7 --- /dev/null +++ b/server/src/migrations/1719359859887-FixLivePhotoVideoRelation.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class FixLivePhotoVideoRelation1719359859887 implements MigrationInterface { + name = 'FixLivePhotoVideoRelation1719359859887' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_16294b83fa8c0149719a1f631ef"`); + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_16294b83fa8c0149719a1f631ef"`); + await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_16294b83fa8c0149719a1f631ef" FOREIGN KEY ("livePhotoVideoId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_16294b83fa8c0149719a1f631ef"`); + await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_16294b83fa8c0149719a1f631ef" UNIQUE ("livePhotoVideoId")`); + await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_16294b83fa8c0149719a1f631ef" FOREIGN KEY ("livePhotoVideoId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`); + } + +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 29d98b9c6f..38c2209c2d 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -377,6 +377,14 @@ WHERE AND ("AssetEntity"."isVisible" = $3) ) +-- AssetRepository.getLivePhotoCount +SELECT + COUNT(1) AS "cnt" +FROM + "assets" "AssetEntity" +WHERE + (("AssetEntity"."livePhotoVideoId" = $1)) + -- AssetRepository.getById SELECT "AssetEntity"."id" AS "AssetEntity_id", diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 358d7e2bbf..c11341fdcf 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -249,6 +249,16 @@ export class AssetRepository implements IAssetRepository { return items.map((asset) => asset.deviceAssetId); } + @GenerateSql({ params: [DummyValue.UUID] }) + getLivePhotoCount(motionId: string): Promise { + return this.repository.count({ + where: { + livePhotoVideoId: motionId, + }, + withDeleted: true, + }); + } + @GenerateSql({ params: [DummyValue.UUID] }) getById( id: string, diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index efe7b1688f..8895e1c369 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -171,6 +171,7 @@ export class AssetMediaService { } if (motionAsset.isVisible) { await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); + this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, auth.user.id, motionAsset.id); } } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 73dbfb6393..c2edc63985 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -445,6 +445,7 @@ describe(AssetService.name, () => { it('should delete a live photo', async () => { assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + assetMock.getLivePhotoCount.mockResolvedValue(0); await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id, @@ -472,6 +473,27 @@ describe(AssetService.name, () => { ]); }); + it('should not delete a live motion part if it is being used by another asset', async () => { + assetMock.getLivePhotoCount.mockResolvedValue(2); + assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + + await sut.handleAssetDeletion({ + id: assetStub.livePhotoStillAsset.id, + deleteOnDisk: true, + }); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.DELETE_FILES, + data: { + files: [undefined, undefined, undefined, undefined, 'fake_path/asset_1.jpeg'], + }, + }, + ], + ]); + }); + it('should update usage', async () => { assetMock.getById.mockResolvedValue(assetStub.image); await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 1c3e81be17..2a3d5aceb2 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -304,12 +304,15 @@ export class AssetService { } this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id); - // TODO refactor this to use cascades + // delete the motion if it is not used by another asset if (asset.livePhotoVideoId) { - await this.jobRepository.queue({ - name: JobName.ASSET_DELETION, - data: { id: asset.livePhotoVideoId, deleteOnDisk }, - }); + const count = await this.assetRepository.getLivePhotoCount(asset.livePhotoVideoId); + if (count === 0) { + await this.jobRepository.queue({ + name: JobName.ASSET_DELETION, + data: { id: asset.livePhotoVideoId, deleteOnDisk }, + }); + } } const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath]; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 7ab4ef8962..e4575e0b9b 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -568,6 +568,21 @@ export const assetStub = { }, } as AssetEntity), + livePhotoStillAssetWithTheSameLivePhotoMotionAsset: Object.freeze({ + id: 'live-photo-still-asset-1', + originalPath: fileStub.livePhotoStill.originalPath, + ownerId: authStub.user1.user.id, + type: AssetType.IMAGE, + livePhotoVideoId: 'live-photo-motion-asset', + isVisible: true, + fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + exifInfo: { + fileSizeInByte: 25_000, + timeZone: `America/New_York`, + }, + } as AssetEntity), + livePhotoWithOriginalFileName: Object.freeze({ id: 'live-photo-still-asset', originalPath: fileStub.livePhotoStill.originalPath, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 58f0ed7264..f1091c041f 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -23,6 +23,7 @@ export const newAssetRepositoryMock = (): Mocked => { getLastUpdatedAssetForAlbumId: vitest.fn(), getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }), getAllByDeviceId: vitest.fn(), + getLivePhotoCount: vitest.fn(), updateAll: vitest.fn(), updateDuplicates: vitest.fn(), getExternalLibraryAssetPaths: vitest.fn(),