mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
add live photo e2e
This commit is contained in:
parent
0be3b06a2a
commit
da52b3ebf4
4 changed files with 179 additions and 19 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { getMyUser, LoginResponseDto } from '@immich/sdk';
|
import { AssetVisibility, getMyUser, LoginResponseDto } from '@immich/sdk';
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { request as httpRequest } from 'node:http';
|
import { request as httpRequest } from 'node:http';
|
||||||
|
|
@ -320,6 +320,132 @@ describe('/upload', () => {
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!'));
|
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', () => {
|
describe('resumeUpload', () => {
|
||||||
|
|
|
||||||
|
|
@ -62,16 +62,22 @@ where
|
||||||
and "ownerId" = $2
|
and "ownerId" = $2
|
||||||
|
|
||||||
-- AssetRepository.setComplete
|
-- AssetRepository.setComplete
|
||||||
update "asset"
|
update "asset" as "complete_asset"
|
||||||
set
|
set
|
||||||
"status" = 'active',
|
"status" = 'active',
|
||||||
"visibility" = (
|
"visibility" = case
|
||||||
case
|
when (
|
||||||
when type = 'VIDEO'
|
"complete_asset"."type" = 'VIDEO'
|
||||||
and "livePhotoVideoId" is not null then 'hidden'
|
and exists (
|
||||||
else 'timeline'
|
select
|
||||||
end
|
from
|
||||||
)::asset_visibility_enum
|
"asset"
|
||||||
|
where
|
||||||
|
"complete_asset"."id" = "asset"."livePhotoVideoId"
|
||||||
|
)
|
||||||
|
) then 'hidden'::asset_visibility_enum
|
||||||
|
else 'timeline'::asset_visibility_enum
|
||||||
|
end
|
||||||
where
|
where
|
||||||
"id" = $1
|
"id" = $1
|
||||||
and "status" = 'partial'
|
and "status" = 'partial'
|
||||||
|
|
|
||||||
|
|
@ -254,8 +254,28 @@ export class AssetRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
createWithMetadata(asset: Insertable<AssetTable> & { id: string }, size: number, metadata?: AssetMetadataItem[]) {
|
createWithMetadata(asset: Insertable<AssetTable> & { id: string }, size: number, metadata?: AssetMetadataItem[]) {
|
||||||
let query = this.db
|
let query = this.db;
|
||||||
.with('asset', (qb) => qb.insertInto('asset').values(asset).returning(['id', 'ownerId']))
|
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<string>`(select id from motion_asset)` } : asset,
|
||||||
|
)
|
||||||
|
.returning(['id', 'ownerId']),
|
||||||
|
)
|
||||||
.with('exif', (qb) =>
|
.with('exif', (qb) =>
|
||||||
qb
|
qb
|
||||||
.insertInto('asset_exif')
|
.insertInto('asset_exif')
|
||||||
|
|
@ -291,11 +311,21 @@ export class AssetRepository {
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
async setComplete(assetId: string) {
|
async setComplete(assetId: string) {
|
||||||
await this.db
|
await this.db
|
||||||
.updateTable('asset')
|
.updateTable('asset as complete_asset')
|
||||||
.set({
|
.set((eb) => ({
|
||||||
status: sql.lit(AssetStatus.Active),
|
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<AssetVisibility>`'hidden'::asset_visibility_enum`)
|
||||||
|
.else(sql<AssetVisibility>`'timeline'::asset_visibility_enum`)
|
||||||
|
.end(),
|
||||||
|
}))
|
||||||
.where('id', '=', assetId)
|
.where('id', '=', assetId)
|
||||||
.where('status', '=', sql.lit(AssetStatus.Partial))
|
.where('status', '=', sql.lit(AssetStatus.Partial))
|
||||||
.execute();
|
.execute();
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,6 @@ export class AssetUploadService extends BaseService {
|
||||||
res.status(201).set('Location', location).setHeader('Upload-Limit', 'min-size=0').send();
|
res.status(201).set('Location', location).setHeader('Upload-Limit', 'min-size=0').send();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.logger.log(`Finished upload to ${asset.path}`);
|
|
||||||
if (dto.checksum.compare(checksumBuffer!) !== 0) {
|
if (dto.checksum.compare(checksumBuffer!) !== 0) {
|
||||||
return await this.sendChecksumMismatch(res, asset.id, asset.path);
|
return await this.sendChecksumMismatch(res, asset.id, asset.path);
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +152,6 @@ export class AssetUploadService extends BaseService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Finished upload to ${path}`);
|
|
||||||
const checksum = await this.cryptoRepository.hashFile(path);
|
const checksum = await this.cryptoRepository.hashFile(path);
|
||||||
if (providedChecksum.compare(checksum) !== 0) {
|
if (providedChecksum.compare(checksum) !== 0) {
|
||||||
return await this.sendChecksumMismatch(res, id, path);
|
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 }) {
|
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;
|
const jobData = { name: JobName.AssetExtractMetadata, data: { id, source: 'upload' } } as const;
|
||||||
await withRetry(() => this.assetRepository.setComplete(id));
|
await withRetry(() => this.assetRepository.setComplete(id));
|
||||||
try {
|
try {
|
||||||
|
|
@ -309,7 +307,7 @@ export class AssetUploadService extends BaseService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async onCancel(assetId: string, path: string): Promise<void> {
|
async onCancel(assetId: string, path: string): Promise<void> {
|
||||||
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.storageRepository.unlink(path));
|
||||||
await withRetry(() => this.assetRepository.removeAndDecrementQuota(assetId));
|
await withRetry(() => this.assetRepository.removeAndDecrementQuota(assetId));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue