From da52b3ebf4a875b9620d2890a0b74b07e7e23d27 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Thu, 9 Oct 2025 19:55:18 -0400 Subject: [PATCH] add live photo e2e --- e2e/src/api/specs/asset-upload.e2e-spec.ts | 128 +++++++++++++++++++- server/src/queries/asset.repository.sql | 22 ++-- server/src/repositories/asset.repository.ts | 42 ++++++- server/src/services/asset-upload.service.ts | 6 +- 4 files changed, 179 insertions(+), 19 deletions(-) diff --git a/e2e/src/api/specs/asset-upload.e2e-spec.ts b/e2e/src/api/specs/asset-upload.e2e-spec.ts index 9d63fa237d..f12f1bca74 100644 --- a/e2e/src/api/specs/asset-upload.e2e-spec.ts +++ b/e2e/src/api/specs/asset-upload.e2e-spec.ts @@ -1,4 +1,4 @@ -import { getMyUser, LoginResponseDto } from '@immich/sdk'; +import { AssetVisibility, getMyUser, LoginResponseDto } from '@immich/sdk'; import { createHash, randomBytes } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { request as httpRequest } from 'node:http'; @@ -320,6 +320,132 @@ describe('/upload', () => { expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!')); }); + + it('should link a motion photo', async () => { + const videoContent = randomBytes(1024); + const videoChecksum = createHash('sha1').update(videoContent).digest('base64'); + const videoResponse = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', makeAssetData({ filename: 'motion-photo.mp4' })) + .set('Repr-Digest', `sha=:${videoChecksum}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'video/mp4') + .set('Upload-Length', '1024') + .send(videoContent); + expect(videoResponse.status).toBe(200); + expect(videoResponse.body).toEqual(expect.objectContaining({ id: expect.any(String) })); + const videoAssetId = videoResponse.body.id; + + const imageContent = randomBytes(1024); + const imageChecksum = createHash('sha1').update(imageContent).digest('base64'); + const imageResponse = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', makeAssetData({ 'live-photo-video-id': videoAssetId })) + .set('Repr-Digest', `sha=:${imageChecksum}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(imageContent); + + expect(imageResponse.status).toBe(200); + expect(imageResponse.body).toEqual(expect.objectContaining({ id: expect.any(String) })); + const imageAssetId = imageResponse.body.id; + + const [videoAsset, imageAsset] = await Promise.all([ + utils.getAssetInfo(user.accessToken, videoAssetId), + utils.getAssetInfo(user.accessToken, imageAssetId), + ]); + expect(imageAsset).toEqual(expect.objectContaining({ livePhotoVideoId: videoAssetId })); + expect(videoAsset).toEqual(expect.objectContaining({ visibility: AssetVisibility.Hidden })); + }); + + it("should not link to another user's video asset", async () => { + const videoContent = randomBytes(1024); + const videoChecksum = createHash('sha1').update(videoContent).digest('base64'); + const videoResponse = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${admin.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', makeAssetData({ filename: 'motion-photo.mp4' })) + .set('Repr-Digest', `sha=:${videoChecksum}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'video/mp4') + .set('Upload-Length', '1024') + .send(videoContent); + expect(videoResponse.status).toBe(200); + expect(videoResponse.body).toEqual(expect.objectContaining({ id: expect.any(String) })); + const videoAssetId = videoResponse.body.id; + + const imageContent = randomBytes(1024); + const imageChecksum = createHash('sha1').update(imageContent).digest('base64'); + const imageResponse = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', makeAssetData({ 'live-photo-video-id': videoAssetId })) + .set('Repr-Digest', `sha=:${imageChecksum}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(imageContent); + + expect(imageResponse.status).toBe(200); + expect(imageResponse.body).toEqual(expect.objectContaining({ id: expect.any(String) })); + const imageAssetId = imageResponse.body.id; + + const [videoAsset, imageAsset] = await Promise.all([ + utils.getAssetInfo(admin.accessToken, videoAssetId), + utils.getAssetInfo(user.accessToken, imageAssetId), + ]); + expect(imageAsset).toEqual(expect.objectContaining({ livePhotoVideoId: null })); + expect(videoAsset).toEqual(expect.objectContaining({ visibility: AssetVisibility.Timeline })); + }); + + it('should not link to a photo', async () => { + const image1Content = randomBytes(1024); + const image1Checksum = createHash('sha1').update(image1Content).digest('base64'); + const image1Response = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', assetData) + .set('Repr-Digest', `sha=:${image1Checksum}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(image1Content); + expect(image1Response.status).toBe(200); + expect(image1Response.body).toEqual(expect.objectContaining({ id: expect.any(String) })); + const image1AssetId = image1Response.body.id; + + const image2Content = randomBytes(1024); + const image2Checksum = createHash('sha1').update(image2Content).digest('base64'); + const image2Response = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', makeAssetData({ 'live-photo-video-id': image1AssetId })) + .set('Repr-Digest', `sha=:${image2Checksum}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(image2Content); + + expect(image2Response.status).toBe(200); + expect(image2Response.body).toEqual(expect.objectContaining({ id: expect.any(String) })); + const image2AssetId = image2Response.body.id; + + const [image1Asset, image2Asset] = await Promise.all([ + utils.getAssetInfo(user.accessToken, image1AssetId), + utils.getAssetInfo(user.accessToken, image2AssetId), + ]); + expect(image1Asset).toEqual(expect.objectContaining({ livePhotoVideoId: null })); + expect(image2Asset).toEqual(expect.objectContaining({ visibility: AssetVisibility.Timeline })); + }); }); describe('resumeUpload', () => { diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 4173bd376e..cb82fbf8a6 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -62,16 +62,22 @@ where and "ownerId" = $2 -- AssetRepository.setComplete -update "asset" +update "asset" as "complete_asset" set "status" = 'active', - "visibility" = ( - case - when type = 'VIDEO' - and "livePhotoVideoId" is not null then 'hidden' - else 'timeline' - end - )::asset_visibility_enum + "visibility" = case + when ( + "complete_asset"."type" = 'VIDEO' + and exists ( + select + from + "asset" + where + "complete_asset"."id" = "asset"."livePhotoVideoId" + ) + ) then 'hidden'::asset_visibility_enum + else 'timeline'::asset_visibility_enum + end where "id" = $1 and "status" = 'partial' diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index feed6cff62..8958151a61 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -254,8 +254,28 @@ export class AssetRepository { } createWithMetadata(asset: Insertable & { id: string }, size: number, metadata?: AssetMetadataItem[]) { - let query = this.db - .with('asset', (qb) => qb.insertInto('asset').values(asset).returning(['id', 'ownerId'])) + let query = this.db; + if (asset.livePhotoVideoId) { + (query as any) = query.with('motion_asset', (qb) => + qb + .updateTable('asset') + .set({ visibility: AssetVisibility.Hidden }) + .where('id', '=', asset.livePhotoVideoId!) + .where('type', '=', sql.lit(AssetType.Video)) + .where('ownerId', '=', asset.ownerId) + .returning('id'), + ); + } + + (query as any) = query + .with('asset', (qb) => + qb + .insertInto('asset') + .values( + asset.livePhotoVideoId ? { ...asset, livePhotoVideoId: sql`(select id from motion_asset)` } : asset, + ) + .returning(['id', 'ownerId']), + ) .with('exif', (qb) => qb .insertInto('asset_exif') @@ -291,11 +311,21 @@ export class AssetRepository { @GenerateSql({ params: [DummyValue.UUID] }) async setComplete(assetId: string) { await this.db - .updateTable('asset') - .set({ + .updateTable('asset as complete_asset') + .set((eb) => ({ status: sql.lit(AssetStatus.Active), - visibility: sql`(case when type = 'VIDEO' and "livePhotoVideoId" is not null then 'hidden' else 'timeline' end)::asset_visibility_enum`, - }) + visibility: eb + .case() + .when( + eb.and([ + eb('complete_asset.type', '=', sql.lit(AssetType.Video)), + eb.exists(eb.selectFrom('asset').whereRef('complete_asset.id', '=', 'asset.livePhotoVideoId')), + ]), + ) + .then(sql`'hidden'::asset_visibility_enum`) + .else(sql`'timeline'::asset_visibility_enum`) + .end(), + })) .where('id', '=', assetId) .where('status', '=', sql.lit(AssetStatus.Partial)) .execute(); diff --git a/server/src/services/asset-upload.service.ts b/server/src/services/asset-upload.service.ts index 73b80512b4..5d2cb814eb 100644 --- a/server/src/services/asset-upload.service.ts +++ b/server/src/services/asset-upload.service.ts @@ -93,7 +93,6 @@ export class AssetUploadService extends BaseService { res.status(201).set('Location', location).setHeader('Upload-Limit', 'min-size=0').send(); return; } - this.logger.log(`Finished upload to ${asset.path}`); if (dto.checksum.compare(checksumBuffer!) !== 0) { return await this.sendChecksumMismatch(res, asset.id, asset.path); } @@ -153,7 +152,6 @@ export class AssetUploadService extends BaseService { return; } - this.logger.log(`Finished upload to ${path}`); const checksum = await this.cryptoRepository.hashFile(path); if (providedChecksum.compare(checksum) !== 0) { return await this.sendChecksumMismatch(res, id, path); @@ -297,7 +295,7 @@ export class AssetUploadService extends BaseService { } async onComplete({ id, path, fileModifiedAt }: { id: string; path: string; fileModifiedAt: Date }) { - this.logger.debug('Completing upload for asset', id); + this.logger.log('Completing upload for asset', id); const jobData = { name: JobName.AssetExtractMetadata, data: { id, source: 'upload' } } as const; await withRetry(() => this.assetRepository.setComplete(id)); try { @@ -309,7 +307,7 @@ export class AssetUploadService extends BaseService { } async onCancel(assetId: string, path: string): Promise { - this.logger.debug('Cancelling upload for asset', assetId); + this.logger.log('Cancelling upload for asset', assetId); await withRetry(() => this.storageRepository.unlink(path)); await withRetry(() => this.assetRepository.removeAndDecrementQuota(assetId)); }