diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 181240e5b8..a9b8e0cbee 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -1,7 +1,8 @@ import { Action, AssetBulkUploadCheckResult, - AssetFileUploadResponseDto, + AssetMediaResponseDto, + AssetMediaStatus, addAssetsToAlbum, checkBulkUpload, createAlbum, @@ -167,7 +168,7 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio newAssets.push({ id: response.id, filepath }); - if (response.duplicate) { + if (response.status === AssetMediaStatus.Duplicate) { duplicateCount++; duplicateSize += stats.size ?? 0; } else { @@ -192,7 +193,7 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio return newAssets; }; -const uploadFile = async (input: string, stats: Stats): Promise => { +const uploadFile = async (input: string, stats: Stats): Promise => { const { baseUrl, headers } = defaults; const assetPath = path.parse(input); @@ -225,7 +226,7 @@ const uploadFile = async (input: string, stats: Stats): Promise, diff --git a/docs/docs/guides/python-file-upload.md b/docs/docs/guides/python-file-upload.md index dc1be79e06..684524f9c4 100644 --- a/docs/docs/guides/python-file-upload.md +++ b/docs/docs/guides/python-file-upload.md @@ -32,7 +32,7 @@ def upload(file): } response = requests.post( - f'{BASE_URL}/asset/upload', headers=headers, data=data, files=files) + f'{BASE_URL}/assets', headers=headers, data=data, files=files) print(response.json()) # {'id': 'ef96f635-61c7-4639-9e60-61a11c4bbfba', 'duplicate': False} diff --git a/e2e/src/api/specs/activity.e2e-spec.ts b/e2e/src/api/specs/activity.e2e-spec.ts index 3258f74d6e..ee75d6070b 100644 --- a/e2e/src/api/specs/activity.e2e-spec.ts +++ b/e2e/src/api/specs/activity.e2e-spec.ts @@ -2,7 +2,7 @@ import { ActivityCreateDto, AlbumResponseDto, AlbumUserRole, - AssetFileUploadResponseDto, + AssetMediaResponseDto, LoginResponseDto, ReactionType, createActivity as create, @@ -17,7 +17,7 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('/activities', () => { let admin: LoginResponseDto; let nonOwner: LoginResponseDto; - let asset: AssetFileUploadResponseDto; + let asset: AssetMediaResponseDto; let album: AlbumResponseDto; const createActivity = (dto: ActivityCreateDto, accessToken?: string) => diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 319cc4033d..7d260d2547 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -2,7 +2,7 @@ import { addAssetsToAlbum, AlbumResponseDto, AlbumUserRole, - AssetFileUploadResponseDto, + AssetMediaResponseDto, AssetOrder, deleteUserAdmin, getAlbumInfo, @@ -26,8 +26,8 @@ const user2NotShared = 'user2NotShared'; describe('/albums', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; - let user1Asset1: AssetFileUploadResponseDto; - let user1Asset2: AssetFileUploadResponseDto; + let user1Asset1: AssetMediaResponseDto; + let user1Asset2: AssetMediaResponseDto; let user1Albums: AlbumResponseDto[]; let user2: LoginResponseDto; let user2Albums: AlbumResponseDto[]; diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index d5cece771d..cf0557bd6c 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1,5 +1,6 @@ import { - AssetFileUploadResponseDto, + AssetMediaResponseDto, + AssetMediaStatus, AssetResponseDto, AssetTypeEnum, LoginResponseDto, @@ -67,10 +68,10 @@ describe('/asset', () => { let statsUser: LoginResponseDto; let stackUser: LoginResponseDto; - let user1Assets: AssetFileUploadResponseDto[]; - let user2Assets: AssetFileUploadResponseDto[]; - let stackAssets: AssetFileUploadResponseDto[]; - let locationAsset: AssetFileUploadResponseDto; + let user1Assets: AssetMediaResponseDto[]; + let user2Assets: AssetMediaResponseDto[]; + let stackAssets: AssetMediaResponseDto[]; + let locationAsset: AssetMediaResponseDto; const setupTests = async () => { await utils.resetDatabase(); @@ -121,7 +122,7 @@ describe('/asset', () => { ]); for (const asset of [...user1Assets, ...user2Assets]) { - expect(asset.duplicate).toBe(false); + expect(asset.status).toBe(AssetMediaStatus.Created); } await Promise.all([ @@ -164,16 +165,34 @@ describe('/asset', () => { utils.disconnectWebsocket(websocket); }); - describe('GET /asset/:id', () => { + describe('GET /assets/:id/original', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/asset/${uuidDto.notFound}`); + const { status, body } = await request(app).get(`/assets/${uuidDto.notFound}/original`); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should download the file', async () => { + const response = await request(app) + .get(`/assets/${user1Assets[0].id}/original`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toEqual('image/png'); + }); + }); + + describe('GET /assets/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get(`/assets/${uuidDto.notFound}`); expect(body).toEqual(errorDto.unauthorized); expect(status).toBe(401); }); it('should require a valid id', async () => { const { status, body } = await request(app) - .get(`/asset/${uuidDto.invalid}`) + .get(`/assets/${uuidDto.invalid}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); @@ -181,7 +200,7 @@ describe('/asset', () => { it('should require access', async () => { const { status, body } = await request(app) - .get(`/asset/${user2Assets[0].id}`) + .get(`/assets/${user2Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorDto.noPermission); @@ -189,7 +208,7 @@ describe('/asset', () => { it('should get the asset info', async () => { const { status, body } = await request(app) - .get(`/asset/${user1Assets[0].id}`) + .get(`/assets/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ id: user1Assets[0].id }); @@ -201,14 +220,14 @@ describe('/asset', () => { assetIds: [user1Assets[0].id], }); - const { status, body } = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`); + const { status, body } = await request(app).get(`/assets/${user1Assets[0].id}?key=${sharedLink.key}`); expect(status).toBe(200); expect(body).toMatchObject({ id: user1Assets[0].id }); }); it('should not send people data for shared links for un-authenticated users', async () => { const { status, body } = await request(app) - .get(`/asset/${user1Assets[0].id}`) + .get(`/assets/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(200); @@ -231,7 +250,7 @@ describe('/asset', () => { assetIds: [user1Assets[0].id], }); - const data = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`); + const data = await request(app).get(`/assets/${user1Assets[0].id}?key=${sharedLink.key}`); expect(data.status).toBe(200); expect(data.body).toMatchObject({ people: [] }); }); @@ -239,7 +258,7 @@ describe('/asset', () => { describe('partner assets', () => { it('should get the asset info', async () => { const { status, body } = await request(app) - .get(`/asset/${user1Assets[0].id}`) + .get(`/assets/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user2.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ id: user1Assets[0].id }); @@ -249,7 +268,7 @@ describe('/asset', () => { const asset = await utils.createAsset(user1.accessToken, { isArchived: true }); const { status } = await request(app) - .get(`/asset/${asset.id}`) + .get(`/assets/${asset.id}`) .set('Authorization', `Bearer ${user2.accessToken}`); expect(status).toBe(400); }); @@ -259,16 +278,16 @@ describe('/asset', () => { await utils.deleteAssets(user1.accessToken, [asset.id]); const { status } = await request(app) - .get(`/asset/${asset.id}`) + .get(`/assets/${asset.id}`) .set('Authorization', `Bearer ${user2.accessToken}`); expect(status).toBe(400); }); }); }); - describe('GET /asset/statistics', () => { + describe('GET /assets/statistics', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/asset/statistics'); + const { status, body } = await request(app).get('/assets/statistics'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -276,7 +295,7 @@ describe('/asset', () => { it('should return stats of all assets', async () => { const { status, body } = await request(app) - .get('/asset/statistics') + .get('/assets/statistics') .set('Authorization', `Bearer ${statsUser.accessToken}`); expect(body).toEqual({ images: 3, videos: 1, total: 4 }); @@ -285,7 +304,7 @@ describe('/asset', () => { it('should return stats of all favored assets', async () => { const { status, body } = await request(app) - .get('/asset/statistics') + .get('/assets/statistics') .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isFavorite: true }); @@ -295,7 +314,7 @@ describe('/asset', () => { it('should return stats of all archived assets', async () => { const { status, body } = await request(app) - .get('/asset/statistics') + .get('/assets/statistics') .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isArchived: true }); @@ -305,7 +324,7 @@ describe('/asset', () => { it('should return stats of all favored and archived assets', async () => { const { status, body } = await request(app) - .get('/asset/statistics') + .get('/assets/statistics') .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isFavorite: true, isArchived: true }); @@ -315,7 +334,7 @@ describe('/asset', () => { it('should return stats of all assets neither favored nor archived', async () => { const { status, body } = await request(app) - .get('/asset/statistics') + .get('/assets/statistics') .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isFavorite: false, isArchived: false }); @@ -324,7 +343,7 @@ describe('/asset', () => { }); }); - describe('GET /asset/random', () => { + describe('GET /assets/random', () => { beforeAll(async () => { await Promise.all([ utils.createAsset(user1.accessToken), @@ -337,7 +356,7 @@ describe('/asset', () => { }); it('should require authentication', async () => { - const { status, body } = await request(app).get('/asset/random'); + const { status, body } = await request(app).get('/assets/random'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -345,7 +364,7 @@ describe('/asset', () => { it.each(TEN_TIMES)('should return 1 random assets', async () => { const { status, body } = await request(app) - .get('/asset/random') + .get('/assets/random') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -357,7 +376,7 @@ describe('/asset', () => { it.each(TEN_TIMES)('should return 2 random assets', async () => { const { status, body } = await request(app) - .get('/asset/random?count=2') + .get('/assets/random?count=2') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -374,7 +393,7 @@ describe('/asset', () => { 'should return 1 asset if there are 10 assets in the database but user 2 only has 1', async () => { const { status, body } = await request(app) - .get('/asset/random') + .get('/assets/random') .set('Authorization', `Bearer ${user2.accessToken}`); expect(status).toBe(200); @@ -384,23 +403,23 @@ describe('/asset', () => { it('should return error', async () => { const { status } = await request(app) - .get('/asset/random?count=ABC') + .get('/assets/random?count=ABC') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); }); }); - describe('PUT /asset/:id', () => { + describe('PUT /assets/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).put(`/asset/:${uuidDto.notFound}`); + const { status, body } = await request(app).put(`/assets/:${uuidDto.notFound}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should require a valid id', async () => { const { status, body } = await request(app) - .put(`/asset/${uuidDto.invalid}`) + .put(`/assets/${uuidDto.invalid}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); @@ -408,7 +427,7 @@ describe('/asset', () => { it('should require access', async () => { const { status, body } = await request(app) - .put(`/asset/${user2Assets[0].id}`) + .put(`/assets/${user2Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorDto.noPermission); @@ -419,7 +438,7 @@ describe('/asset', () => { expect(before.isFavorite).toBe(false); const { status, body } = await request(app) - .put(`/asset/${user1Assets[0].id}`) + .put(`/assets/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isFavorite: true }); expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true }); @@ -431,7 +450,7 @@ describe('/asset', () => { expect(before.isArchived).toBe(false); const { status, body } = await request(app) - .put(`/asset/${user1Assets[0].id}`) + .put(`/assets/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isArchived: true }); expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true }); @@ -440,7 +459,7 @@ describe('/asset', () => { it('should update date time original', async () => { const { status, body } = await request(app) - .put(`/asset/${user1Assets[0].id}`) + .put(`/assets/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); @@ -467,7 +486,7 @@ describe('/asset', () => { { latitude: 12, longitude: 181 }, ]) { const { status, body } = await request(app) - .put(`/asset/${user1Assets[0].id}`) + .put(`/assets/${user1Assets[0].id}`) .send(test) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); @@ -477,7 +496,7 @@ describe('/asset', () => { it('should update gps data', async () => { const { status, body } = await request(app) - .put(`/asset/${user1Assets[0].id}`) + .put(`/assets/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ latitude: 12, longitude: 12 }); @@ -490,7 +509,7 @@ describe('/asset', () => { it('should set the description', async () => { const { status, body } = await request(app) - .put(`/asset/${user1Assets[0].id}`) + .put(`/assets/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ description: 'Test asset description' }); expect(body).toMatchObject({ @@ -504,7 +523,7 @@ describe('/asset', () => { it('should return tagged people', async () => { const { status, body } = await request(app) - .put(`/asset/${user1Assets[0].id}`) + .put(`/assets/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isFavorite: true }); expect(status).toEqual(200); @@ -524,10 +543,10 @@ describe('/asset', () => { }); }); - describe('DELETE /asset', () => { + describe('DELETE /assets', () => { it('should require authentication', async () => { const { status, body } = await request(app) - .delete(`/asset`) + .delete(`/assets`) .send({ ids: [uuidDto.notFound] }); expect(status).toBe(401); @@ -536,7 +555,7 @@ describe('/asset', () => { it('should require a valid uuid', async () => { const { status, body } = await request(app) - .delete(`/asset`) + .delete(`/assets`) .send({ ids: [uuidDto.invalid] }) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -546,7 +565,7 @@ describe('/asset', () => { it('should throw an error when the id is not found', async () => { const { status, body } = await request(app) - .delete(`/asset`) + .delete(`/assets`) .send({ ids: [uuidDto.notFound] }) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -561,7 +580,7 @@ describe('/asset', () => { expect(before.isTrashed).toBe(false); const { status } = await request(app) - .delete('/asset') + .delete('/assets') .send({ ids: [assetId] }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); @@ -571,9 +590,9 @@ describe('/asset', () => { }); }); - describe('GET /asset/thumbnail/:id', () => { + describe('GET /assets/:id/thumbnail', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`); + const { status, body } = await request(app).get(`/assets/${locationAsset.id}/thumbnail`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -586,7 +605,7 @@ describe('/asset', () => { }); const { status, body, type } = await request(app) - .get(`/asset/thumbnail/${locationAsset.id}?format=WEBP`) + .get(`/assets/${locationAsset.id}/thumbnail?format=WEBP`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -598,9 +617,9 @@ describe('/asset', () => { expect(exifData).not.toHaveProperty('GPSLatitude'); }); - it('should not include gps data for jpeg thumbnails', async () => { + it('should not include gps data for jpeg previews', async () => { const { status, body, type } = await request(app) - .get(`/asset/thumbnail/${locationAsset.id}?format=JPEG`) + .get(`/assets/${locationAsset.id}/thumbnail?size=preview`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -613,9 +632,9 @@ describe('/asset', () => { }); }); - describe('GET /asset/file/:id', () => { + describe('GET /assets/:id/original', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`); + const { status, body } = await request(app).get(`/assets/${locationAsset.id}/original`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -623,7 +642,7 @@ describe('/asset', () => { it('should download the original', async () => { const { status, body, type } = await request(app) - .get(`/asset/file/${locationAsset.id}`) + .get(`/assets/${locationAsset.id}/original`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -641,9 +660,9 @@ describe('/asset', () => { }); }); - describe('PUT /asset', () => { + describe('PUT /assets', () => { it('should require authentication', async () => { - const { status, body } = await request(app).put('/asset'); + const { status, body } = await request(app).put('/assets'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -651,7 +670,7 @@ describe('/asset', () => { it('should require a valid parent id', async () => { const { status, body } = await request(app) - .put('/asset') + .put('/assets') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); @@ -661,7 +680,7 @@ describe('/asset', () => { it('should require access to the parent', async () => { const { status, body } = await request(app) - .put('/asset') + .put('/assets') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); @@ -671,7 +690,7 @@ describe('/asset', () => { it('should add stack children', async () => { const { status } = await request(app) - .put('/asset') + .put('/assets') .set('Authorization', `Bearer ${stackUser.accessToken}`) .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); @@ -684,7 +703,7 @@ describe('/asset', () => { it('should remove stack children', async () => { const { status } = await request(app) - .put('/asset') + .put('/assets') .set('Authorization', `Bearer ${stackUser.accessToken}`) .send({ removeParent: true, ids: [stackAssets[1].id] }); @@ -702,7 +721,7 @@ describe('/asset', () => { it('should remove all stack children', async () => { const { status } = await request(app) - .put('/asset') + .put('/assets') .set('Authorization', `Bearer ${stackUser.accessToken}`) .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); @@ -720,7 +739,7 @@ describe('/asset', () => { ); const { status } = await request(app) - .put('/asset') + .put('/assets') .set('Authorization', `Bearer ${stackUser.accessToken}`) .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); @@ -738,9 +757,9 @@ describe('/asset', () => { }); }); - describe('PUT /asset/stack/parent', () => { + describe('PUT /assets/stack/parent', () => { it('should require authentication', async () => { - const { status, body } = await request(app).put('/asset/stack/parent'); + const { status, body } = await request(app).put('/assets/stack/parent'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -748,7 +767,7 @@ describe('/asset', () => { it('should require a valid id', async () => { const { status, body } = await request(app) - .put('/asset/stack/parent') + .put('/assets/stack/parent') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid }); @@ -758,7 +777,7 @@ describe('/asset', () => { it('should require access', async () => { const { status, body } = await request(app) - .put('/asset/stack/parent') + .put('/assets/stack/parent') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); @@ -768,7 +787,7 @@ describe('/asset', () => { it('should make old parent child of new parent', async () => { const { status } = await request(app) - .put('/asset/stack/parent') + .put('/assets/stack/parent') .set('Authorization', `Bearer ${stackUser.accessToken}`) .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); @@ -787,11 +806,11 @@ describe('/asset', () => { ); }); }); - describe('POST /asset/upload', () => { + describe('POST /assets', () => { beforeAll(setupTests, 30_000); it('should require authentication', async () => { - const { status, body } = await request(app).post(`/asset/upload`); + const { status, body } = await request(app).post(`/assets`); expect(body).toEqual(errorDto.unauthorized); expect(status).toBe(401); }); @@ -807,7 +826,7 @@ describe('/asset', () => { { should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } }, ])('should $should', async ({ dto }) => { const { status, body } = await request(app) - .post('/asset/upload') + .post('/assets') .set('Authorization', `Bearer ${user1.accessToken}`) .attach('assetData', makeRandomImage(), 'example.png') .field(dto); @@ -1033,11 +1052,11 @@ describe('/asset', () => { }, ])(`should upload and generate a thumbnail for $input`, async ({ input, expected }) => { const filepath = join(testAssetDir, input); - const { id, duplicate } = await utils.createAsset(admin.accessToken, { + const { id, status } = await utils.createAsset(admin.accessToken, { assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, }); - expect(duplicate).toBe(false); + expect(status).toBe(AssetMediaStatus.Created); await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id }); @@ -1050,19 +1069,19 @@ describe('/asset', () => { it('should handle a duplicate', async () => { const filepath = 'formats/jpeg/el_torcal_rocks.jpeg'; - const { duplicate } = await utils.createAsset(admin.accessToken, { + const { status } = await utils.createAsset(admin.accessToken, { assetData: { bytes: await readFile(join(testAssetDir, filepath)), filename: basename(filepath), }, }); - expect(duplicate).toBe(true); + expect(status).toBe(AssetMediaStatus.Duplicate); }); it('should update the used quota', async () => { const { body, status } = await request(app) - .post('/asset/upload') + .post('/assets') .set('Authorization', `Bearer ${quotaUser.accessToken}`) .field('deviceAssetId', 'example-image') .field('deviceId', 'e2e') @@ -1070,7 +1089,7 @@ describe('/asset', () => { .field('fileModifiedAt', new Date().toISOString()) .attach('assetData', makeRandomImage(), 'example.jpg'); - expect(body).toEqual({ id: expect.any(String), duplicate: false }); + expect(body).toEqual({ id: expect.any(String), status: AssetMediaStatus.Created }); expect(status).toBe(201); const user = await getMyUser({ headers: asBearerAuth(quotaUser.accessToken) }); @@ -1080,7 +1099,7 @@ describe('/asset', () => { it('should not upload an asset if it would exceed the quota', async () => { const { body, status } = await request(app) - .post('/asset/upload') + .post('/assets') .set('Authorization', `Bearer ${quotaUser.accessToken}`) .field('deviceAssetId', 'example-image') .field('deviceId', 'e2e') @@ -1120,7 +1139,7 @@ describe('/asset', () => { await utils.waitForWebsocketEvent({ event: 'assetUpload', id: response.id }); - expect(response.duplicate).toBe(false); + expect(response.status).toBe(AssetMediaStatus.Created); const asset = await utils.getAssetInfo(admin.accessToken, response.id); expect(asset.livePhotoVideoId).toBeDefined(); diff --git a/e2e/src/api/specs/download.e2e-spec.ts b/e2e/src/api/specs/download.e2e-spec.ts index ef14778dac..3d3e6c7650 100644 --- a/e2e/src/api/specs/download.e2e-spec.ts +++ b/e2e/src/api/specs/download.e2e-spec.ts @@ -1,4 +1,4 @@ -import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk'; +import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; import { readFile, writeFile } from 'node:fs/promises'; import { errorDto } from 'src/responses'; import { app, tempDir, utils } from 'src/utils'; @@ -7,8 +7,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; describe('/download', () => { let admin: LoginResponseDto; - let asset1: AssetFileUploadResponseDto; - let asset2: AssetFileUploadResponseDto; + let asset1: AssetMediaResponseDto; + let asset2: AssetMediaResponseDto; beforeAll(async () => { await utils.resetDatabase(); @@ -73,22 +73,4 @@ describe('/download', () => { } }); }); - - describe('POST /download/asset/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/download/asset/${asset1.id}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should download file', async () => { - const response = await request(app) - .post(`/download/asset/${asset1.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(response.status).toBe(200); - expect(response.headers['content-type']).toEqual('image/png'); - }); - }); }); diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts index 2a0defc724..dcfdf0bc58 100644 --- a/e2e/src/api/specs/map.e2e-spec.ts +++ b/e2e/src/api/specs/map.e2e-spec.ts @@ -1,4 +1,4 @@ -import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; +import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; @@ -12,7 +12,7 @@ describe('/map', () => { let websocket: Socket; let admin: LoginResponseDto; let nonAdmin: LoginResponseDto; - let asset: AssetFileUploadResponseDto; + let asset: AssetMediaResponseDto; beforeAll(async () => { await utils.resetDatabase(); diff --git a/e2e/src/api/specs/memory.e2e-spec.ts b/e2e/src/api/specs/memory.e2e-spec.ts index 35af1fea9e..a404da0ada 100644 --- a/e2e/src/api/specs/memory.e2e-spec.ts +++ b/e2e/src/api/specs/memory.e2e-spec.ts @@ -1,5 +1,5 @@ import { - AssetFileUploadResponseDto, + AssetMediaResponseDto, LoginResponseDto, MemoryResponseDto, MemoryType, @@ -15,9 +15,9 @@ import { beforeAll, describe, expect, it } from 'vitest'; describe('/memories', () => { let admin: LoginResponseDto; let user: LoginResponseDto; - let adminAsset: AssetFileUploadResponseDto; - let userAsset1: AssetFileUploadResponseDto; - let userAsset2: AssetFileUploadResponseDto; + let adminAsset: AssetMediaResponseDto; + let userAsset1: AssetMediaResponseDto; + let userAsset2: AssetMediaResponseDto; let userMemory: MemoryResponseDto; beforeAll(async () => { diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index c7e2c3c218..f46c07e0b8 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -1,4 +1,4 @@ -import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk'; +import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk'; import { DateTime } from 'luxon'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -13,25 +13,25 @@ describe('/search', () => { let admin: LoginResponseDto; let websocket: Socket; - let assetFalcon: AssetFileUploadResponseDto; - let assetDenali: AssetFileUploadResponseDto; - let assetCyclamen: AssetFileUploadResponseDto; - let assetNotocactus: AssetFileUploadResponseDto; - let assetSilver: AssetFileUploadResponseDto; - let assetDensity: AssetFileUploadResponseDto; - // let assetPhiladelphia: AssetFileUploadResponseDto; - // let assetOrychophragmus: AssetFileUploadResponseDto; - // let assetRidge: AssetFileUploadResponseDto; - // let assetPolemonium: AssetFileUploadResponseDto; - // let assetWood: AssetFileUploadResponseDto; - // let assetGlarus: AssetFileUploadResponseDto; - let assetHeic: AssetFileUploadResponseDto; - let assetRocks: AssetFileUploadResponseDto; - let assetOneJpg6: AssetFileUploadResponseDto; - let assetOneHeic6: AssetFileUploadResponseDto; - let assetOneJpg5: AssetFileUploadResponseDto; - let assetSprings: AssetFileUploadResponseDto; - let assetLast: AssetFileUploadResponseDto; + let assetFalcon: AssetMediaResponseDto; + let assetDenali: AssetMediaResponseDto; + let assetCyclamen: AssetMediaResponseDto; + let assetNotocactus: AssetMediaResponseDto; + let assetSilver: AssetMediaResponseDto; + let assetDensity: AssetMediaResponseDto; + // let assetPhiladelphia: AssetMediaResponseDto; + // let assetOrychophragmus: AssetMediaResponseDto; + // let assetRidge: AssetMediaResponseDto; + // let assetPolemonium: AssetMediaResponseDto; + // let assetWood: AssetMediaResponseDto; + // let assetGlarus: AssetMediaResponseDto; + let assetHeic: AssetMediaResponseDto; + let assetRocks: AssetMediaResponseDto; + let assetOneJpg6: AssetMediaResponseDto; + let assetOneHeic6: AssetMediaResponseDto; + let assetOneJpg5: AssetMediaResponseDto; + let assetSprings: AssetMediaResponseDto; + let assetLast: AssetMediaResponseDto; let cities: string[]; let states: string[]; let countries: string[]; @@ -66,7 +66,7 @@ describe('/search', () => { // last asset { filename: '/albums/nature/wood_anemones.jpg' }, ]; - const assets: AssetFileUploadResponseDto[] = []; + const assets: AssetMediaResponseDto[] = []; for (const { filename, dto } of files) { const bytes = await readFile(join(testAssetDir, filename)); assets.push( @@ -134,7 +134,7 @@ describe('/search', () => { // assetWood, ] = assets; - assetLast = assets.at(-1) as AssetFileUploadResponseDto; + assetLast = assets.at(-1) as AssetMediaResponseDto; await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) }); diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index 0d76fb6efe..3448b2c5f2 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -1,6 +1,6 @@ import { AlbumResponseDto, - AssetFileUploadResponseDto, + AssetMediaResponseDto, LoginResponseDto, SharedLinkResponseDto, SharedLinkType, @@ -15,8 +15,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; describe('/shared-links', () => { let admin: LoginResponseDto; - let asset1: AssetFileUploadResponseDto; - let asset2: AssetFileUploadResponseDto; + let asset1: AssetMediaResponseDto; + let asset2: AssetMediaResponseDto; let user1: LoginResponseDto; let user2: LoginResponseDto; let album: AlbumResponseDto; diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts index af71d0e088..2a1f891583 100644 --- a/e2e/src/api/specs/timeline.e2e-spec.ts +++ b/e2e/src/api/specs/timeline.e2e-spec.ts @@ -1,4 +1,4 @@ -import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk'; +import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk'; import { DateTime } from 'luxon'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -19,7 +19,7 @@ describe('/timeline', () => { let user: LoginResponseDto; let timeBucketUser: LoginResponseDto; - let userAssets: AssetFileUploadResponseDto[]; + let userAssets: AssetMediaResponseDto[]; beforeAll(async () => { await utils.resetDatabase(); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index f9bc7a4445..d246293d94 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -1,9 +1,9 @@ import { AllJobStatusResponseDto, - AssetFileUploadResponseDto, + AssetMediaCreateDto, + AssetMediaResponseDto, AssetResponseDto, CreateAlbumDto, - CreateAssetDto, CreateLibraryDto, MetadataSearchDto, PersonCreateDto, @@ -292,7 +292,7 @@ export const utils = { createAsset: async ( accessToken: string, - dto?: Partial> & { assetData?: AssetData }, + dto?: Partial> & { assetData?: AssetData }, ) => { const _dto = { deviceAssetId: 'test-1', @@ -310,7 +310,7 @@ export const utils = { } const builder = request(app) - .post(`/asset/upload`) + .post(`/assets`) .attach('assetData', assetData, filename) .set('Authorization', `Bearer ${accessToken}`); @@ -320,7 +320,7 @@ export const utils = { const { body } = await builder; - return body as AssetFileUploadResponseDto; + return body as AssetMediaResponseDto; }, createImageFile: (path: string) => { diff --git a/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts index 9cfcc4f37b..072b48908e 100644 --- a/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts +++ b/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts @@ -1,10 +1,10 @@ -import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; +import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; import { expect, test } from '@playwright/test'; import { utils } from 'src/utils'; test.describe('Detail Panel', () => { let admin: LoginResponseDto; - let asset: AssetFileUploadResponseDto; + let asset: AssetMediaResponseDto; test.beforeAll(async () => { utils.initSdk(); diff --git a/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts index 642d63d5d3..c94340484b 100644 --- a/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts +++ b/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts @@ -1,10 +1,10 @@ -import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; +import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; import { expect, test } from '@playwright/test'; import { utils } from 'src/utils'; test.describe('Asset Viewer Navbar', () => { let admin: LoginResponseDto; - let asset: AssetFileUploadResponseDto; + let asset: AssetMediaResponseDto; test.beforeAll(async () => { utils.initSdk(); diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index 8687306615..26dda709bd 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -1,6 +1,6 @@ import { AlbumResponseDto, - AssetFileUploadResponseDto, + AssetMediaResponseDto, LoginResponseDto, SharedLinkResponseDto, SharedLinkType, @@ -11,7 +11,7 @@ import { asBearerAuth, utils } from 'src/utils'; test.describe('Shared Links', () => { let admin: LoginResponseDto; - let asset: AssetFileUploadResponseDto; + let asset: AssetMediaResponseDto; let album: AlbumResponseDto; let sharedLink: SharedLinkResponseDto; let sharedLinkPassword: SharedLinkResponseDto; diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 505d339aba..c86038ce35 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -30,7 +30,6 @@ import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.d import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart'; import 'package:isar/isar.dart'; -import 'package:openapi/api.dart' show ThumbnailFormat; @RoutePage() // ignore: must_be_immutable @@ -52,9 +51,6 @@ class GalleryViewerPage extends HookConsumerWidget { final PageController controller; - static const jpeg = ThumbnailFormat.JPEG; - static const webp = ThumbnailFormat.WEBP; - @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(appSettingsServiceProvider); diff --git a/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart index 23a237448e..f0f358343d 100644 --- a/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart @@ -22,8 +22,8 @@ Future videoPlayerController( // Use a network URL for the video player controller final serverEndpoint = Store.get(StoreKey.serverEndpoint); final String videoUrl = asset.livePhotoVideoId != null - ? '$serverEndpoint/asset/file/${asset.livePhotoVideoId}' - : '$serverEndpoint/asset/file/${asset.remoteId}'; + ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback' + : '$serverEndpoint/assets/${asset.remoteId}/video/playback'; final url = Uri.parse(videoUrl); final accessToken = Store.get(StoreKey.accessToken); diff --git a/mobile/lib/providers/image/immich_remote_image_provider.dart b/mobile/lib/providers/image/immich_remote_image_provider.dart index 8eeca91f22..2756ed1dc9 100644 --- a/mobile/lib/providers/image/immich_remote_image_provider.dart +++ b/mobile/lib/providers/image/immich_remote_image_provider.dart @@ -74,7 +74,7 @@ class ImmichRemoteImageProvider if (_loadPreview) { final preview = getThumbnailUrlForRemoteId( key.assetId, - type: api.ThumbnailFormat.WEBP, + type: api.AssetMediaSize.thumbnail, ); yield await ImageLoader.loadImageFromCache( @@ -88,7 +88,7 @@ class ImmichRemoteImageProvider // Load the higher resolution version of the image final url = getThumbnailUrlForRemoteId( key.assetId, - type: api.ThumbnailFormat.JPEG, + type: api.AssetMediaSize.preview, ); final codec = await ImageLoader.loadImageFromCache( url, diff --git a/mobile/lib/providers/image/immich_remote_thumbnail_provider.dart b/mobile/lib/providers/image/immich_remote_thumbnail_provider.dart index 11b70f30e1..a1e9c737b0 100644 --- a/mobile/lib/providers/image/immich_remote_thumbnail_provider.dart +++ b/mobile/lib/providers/image/immich_remote_thumbnail_provider.dart @@ -61,7 +61,7 @@ class ImmichRemoteThumbnailProvider // Load a preview to the chunk events final preview = getThumbnailUrlForRemoteId( key.assetId, - type: api.ThumbnailFormat.WEBP, + type: api.AssetMediaSize.thumbnail, ); yield await ImageLoader.loadImageFromCache( diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 24acba09b8..31a87a7aaa 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -2,26 +2,26 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:cancellation_token_http/http.dart' as http; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; -import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; +import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; +import 'package:path/path.dart' as p; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart'; -import 'package:cancellation_token_http/http.dart' as http; -import 'package:path/path.dart' as p; final backupServiceProvider = Provider( (ref) => BackupService( @@ -270,10 +270,12 @@ class BackupService { ); file = await entity.loadFile(progressHandler: pmProgressHandler); - livePhotoFile = await entity.loadFile( - withSubtype: true, - progressHandler: pmProgressHandler, - ); + if (entity.isLivePhoto) { + livePhotoFile = await entity.loadFile( + withSubtype: true, + progressHandler: pmProgressHandler, + ); + } } else { if (entity.type == AssetType.video) { file = await entity.originFile; @@ -288,6 +290,15 @@ class BackupService { if (file != null) { String originalFileName = await entity.titleAsync; + + if (entity.isLivePhoto) { + if (livePhotoFile == null) { + _log.warning( + "Failed to obtain motion part of the livePhoto - $originalFileName", + ); + } + } + var fileStream = file.openRead(); var assetRawUploadData = http.MultipartFile( "assetData", @@ -296,50 +307,29 @@ class BackupService { filename: originalFileName, ); - var req = MultipartRequest( + var baseRequest = MultipartRequest( 'POST', - Uri.parse('$savedEndpoint/asset/upload'), + Uri.parse('$savedEndpoint/assets'), onProgress: ((bytes, totalBytes) => uploadProgressCb(bytes, totalBytes)), ); - req.headers["x-immich-user-token"] = Store.get(StoreKey.accessToken); - req.headers["Transfer-Encoding"] = "chunked"; + baseRequest.headers["x-immich-user-token"] = + Store.get(StoreKey.accessToken); + baseRequest.headers["Transfer-Encoding"] = "chunked"; - req.fields['deviceAssetId'] = entity.id; - req.fields['deviceId'] = deviceId; - req.fields['fileCreatedAt'] = + baseRequest.fields['deviceAssetId'] = entity.id; + baseRequest.fields['deviceId'] = deviceId; + baseRequest.fields['fileCreatedAt'] = entity.createDateTime.toUtc().toIso8601String(); - req.fields['fileModifiedAt'] = + baseRequest.fields['fileModifiedAt'] = entity.modifiedDateTime.toUtc().toIso8601String(); - req.fields['isFavorite'] = entity.isFavorite.toString(); - req.fields['duration'] = entity.videoDuration.toString(); + baseRequest.fields['isFavorite'] = entity.isFavorite.toString(); + baseRequest.fields['duration'] = entity.videoDuration.toString(); - req.files.add(assetRawUploadData); + baseRequest.files.add(assetRawUploadData); var fileSize = file.lengthSync(); - if (entity.isLivePhoto) { - if (livePhotoFile != null) { - final livePhotoTitle = p.setExtension( - originalFileName, - p.extension(livePhotoFile.path), - ); - final fileStream = livePhotoFile.openRead(); - final livePhotoRawUploadData = http.MultipartFile( - "livePhotoData", - fileStream, - livePhotoFile.lengthSync(), - filename: livePhotoTitle, - ); - req.files.add(livePhotoRawUploadData); - fileSize += livePhotoFile.lengthSync(); - } else { - _log.warning( - "Failed to obtain motion part of the livePhoto - $originalFileName", - ); - } - } - setCurrentUploadAssetCb( CurrentUploadAsset( id: entity.id, @@ -353,19 +343,29 @@ class BackupService { ), ); - var response = - await httpClient.send(req, cancellationToken: cancelToken); + String? livePhotoVideoId; + if (entity.isLivePhoto && livePhotoFile != null) { + livePhotoVideoId = await uploadLivePhotoVideo( + originalFileName, + livePhotoFile, + baseRequest, + cancelToken, + ); + } - if (response.statusCode == 200) { - // asset is a duplicate (already exists on the server) - duplicatedAssetIds.add(entity.id); - uploadSuccessCb(entity.id, deviceId, true); - } else if (response.statusCode == 201) { - // stored a new asset on the server - uploadSuccessCb(entity.id, deviceId, false); - } else { - var data = await response.stream.bytesToString(); - var error = jsonDecode(data); + if (livePhotoVideoId != null) { + baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; + } + + var response = await httpClient.send( + baseRequest, + cancellationToken: cancelToken, + ); + + var responseBody = jsonDecode(await response.stream.bytesToString()); + + if (![200, 201].contains(response.statusCode)) { + var error = responseBody; var errorMessage = error['message'] ?? error['error']; debugPrint( @@ -389,6 +389,14 @@ class BackupService { } continue; } + + var isDuplicate = false; + if (response.statusCode == 200) { + isDuplicate = true; + duplicatedAssetIds.add(entity.id); + } + + uploadSuccessCb(entity.id, deviceId, isDuplicate); } } on http.CancelledException { debugPrint("Backup was cancelled by the user"); @@ -415,6 +423,54 @@ class BackupService { return !anyErrors; } + Future uploadLivePhotoVideo( + String originalFileName, + File? livePhotoVideoFile, + MultipartRequest baseRequest, + http.CancellationToken cancelToken, + ) async { + if (livePhotoVideoFile == null) { + return null; + } + final livePhotoTitle = p.setExtension( + originalFileName, + p.extension(livePhotoVideoFile.path), + ); + final fileStream = livePhotoVideoFile.openRead(); + final livePhotoRawUploadData = http.MultipartFile( + "assetData", + fileStream, + livePhotoVideoFile.lengthSync(), + filename: livePhotoTitle, + ); + final livePhotoReq = MultipartRequest( + baseRequest.method, + baseRequest.url, + onProgress: baseRequest.onProgress, + ) + ..headers.addAll(baseRequest.headers) + ..fields.addAll(baseRequest.fields); + + livePhotoReq.files.add(livePhotoRawUploadData); + + var response = await httpClient.send( + livePhotoReq, + cancellationToken: cancelToken, + ); + + var responseBody = jsonDecode(await response.stream.bytesToString()); + + if (![200, 201].contains(response.statusCode)) { + var error = responseBody; + + debugPrint( + "Error(${error['statusCode']}) uploading livePhoto for assetId | $livePhotoTitle | ${error['error']}", + ); + } + + return responseBody.containsKey('id') ? responseBody['id'] : null; + } + String _getAssetType(AssetType assetType) { switch (assetType) { case AssetType.audio: diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index a73881310d..c7cd134cb1 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -165,8 +165,8 @@ class BackupVerificationService { // (skip first few KBs containing metadata) final Uint64List localImage = _fakeDecodeImg(local, await file.readAsBytes()); - final res = await apiService.downloadApi - .downloadFileWithHttpInfo(remote.remoteId!); + final res = await apiService.assetsApi + .downloadAssetWithHttpInfo(remote.remoteId!); final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes); final eq = const ListEquality().equals(remoteImage, localImage); diff --git a/mobile/lib/services/image_viewer.service.dart b/mobile/lib/services/image_viewer.service.dart index 65b117ed1f..e61573af37 100644 --- a/mobile/lib/services/image_viewer.service.dart +++ b/mobile/lib/services/image_viewer.service.dart @@ -26,19 +26,19 @@ class ImageViewerService { // Download LivePhotos image and motion part if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { var imageResponse = - await _apiService.downloadApi.downloadFileWithHttpInfo( + await _apiService.assetsApi.downloadAssetWithHttpInfo( asset.remoteId!, ); - var motionReponse = - await _apiService.downloadApi.downloadFileWithHttpInfo( + var motionResponse = + await _apiService.assetsApi.downloadAssetWithHttpInfo( asset.livePhotoVideoId!, ); if (imageResponse.statusCode != 200 || - motionReponse.statusCode != 200) { + motionResponse.statusCode != 200) { final failedResponse = - imageResponse.statusCode != 200 ? imageResponse : motionReponse; + imageResponse.statusCode != 200 ? imageResponse : motionResponse; _log.severe( "Motion asset download failed", failedResponse.toLoggerString(), @@ -51,7 +51,7 @@ class ImageViewerService { final tempDir = await getTemporaryDirectory(); videoFile = await File('${tempDir.path}/livephoto.mov').create(); imageFile = await File('${tempDir.path}/livephoto.heic').create(); - videoFile.writeAsBytesSync(motionReponse.bodyBytes); + videoFile.writeAsBytesSync(motionResponse.bodyBytes); imageFile.writeAsBytesSync(imageResponse.bodyBytes); entity = await PhotoManager.editor.darwin.saveLivePhoto( @@ -73,8 +73,8 @@ class ImageViewerService { return entity != null; } else { - var res = await _apiService.downloadApi - .downloadFileWithHttpInfo(asset.remoteId!); + var res = await _apiService.assetsApi + .downloadAssetWithHttpInfo(asset.remoteId!); if (res.statusCode != 200) { _log.severe("Asset download failed", res.toLoggerString()); diff --git a/mobile/lib/services/share.service.dart b/mobile/lib/services/share.service.dart index e677b499d3..d5bc31783b 100644 --- a/mobile/lib/services/share.service.dart +++ b/mobile/lib/services/share.service.dart @@ -37,8 +37,8 @@ class ShareService { final tempDir = await getTemporaryDirectory(); final fileName = asset.fileName; final tempFile = await File('${tempDir.path}/$fileName').create(); - final res = await _apiService.downloadApi - .downloadFileWithHttpInfo(asset.remoteId!); + final res = await _apiService.assetsApi + .downloadAssetWithHttpInfo(asset.remoteId!); if (res.statusCode != 200) { _log.severe( diff --git a/mobile/lib/utils/hooks/chewiew_controller_hook.dart b/mobile/lib/utils/hooks/chewiew_controller_hook.dart index 08c30b2770..2868e896cf 100644 --- a/mobile/lib/utils/hooks/chewiew_controller_hook.dart +++ b/mobile/lib/utils/hooks/chewiew_controller_hook.dart @@ -129,8 +129,8 @@ class _ChewieControllerHookState // Use a network URL for the video player controller final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint); final String videoUrl = hook.asset.livePhotoVideoId != null - ? '$serverEndpoint/asset/file/${hook.asset.livePhotoVideoId}' - : '$serverEndpoint/asset/file/${hook.asset.remoteId}'; + ? '$serverEndpoint/assets/${hook.asset.livePhotoVideoId}/video/playback' + : '$serverEndpoint/assets/${hook.asset.remoteId}/video/playback'; final url = Uri.parse(videoUrl); final accessToken = store.Store.get(StoreKey.accessToken); diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 5d5719313e..a4b7ee8f7d 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -6,23 +6,23 @@ import 'package:openapi/api.dart'; String getThumbnailUrl( final Asset asset, { - ThumbnailFormat type = ThumbnailFormat.WEBP, + AssetMediaSize type = AssetMediaSize.thumbnail, }) { return getThumbnailUrlForRemoteId(asset.remoteId!, type: type); } String getThumbnailCacheKey( final Asset asset, { - ThumbnailFormat type = ThumbnailFormat.WEBP, + AssetMediaSize type = AssetMediaSize.thumbnail, }) { return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type); } String getThumbnailCacheKeyForRemoteId( final String id, { - ThumbnailFormat type = ThumbnailFormat.WEBP, + AssetMediaSize type = AssetMediaSize.thumbnail, }) { - if (type == ThumbnailFormat.WEBP) { + if (type == AssetMediaSize.thumbnail) { return 'thumbnail-image-$id'; } else { return '${id}_previewStage'; @@ -31,7 +31,7 @@ String getThumbnailCacheKeyForRemoteId( String getAlbumThumbnailUrl( final Album album, { - ThumbnailFormat type = ThumbnailFormat.WEBP, + AssetMediaSize type = AssetMediaSize.thumbnail, }) { if (album.thumbnail.value?.remoteId == null) { return ''; @@ -44,7 +44,7 @@ String getAlbumThumbnailUrl( String getAlbumThumbNailCacheKey( final Album album, { - ThumbnailFormat type = ThumbnailFormat.WEBP, + AssetMediaSize type = AssetMediaSize.thumbnail, }) { if (album.thumbnail.value?.remoteId == null) { return ''; @@ -60,7 +60,7 @@ String getImageUrl(final Asset asset) { } String getImageUrlFromId(final String id) { - return '${Store.get(StoreKey.serverEndpoint)}/asset/file/$id?isThumb=false'; + return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=preview'; } String getImageCacheKey(final Asset asset) { @@ -71,9 +71,9 @@ String getImageCacheKey(final Asset asset) { String getThumbnailUrlForRemoteId( final String id, { - ThumbnailFormat type = ThumbnailFormat.WEBP, + AssetMediaSize type = AssetMediaSize.thumbnail, }) { - return '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$id?format=${type.value}'; + return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?format=${type.value}'; } String getFaceThumbnailUrl(final String personId) { diff --git a/mobile/lib/widgets/album/album_thumbnail_listtile.dart b/mobile/lib/widgets/album/album_thumbnail_listtile.dart index 6107428e07..0018ceebcb 100644 --- a/mobile/lib/widgets/album/album_thumbnail_listtile.dart +++ b/mobile/lib/widgets/album/album_thumbnail_listtile.dart @@ -46,12 +46,13 @@ class AlbumThumbnailListTile extends StatelessWidget { fadeInDuration: const Duration(milliseconds: 200), imageUrl: getAlbumThumbnailUrl( album, - type: ThumbnailFormat.WEBP, + type: AssetMediaSize.thumbnail, ), httpHeaders: { "x-immich-user-token": Store.get(StoreKey.accessToken), }, - cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.WEBP), + cacheKey: + getAlbumThumbNailCacheKey(album, type: AssetMediaSize.thumbnail), errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined), ); diff --git a/mobile/lib/widgets/search/curated_places_row.dart b/mobile/lib/widgets/search/curated_places_row.dart index 49737b9fcf..babb20035a 100644 --- a/mobile/lib/widgets/search/curated_places_row.dart +++ b/mobile/lib/widgets/search/curated_places_row.dart @@ -115,7 +115,7 @@ class CuratedPlacesRow extends CuratedRow { final actualIndex = index - actualContentIndex; final object = content[actualIndex]; final thumbnailRequestUrl = - '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}'; + '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail'; return SizedBox( width: imageSize, height: imageSize, diff --git a/mobile/lib/widgets/search/curated_row.dart b/mobile/lib/widgets/search/curated_row.dart index 326faea48c..96537f65b4 100644 --- a/mobile/lib/widgets/search/curated_row.dart +++ b/mobile/lib/widgets/search/curated_row.dart @@ -46,7 +46,7 @@ class CuratedRow extends StatelessWidget { itemBuilder: (context, index) { final object = content[index]; final thumbnailRequestUrl = - '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}'; + '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail'; return SizedBox( width: imageSize, height: imageSize, diff --git a/mobile/lib/widgets/search/explore_grid.dart b/mobile/lib/widgets/search/explore_grid.dart index 91e903854d..8e90cc8504 100644 --- a/mobile/lib/widgets/search/explore_grid.dart +++ b/mobile/lib/widgets/search/explore_grid.dart @@ -44,7 +44,7 @@ class ExploreGrid extends StatelessWidget { final content = curatedContent[index]; final thumbnailRequestUrl = isPeople ? getFaceThumbnailUrl(content.id) - : '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${content.id}'; + : '${Store.get(StoreKey.serverEndpoint)}/assets/${content.id}/thumbnail'; return ThumbnailWithInfo( imageUrl: thumbnailRequestUrl, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3010822ee5..5e543c026b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -93,22 +93,23 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | *AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | *AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | -*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /asset/bulk-upload-check | -*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /asset/exist | -*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /asset | -*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | -*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /asset/{id} | -*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /asset/statistics | -*AssetsApi* | [**getAssetThumbnail**](doc//AssetsApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | -*AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /asset/memory-lane | -*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /asset/random | -*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /asset/{id}/file | -*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /asset/jobs | -*AssetsApi* | [**serveFile**](doc//AssetsApi.md#servefile) | **GET** /asset/file/{id} | -*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /asset/{id} | -*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /asset | -*AssetsApi* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /asset/stack/parent | -*AssetsApi* | [**uploadFile**](doc//AssetsApi.md#uploadfile) | **POST** /asset/upload | +*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | +*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | +*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | +*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | +*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | +*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | +*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | +*AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /assets/memory-lane | +*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | +*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | +*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | +*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | +*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | +*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | +*AssetsApi* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /assets/stack/parent | +*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | +*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | *AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | @@ -116,7 +117,6 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | -*DownloadApi* | [**downloadFile**](doc//DownloadApi.md#downloadfile) | **POST** /download/asset/{id} | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | *FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | @@ -260,13 +260,13 @@ Class | Method | HTTP request | Description - [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md) - [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md) - [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md) - - [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md) - [AssetFullSyncDto](doc//AssetFullSyncDto.md) - [AssetIdsDto](doc//AssetIdsDto.md) - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md) - [AssetJobName](doc//AssetJobName.md) - [AssetJobsDto](doc//AssetJobsDto.md) - [AssetMediaResponseDto](doc//AssetMediaResponseDto.md) + - [AssetMediaSize](doc//AssetMediaSize.md) - [AssetMediaStatus](doc//AssetMediaStatus.md) - [AssetOrder](doc//AssetOrder.md) - [AssetResponseDto](doc//AssetResponseDto.md) @@ -398,7 +398,6 @@ Class | Method | HTTP request | Description - [SystemConfigUserDto](doc//SystemConfigUserDto.md) - [TagResponseDto](doc//TagResponseDto.md) - [TagTypeEnum](doc//TagTypeEnum.md) - - [ThumbnailFormat](doc//ThumbnailFormat.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) - [ToneMapping](doc//ToneMapping.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8778e241ce..4c4c75f187 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -87,13 +87,13 @@ part 'model/asset_face_response_dto.dart'; part 'model/asset_face_update_dto.dart'; part 'model/asset_face_update_item.dart'; part 'model/asset_face_without_person_response_dto.dart'; -part 'model/asset_file_upload_response_dto.dart'; part 'model/asset_full_sync_dto.dart'; part 'model/asset_ids_dto.dart'; part 'model/asset_ids_response_dto.dart'; part 'model/asset_job_name.dart'; part 'model/asset_jobs_dto.dart'; part 'model/asset_media_response_dto.dart'; +part 'model/asset_media_size.dart'; part 'model/asset_media_status.dart'; part 'model/asset_order.dart'; part 'model/asset_response_dto.dart'; @@ -225,7 +225,6 @@ part 'model/system_config_trash_dto.dart'; part 'model/system_config_user_dto.dart'; part 'model/tag_response_dto.dart'; part 'model/tag_type_enum.dart'; -part 'model/thumbnail_format.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; part 'model/tone_mapping.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index ba7c6f54e0..d7d386130b 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -25,7 +25,7 @@ class AssetsApi { /// * [AssetBulkUploadCheckDto] assetBulkUploadCheckDto (required): Future checkBulkUploadWithHttpInfo(AssetBulkUploadCheckDto assetBulkUploadCheckDto,) async { // ignore: prefer_const_declarations - final path = r'/asset/bulk-upload-check'; + final path = r'/assets/bulk-upload-check'; // ignore: prefer_final_locals Object? postBody = assetBulkUploadCheckDto; @@ -77,7 +77,7 @@ class AssetsApi { /// * [CheckExistingAssetsDto] checkExistingAssetsDto (required): Future checkExistingAssetsWithHttpInfo(CheckExistingAssetsDto checkExistingAssetsDto,) async { // ignore: prefer_const_declarations - final path = r'/asset/exist'; + final path = r'/assets/exist'; // ignore: prefer_final_locals Object? postBody = checkExistingAssetsDto; @@ -120,13 +120,13 @@ class AssetsApi { return null; } - /// Performs an HTTP 'DELETE /asset' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /assets' operation and returns the [Response]. /// Parameters: /// /// * [AssetBulkDeleteDto] assetBulkDeleteDto (required): Future deleteAssetsWithHttpInfo(AssetBulkDeleteDto assetBulkDeleteDto,) async { // ignore: prefer_const_declarations - final path = r'/asset'; + final path = r'/assets'; // ignore: prefer_final_locals Object? postBody = assetBulkDeleteDto; @@ -159,6 +159,62 @@ class AssetsApi { } } + /// Performs an HTTP 'GET /assets/{id}/original' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + Future downloadAssetWithHttpInfo(String id, { String? key, }) async { + // ignore: prefer_const_declarations + final path = r'/assets/{id}/original' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + Future downloadAsset(String id, { String? key, }) async { + final response = await downloadAssetWithHttpInfo(id, key: key, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; + + } + return null; + } + /// Get all asset of a device that are in the database, ID only. /// /// Note: This method returns the HTTP [Response]. @@ -168,7 +224,7 @@ class AssetsApi { /// * [String] deviceId (required): Future getAllUserAssetsByDeviceIdWithHttpInfo(String deviceId,) async { // ignore: prefer_const_declarations - final path = r'/asset/device/{deviceId}' + final path = r'/assets/device/{deviceId}' .replaceAll('{deviceId}', deviceId); // ignore: prefer_final_locals @@ -215,7 +271,7 @@ class AssetsApi { return null; } - /// Performs an HTTP 'GET /asset/{id}' operation and returns the [Response]. + /// Performs an HTTP 'GET /assets/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -223,7 +279,7 @@ class AssetsApi { /// * [String] key: Future getAssetInfoWithHttpInfo(String id, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/asset/{id}' + final path = r'/assets/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -271,7 +327,7 @@ class AssetsApi { return null; } - /// Performs an HTTP 'GET /asset/statistics' operation and returns the [Response]. + /// Performs an HTTP 'GET /assets/statistics' operation and returns the [Response]. /// Parameters: /// /// * [bool] isArchived: @@ -281,7 +337,7 @@ class AssetsApi { /// * [bool] isTrashed: Future getAssetStatisticsWithHttpInfo({ bool? isArchived, bool? isFavorite, bool? isTrashed, }) async { // ignore: prefer_const_declarations - final path = r'/asset/statistics'; + final path = r'/assets/statistics'; // ignore: prefer_final_locals Object? postBody; @@ -336,70 +392,7 @@ class AssetsApi { return null; } - /// Performs an HTTP 'GET /asset/thumbnail/{id}' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [ThumbnailFormat] format: - /// - /// * [String] key: - Future getAssetThumbnailWithHttpInfo(String id, { ThumbnailFormat? format, String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/asset/thumbnail/{id}' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (format != null) { - queryParams.addAll(_queryParams('', 'format', format)); - } - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [ThumbnailFormat] format: - /// - /// * [String] key: - Future getAssetThumbnail(String id, { ThumbnailFormat? format, String? key, }) async { - final response = await getAssetThumbnailWithHttpInfo(id, format: format, key: key, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; - - } - return null; - } - - /// Performs an HTTP 'GET /asset/memory-lane' operation and returns the [Response]. + /// Performs an HTTP 'GET /assets/memory-lane' operation and returns the [Response]. /// Parameters: /// /// * [int] day (required): @@ -407,7 +400,7 @@ class AssetsApi { /// * [int] month (required): Future getMemoryLaneWithHttpInfo(int day, int month,) async { // ignore: prefer_const_declarations - final path = r'/asset/memory-lane'; + final path = r'/assets/memory-lane'; // ignore: prefer_final_locals Object? postBody; @@ -456,13 +449,13 @@ class AssetsApi { return null; } - /// Performs an HTTP 'GET /asset/random' operation and returns the [Response]. + /// Performs an HTTP 'GET /assets/random' operation and returns the [Response]. /// Parameters: /// /// * [num] count: Future getRandomWithHttpInfo({ num? count, }) async { // ignore: prefer_const_declarations - final path = r'/asset/random'; + final path = r'/assets/random'; // ignore: prefer_final_locals Object? postBody; @@ -510,6 +503,62 @@ class AssetsApi { return null; } + /// Performs an HTTP 'GET /assets/{id}/video/playback' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + Future playAssetVideoWithHttpInfo(String id, { String? key, }) async { + // ignore: prefer_const_declarations + final path = r'/assets/{id}/video/playback' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + Future playAssetVideo(String id, { String? key, }) async { + final response = await playAssetVideoWithHttpInfo(id, key: key, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; + + } + return null; + } + /// Replace the asset with new file, without changing its id /// /// Note: This method returns the HTTP [Response]. @@ -533,7 +582,7 @@ class AssetsApi { /// * [String] duration: Future replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, }) async { // ignore: prefer_const_declarations - final path = r'/asset/{id}/file' + final path = r'/assets/{id}/original' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -625,13 +674,13 @@ class AssetsApi { return null; } - /// Performs an HTTP 'POST /asset/jobs' operation and returns the [Response]. + /// Performs an HTTP 'POST /assets/jobs' operation and returns the [Response]. /// Parameters: /// /// * [AssetJobsDto] assetJobsDto (required): Future runAssetJobsWithHttpInfo(AssetJobsDto assetJobsDto,) async { // ignore: prefer_const_declarations - final path = r'/asset/jobs'; + final path = r'/assets/jobs'; // ignore: prefer_final_locals Object? postBody = assetJobsDto; @@ -664,77 +713,7 @@ class AssetsApi { } } - /// Performs an HTTP 'GET /asset/file/{id}' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [bool] isThumb: - /// - /// * [bool] isWeb: - /// - /// * [String] key: - Future serveFileWithHttpInfo(String id, { bool? isThumb, bool? isWeb, String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/asset/file/{id}' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (isThumb != null) { - queryParams.addAll(_queryParams('', 'isThumb', isThumb)); - } - if (isWeb != null) { - queryParams.addAll(_queryParams('', 'isWeb', isWeb)); - } - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [bool] isThumb: - /// - /// * [bool] isWeb: - /// - /// * [String] key: - Future serveFile(String id, { bool? isThumb, bool? isWeb, String? key, }) async { - final response = await serveFileWithHttpInfo(id, isThumb: isThumb, isWeb: isWeb, key: key, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; - - } - return null; - } - - /// Performs an HTTP 'PUT /asset/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PUT /assets/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -742,7 +721,7 @@ class AssetsApi { /// * [UpdateAssetDto] updateAssetDto (required): Future updateAssetWithHttpInfo(String id, UpdateAssetDto updateAssetDto,) async { // ignore: prefer_const_declarations - final path = r'/asset/{id}' + final path = r'/assets/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -786,13 +765,13 @@ class AssetsApi { return null; } - /// Performs an HTTP 'PUT /asset' operation and returns the [Response]. + /// Performs an HTTP 'PUT /assets' operation and returns the [Response]. /// Parameters: /// /// * [AssetBulkUpdateDto] assetBulkUpdateDto (required): Future updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/asset'; + final path = r'/assets'; // ignore: prefer_final_locals Object? postBody = assetBulkUpdateDto; @@ -825,13 +804,13 @@ class AssetsApi { } } - /// Performs an HTTP 'PUT /asset/stack/parent' operation and returns the [Response]. + /// Performs an HTTP 'PUT /assets/stack/parent' operation and returns the [Response]. /// Parameters: /// /// * [UpdateStackParentDto] updateStackParentDto (required): Future updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async { // ignore: prefer_const_declarations - final path = r'/asset/stack/parent'; + final path = r'/assets/stack/parent'; // ignore: prefer_final_locals Object? postBody = updateStackParentDto; @@ -864,7 +843,7 @@ class AssetsApi { } } - /// Performs an HTTP 'POST /asset/upload' operation and returns the [Response]. + /// Performs an HTTP 'POST /assets' operation and returns the [Response]. /// Parameters: /// /// * [MultipartFile] assetData (required): @@ -892,12 +871,12 @@ class AssetsApi { /// /// * [bool] isVisible: /// - /// * [MultipartFile] livePhotoData: + /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { + Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { // ignore: prefer_const_declarations - final path = r'/asset/upload'; + final path = r'/assets'; // ignore: prefer_final_locals Object? postBody; @@ -959,10 +938,9 @@ class AssetsApi { hasFields = true; mp.fields[r'isVisible'] = parameterToString(isVisible); } - if (livePhotoData != null) { + if (livePhotoVideoId != null) { hasFields = true; - mp.fields[r'livePhotoData'] = livePhotoData.field; - mp.files.add(livePhotoData); + mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId); } if (sidecarData != null) { hasFields = true; @@ -1011,11 +989,11 @@ class AssetsApi { /// /// * [bool] isVisible: /// - /// * [MultipartFile] livePhotoData: + /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { - final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoData: livePhotoData, sidecarData: sidecarData, ); + Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -1023,7 +1001,70 @@ class AssetsApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetFileUploadResponseDto',) as AssetFileUploadResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMediaResponseDto',) as AssetMediaResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /assets/{id}/thumbnail' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + /// + /// * [AssetMediaSize] size: + Future viewAssetWithHttpInfo(String id, { String? key, AssetMediaSize? size, }) async { + // ignore: prefer_const_declarations + final path = r'/assets/{id}/thumbnail' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (size != null) { + queryParams.addAll(_queryParams('', 'size', size)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + /// + /// * [AssetMediaSize] size: + Future viewAsset(String id, { String? key, AssetMediaSize? size, }) async { + final response = await viewAssetWithHttpInfo(id, key: key, size: size, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; } return null; diff --git a/mobile/openapi/lib/api/download_api.dart b/mobile/openapi/lib/api/download_api.dart index 2676313fae..b89f340ec7 100644 --- a/mobile/openapi/lib/api/download_api.dart +++ b/mobile/openapi/lib/api/download_api.dart @@ -71,62 +71,6 @@ class DownloadApi { return null; } - /// Performs an HTTP 'POST /download/asset/{id}' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [String] key: - Future downloadFileWithHttpInfo(String id, { String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/download/asset/{id}' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [String] key: - Future downloadFile(String id, { String? key, }) async { - final response = await downloadFileWithHttpInfo(id, key: key, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; - - } - return null; - } - /// Performs an HTTP 'POST /download/info' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index bf306ac10a..fd62d63ccb 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -238,8 +238,6 @@ class ApiClient { return AssetFaceUpdateItem.fromJson(value); case 'AssetFaceWithoutPersonResponseDto': return AssetFaceWithoutPersonResponseDto.fromJson(value); - case 'AssetFileUploadResponseDto': - return AssetFileUploadResponseDto.fromJson(value); case 'AssetFullSyncDto': return AssetFullSyncDto.fromJson(value); case 'AssetIdsDto': @@ -252,6 +250,8 @@ class ApiClient { return AssetJobsDto.fromJson(value); case 'AssetMediaResponseDto': return AssetMediaResponseDto.fromJson(value); + case 'AssetMediaSize': + return AssetMediaSizeTypeTransformer().decode(value); case 'AssetMediaStatus': return AssetMediaStatusTypeTransformer().decode(value); case 'AssetOrder': @@ -514,8 +514,6 @@ class ApiClient { return TagResponseDto.fromJson(value); case 'TagTypeEnum': return TagTypeEnumTypeTransformer().decode(value); - case 'ThumbnailFormat': - return ThumbnailFormatTypeTransformer().decode(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); case 'TimeBucketSize': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 4a8c774623..6f0acc0976 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -61,6 +61,9 @@ String parameterToString(dynamic value) { if (value is AssetJobName) { return AssetJobNameTypeTransformer().encode(value).toString(); } + if (value is AssetMediaSize) { + return AssetMediaSizeTypeTransformer().encode(value).toString(); + } if (value is AssetMediaStatus) { return AssetMediaStatusTypeTransformer().encode(value).toString(); } @@ -127,9 +130,6 @@ String parameterToString(dynamic value) { if (value is TagTypeEnum) { return TagTypeEnumTypeTransformer().encode(value).toString(); } - if (value is ThumbnailFormat) { - return ThumbnailFormatTypeTransformer().encode(value).toString(); - } if (value is TimeBucketSize) { return TimeBucketSizeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_file_upload_response_dto.dart b/mobile/openapi/lib/model/asset_file_upload_response_dto.dart deleted file mode 100644 index f198903a40..0000000000 --- a/mobile/openapi/lib/model/asset_file_upload_response_dto.dart +++ /dev/null @@ -1,106 +0,0 @@ -// -// 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 AssetFileUploadResponseDto { - /// Returns a new [AssetFileUploadResponseDto] instance. - AssetFileUploadResponseDto({ - required this.duplicate, - required this.id, - }); - - bool duplicate; - - String id; - - @override - bool operator ==(Object other) => identical(this, other) || other is AssetFileUploadResponseDto && - other.duplicate == duplicate && - other.id == id; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (duplicate.hashCode) + - (id.hashCode); - - @override - String toString() => 'AssetFileUploadResponseDto[duplicate=$duplicate, id=$id]'; - - Map toJson() { - final json = {}; - json[r'duplicate'] = this.duplicate; - json[r'id'] = this.id; - return json; - } - - /// Returns a new [AssetFileUploadResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static AssetFileUploadResponseDto? fromJson(dynamic value) { - if (value is Map) { - final json = value.cast(); - - return AssetFileUploadResponseDto( - duplicate: mapValueOfType(json, r'duplicate')!, - id: mapValueOfType(json, r'id')!, - ); - } - 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 = AssetFileUploadResponseDto.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 = AssetFileUploadResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of AssetFileUploadResponseDto-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] = AssetFileUploadResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'duplicate', - 'id', - }; -} - diff --git a/mobile/openapi/lib/model/thumbnail_format.dart b/mobile/openapi/lib/model/asset_media_size.dart similarity index 50% rename from mobile/openapi/lib/model/thumbnail_format.dart rename to mobile/openapi/lib/model/asset_media_size.dart index a255fac25e..2a950db411 100644 --- a/mobile/openapi/lib/model/thumbnail_format.dart +++ b/mobile/openapi/lib/model/asset_media_size.dart @@ -11,9 +11,9 @@ part of openapi.api; -class ThumbnailFormat { +class AssetMediaSize { /// Instantiate a new enum with the provided [value]. - const ThumbnailFormat._(this.value); + const AssetMediaSize._(this.value); /// The underlying value of this enum member. final String value; @@ -23,22 +23,22 @@ class ThumbnailFormat { String toJson() => value; - static const JPEG = ThumbnailFormat._(r'JPEG'); - static const WEBP = ThumbnailFormat._(r'WEBP'); + static const preview = AssetMediaSize._(r'preview'); + static const thumbnail = AssetMediaSize._(r'thumbnail'); - /// List of all possible values in this [enum][ThumbnailFormat]. - static const values = [ - JPEG, - WEBP, + /// List of all possible values in this [enum][AssetMediaSize]. + static const values = [ + preview, + thumbnail, ]; - static ThumbnailFormat? fromJson(dynamic value) => ThumbnailFormatTypeTransformer().decode(value); + static AssetMediaSize? fromJson(dynamic value) => AssetMediaSizeTypeTransformer().decode(value); - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = ThumbnailFormat.fromJson(row); + final value = AssetMediaSize.fromJson(row); if (value != null) { result.add(value); } @@ -48,16 +48,16 @@ class ThumbnailFormat { } } -/// Transformation class that can [encode] an instance of [ThumbnailFormat] to String, -/// and [decode] dynamic data back to [ThumbnailFormat]. -class ThumbnailFormatTypeTransformer { - factory ThumbnailFormatTypeTransformer() => _instance ??= const ThumbnailFormatTypeTransformer._(); +/// Transformation class that can [encode] an instance of [AssetMediaSize] to String, +/// and [decode] dynamic data back to [AssetMediaSize]. +class AssetMediaSizeTypeTransformer { + factory AssetMediaSizeTypeTransformer() => _instance ??= const AssetMediaSizeTypeTransformer._(); - const ThumbnailFormatTypeTransformer._(); + const AssetMediaSizeTypeTransformer._(); - String encode(ThumbnailFormat data) => data.value; + String encode(AssetMediaSize data) => data.value; - /// Decodes a [dynamic value][data] to a ThumbnailFormat. + /// Decodes a [dynamic value][data] to a AssetMediaSize. /// /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] @@ -65,11 +65,11 @@ class ThumbnailFormatTypeTransformer { /// /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, /// and users are still using an old app with the old code. - ThumbnailFormat? decode(dynamic data, {bool allowNull = true}) { + AssetMediaSize? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { - case r'JPEG': return ThumbnailFormat.JPEG; - case r'WEBP': return ThumbnailFormat.WEBP; + case r'preview': return AssetMediaSize.preview; + case r'thumbnail': return AssetMediaSize.thumbnail; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); @@ -79,7 +79,7 @@ class ThumbnailFormatTypeTransformer { return null; } - /// Singleton [ThumbnailFormatTypeTransformer] instance. - static ThumbnailFormatTypeTransformer? _instance; + /// Singleton [AssetMediaSizeTypeTransformer] instance. + static AssetMediaSizeTypeTransformer? _instance; } diff --git a/mobile/openapi/lib/model/asset_media_status.dart b/mobile/openapi/lib/model/asset_media_status.dart index ff6f62e33f..42fec08cc7 100644 --- a/mobile/openapi/lib/model/asset_media_status.dart +++ b/mobile/openapi/lib/model/asset_media_status.dart @@ -23,11 +23,13 @@ class AssetMediaStatus { String toJson() => value; + static const created = AssetMediaStatus._(r'created'); static const replaced = AssetMediaStatus._(r'replaced'); static const duplicate = AssetMediaStatus._(r'duplicate'); /// List of all possible values in this [enum][AssetMediaStatus]. static const values = [ + created, replaced, duplicate, ]; @@ -68,6 +70,7 @@ class AssetMediaStatusTypeTransformer { AssetMediaStatus? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { + case r'created': return AssetMediaStatus.created; case r'replaced': return AssetMediaStatus.replaced; case r'duplicate': return AssetMediaStatus.duplicate; default: diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index b1c1db2e1e..fde18953e6 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1805,4 +1805,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.22.1" diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1ee7ad8b08..5ffd86a07c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1295,7 +1295,7 @@ ] } }, - "/asset": { + "/assets": { "delete": { "operationId": "deleteAssets", "parameters": [], @@ -1329,6 +1329,65 @@ "Assets" ] }, + "post": { + "operationId": "uploadAsset", + "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "x-immich-checksum", + "in": "header", + "description": "sha1 checksum that can be used for duplicate detection before the file is uploaded", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AssetMediaCreateDto" + } + } + }, + "description": "Asset Upload Information", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMediaResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ] + }, "put": { "operationId": "updateAssets", "parameters": [], @@ -1363,7 +1422,7 @@ ] } }, - "/asset/bulk-upload-check": { + "/assets/bulk-upload-check": { "post": { "description": "Checks if assets exist by checksums", "operationId": "checkBulkUpload", @@ -1406,7 +1465,7 @@ ] } }, - "/asset/device/{deviceId}": { + "/assets/device/{deviceId}": { "get": { "description": "Get all asset of a device that are in the database, ID only.", "operationId": "getAllUserAssetsByDeviceId", @@ -1451,7 +1510,7 @@ ] } }, - "/asset/exist": { + "/assets/exist": { "post": { "description": "Checks if multiple assets exist on the server and returns all existing - used by background backup", "operationId": "checkExistingAssets", @@ -1494,76 +1553,7 @@ ] } }, - "/asset/file/{id}": { - "get": { - "operationId": "serveFile", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "isThumb", - "required": false, - "in": "query", - "schema": { - "title": "Is serve thumbnail (resize) file", - "type": "boolean" - } - }, - { - "name": "isWeb", - "required": false, - "in": "query", - "schema": { - "title": "Is request made from web", - "type": "boolean" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/octet-stream": { - "schema": { - "format": "binary", - "type": "string" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Assets" - ] - } - }, - "/asset/jobs": { + "/assets/jobs": { "post": { "operationId": "runAssetJobs", "parameters": [], @@ -1598,7 +1588,7 @@ ] } }, - "/asset/memory-lane": { + "/assets/memory-lane": { "get": { "operationId": "getMemoryLane", "parameters": [ @@ -1654,7 +1644,7 @@ ] } }, - "/asset/random": { + "/assets/random": { "get": { "operationId": "getRandom", "parameters": [ @@ -1699,7 +1689,7 @@ ] } }, - "/asset/stack/parent": { + "/assets/stack/parent": { "put": { "operationId": "updateStackParent", "parameters": [], @@ -1734,7 +1724,7 @@ ] } }, - "/asset/statistics": { + "/assets/statistics": { "get": { "operationId": "getAssetStatistics", "parameters": [ @@ -1791,127 +1781,7 @@ ] } }, - "/asset/thumbnail/{id}": { - "get": { - "operationId": "getAssetThumbnail", - "parameters": [ - { - "name": "format", - "required": false, - "in": "query", - "schema": { - "$ref": "#/components/schemas/ThumbnailFormat" - } - }, - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/octet-stream": { - "schema": { - "format": "binary", - "type": "string" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Assets" - ] - } - }, - "/asset/upload": { - "post": { - "operationId": "uploadFile", - "parameters": [ - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "x-immich-checksum", - "in": "header", - "description": "sha1 checksum that can be used for duplicate detection before the file is uploaded", - "required": false, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CreateAssetDto" - } - } - }, - "description": "Asset Upload Information", - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AssetFileUploadResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Assets" - ] - } - }, - "/asset/{id}": { + "/assets/{id}": { "get": { "operationId": "getAssetInfo", "parameters": [ @@ -2011,7 +1881,56 @@ ] } }, - "/asset/{id}/file": { + "/assets/{id}/original": { + "get": { + "operationId": "downloadAsset", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ] + }, "put": { "description": "Replace the asset with new file, without changing its id", "operationId": "replaceAsset", @@ -2075,6 +1994,116 @@ } } }, + "/assets/{id}/thumbnail": { + "get": { + "operationId": "viewAsset", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "size", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetMediaSize" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ] + } + }, + "/assets/{id}/video/playback": { + "get": { + "operationId": "playAssetVideo", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ] + } + }, "/audit/deletes": { "get": { "operationId": "getAuditDeletes", @@ -2354,57 +2383,6 @@ ] } }, - "/download/asset/{id}": { - "post": { - "operationId": "downloadFile", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/octet-stream": { - "schema": { - "format": "binary", - "type": "string" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Download" - ] - } - }, "/download/info": { "post": { "operationId": "getDownloadInfo", @@ -7417,21 +7395,6 @@ ], "type": "object" }, - "AssetFileUploadResponseDto": { - "properties": { - "duplicate": { - "type": "boolean" - }, - "id": { - "type": "string" - } - }, - "required": [ - "duplicate", - "id" - ], - "type": "object" - }, "AssetFullSyncDto": { "properties": { "lastCreationDate": { @@ -7526,6 +7489,59 @@ ], "type": "object" }, + "AssetMediaCreateDto": { + "properties": { + "assetData": { + "format": "binary", + "type": "string" + }, + "deviceAssetId": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "duration": { + "type": "string" + }, + "fileCreatedAt": { + "format": "date-time", + "type": "string" + }, + "fileModifiedAt": { + "format": "date-time", + "type": "string" + }, + "isArchived": { + "type": "boolean" + }, + "isFavorite": { + "type": "boolean" + }, + "isOffline": { + "type": "boolean" + }, + "isVisible": { + "type": "boolean" + }, + "livePhotoVideoId": { + "format": "uuid", + "type": "string" + }, + "sidecarData": { + "format": "binary", + "type": "string" + } + }, + "required": [ + "assetData", + "deviceAssetId", + "deviceId", + "fileCreatedAt", + "fileModifiedAt" + ], + "type": "object" + }, "AssetMediaReplaceDto": { "properties": { "assetData": { @@ -7574,8 +7590,16 @@ ], "type": "object" }, + "AssetMediaSize": { + "enum": [ + "preview", + "thumbnail" + ], + "type": "string" + }, "AssetMediaStatus": { "enum": [ + "created", "replaced", "duplicate" ], @@ -7963,59 +7987,6 @@ ], "type": "object" }, - "CreateAssetDto": { - "properties": { - "assetData": { - "format": "binary", - "type": "string" - }, - "deviceAssetId": { - "type": "string" - }, - "deviceId": { - "type": "string" - }, - "duration": { - "type": "string" - }, - "fileCreatedAt": { - "format": "date-time", - "type": "string" - }, - "fileModifiedAt": { - "format": "date-time", - "type": "string" - }, - "isArchived": { - "type": "boolean" - }, - "isFavorite": { - "type": "boolean" - }, - "isOffline": { - "type": "boolean" - }, - "isVisible": { - "type": "boolean" - }, - "livePhotoData": { - "format": "binary", - "type": "string" - }, - "sidecarData": { - "format": "binary", - "type": "string" - } - }, - "required": [ - "assetData", - "deviceAssetId", - "deviceId", - "fileCreatedAt", - "fileModifiedAt" - ], - "type": "object" - }, "CreateLibraryDto": { "properties": { "exclusionPatterns": { @@ -10872,13 +10843,6 @@ ], "type": "string" }, - "ThumbnailFormat": { - "enum": [ - "JPEG", - "WEBP" - ], - "type": "string" - }, "TimeBucketResponseDto": { "properties": { "count": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c6d8bd65de..b533a8f3af 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -264,6 +264,24 @@ export type AssetBulkDeleteDto = { force?: boolean; ids: string[]; }; +export type AssetMediaCreateDto = { + assetData: Blob; + deviceAssetId: string; + deviceId: string; + duration?: string; + fileCreatedAt: string; + fileModifiedAt: string; + isArchived?: boolean; + isFavorite?: boolean; + isOffline?: boolean; + isVisible?: boolean; + livePhotoVideoId?: string; + sidecarData?: Blob; +}; +export type AssetMediaResponseDto = { + id: string; + status: AssetMediaStatus; +}; export type AssetBulkUpdateDto = { dateTimeOriginal?: string; duplicateId?: string | null; @@ -316,24 +334,6 @@ export type AssetStatsResponseDto = { total: number; videos: number; }; -export type CreateAssetDto = { - assetData: Blob; - deviceAssetId: string; - deviceId: string; - duration?: string; - fileCreatedAt: string; - fileModifiedAt: string; - isArchived?: boolean; - isFavorite?: boolean; - isOffline?: boolean; - isVisible?: boolean; - livePhotoData?: Blob; - sidecarData?: Blob; -}; -export type AssetFileUploadResponseDto = { - duplicate: boolean; - id: string; -}; export type UpdateAssetDto = { dateTimeOriginal?: string; description?: string; @@ -350,10 +350,6 @@ export type AssetMediaReplaceDto = { fileCreatedAt: string; fileModifiedAt: string; }; -export type AssetMediaResponseDto = { - id: string; - status: AssetMediaStatus; -}; export type AuditDeletesResponseDto = { ids: string[]; needsFullSync: boolean; @@ -1434,16 +1430,35 @@ export function updateApiKey({ id, apiKeyUpdateDto }: { export function deleteAssets({ assetBulkDeleteDto }: { assetBulkDeleteDto: AssetBulkDeleteDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/asset", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/assets", oazapfts.json({ ...opts, method: "DELETE", body: assetBulkDeleteDto }))); } +export function uploadAsset({ key, xImmichChecksum, assetMediaCreateDto }: { + key?: string; + xImmichChecksum?: string; + assetMediaCreateDto: AssetMediaCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: AssetMediaResponseDto; + }>(`/assets${QS.query(QS.explode({ + key + }))}`, oazapfts.multipart({ + ...opts, + method: "POST", + body: assetMediaCreateDto, + headers: oazapfts.mergeHeaders(opts?.headers, { + "x-immich-checksum": xImmichChecksum + }) + }))); +} export function updateAssets({ assetBulkUpdateDto }: { assetBulkUpdateDto: AssetBulkUpdateDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/asset", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/assets", oazapfts.json({ ...opts, method: "PUT", body: assetBulkUpdateDto @@ -1458,7 +1473,7 @@ export function checkBulkUpload({ assetBulkUploadCheckDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetBulkUploadCheckResponseDto; - }>("/asset/bulk-upload-check", oazapfts.json({ + }>("/assets/bulk-upload-check", oazapfts.json({ ...opts, method: "POST", body: assetBulkUploadCheckDto @@ -1473,7 +1488,7 @@ export function getAllUserAssetsByDeviceId({ deviceId }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: string[]; - }>(`/asset/device/${encodeURIComponent(deviceId)}`, { + }>(`/assets/device/${encodeURIComponent(deviceId)}`, { ...opts })); } @@ -1486,33 +1501,16 @@ export function checkExistingAssets({ checkExistingAssetsDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: CheckExistingAssetsResponseDto; - }>("/asset/exist", oazapfts.json({ + }>("/assets/exist", oazapfts.json({ ...opts, method: "POST", body: checkExistingAssetsDto }))); } -export function serveFile({ id, isThumb, isWeb, key }: { - id: string; - isThumb?: boolean; - isWeb?: boolean; - key?: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchBlob<{ - status: 200; - data: Blob; - }>(`/asset/file/${encodeURIComponent(id)}${QS.query(QS.explode({ - isThumb, - isWeb, - key - }))}`, { - ...opts - })); -} export function runAssetJobs({ assetJobsDto }: { assetJobsDto: AssetJobsDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/asset/jobs", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/assets/jobs", oazapfts.json({ ...opts, method: "POST", body: assetJobsDto @@ -1525,7 +1523,7 @@ export function getMemoryLane({ day, month }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: MemoryLaneResponseDto[]; - }>(`/asset/memory-lane${QS.query(QS.explode({ + }>(`/assets/memory-lane${QS.query(QS.explode({ day, month }))}`, { @@ -1538,7 +1536,7 @@ export function getRandom({ count }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetResponseDto[]; - }>(`/asset/random${QS.query(QS.explode({ + }>(`/assets/random${QS.query(QS.explode({ count }))}`, { ...opts @@ -1547,7 +1545,7 @@ export function getRandom({ count }: { export function updateStackParent({ updateStackParentDto }: { updateStackParentDto: UpdateStackParentDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/asset/stack/parent", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/assets/stack/parent", oazapfts.json({ ...opts, method: "PUT", body: updateStackParentDto @@ -1561,7 +1559,7 @@ export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetStatsResponseDto; - }>(`/asset/statistics${QS.query(QS.explode({ + }>(`/assets/statistics${QS.query(QS.explode({ isArchived, isFavorite, isTrashed @@ -1569,40 +1567,6 @@ export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: { ...opts })); } -export function getAssetThumbnail({ format, id, key }: { - format?: ThumbnailFormat; - id: string; - key?: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchBlob<{ - status: 200; - data: Blob; - }>(`/asset/thumbnail/${encodeURIComponent(id)}${QS.query(QS.explode({ - format, - key - }))}`, { - ...opts - })); -} -export function uploadFile({ key, xImmichChecksum, createAssetDto }: { - key?: string; - xImmichChecksum?: string; - createAssetDto: CreateAssetDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; - data: AssetFileUploadResponseDto; - }>(`/asset/upload${QS.query(QS.explode({ - key - }))}`, oazapfts.multipart({ - ...opts, - method: "POST", - body: createAssetDto, - headers: oazapfts.mergeHeaders(opts?.headers, { - "x-immich-checksum": xImmichChecksum - }) - }))); -} export function getAssetInfo({ id, key }: { id: string; key?: string; @@ -1610,7 +1574,7 @@ export function getAssetInfo({ id, key }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetResponseDto; - }>(`/asset/${encodeURIComponent(id)}${QS.query(QS.explode({ + }>(`/assets/${encodeURIComponent(id)}${QS.query(QS.explode({ key }))}`, { ...opts @@ -1623,12 +1587,25 @@ export function updateAsset({ id, updateAssetDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetResponseDto; - }>(`/asset/${encodeURIComponent(id)}`, oazapfts.json({ + }>(`/assets/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, method: "PUT", body: updateAssetDto }))); } +export function downloadAsset({ id, key }: { + id: string; + key?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({ + key + }))}`, { + ...opts + })); +} /** * Replace the asset with new file, without changing its id */ @@ -1640,7 +1617,7 @@ export function replaceAsset({ id, key, assetMediaReplaceDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetMediaResponseDto; - }>(`/asset/${encodeURIComponent(id)}/file${QS.query(QS.explode({ + }>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({ key }))}`, oazapfts.multipart({ ...opts, @@ -1648,6 +1625,34 @@ export function replaceAsset({ id, key, assetMediaReplaceDto }: { body: assetMediaReplaceDto }))); } +export function viewAsset({ id, key, size }: { + id: string; + key?: string; + size?: AssetMediaSize; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/assets/${encodeURIComponent(id)}/thumbnail${QS.query(QS.explode({ + key, + size + }))}`, { + ...opts + })); +} +export function playAssetVideo({ id, key }: { + id: string; + key?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/assets/${encodeURIComponent(id)}/video/playback${QS.query(QS.explode({ + key + }))}`, { + ...opts + })); +} export function getAuditDeletes({ after, entityType, userId }: { after: string; entityType: EntityType; @@ -1733,20 +1738,6 @@ export function downloadArchive({ key, assetIdsDto }: { body: assetIdsDto }))); } -export function downloadFile({ id, key }: { - id: string; - key?: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchBlob<{ - status: 200; - data: Blob; - }>(`/download/asset/${encodeURIComponent(id)}${QS.query(QS.explode({ - key - }))}`, { - ...opts, - method: "POST" - })); -} export function getDownloadInfo({ key, downloadInfoDto }: { key?: string; downloadInfoDto: DownloadInfoDto; @@ -2929,6 +2920,11 @@ export enum Error { NotFound = "not_found", Unknown = "unknown" } +export enum AssetMediaStatus { + Created = "created", + Replaced = "replaced", + Duplicate = "duplicate" +} export enum Action { Accept = "accept", Reject = "reject" @@ -2942,13 +2938,9 @@ export enum AssetJobName { RefreshMetadata = "refresh-metadata", TranscodeVideo = "transcode-video" } -export enum ThumbnailFormat { - Jpeg = "JPEG", - Webp = "WEBP" -} -export enum AssetMediaStatus { - Replaced = "replaced", - Duplicate = "duplicate" +export enum AssetMediaSize { + Preview = "preview", + Thumbnail = "thumbnail" } export enum EntityType { Asset = "ASSET", diff --git a/open-api/typescript-sdk/src/index.ts b/open-api/typescript-sdk/src/index.ts index c06ac4802e..77be18f0e7 100644 --- a/open-api/typescript-sdk/src/index.ts +++ b/open-api/typescript-sdk/src/index.ts @@ -24,9 +24,12 @@ export const setApiKey = (apiKey: string) => { defaults.headers['x-api-key'] = apiKey; }; -export const getAssetOriginalPath = (id: string) => `/asset/file/${id}`; +export const getAssetOriginalPath = (id: string) => `/assets/${id}/original`; -export const getAssetThumbnailPath = (id: string) => `/asset/thumbnail/${id}`; +export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`; + +export const getAssetPlaybackPath = (id: string) => + `/assets/${id}/video/playback`; export const getUserProfileImagePath = (userId: string) => `/users/${userId}/profile-image`; diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index d70afc9dab..48fea8b8a6 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -1,37 +1,44 @@ import { Body, Controller, + Get, HttpCode, HttpStatus, Inject, + Next, Param, ParseFilePipe, Post, Put, + Query, Res, UploadedFiles, UseInterceptors, } from '@nestjs/common'; -import { ApiConsumes, ApiTags } from '@nestjs/swagger'; -import { Response } from 'express'; +import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; +import { NextFunction, Response } from 'express'; import { EndpointLifecycle } from 'src/decorators'; import { AssetBulkUploadCheckResponseDto, AssetMediaResponseDto, - AssetMediaStatusEnum, + AssetMediaStatus, CheckExistingAssetsResponseDto, } from 'src/dtos/asset-media-response.dto'; import { AssetBulkUploadCheckDto, + AssetMediaCreateDto, + AssetMediaOptionsDto, AssetMediaReplaceDto, CheckExistingAssetsDto, UploadFieldName, } from 'src/dtos/asset-media.dto'; -import { AuthDto } from 'src/dtos/auth.dto'; +import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; +import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { FileUploadInterceptor, Route, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; import { AssetMediaService } from 'src/services/asset-media.service'; +import { sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; @ApiTags('Assets') @@ -42,10 +49,48 @@ export class AssetMediaController { private service: AssetMediaService, ) {} + @Post() + @UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor) + @ApiConsumes('multipart/form-data') + @ApiHeader({ + name: ImmichHeader.CHECKSUM, + description: 'sha1 checksum that can be used for duplicate detection before the file is uploaded', + required: false, + }) + @ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto }) + @Authenticated({ sharedLink: true }) + async uploadAsset( + @Auth() auth: AuthDto, + @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles, + @Body() dto: AssetMediaCreateDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + const { file, sidecarFile } = getFiles(files); + const responseDto = await this.service.uploadAsset(auth, dto, file, sidecarFile); + + if (responseDto.status === AssetMediaStatus.DUPLICATE) { + res.status(HttpStatus.OK); + } + + return responseDto; + } + + @Get(':id/original') + @FileResponse() + @Authenticated({ sharedLink: true }) + async downloadAsset( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Res() res: Response, + @Next() next: NextFunction, + ) { + await sendFile(res, next, () => this.service.downloadOriginal(auth, id), this.logger); + } + /** * Replace the asset with new file, without changing its id */ - @Put(':id/file') + @Put(':id/original') @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') @Authenticated({ sharedLink: true }) @@ -60,12 +105,37 @@ export class AssetMediaController { ): Promise { const { file } = getFiles(files); const responseDto = await this.service.replaceAsset(auth, id, dto, file); - if (responseDto.status === AssetMediaStatusEnum.DUPLICATE) { + if (responseDto.status === AssetMediaStatus.DUPLICATE) { res.status(HttpStatus.OK); } return responseDto; } + @Get(':id/thumbnail') + @FileResponse() + @Authenticated({ sharedLink: true }) + async viewAsset( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Query() dto: AssetMediaOptionsDto, + @Res() res: Response, + @Next() next: NextFunction, + ) { + await sendFile(res, next, () => this.service.viewThumbnail(auth, id, dto), this.logger); + } + + @Get(':id/video/playback') + @FileResponse() + @Authenticated({ sharedLink: true }) + async playAssetVideo( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Res() res: Response, + @Next() next: NextFunction, + ) { + await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger); + } + /** * Checks if multiple assets exist on the server and returns all existing - used by background backup */ diff --git a/server/src/controllers/asset-v1.controller.ts b/server/src/controllers/asset-v1.controller.ts deleted file mode 100644 index d1e71a6175..0000000000 --- a/server/src/controllers/asset-v1.controller.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - Body, - Controller, - Get, - HttpStatus, - Inject, - Next, - Param, - ParseFilePipe, - Post, - Query, - Res, - UploadedFiles, - UseInterceptors, -} from '@nestjs/common'; -import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; -import { NextFunction, Response } from 'express'; -import { AssetFileUploadResponseDto } from 'src/dtos/asset-v1-response.dto'; -import { CreateAssetDto, GetAssetThumbnailDto, ServeFileDto } from 'src/dtos/asset-v1.dto'; -import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; -import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FileUploadInterceptor, Route, UploadFiles, mapToUploadFile } from 'src/middleware/file-upload.interceptor'; -import { AssetServiceV1 } from 'src/services/asset-v1.service'; -import { sendFile } from 'src/utils/file'; -import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; - -@ApiTags('Assets') -@Controller(Route.ASSET) -export class AssetControllerV1 { - constructor( - private service: AssetServiceV1, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) {} - - @Post('upload') - @UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor) - @ApiConsumes('multipart/form-data') - @ApiHeader({ - name: ImmichHeader.CHECKSUM, - description: 'sha1 checksum that can be used for duplicate detection before the file is uploaded', - required: false, - }) - @ApiBody({ description: 'Asset Upload Information', type: CreateAssetDto }) - @Authenticated({ sharedLink: true }) - async uploadFile( - @Auth() auth: AuthDto, - @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles, - @Body() dto: CreateAssetDto, - @Res({ passthrough: true }) res: Response, - ): Promise { - const file = mapToUploadFile(files.assetData[0]); - const _livePhotoFile = files.livePhotoData?.[0]; - const _sidecarFile = files.sidecarData?.[0]; - let livePhotoFile; - if (_livePhotoFile) { - livePhotoFile = mapToUploadFile(_livePhotoFile); - } - - let sidecarFile; - if (_sidecarFile) { - sidecarFile = mapToUploadFile(_sidecarFile); - } - - const responseDto = await this.service.uploadFile(auth, dto, file, livePhotoFile, sidecarFile); - if (responseDto.duplicate) { - res.status(HttpStatus.OK); - } - - return responseDto; - } - - @Get('/file/:id') - @FileResponse() - @Authenticated({ sharedLink: true }) - async serveFile( - @Res() res: Response, - @Next() next: NextFunction, - @Auth() auth: AuthDto, - @Param() { id }: UUIDParamDto, - @Query() dto: ServeFileDto, - ) { - await sendFile(res, next, () => this.service.serveFile(auth, id, dto), this.logger); - } - - @Get('/thumbnail/:id') - @FileResponse() - @Authenticated({ sharedLink: true }) - async getAssetThumbnail( - @Res() res: Response, - @Next() next: NextFunction, - @Auth() auth: AuthDto, - @Param() { id }: UUIDParamDto, - @Query() dto: GetAssetThumbnailDto, - ) { - await sendFile(res, next, () => this.service.serveThumbnail(auth, id, dto), this.logger); - } -} diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index bc1d01c4be..880e636dd1 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -1,22 +1,16 @@ -import { Body, Controller, HttpCode, HttpStatus, Inject, Next, Param, Post, Res, StreamableFile } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { NextFunction, Response } from 'express'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { DownloadService } from 'src/services/download.service'; -import { asStreamableFile, sendFile } from 'src/utils/file'; -import { UUIDParamDto } from 'src/validation'; +import { asStreamableFile } from 'src/utils/file'; @ApiTags('Download') @Controller('download') export class DownloadController { - constructor( - private service: DownloadService, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) {} + constructor(private service: DownloadService) {} @Post('info') @Authenticated({ sharedLink: true }) @@ -31,17 +25,4 @@ export class DownloadController { downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise { return this.service.downloadArchive(auth, dto).then(asStreamableFile); } - - @Post('asset/:id') - @HttpCode(HttpStatus.OK) - @FileResponse() - @Authenticated({ sharedLink: true }) - async downloadFile( - @Res() res: Response, - @Next() next: NextFunction, - @Auth() auth: AuthDto, - @Param() { id }: UUIDParamDto, - ) { - await sendFile(res, next, () => this.service.downloadFile(auth, id), this.logger); - } } diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 0f2112b0b4..221e382cfe 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -3,7 +3,6 @@ import { AlbumController } from 'src/controllers/album.controller'; import { APIKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller'; -import { AssetControllerV1 } from 'src/controllers/asset-v1.controller'; import { AssetController } from 'src/controllers/asset.controller'; import { AuditController } from 'src/controllers/audit.controller'; import { AuthController } from 'src/controllers/auth.controller'; @@ -37,7 +36,6 @@ export const controllers = [ AlbumController, AppController, AssetController, - AssetControllerV1, AssetMediaController, AuditController, AuthController, diff --git a/server/src/dtos/asset-media-response.dto.ts b/server/src/dtos/asset-media-response.dto.ts index 66e2e3160a..33fa080bc1 100644 --- a/server/src/dtos/asset-media-response.dto.ts +++ b/server/src/dtos/asset-media-response.dto.ts @@ -1,12 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; -export enum AssetMediaStatusEnum { +export enum AssetMediaStatus { + CREATED = 'created', REPLACED = 'replaced', DUPLICATE = 'duplicate', } export class AssetMediaResponseDto { - @ApiProperty({ enum: AssetMediaStatusEnum, enumName: 'AssetMediaStatus' }) - status!: AssetMediaStatusEnum; + @ApiProperty({ enum: AssetMediaStatus, enumName: 'AssetMediaStatus' }) + status!: AssetMediaStatus; id!: string; } diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index 2f8fa105cb..e9e346c4cb 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -1,16 +1,27 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; -import { Optional, ValidateDate } from 'src/validation'; +import { ArrayNotEmpty, IsArray, IsEnum, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; + +export enum AssetMediaSize { + PREVIEW = 'preview', + THUMBNAIL = 'thumbnail', +} + +export class AssetMediaOptionsDto { + @Optional() + @IsEnum(AssetMediaSize) + @ApiProperty({ enumName: 'AssetMediaSize', enum: AssetMediaSize }) + size?: AssetMediaSize; +} export enum UploadFieldName { ASSET_DATA = 'assetData', - LIVE_PHOTO_DATA = 'livePhotoData', SIDECAR_DATA = 'sidecarData', PROFILE_DATA = 'file', } -export class AssetMediaReplaceDto { +class AssetMediaBase { @IsNotEmpty() @IsString() deviceAssetId!: string; @@ -35,6 +46,28 @@ export class AssetMediaReplaceDto { [UploadFieldName.ASSET_DATA]!: any; } +export class AssetMediaCreateDto extends AssetMediaBase { + @ValidateBoolean({ optional: true }) + isFavorite?: boolean; + + @ValidateBoolean({ optional: true }) + isArchived?: boolean; + + @ValidateBoolean({ optional: true }) + isVisible?: boolean; + + @ValidateBoolean({ optional: true }) + isOffline?: boolean; + + @ValidateUUID({ optional: true }) + livePhotoVideoId?: string; + + @ApiProperty({ type: 'string', format: 'binary', required: false }) + [UploadFieldName.SIDECAR_DATA]?: any; +} + +export class AssetMediaReplaceDto extends AssetMediaBase {} + export class AssetBulkUploadCheckItem { @IsString() @IsNotEmpty() diff --git a/server/src/dtos/asset-v1-response.dto.ts b/server/src/dtos/asset-v1-response.dto.ts deleted file mode 100644 index f628b708dc..0000000000 --- a/server/src/dtos/asset-v1-response.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class AssetFileUploadResponseDto { - id!: string; - duplicate!: boolean; -} diff --git a/server/src/dtos/asset-v1.dto.ts b/server/src/dtos/asset-v1.dto.ts deleted file mode 100644 index 0ec68e677a..0000000000 --- a/server/src/dtos/asset-v1.dto.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { UploadFieldName } from 'src/dtos/asset.dto'; -import { Optional, ValidateBoolean, ValidateDate } from 'src/validation'; - -export class CreateAssetDto { - @IsNotEmpty() - @IsString() - deviceAssetId!: string; - - @IsNotEmpty() - @IsString() - deviceId!: string; - - @ValidateDate() - fileCreatedAt!: Date; - - @ValidateDate() - fileModifiedAt!: Date; - - @Optional() - @IsString() - duration?: string; - - @ValidateBoolean({ optional: true }) - isFavorite?: boolean; - - @ValidateBoolean({ optional: true }) - isArchived?: boolean; - - @ValidateBoolean({ optional: true }) - isVisible?: boolean; - - @ValidateBoolean({ optional: true }) - isOffline?: boolean; - - // The properties below are added to correctly generate the API docs - // and client SDKs. Validation should be handled in the controller. - @ApiProperty({ type: 'string', format: 'binary' }) - [UploadFieldName.ASSET_DATA]!: any; - - @ApiProperty({ type: 'string', format: 'binary', required: false }) - [UploadFieldName.LIVE_PHOTO_DATA]?: any; - - @ApiProperty({ type: 'string', format: 'binary', required: false }) - [UploadFieldName.SIDECAR_DATA]?: any; -} - -export enum GetAssetThumbnailFormatEnum { - JPEG = 'JPEG', - WEBP = 'WEBP', -} - -export class GetAssetThumbnailDto { - @Optional() - @IsEnum(GetAssetThumbnailFormatEnum) - @ApiProperty({ - type: String, - enum: GetAssetThumbnailFormatEnum, - default: GetAssetThumbnailFormatEnum.WEBP, - required: false, - enumName: 'ThumbnailFormat', - }) - format: GetAssetThumbnailFormatEnum = GetAssetThumbnailFormatEnum.WEBP; -} - -export class ServeFileDto { - @ValidateBoolean({ optional: true }) - @ApiProperty({ title: 'Is serve thumbnail (resize) file' }) - isThumb?: boolean; - - @ValidateBoolean({ optional: true }) - @ApiProperty({ title: 'Is request made from web' }) - isWeb?: boolean; -} diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index e60c323a6e..4d2ddb0a3e 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -127,9 +127,3 @@ export const mapStats = (stats: AssetStats): AssetStatsResponseDto => { total: Object.values(stats).reduce((total, value) => total + value, 0), }; }; -export enum UploadFieldName { - ASSET_DATA = 'assetData', - LIVE_PHOTO_DATA = 'livePhotoData', - SIDECAR_DATA = 'sidecarData', - PROFILE_DATA = 'file', -} diff --git a/server/src/dtos/user-profile.dto.ts b/server/src/dtos/user-profile.dto.ts index bc879380c4..b14662c844 100644 --- a/server/src/dtos/user-profile.dto.ts +++ b/server/src/dtos/user-profile.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { UploadFieldName } from 'src/dtos/asset.dto'; +import { UploadFieldName } from 'src/dtos/asset-media.dto'; export class CreateProfileImageDto { @ApiProperty({ type: 'string', format: 'binary' }) diff --git a/server/src/interfaces/asset-v1.interface.ts b/server/src/interfaces/asset-v1.interface.ts deleted file mode 100644 index 73d90019e2..0000000000 --- a/server/src/interfaces/asset-v1.interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; - -export interface AssetCheck { - id: string; - checksum: Buffer; -} - -export interface AssetOwnerCheck extends AssetCheck { - ownerId: string; -} - -export interface IAssetRepositoryV1 { - get(id: string): Promise; - getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise; -} - -export const IAssetRepositoryV1 = 'IAssetRepositoryV1'; diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index bd617b894c..80a7538e0c 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -153,7 +153,7 @@ export interface IAssetRepository { ): Promise; getByIdsWithAllRelations(ids: string[]): Promise; getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise; - getByChecksum(libraryId: string | null, checksum: Buffer): Promise; + getByChecksum(options: { ownerId: string; checksum: Buffer; libraryId?: string }): Promise; getByChecksums(userId: string, checksums: Buffer[]): Promise; getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; diff --git a/server/src/middleware/asset-upload.interceptor.ts b/server/src/middleware/asset-upload.interceptor.ts index 845e6f906d..5a460fb2d9 100644 --- a/server/src/middleware/asset-upload.interceptor.ts +++ b/server/src/middleware/asset-upload.interceptor.ts @@ -1,18 +1,18 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Response } from 'express'; -import { AssetFileUploadResponseDto } from 'src/dtos/asset-v1-response.dto'; +import { AssetMediaResponseDto } from 'src/dtos/asset-media-response.dto'; import { ImmichHeader } from 'src/dtos/auth.dto'; import { AuthenticatedRequest } from 'src/middleware/auth.guard'; -import { AssetService } from 'src/services/asset.service'; +import { AssetMediaService } from 'src/services/asset-media.service'; import { fromMaybeArray } from 'src/utils/request'; @Injectable() export class AssetUploadInterceptor implements NestInterceptor { - constructor(private service: AssetService) {} + constructor(private service: AssetMediaService) {} async intercept(context: ExecutionContext, next: CallHandler) { const req = context.switchToHttp().getRequest(); - const res = context.switchToHttp().getResponse>(); + const res = context.switchToHttp().getResponse>(); const checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]); const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum); diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index f4fc755100..6ec8b401ef 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -9,16 +9,14 @@ import { Observable } from 'rxjs'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; -import { UploadFile } from 'src/services/asset-media.service'; -import { AssetService } from 'src/services/asset.service'; +import { AssetMediaService, UploadFile } from 'src/services/asset-media.service'; export interface UploadFiles { assetData: ImmichFile[]; - livePhotoData?: ImmichFile[]; sidecarData: ImmichFile[]; } -export function getFile(files: UploadFiles, property: 'assetData' | 'livePhotoData' | 'sidecarData') { +export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') { const file = files[property]?.[0]; return file ? mapToUploadFile(file) : file; } @@ -26,13 +24,12 @@ export function getFile(files: UploadFiles, property: 'assetData' | 'livePhotoDa export function getFiles(files: UploadFiles) { return { file: getFile(files, 'assetData') as UploadFile, - livePhotoFile: getFile(files, 'livePhotoData'), sidecarFile: getFile(files, 'sidecarData'), }; } export enum Route { - ASSET = 'asset', + ASSET = 'assets', USER = 'users', } @@ -87,7 +84,7 @@ export class FileUploadInterceptor implements NestInterceptor { constructor( private reflect: Reflector, - private assetService: AssetService, + private assetService: AssetMediaService, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(FileUploadInterceptor.name); @@ -109,7 +106,6 @@ export class FileUploadInterceptor implements NestInterceptor { userProfile: instance.single(UploadFieldName.PROFILE_DATA), assetUpload: instance.fields([ { name: UploadFieldName.ASSET_DATA, maxCount: 1 }, - { name: UploadFieldName.LIVE_PHOTO_DATA, maxCount: 1 }, { name: UploadFieldName.SIDECAR_DATA, maxCount: 1 }, ]), }; @@ -172,8 +168,7 @@ export class FileUploadInterceptor implements NestInterceptor { private isAssetUploadFile(file: Express.Multer.File) { switch (file.fieldname as UploadFieldName) { - case UploadFieldName.ASSET_DATA: - case UploadFieldName.LIVE_PHOTO_DATA: { + case UploadFieldName.ASSET_DATA: { return true; } } diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 9615ebbcef..3ee46c4c13 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -474,8 +474,9 @@ FROM WHERE ( ( - ("AssetEntity"."libraryId" = $1) - AND ("AssetEntity"."checksum" = $2) + ("AssetEntity"."ownerId" = $1) + AND ("AssetEntity"."libraryId" = $2) + AND ("AssetEntity"."checksum" = $3) ) ) AND ("AssetEntity"."deletedAt" IS NULL) diff --git a/server/src/repositories/asset-v1.repository.ts b/server/src/repositories/asset-v1.repository.ts deleted file mode 100644 index 1278c17e95..0000000000 --- a/server/src/repositories/asset-v1.repository.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetCheck, IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface'; -import { In } from 'typeorm/find-options/operator/In.js'; -import { Repository } from 'typeorm/repository/Repository.js'; - -@Injectable() -export class AssetRepositoryV1 implements IAssetRepositoryV1 { - constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} - - get(id: string): Promise { - return this.assetRepository.findOne({ - where: { id }, - relations: { - faces: { - person: true, - }, - library: true, - }, - withDeleted: true, - }); - } - - /** - * Get assets by checksums on the database - * @param ownerId - * @param checksums - * - */ - getAssetsByChecksums(ownerId: string, checksums: Buffer[]): Promise { - return this.assetRepository.find({ - select: { - id: true, - checksum: true, - }, - where: { - ownerId, - checksum: In(checksums), - }, - withDeleted: true, - }); - } -} diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 01f76c9075..d1efa8b197 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -300,10 +300,19 @@ export class AssetRepository implements IAssetRepository { await this.repository.remove(asset); } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) - getByChecksum(libraryId: string | null, checksum: Buffer): Promise { + @GenerateSql({ params: [{ ownerId: DummyValue.UUID, libraryId: DummyValue.UUID, checksum: DummyValue.BUFFER }] }) + getByChecksum({ + ownerId, + libraryId, + checksum, + }: { + ownerId: string; + checksum: Buffer; + libraryId?: string; + }): Promise { return this.repository.findOne({ where: { + ownerId, libraryId: libraryId || IsNull(), checksum, }, diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 3298f984e7..758e82d9e7 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -4,7 +4,6 @@ import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; -import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -37,7 +36,6 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AssetStackRepository } from 'src/repositories/asset-stack.repository'; -import { AssetRepositoryV1 } from 'src/repositories/asset-v1.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; @@ -71,7 +69,6 @@ export const repositories = [ { provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAlbumUserRepository, useClass: AlbumUserRepository }, { provide: IAssetRepository, useClass: AssetRepository }, - { provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 }, { provide: IAssetStackRepository, useClass: AssetStackRepository }, { provide: IAuditRepository, useClass: AuditRepository }, { provide: ICryptoRepository, useClass: CryptoRepository }, diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index a75ece42f5..bf545ff3b3 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -1,16 +1,17 @@ +import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { Stats } from 'node:fs'; -import { AssetMediaStatusEnum, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; -import { AssetMediaReplaceDto } from 'src/dtos/asset-media.dto'; +import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; +import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; -import { AssetMediaService, UploadFile } from 'src/services/asset-media.service'; -import { mimeTypes } from 'src/utils/mime-types'; +import { AssetMediaService } from 'src/services/asset-media.service'; +import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; @@ -23,65 +24,169 @@ import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { QueryFailedError } from 'typeorm'; import { Mocked } from 'vitest'; -const _getUpdateAssetDto = (): AssetMediaReplaceDto => { - return Object.assign(new AssetMediaReplaceDto(), { - deviceAssetId: 'deviceAssetId', - deviceId: 'deviceId', - fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'), - fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'), - updatedAt: new Date('2024-04-15T23:41:36.910Z'), - }); +const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); + +const uploadFile = { + nullAuth: { + auth: null, + fieldName: UploadFieldName.ASSET_DATA, + file: { + uuid: 'random-uuid', + checksum: Buffer.from('checksum', 'utf8'), + originalPath: 'upload/admin/image.jpeg', + originalName: 'image.jpeg', + size: 1000, + }, + }, + filename: (fieldName: UploadFieldName, filename: string) => { + return { + auth: authStub.admin, + fieldName, + file: { + uuid: 'random-uuid', + mimeType: 'image/jpeg', + checksum: Buffer.from('checksum', 'utf8'), + originalPath: `upload/admin/${filename}`, + originalName: filename, + size: 1000, + }, + }; + }, }; -const _getAsset_1 = () => { - const asset_1 = new AssetEntity(); +const validImages = [ + '.3fr', + '.ari', + '.arw', + '.avif', + '.cap', + '.cin', + '.cr2', + '.cr3', + '.crw', + '.dcr', + '.dng', + '.erf', + '.fff', + '.gif', + '.heic', + '.heif', + '.iiq', + '.jpeg', + '.jpg', + '.jxl', + '.k25', + '.kdc', + '.mrw', + '.nef', + '.orf', + '.ori', + '.pef', + '.png', + '.psd', + '.raf', + '.raw', + '.rwl', + '.sr2', + '.srf', + '.srw', + '.svg', + '.tiff', + '.webp', + '.x3f', +]; - asset_1.id = 'id_1'; - asset_1.ownerId = 'user_id_1'; - asset_1.deviceAssetId = 'device_asset_id_1'; - asset_1.deviceId = 'device_id_1'; - asset_1.type = AssetType.VIDEO; - asset_1.originalPath = 'fake_path/asset_1.jpeg'; - asset_1.previewPath = ''; - asset_1.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z'); - asset_1.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z'); - asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z'); - asset_1.isFavorite = false; - asset_1.isArchived = false; - asset_1.thumbnailPath = ''; - asset_1.encodedVideoPath = ''; - asset_1.duration = '0:00:00.000000'; - asset_1.exifInfo = new ExifEntity(); - asset_1.exifInfo.latitude = 49.533_547; - asset_1.exifInfo.longitude = 10.703_075; - asset_1.livePhotoVideoId = null; - asset_1.sidecarPath = null; - return asset_1; -}; -const _getExistingAsset = () => { - return { - ..._getAsset_1(), - duration: null, - type: AssetType.IMAGE, - checksum: Buffer.from('_getExistingAsset', 'utf8'), - libraryId: 'libraryId', - } as AssetEntity; -}; -const _getExistingAssetWithSideCar = () => { - return { - ..._getExistingAsset(), - sidecarPath: 'sidecar-path', - checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'), - } as AssetEntity; -}; -const _getCopiedAsset = () => { - return { - id: 'copied-asset', - originalPath: 'copied-path', - } as AssetEntity; -}; +const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv']; -describe('AssetMediaService', () => { +const uploadTests = [ + { + label: 'asset images', + fieldName: UploadFieldName.ASSET_DATA, + valid: validImages, + invalid: ['.html', '.xml'], + }, + { + label: 'asset videos', + fieldName: UploadFieldName.ASSET_DATA, + valid: validVideos, + invalid: ['.html', '.xml'], + }, + { + label: 'sidecar', + fieldName: UploadFieldName.SIDECAR_DATA, + valid: ['.xmp'], + invalid: ['.html', '.jpeg', '.jpg', '.mov', '.mp4', '.xml'], + }, + { + label: 'profile', + fieldName: UploadFieldName.PROFILE_DATA, + valid: ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'], + invalid: ['.arf', '.cr2', '.html', '.mov', '.mp4', '.xml'], + }, +]; + +const createDto = Object.freeze({ + deviceAssetId: 'deviceAssetId', + deviceId: 'deviceId', + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), + isFavorite: false, + isArchived: false, + duration: '0:00:00.000000', +}) as AssetMediaCreateDto; + +const replaceDto = Object.freeze({ + deviceAssetId: 'deviceAssetId', + deviceId: 'deviceId', + fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'), + fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'), +}) as AssetMediaReplaceDto; + +const assetEntity = Object.freeze({ + id: 'id_1', + ownerId: 'user_id_1', + deviceAssetId: 'device_asset_id_1', + deviceId: 'device_id_1', + type: AssetType.VIDEO, + originalPath: 'fake_path/asset_1.jpeg', + previewPath: '', + fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + updatedAt: new Date('2022-06-19T23:41:36.910Z'), + isFavorite: false, + isArchived: false, + thumbnailPath: '', + encodedVideoPath: '', + duration: '0:00:00.000000', + exifInfo: { + latitude: 49.533_547, + longitude: 10.703_075, + }, + livePhotoVideoId: null, + sidecarPath: null, +}) as AssetEntity; + +const existingAsset = Object.freeze({ + ...assetEntity, + duration: null, + type: AssetType.IMAGE, + checksum: Buffer.from('_getExistingAsset', 'utf8'), + libraryId: 'libraryId', + originalFileName: 'existing-filename.jpeg', +}) as AssetEntity; + +const sidecarAsset = Object.freeze({ + ...existingAsset, + sidecarPath: 'sidecar-path', + checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'), +}) as AssetEntity; + +const copiedAsset = Object.freeze({ + id: 'copied-asset', + originalPath: 'copied-path', +}) as AssetEntity; + +describe(AssetMediaService.name, () => { let sut: AssetMediaService; let accessMock: IAccessRepositoryMock; let assetMock: Mocked; @@ -103,171 +208,359 @@ describe('AssetMediaService', () => { sut = new AssetMediaService(accessMock, assetMock, jobMock, storageMock, userMock, eventMock, loggerMock); }); + describe('getUploadAssetIdByChecksum', () => { + it('should handle a non-existent asset', async () => { + await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined(); + expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + }); + + it('should find an existing asset', async () => { + assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); + await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({ + id: 'asset-id', + status: AssetMediaStatus.DUPLICATE, + }); + expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + }); + + it('should find an existing asset by base64', async () => { + assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); + await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({ + id: 'asset-id', + status: AssetMediaStatus.DUPLICATE, + }); + expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + }); + }); + + describe('canUpload', () => { + it('should require an authenticated user', () => { + expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + for (const { fieldName, valid, invalid } of uploadTests) { + describe(fieldName, () => { + for (const filetype of valid) { + it(`should accept ${filetype}`, () => { + expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true); + }); + } + + for (const filetype of invalid) { + it(`should reject ${filetype}`, () => { + expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError( + BadRequestException, + ); + }); + } + + it('should be sorted (valid)', () => { + // TODO: use toSorted in NodeJS 20. + expect(valid).toEqual([...valid].sort()); + }); + + it('should be sorted (invalid)', () => { + // TODO: use toSorted in NodeJS 20. + expect(invalid).toEqual([...invalid].sort()); + }); + }); + } + }); + + describe('getUploadFilename', () => { + it('should require authentication', () => { + expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + it('should be the original extension for asset upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( + 'random-uuid.jpg', + ); + }); + + it('should be the xmp extension for sidecar upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual( + 'random-uuid.xmp', + ); + }); + + it('should be the original extension for profile upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( + 'random-uuid.jpg', + ); + }); + }); + + describe('getUploadFolder', () => { + it('should require authentication', () => { + expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + it('should return profile for profile uploads', () => { + expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( + 'upload/profile/admin_id', + ); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); + }); + + it('should return upload for everything else', () => { + expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( + 'upload/upload/admin_id/ra/nd', + ); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd'); + }); + }); + + describe('uploadAsset', () => { + it('should handle a file upload', async () => { + const file = { + uuid: 'random-uuid', + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + size: 42, + }; + + assetMock.create.mockResolvedValue(assetEntity); + + await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({ + id: 'id_1', + status: AssetMediaStatus.CREATED, + }); + + expect(assetMock.create).toHaveBeenCalled(); + expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); + expect(storageMock.utimes).toHaveBeenCalledWith( + file.originalPath, + expect.any(Date), + new Date(createDto.fileModifiedAt), + ); + }); + + it('should handle a duplicate', async () => { + const file = { + uuid: 'random-uuid', + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + size: 0, + }; + const error = new QueryFailedError('', [], new Error('unique key violation')); + (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; + + assetMock.create.mockRejectedValue(error); + assetMock.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id); + + await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({ + id: 'id_1', + status: AssetMediaStatus.DUPLICATE, + }); + + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.DELETE_FILES, + data: { files: ['fake_path/asset_1.jpeg', undefined] }, + }); + expect(userMock.updateUsage).not.toHaveBeenCalled(); + }); + + it('should handle a live photo', async () => { + assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); + assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + + await expect( + sut.uploadAsset( + authStub.user1, + { ...createDto, livePhotoVideoId: 'live-photo-motion-asset' }, + fileStub.livePhotoStill, + ), + ).resolves.toEqual({ + status: AssetMediaStatus.CREATED, + id: 'live-photo-still-asset', + }); + + expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset'); + expect(assetMock.update).not.toHaveBeenCalled(); + }); + + it('should hide the linked motion asset', async () => { + assetMock.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true }); + assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + + await expect( + sut.uploadAsset( + authStub.user1, + { ...createDto, livePhotoVideoId: 'live-photo-motion-asset' }, + fileStub.livePhotoStill, + ), + ).resolves.toEqual({ + status: AssetMediaStatus.CREATED, + id: 'live-photo-still-asset', + }); + + expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset'); + expect(assetMock.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false }); + }); + }); + + describe('downloadOriginal', () => { + it('should require the asset.download permission', async () => { + await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + }); + + it('should throw an error if the asset is not found', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + assetMock.getById.mockResolvedValue(null); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException); + + expect(assetMock.getById).toHaveBeenCalledWith('asset-1'); + }); + + it('should download a file', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + assetMock.getById.mockResolvedValue(assetStub.image); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual( + new ImmichFileResponse({ + path: '/original/path.jpg', + contentType: 'image/jpeg', + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + }), + ); + }); + }); + describe('replaceAsset', () => { - const expectAssetUpdate = ( - existingAsset: AssetEntity, - uploadFile: UploadFile, - dto: AssetMediaReplaceDto, - livePhotoVideo?: AssetEntity, - sidecarPath?: UploadFile, - // eslint-disable-next-line unicorn/consistent-function-scoping - ) => { - expect(assetMock.update).toHaveBeenCalledWith({ - id: existingAsset.id, - checksum: uploadFile.checksum, - originalFileName: uploadFile.originalName, - originalPath: uploadFile.originalPath, - deviceAssetId: dto.deviceAssetId, - deviceId: dto.deviceId, - fileCreatedAt: dto.fileCreatedAt, - fileModifiedAt: dto.fileModifiedAt, - localDateTime: dto.fileCreatedAt, - type: mimeTypes.assetType(uploadFile.originalPath), - duration: dto.duration || null, - livePhotoVideo: livePhotoVideo ? { id: livePhotoVideo?.id } : null, - sidecarPath: sidecarPath?.originalPath || null, - }); - }; - - // eslint-disable-next-line unicorn/consistent-function-scoping - const expectAssetCreateCopy = (existingAsset: AssetEntity) => { - expect(assetMock.create).toHaveBeenCalledWith({ - ownerId: existingAsset.ownerId, - originalPath: existingAsset.originalPath, - originalFileName: existingAsset.originalFileName, - libraryId: existingAsset.libraryId, - deviceAssetId: existingAsset.deviceAssetId, - deviceId: existingAsset.deviceId, - type: existingAsset.type, - checksum: existingAsset.checksum, - fileCreatedAt: existingAsset.fileCreatedAt, - localDateTime: existingAsset.localDateTime, - fileModifiedAt: existingAsset.fileModifiedAt, - livePhotoVideoId: existingAsset.livePhotoVideoId || null, - sidecarPath: existingAsset.sidecarPath || null, - }); - }; - it('should error when update photo does not exist', async () => { - const dto = _getUpdateAssetDto(); assetMock.getById.mockResolvedValueOnce(null); - await expect(sut.replaceAsset(authStub.user1, 'id', dto, fileStub.photo)).rejects.toThrow( + await expect(sut.replaceAsset(authStub.user1, 'id', replaceDto, fileStub.photo)).rejects.toThrow( 'Not found or no asset.update access', ); expect(assetMock.create).not.toHaveBeenCalled(); }); + it('should update a photo with no sidecar to photo with no sidecar', async () => { - const existingAsset = _getExistingAsset(); const updatedFile = fileStub.photo; const updatedAsset = { ...existingAsset, ...updatedFile }; - const dto = _getUpdateAssetDto(); assetMock.getById.mockResolvedValueOnce(existingAsset); assetMock.getById.mockResolvedValueOnce(updatedAsset); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); // this is the original file size storageMock.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(_getCopiedAsset()); + assetMock.create.mockResolvedValue(copiedAsset); - await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({ - status: AssetMediaStatusEnum.REPLACED, - id: _getCopiedAsset().id, + await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ + status: AssetMediaStatus.REPLACED, + id: 'copied-asset', }); - expectAssetUpdate(existingAsset, updatedFile, dto); - expectAssetCreateCopy(existingAsset); + expect(assetMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: existingAsset.id, + sidecarPath: null, + originalFileName: 'photo1.jpeg', + originalPath: 'fake_path/photo1.jpeg', + }), + ); + expect(assetMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + sidecarPath: null, + originalFileName: 'existing-filename.jpeg', + originalPath: 'fake_path/asset_1.jpeg', + }), + ); - expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]); + expect(assetMock.softDeleteAll).toHaveBeenCalledWith([copiedAsset.id]); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(storageMock.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), - new Date(dto.fileModifiedAt), + new Date(replaceDto.fileModifiedAt), ); }); - it('should update a photo with sidecar to photo with sidecar', async () => { - const existingAsset = _getExistingAssetWithSideCar(); + it('should update a photo with sidecar to photo with sidecar', async () => { const updatedFile = fileStub.photo; const sidecarFile = fileStub.photoSidecar; - const dto = _getUpdateAssetDto(); - const updatedAsset = { ...existingAsset, ...updatedFile }; + const updatedAsset = { ...sidecarAsset, ...updatedFile }; assetMock.getById.mockResolvedValueOnce(existingAsset); assetMock.getById.mockResolvedValueOnce(updatedAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size storageMock.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(_getCopiedAsset()); + assetMock.create.mockResolvedValue(copiedAsset); - await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile, sidecarFile)).resolves.toEqual({ - status: AssetMediaStatusEnum.REPLACED, - id: _getCopiedAsset().id, + await expect( + sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile), + ).resolves.toEqual({ + status: AssetMediaStatus.REPLACED, + id: 'copied-asset', }); - expectAssetUpdate(existingAsset, updatedFile, dto, undefined, sidecarFile); - expectAssetCreateCopy(existingAsset); - expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]); + expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(storageMock.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), - new Date(dto.fileModifiedAt), + new Date(replaceDto.fileModifiedAt), ); }); + it('should update a photo with a sidecar to photo with no sidecar', async () => { - const existingAsset = _getExistingAssetWithSideCar(); const updatedFile = fileStub.photo; - const dto = _getUpdateAssetDto(); - const updatedAsset = { ...existingAsset, ...updatedFile }; - assetMock.getById.mockResolvedValueOnce(existingAsset); + const updatedAsset = { ...sidecarAsset, ...updatedFile }; + assetMock.getById.mockResolvedValueOnce(sidecarAsset); assetMock.getById.mockResolvedValueOnce(updatedAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size storageMock.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the copy call - assetMock.create.mockResolvedValue(_getCopiedAsset()); + assetMock.create.mockResolvedValue(copiedAsset); - await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({ - status: AssetMediaStatusEnum.REPLACED, - id: _getCopiedAsset().id, + await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ + status: AssetMediaStatus.REPLACED, + id: 'copied-asset', }); - expectAssetUpdate(existingAsset, updatedFile, dto); - expectAssetCreateCopy(existingAsset); - expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]); + expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(storageMock.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), - new Date(dto.fileModifiedAt), + new Date(replaceDto.fileModifiedAt), ); }); + it('should handle a photo with sidecar to duplicate photo ', async () => { - const existingAsset = _getExistingAssetWithSideCar(); const updatedFile = fileStub.photo; - const dto = _getUpdateAssetDto(); const error = new QueryFailedError('', [], new Error('unique key violation')); (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; assetMock.update.mockRejectedValue(error); - assetMock.getById.mockResolvedValueOnce(existingAsset); - assetMock.getUploadAssetIdByChecksum.mockResolvedValue(existingAsset.id); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + assetMock.getById.mockResolvedValueOnce(sidecarAsset); + assetMock.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size storageMock.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(_getCopiedAsset()); + assetMock.create.mockResolvedValue(copiedAsset); - await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({ - status: AssetMediaStatusEnum.DUPLICATE, - id: existingAsset.id, + await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({ + status: AssetMediaStatus.DUPLICATE, + id: sidecarAsset.id, }); - expectAssetUpdate(existingAsset, updatedFile, dto); expect(assetMock.create).not.toHaveBeenCalled(); expect(assetMock.softDeleteAll).not.toHaveBeenCalled(); expect(jobMock.queue).toHaveBeenCalledWith({ @@ -277,6 +570,7 @@ describe('AssetMediaService', () => { expect(userMock.updateUsage).not.toHaveBeenCalled(); }); }); + describe('bulkUploadCheck', () => { it('should accept hex and base64 checksums', async () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index ddb8f105a3..d11fc5dbaf 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -1,21 +1,33 @@ -import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { + BadRequestException, + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { extname } from 'node:path'; +import sanitize from 'sanitize-filename'; import { AccessCore, Permission } from 'src/cores/access.core'; +import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { AssetBulkUploadCheckResponseDto, AssetMediaResponseDto, - AssetMediaStatusEnum, + AssetMediaStatus, AssetRejectReason, AssetUploadAction, CheckExistingAssetsResponseDto, } from 'src/dtos/asset-media-response.dto'; import { AssetBulkUploadCheckDto, + AssetMediaCreateDto, + AssetMediaOptionsDto, AssetMediaReplaceDto, + AssetMediaSize, CheckExistingAssetsDto, UploadFieldName, } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; +import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; @@ -23,6 +35,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; import { QueryFailedError } from 'typeorm'; @@ -57,7 +70,121 @@ export class AssetMediaService { this.access = AccessCore.create(accessRepository); } - public async replaceAsset( + async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { + if (!checksum) { + return; + } + + const assetId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum)); + if (!assetId) { + return; + } + + return { id: assetId, status: AssetMediaStatus.DUPLICATE }; + } + + canUploadFile({ auth, fieldName, file }: UploadRequest): true { + this.access.requireUploadAccess(auth); + + const filename = file.originalName; + + switch (fieldName) { + case UploadFieldName.ASSET_DATA: { + if (mimeTypes.isAsset(filename)) { + return true; + } + break; + } + + case UploadFieldName.SIDECAR_DATA: { + if (mimeTypes.isSidecar(filename)) { + return true; + } + break; + } + + case UploadFieldName.PROFILE_DATA: { + if (mimeTypes.isProfile(filename)) { + return true; + } + break; + } + } + + this.logger.error(`Unsupported file type ${filename}`); + throw new BadRequestException(`Unsupported file type ${filename}`); + } + + getUploadFilename({ auth, fieldName, file }: UploadRequest): string { + this.access.requireUploadAccess(auth); + + const originalExtension = extname(file.originalName); + + const lookup = { + [UploadFieldName.ASSET_DATA]: originalExtension, + [UploadFieldName.SIDECAR_DATA]: '.xmp', + [UploadFieldName.PROFILE_DATA]: originalExtension, + }; + + return sanitize(`${file.uuid}${lookup[fieldName]}`); + } + + getUploadFolder({ auth, fieldName, file }: UploadRequest): string { + auth = this.access.requireUploadAccess(auth); + + let folder = StorageCore.getNestedFolder(StorageFolder.UPLOAD, auth.user.id, file.uuid); + if (fieldName === UploadFieldName.PROFILE_DATA) { + folder = StorageCore.getFolderLocation(StorageFolder.PROFILE, auth.user.id); + } + + this.storageRepository.mkdirSync(folder); + + return folder; + } + + async uploadAsset( + auth: AuthDto, + dto: AssetMediaCreateDto, + file: UploadFile, + sidecarFile?: UploadFile, + ): Promise { + try { + await this.access.requirePermission( + auth, + Permission.ASSET_UPLOAD, + // do not need an id here, but the interface requires it + auth.user.id, + ); + + this.requireQuota(auth, file.size); + + if (dto.livePhotoVideoId) { + const motionAsset = await this.assetRepository.getById(dto.livePhotoVideoId); + if (!motionAsset) { + throw new BadRequestException('Live photo video not found'); + } + if (motionAsset.type !== AssetType.VIDEO) { + throw new BadRequestException('Live photo vide must be a video'); + } + if (motionAsset.ownerId !== auth.user.id) { + throw new BadRequestException('Live photo video does not belong to the user'); + } + if (motionAsset.isVisible) { + await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); + } + } + + const asset = await this.create(auth.user.id, dto, file, sidecarFile); + + await this.userRepository.updateUsage(auth.user.id, file.size); + + return { id: asset.id, status: AssetMediaStatus.CREATED }; + } catch (error: any) { + return this.handleUploadError(error, auth, file, sidecarFile); + } + } + + async replaceAsset( auth: AuthDto, id: string, dto: AssetMediaReplaceDto, @@ -66,27 +193,131 @@ export class AssetMediaService { ): Promise { try { await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); - const existingAssetEntity = (await this.assetRepository.getById(id)) as AssetEntity; + const asset = (await this.assetRepository.getById(id)) as AssetEntity; this.requireQuota(auth, file.size); - await this.replaceFileData(existingAssetEntity.id, dto, file, sidecarFile?.originalPath); + await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath); // Next, create a backup copy of the existing record. The db record has already been updated above, // but the local variable holds the original file data paths. - const copiedPhoto = await this.createCopy(existingAssetEntity); + const copiedPhoto = await this.createCopy(asset); // and immediate trash it await this.assetRepository.softDeleteAll([copiedPhoto.id]); + this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]); await this.userRepository.updateUsage(auth.user.id, file.size); - return { status: AssetMediaStatusEnum.REPLACED, id: copiedPhoto.id }; + return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id }; } catch (error: any) { - return await this.handleUploadError(error, auth, file, sidecarFile); + return this.handleUploadError(error, auth, file, sidecarFile); } } + async downloadOriginal(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id); + + const asset = await this.findOrFail(id); + if (!asset) { + throw new NotFoundException('Asset does not exist'); + } + + return new ImmichFileResponse({ + path: asset.originalPath, + contentType: mimeTypes.lookup(asset.originalPath), + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + }); + } + + async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise { + await this.access.requirePermission(auth, Permission.ASSET_VIEW, id); + + const asset = await this.findOrFail(id); + const size = dto.size ?? AssetMediaSize.THUMBNAIL; + + let filepath = asset.previewPath; + if (size === AssetMediaSize.THUMBNAIL && asset.thumbnailPath) { + filepath = asset.thumbnailPath; + } + + if (!filepath) { + throw new NotFoundException('Asset media not found'); + } + + return new ImmichFileResponse({ + path: filepath, + contentType: mimeTypes.lookup(filepath), + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + }); + } + + async playbackVideo(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.ASSET_VIEW, id); + + const asset = await this.findOrFail(id); + if (!asset) { + throw new NotFoundException('Asset does not exist'); + } + + if (asset.type !== AssetType.VIDEO) { + throw new BadRequestException('Asset is not a video'); + } + + const filepath = asset.encodedVideoPath || asset.originalPath; + + return new ImmichFileResponse({ + path: filepath, + contentType: mimeTypes.lookup(filepath), + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + }); + } + + async checkExistingAssets( + auth: AuthDto, + checkExistingAssetsDto: CheckExistingAssetsDto, + ): Promise { + const assets = await this.assetRepository.getByDeviceIds( + auth.user.id, + checkExistingAssetsDto.deviceId, + checkExistingAssetsDto.deviceAssetIds, + ); + return { + existingIds: assets.map((asset) => asset.id), + }; + } + + async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise { + const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum)); + const results = await this.assetRepository.getByChecksums(auth.user.id, checksums); + const checksumMap: Record = {}; + + for (const { id, checksum } of results) { + checksumMap[checksum.toString('hex')] = id; + } + + return { + results: dto.assets.map(({ id, checksum }) => { + const duplicate = checksumMap[fromChecksum(checksum).toString('hex')]; + if (duplicate) { + return { + id, + assetId: duplicate, + action: AssetUploadAction.REJECT, + reason: AssetRejectReason.DUPLICATE, + }; + } + + // TODO mime-check + + return { + id, + action: AssetUploadAction.ACCEPT, + }; + }), + }; + } + private async handleUploadError( error: any, auth: AuthDto, @@ -106,7 +337,7 @@ export class AssetMediaService { this.logger.error(`Error locating duplicate for checksum constraint`); throw new InternalServerErrorException(); } - return { status: AssetMediaStatusEnum.DUPLICATE, id: duplicateId }; + return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; } this.logger.error(`Error uploading file ${error}`, error?.stack); @@ -181,54 +412,59 @@ export class AssetMediaService { return created; } + private async create( + ownerId: string, + dto: AssetMediaCreateDto, + file: UploadFile, + sidecarFile?: UploadFile, + ): Promise { + const asset = await this.assetRepository.create({ + ownerId, + libraryId: null, + + checksum: file.checksum, + originalPath: file.originalPath, + + deviceAssetId: dto.deviceAssetId, + deviceId: dto.deviceId, + + fileCreatedAt: dto.fileCreatedAt, + fileModifiedAt: dto.fileModifiedAt, + localDateTime: dto.fileCreatedAt, + + type: mimeTypes.assetType(file.originalPath), + isFavorite: dto.isFavorite, + isArchived: dto.isArchived ?? false, + duration: dto.duration || null, + isVisible: dto.isVisible ?? true, + livePhotoVideoId: dto.livePhotoVideoId, + originalFileName: file.originalName, + sidecarPath: sidecarFile?.originalPath, + isOffline: dto.isOffline ?? false, + }); + + if (sidecarFile) { + await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt)); + } + await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); + await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size }); + await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); + + return asset; + } + private requireQuota(auth: AuthDto, size: number) { if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) { throw new BadRequestException('Quota has been exceeded!'); } } - async checkExistingAssets( - auth: AuthDto, - checkExistingAssetsDto: CheckExistingAssetsDto, - ): Promise { - const assets = await this.assetRepository.getByDeviceIds( - auth.user.id, - checkExistingAssetsDto.deviceId, - checkExistingAssetsDto.deviceAssetIds, - ); - return { - existingIds: assets.map((asset) => asset.id), - }; - } - - async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise { - const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum)); - const results = await this.assetRepository.getByChecksums(auth.user.id, checksums); - const checksumMap: Record = {}; - - for (const { id, checksum } of results) { - checksumMap[checksum.toString('hex')] = id; + private async findOrFail(id: string): Promise { + const asset = await this.assetRepository.getById(id); + if (!asset) { + throw new NotFoundException('Asset not found'); } - return { - results: dto.assets.map(({ id, checksum }) => { - const duplicate = checksumMap[fromChecksum(checksum).toString('hex')]; - if (duplicate) { - return { - id, - assetId: duplicate, - action: AssetUploadAction.REJECT, - reason: AssetRejectReason.DUPLICATE, - }; - } - - // TODO mime-check - - return { - id, - action: AssetUploadAction.ACCEPT, - }; - }), - }; + return asset; } } diff --git a/server/src/services/asset-v1.service.spec.ts b/server/src/services/asset-v1.service.spec.ts deleted file mode 100644 index b359bbf487..0000000000 --- a/server/src/services/asset-v1.service.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { CreateAssetDto } from 'src/dtos/asset-v1.dto'; -import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; -import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; -import { AssetServiceV1 } from 'src/services/asset-v1.service'; -import { assetStub } from 'test/fixtures/asset.stub'; -import { authStub } from 'test/fixtures/auth.stub'; -import { fileStub } from 'test/fixtures/file.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; -import { QueryFailedError } from 'typeorm'; -import { Mocked, vitest } from 'vitest'; - -const _getCreateAssetDto = (): CreateAssetDto => { - const createAssetDto = new CreateAssetDto(); - createAssetDto.deviceAssetId = 'deviceAssetId'; - createAssetDto.deviceId = 'deviceId'; - createAssetDto.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z'); - createAssetDto.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z'); - createAssetDto.isFavorite = false; - createAssetDto.isArchived = false; - createAssetDto.duration = '0:00:00.000000'; - - return createAssetDto; -}; - -const _getAsset_1 = () => { - const asset_1 = new AssetEntity(); - - asset_1.id = 'id_1'; - asset_1.ownerId = 'user_id_1'; - asset_1.deviceAssetId = 'device_asset_id_1'; - asset_1.deviceId = 'device_id_1'; - asset_1.type = AssetType.VIDEO; - asset_1.originalPath = 'fake_path/asset_1.jpeg'; - asset_1.previewPath = ''; - asset_1.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z'); - asset_1.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z'); - asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z'); - asset_1.isFavorite = false; - asset_1.isArchived = false; - asset_1.thumbnailPath = ''; - asset_1.encodedVideoPath = ''; - asset_1.duration = '0:00:00.000000'; - asset_1.exifInfo = new ExifEntity(); - asset_1.exifInfo.latitude = 49.533_547; - asset_1.exifInfo.longitude = 10.703_075; - return asset_1; -}; - -describe('AssetService', () => { - let sut: AssetServiceV1; - let accessMock: IAccessRepositoryMock; - let assetRepositoryMockV1: Mocked; - let assetMock: Mocked; - let jobMock: Mocked; - let libraryMock: Mocked; - let loggerMock: Mocked; - let storageMock: Mocked; - let userMock: Mocked; - - beforeEach(() => { - assetRepositoryMockV1 = { - get: vitest.fn(), - getAssetsByChecksums: vitest.fn(), - }; - - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - jobMock = newJobRepositoryMock(); - libraryMock = newLibraryRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - storageMock = newStorageRepositoryMock(); - userMock = newUserRepositoryMock(); - - sut = new AssetServiceV1( - accessMock, - assetRepositoryMockV1, - assetMock, - jobMock, - libraryMock, - storageMock, - userMock, - loggerMock, - ); - - assetRepositoryMockV1.get.mockImplementation((assetId) => - Promise.resolve( - [assetStub.livePhotoMotionAsset, assetStub.livePhotoMotionAsset].find((asset) => asset.id === assetId) ?? null, - ), - ); - }); - - describe('uploadFile', () => { - it('should handle a file upload', async () => { - const assetEntity = _getAsset_1(); - const file = { - uuid: 'random-uuid', - originalPath: 'fake_path/asset_1.jpeg', - mimeType: 'image/jpeg', - checksum: Buffer.from('file hash', 'utf8'), - originalName: 'asset_1.jpeg', - size: 42, - }; - const dto = _getCreateAssetDto(); - - assetMock.create.mockResolvedValue(assetEntity); - - await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' }); - - expect(assetMock.create).toHaveBeenCalled(); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); - expect(storageMock.utimes).toHaveBeenCalledWith( - file.originalPath, - expect.any(Date), - new Date(dto.fileModifiedAt), - ); - }); - - it('should handle a duplicate', async () => { - const file = { - uuid: 'random-uuid', - originalPath: 'fake_path/asset_1.jpeg', - mimeType: 'image/jpeg', - checksum: Buffer.from('file hash', 'utf8'), - originalName: 'asset_1.jpeg', - size: 0, - }; - const dto = _getCreateAssetDto(); - const error = new QueryFailedError('', [], new Error('unique key violation')); - (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; - - assetMock.create.mockRejectedValue(error); - assetRepositoryMockV1.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]); - - await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' }); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.DELETE_FILES, - data: { files: ['fake_path/asset_1.jpeg', undefined, undefined] }, - }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); - }); - - it('should handle a live photo', async () => { - const dto = _getCreateAssetDto(); - const error = new QueryFailedError('', [], new Error('unique key violation')); - (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; - - assetMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); - assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); - - await expect( - sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion), - ).resolves.toEqual({ - duplicate: false, - id: 'live-photo-still-asset', - }); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.METADATA_EXTRACTION, - data: { id: assetStub.livePhotoMotionAsset.id, source: 'upload' }, - }, - ], - [{ name: JobName.METADATA_EXTRACTION, data: { id: assetStub.livePhotoStillAsset.id, source: 'upload' } }], - ]); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, 111); - expect(storageMock.utimes).toHaveBeenCalledWith( - fileStub.livePhotoStill.originalPath, - expect.any(Date), - new Date(dto.fileModifiedAt), - ); - expect(storageMock.utimes).toHaveBeenCalledWith( - fileStub.livePhotoMotion.originalPath, - expect.any(Date), - new Date(dto.fileModifiedAt), - ); - }); - }); -}); diff --git a/server/src/services/asset-v1.service.ts b/server/src/services/asset-v1.service.ts deleted file mode 100644 index 32841b0214..0000000000 --- a/server/src/services/asset-v1.service.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { - BadRequestException, - Inject, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; -import { AccessCore, Permission } from 'src/cores/access.core'; -import { AssetFileUploadResponseDto } from 'src/dtos/asset-v1-response.dto'; -import { CreateAssetDto, GetAssetThumbnailDto, GetAssetThumbnailFormatEnum, ServeFileDto } from 'src/dtos/asset-v1.dto'; -import { AuthDto } from 'src/dtos/auth.dto'; -import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; -import { UploadFile } from 'src/services/asset-media.service'; -import { CacheControl, ImmichFileResponse, getLivePhotoMotionFilename } from 'src/utils/file'; -import { mimeTypes } from 'src/utils/mime-types'; -import { QueryFailedError } from 'typeorm'; - -@Injectable() -/** @deprecated */ -export class AssetServiceV1 { - private access: AccessCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(IAssetRepositoryV1) private assetRepositoryV1: IAssetRepositoryV1, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ILibraryRepository) private libraryRepository: ILibraryRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.access = AccessCore.create(accessRepository); - this.logger.setContext(AssetServiceV1.name); - } - - public async uploadFile( - auth: AuthDto, - dto: CreateAssetDto, - file: UploadFile, - livePhotoFile?: UploadFile, - sidecarFile?: UploadFile, - ): Promise { - if (livePhotoFile) { - livePhotoFile = { - ...livePhotoFile, - originalName: getLivePhotoMotionFilename(file.originalName, livePhotoFile.originalName), - }; - } - - let livePhotoAsset: AssetEntity | null = null; - - try { - await this.access.requirePermission( - auth, - Permission.ASSET_UPLOAD, - // do not need an id here, but the interface requires it - auth.user.id, - ); - - this.requireQuota(auth, file.size); - if (livePhotoFile) { - const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false }; - livePhotoAsset = await this.create(auth, livePhotoDto, livePhotoFile); - } - - const asset = await this.create(auth, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath); - - await this.userRepository.updateUsage(auth.user.id, (livePhotoFile?.size || 0) + file.size); - - return { id: asset.id, duplicate: false }; - } catch (error: any) { - // clean up files - await this.jobRepository.queue({ - name: JobName.DELETE_FILES, - data: { files: [file.originalPath, livePhotoFile?.originalPath, sidecarFile?.originalPath] }, - }); - - // handle duplicates with a success response - if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) { - const checksums = [file.checksum, livePhotoFile?.checksum].filter((checksum): checksum is Buffer => !!checksum); - const [duplicate] = await this.assetRepositoryV1.getAssetsByChecksums(auth.user.id, checksums); - return { id: duplicate.id, duplicate: true }; - } - - this.logger.error(`Error uploading file ${error}`, error?.stack); - throw error; - } - } - - async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId); - - const asset = await this.assetRepositoryV1.get(assetId); - if (!asset) { - throw new NotFoundException('Asset not found'); - } - - const filepath = this.getThumbnailPath(asset, dto.format); - - return new ImmichFileResponse({ - path: filepath, - contentType: mimeTypes.lookup(filepath), - cacheControl: CacheControl.PRIVATE_WITH_CACHE, - }); - } - - public async serveFile(auth: AuthDto, assetId: string, dto: ServeFileDto): Promise { - // this is not quite right as sometimes this returns the original still - await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId); - - const asset = await this.assetRepository.getById(assetId); - if (!asset) { - throw new NotFoundException('Asset does not exist'); - } - - const allowOriginalFile = !!(!auth.sharedLink || auth.sharedLink?.allowDownload); - - const filepath = - asset.type === AssetType.IMAGE - ? this.getServePath(asset, dto, allowOriginalFile) - : asset.encodedVideoPath || asset.originalPath; - - return new ImmichFileResponse({ - path: filepath, - contentType: mimeTypes.lookup(filepath), - cacheControl: CacheControl.PRIVATE_WITH_CACHE, - }); - } - - private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) { - switch (format) { - case GetAssetThumbnailFormatEnum.WEBP: { - if (asset.thumbnailPath) { - return asset.thumbnailPath; - } - this.logger.warn(`WebP thumbnail requested but not found for asset ${asset.id}, falling back to JPEG`); - } - case GetAssetThumbnailFormatEnum.JPEG: { - if (!asset.previewPath) { - throw new NotFoundException(`No thumbnail found for asset ${asset.id}`); - } - return asset.previewPath; - } - } - } - - private getServePath(asset: AssetEntity, dto: ServeFileDto, allowOriginalFile: boolean): string { - const mimeType = mimeTypes.lookup(asset.originalPath); - - /** - * Serve file viewer on the web - */ - if (dto.isWeb && mimeType != 'image/gif') { - if (!asset.previewPath) { - this.logger.error('Error serving IMAGE asset for web'); - throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile'); - } - - return asset.previewPath; - } - - /** - * Serve thumbnail image for both web and mobile app - */ - if ((!dto.isThumb && allowOriginalFile) || (dto.isWeb && mimeType === 'image/gif')) { - return asset.originalPath; - } - - if (asset.thumbnailPath && asset.thumbnailPath.length > 0) { - return asset.thumbnailPath; - } - - if (!asset.previewPath) { - throw new Error('previewPath not set'); - } - - return asset.previewPath; - } - - private async create( - auth: AuthDto, - dto: CreateAssetDto, - file: UploadFile, - livePhotoAssetId?: string, - sidecarPath?: string, - ): Promise { - const asset = await this.assetRepository.create({ - ownerId: auth.user.id, - libraryId: null, - - checksum: file.checksum, - originalPath: file.originalPath, - - deviceAssetId: dto.deviceAssetId, - deviceId: dto.deviceId, - - fileCreatedAt: dto.fileCreatedAt, - fileModifiedAt: dto.fileModifiedAt, - localDateTime: dto.fileCreatedAt, - - type: mimeTypes.assetType(file.originalPath), - isFavorite: dto.isFavorite, - isArchived: dto.isArchived ?? false, - duration: dto.duration || null, - isVisible: dto.isVisible ?? true, - livePhotoVideo: livePhotoAssetId === null ? null : ({ id: livePhotoAssetId } as AssetEntity), - originalFileName: file.originalName, - sidecarPath: sidecarPath || null, - isOffline: dto.isOffline ?? false, - }); - - if (sidecarPath) { - await this.storageRepository.utimes(sidecarPath, new Date(), new Date(dto.fileModifiedAt)); - } - await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); - await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size }); - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); - - return asset; - } - - private requireQuota(auth: AuthDto, size: number) { - if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) { - throw new BadRequestException('Quota has been exceeded!'); - } - } -} diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 8f85e1e5ce..70a2b94ecc 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -1,6 +1,6 @@ -import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; import { mapAsset } from 'src/dtos/asset-response.dto'; -import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/asset.dto'; +import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; @@ -8,7 +8,6 @@ import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AssetService } from 'src/services/asset.service'; @@ -24,13 +23,10 @@ import { newEventRepositoryMock } from 'test/repositories/event.repository.mock' import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked, vitest } from 'vitest'; -const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); - const stats: AssetStats = { [AssetType.IMAGE]: 10, [AssetType.VIDEO]: 23, @@ -44,117 +40,11 @@ const statResponse: AssetStatsResponseDto = { total: 33, }; -const uploadFile = { - nullAuth: { - auth: null, - fieldName: UploadFieldName.ASSET_DATA, - file: { - uuid: 'random-uuid', - checksum: Buffer.from('checksum', 'utf8'), - originalPath: 'upload/admin/image.jpeg', - originalName: 'image.jpeg', - size: 1000, - }, - }, - filename: (fieldName: UploadFieldName, filename: string) => { - return { - auth: authStub.admin, - fieldName, - file: { - uuid: 'random-uuid', - mimeType: 'image/jpeg', - checksum: Buffer.from('checksum', 'utf8'), - originalPath: `upload/admin/${filename}`, - originalName: filename, - size: 1000, - }, - }; - }, -}; - -const validImages = [ - '.3fr', - '.ari', - '.arw', - '.avif', - '.cap', - '.cin', - '.cr2', - '.cr3', - '.crw', - '.dcr', - '.dng', - '.erf', - '.fff', - '.gif', - '.heic', - '.heif', - '.iiq', - '.jpeg', - '.jpg', - '.jxl', - '.k25', - '.kdc', - '.mrw', - '.nef', - '.orf', - '.ori', - '.pef', - '.png', - '.psd', - '.raf', - '.raw', - '.rwl', - '.sr2', - '.srf', - '.srw', - '.svg', - '.tiff', - '.webp', - '.x3f', -]; - -const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv']; - -const uploadTests = [ - { - label: 'asset images', - fieldName: UploadFieldName.ASSET_DATA, - valid: validImages, - invalid: ['.html', '.xml'], - }, - { - label: 'asset videos', - fieldName: UploadFieldName.ASSET_DATA, - valid: validVideos, - invalid: ['.html', '.xml'], - }, - { - label: 'live photo', - fieldName: UploadFieldName.LIVE_PHOTO_DATA, - valid: validVideos, - invalid: ['.html', '.jpeg', '.jpg', '.xml'], - }, - { - label: 'sidecar', - fieldName: UploadFieldName.SIDECAR_DATA, - valid: ['.xmp'], - invalid: ['.html', '.jpeg', '.jpg', '.mov', '.mp4', '.xml'], - }, - { - label: 'profile', - fieldName: UploadFieldName.PROFILE_DATA, - valid: ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'], - invalid: ['.arf', '.cr2', '.html', '.mov', '.mp4', '.xml'], - }, -]; - describe(AssetService.name, () => { let sut: AssetService; let accessMock: IAccessRepositoryMock; let assetMock: Mocked; let jobMock: Mocked; - let storageMock: Mocked; let userMock: Mocked; let eventMock: Mocked; let systemMock: Mocked; @@ -177,7 +67,6 @@ describe(AssetService.name, () => { assetMock = newAssetRepositoryMock(); eventMock = newEventRepositoryMock(); jobMock = newJobRepositoryMock(); - storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); partnerMock = newPartnerRepositoryMock(); @@ -189,7 +78,6 @@ describe(AssetService.name, () => { assetMock, jobMock, systemMock, - storageMock, userMock, eventMock, partnerMock, @@ -200,115 +88,6 @@ describe(AssetService.name, () => { mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); }); - describe('getUploadAssetIdByChecksum', () => { - it('should handle a non-existent asset', async () => { - await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined(); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); - }); - - it('should find an existing asset', async () => { - assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); - await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({ - id: 'asset-id', - duplicate: true, - }); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); - }); - - it('should find an existing asset by base64', async () => { - assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); - await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({ - id: 'asset-id', - duplicate: true, - }); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); - }); - }); - - describe('canUpload', () => { - it('should require an authenticated user', () => { - expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException); - }); - - for (const { fieldName, valid, invalid } of uploadTests) { - describe(fieldName, () => { - for (const filetype of valid) { - it(`should accept ${filetype}`, () => { - expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true); - }); - } - - for (const filetype of invalid) { - it(`should reject ${filetype}`, () => { - expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError( - BadRequestException, - ); - }); - } - - it('should be sorted (valid)', () => { - // TODO: use toSorted in NodeJS 20. - expect(valid).toEqual([...valid].sort()); - }); - - it('should be sorted (invalid)', () => { - // TODO: use toSorted in NodeJS 20. - expect(invalid).toEqual([...invalid].sort()); - }); - }); - } - }); - - describe('getUploadFilename', () => { - it('should require authentication', () => { - expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException); - }); - - it('should be the original extension for asset upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( - 'random-uuid.jpg', - ); - }); - - it('should be the mov extension for live photo upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual( - 'random-uuid.mov', - ); - }); - - it('should be the xmp extension for sidecar upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual( - 'random-uuid.xmp', - ); - }); - - it('should be the original extension for profile upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( - 'random-uuid.jpg', - ); - }); - }); - - describe('getUploadFolder', () => { - it('should require authentication', () => { - expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException); - }); - - it('should return profile for profile uploads', () => { - expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( - 'upload/profile/admin_id', - ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); - }); - - it('should return upload for everything else', () => { - expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( - 'upload/upload/admin_id/ra/nd', - ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd'); - }); - }); - describe('getMemoryLane', () => { beforeAll(() => { vitest.useFakeTimers(); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 5272ac4027..58ff0d1f85 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,10 +1,7 @@ import { BadRequestException, Inject } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; -import { extname } from 'node:path'; -import sanitize from 'sanitize-filename'; import { AccessCore, Permission } from 'src/cores/access.core'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetResponseDto, @@ -12,7 +9,6 @@ import { SanitizedAssetResponseDto, mapAsset, } from 'src/dtos/asset-response.dto'; -import { AssetFileUploadResponseDto } from 'src/dtos/asset-v1-response.dto'; import { AssetBulkDeleteDto, AssetBulkUpdateDto, @@ -20,7 +16,6 @@ import { AssetJobsDto, AssetStatsDto, UpdateAssetDto, - UploadFieldName, mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -42,13 +37,9 @@ import { } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; -import { UploadRequest } from 'src/services/asset-media.service'; -import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; -import { fromChecksum } from 'src/utils/request'; export class AssetService { private access: AccessCore; @@ -59,7 +50,6 @@ export class AssetService { @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @@ -71,86 +61,6 @@ export class AssetService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { - if (!checksum) { - return; - } - - const assetId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum)); - if (!assetId) { - return; - } - - return { id: assetId, duplicate: true }; - } - - canUploadFile({ auth, fieldName, file }: UploadRequest): true { - this.access.requireUploadAccess(auth); - - const filename = file.originalName; - - switch (fieldName) { - case UploadFieldName.ASSET_DATA: { - if (mimeTypes.isAsset(filename)) { - return true; - } - break; - } - - case UploadFieldName.LIVE_PHOTO_DATA: { - if (mimeTypes.isVideo(filename)) { - return true; - } - break; - } - - case UploadFieldName.SIDECAR_DATA: { - if (mimeTypes.isSidecar(filename)) { - return true; - } - break; - } - - case UploadFieldName.PROFILE_DATA: { - if (mimeTypes.isProfile(filename)) { - return true; - } - break; - } - } - - this.logger.error(`Unsupported file type ${filename}`); - throw new BadRequestException(`Unsupported file type ${filename}`); - } - - getUploadFilename({ auth, fieldName, file }: UploadRequest): string { - this.access.requireUploadAccess(auth); - - const originalExtension = extname(file.originalName); - - const lookup = { - [UploadFieldName.ASSET_DATA]: originalExtension, - [UploadFieldName.LIVE_PHOTO_DATA]: '.mov', - [UploadFieldName.SIDECAR_DATA]: '.xmp', - [UploadFieldName.PROFILE_DATA]: originalExtension, - }; - - return sanitize(`${file.uuid}${lookup[fieldName]}`); - } - - getUploadFolder({ auth, fieldName, file }: UploadRequest): string { - auth = this.access.requireUploadAccess(auth); - - let folder = StorageCore.getNestedFolder(StorageFolder.UPLOAD, auth.user.id, file.uuid); - if (fieldName === UploadFieldName.PROFILE_DATA) { - folder = StorageCore.getFolderLocation(StorageFolder.PROFILE, auth.user.id); - } - - this.storageRepository.mkdirSync(folder); - - return folder; - } - async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { const currentYear = new Date().getFullYear(); diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 48231a11a8..6216a4dc3a 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -4,7 +4,6 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { DownloadService } from 'src/services/download.service'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; @@ -41,46 +40,7 @@ describe(DownloadService.name, () => { sut = new DownloadService(accessMock, assetMock, storageMock); }); - describe('downloadFile', () => { - it('should require the asset.download permission', async () => { - await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - }); - - it('should throw an error if the asset is not found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getByIds.mockResolvedValue([]); - - await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - - expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']); - }); - - it('should throw an error if the asset is offline', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getByIds.mockResolvedValue([assetStub.offline]); - - await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - - expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']); - }); - - it('should download a file', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - - await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual( - new ImmichFileResponse({ - path: '/original/path.jpg', - contentType: 'image/jpeg', - cacheControl: CacheControl.NONE, - }), - ); - }); - + describe('downloadArchive', () => { it('should download an archive', async () => { const archiveMock = { addFile: vitest.fn(), diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index b0b68a1e8c..07ef03efb5 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -9,8 +9,6 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IStorageRepository, ImmichReadStream } from 'src/interfaces/storage.interface'; import { HumanReadableSize } from 'src/utils/bytes'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; -import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @Injectable() @@ -25,25 +23,6 @@ export class DownloadService { this.access = AccessCore.create(accessRepository); } - async downloadFile(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id); - - const [asset] = await this.assetRepository.getByIds([id]); - if (!asset) { - throw new BadRequestException('Asset not found'); - } - - if (asset.isOffline) { - throw new BadRequestException('Asset is offline'); - } - - return new ImmichFileResponse({ - path: asset.originalPath, - contentType: mimeTypes.lookup(asset.originalPath), - cacheControl: CacheControl.NONE, - }); - } - async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise { const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; const archives: DownloadArchiveInfo[] = []; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index b55bb8fd25..2e79bf6fd2 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -3,7 +3,6 @@ import { AlbumService } from 'src/services/album.service'; import { APIKeyService } from 'src/services/api-key.service'; import { ApiService } from 'src/services/api.service'; import { AssetMediaService } from 'src/services/asset-media.service'; -import { AssetServiceV1 } from 'src/services/asset-v1.service'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; import { AuthService } from 'src/services/auth.service'; @@ -45,7 +44,6 @@ export const services = [ ApiService, AssetMediaService, AssetService, - AssetServiceV1, AuditService, AuthService, CliService, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index df870183a9..0bafe032e2 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -411,7 +411,11 @@ export class MetadataService { } const checksum = this.cryptoRepository.hashSha1(video); - let motionAsset = await this.assetRepository.getByChecksum(asset.libraryId ?? null, checksum); + let motionAsset = await this.assetRepository.getByChecksum({ + ownerId: asset.ownerId, + libraryId: asset.libraryId ?? undefined, + checksum, + }); if (motionAsset) { this.logger.debug( `Asset ${asset.id}'s motion photo video with checksum ${checksum.toString( diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 6baeac858a..65776167b5 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -271,7 +271,7 @@ describe(SharedLinkService.name, () => { await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', imageUrl: - '/api/asset/thumbnail/asset-id?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0', + '/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0', title: 'Public Share', }); expect(shareMock.get).toHaveBeenCalled(); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 28c29594c2..489b5e5f0a 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -191,7 +191,7 @@ export class SharedLinkService { title: sharedLink.album ? sharedLink.album.albumName : 'Public Share', description: sharedLink.description || `${assetCount} shared photos & videos`, imageUrl: assetId - ? `/api/asset/thumbnail/${assetId}?key=${sharedLink.key.toString('base64url')}` + ? `/api/assets/${assetId}/thumbnail?key=${sharedLink.key.toString('base64url')}` : '/feature-panel.png', }; } diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts index 0c4996fbfc..9b0d814dab 100644 --- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts @@ -44,7 +44,7 @@ describe('AlbumCard component', () => { await waitFor(() => expect(albumImgElement).toHaveAttribute('src')); expect(albumImgElement).toHaveAttribute('alt', album.albumName); - expect(sdkMock.getAssetThumbnail).not.toHaveBeenCalled(); + expect(sdkMock.viewAsset).not.toHaveBeenCalled(); expect(albumNameElement).toHaveTextContent(album.albumName); expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText)); diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte index 8e289de03a..aa8baebd04 100644 --- a/web/src/lib/components/album-page/album-cover.svelte +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -1,15 +1,13 @@
diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 7cf0989797..0e4b61080b 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -9,7 +9,6 @@ import { isTenMinutesApart } from '$lib/utils/timesince'; import { ReactionType, - ThumbnailFormat, createActivity, deleteActivity, getActivities, @@ -182,7 +181,7 @@ Profile picture of {reaction.user.name}, who commented on this asset @@ -235,7 +234,7 @@ > Profile picture of {reaction.user.name}, who liked this asset diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index 405d306be6..ab049da652 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -1,6 +1,6 @@