refactor: asset media endpoints (#9831)

* refactor: asset media endpoints

* refactor: mobile upload livePhoto as separate request

* refactor: change mobile backup flow to use new asset upload endpoints

* chore: format and analyze dart code

* feat: mark motion as hidden when linked

* feat: upload video portion of live photo before image portion

* fix: incorrect assetApi calls in mobile code

* fix: download asset

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
Jason Rasmussen 2024-05-31 13:44:04 -04:00 committed by GitHub
parent 66fced40e7
commit 69d2fcb43e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 1674 additions and 2076 deletions

View file

@ -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<AssetFileUploadResponseDto> => {
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
const { baseUrl, headers } = defaults;
const assetPath = path.parse(input);
@ -225,7 +226,7 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetFileUploadR
formData.append('sidecarData', sidecarData);
}
const response = await fetch(`${baseUrl}/asset/upload`, {
const response = await fetch(`${baseUrl}/assets`, {
method: 'post',
redirect: 'error',
headers: headers as Record<string, string>,

View file

@ -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}

View file

@ -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) =>

View file

@ -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[];

View file

@ -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();

View file

@ -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');
});
});
});

View file

@ -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();

View file

@ -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 () => {

View file

@ -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) });

View file

@ -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;

View file

@ -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();

View file

@ -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<Omit<CreateAssetDto, 'assetData'>> & { assetData?: AssetData },
dto?: Partial<Omit<AssetMediaCreateDto, 'assetData'>> & { 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) => {

View file

@ -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();

View file

@ -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();

View file

@ -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;

View file

@ -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);

View file

@ -22,8 +22,8 @@ Future<VideoPlayerController> 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);

View file

@ -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,

View file

@ -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(

View file

@ -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<String?> 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:

View file

@ -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);

View file

@ -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());

View file

@ -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(

View file

@ -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);

View file

@ -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) {

View file

@ -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),
);

View file

@ -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,

View file

@ -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,

View file

@ -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,

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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"

View file

@ -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": {

View file

@ -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",

View file

@ -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`;

View file

@ -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<AssetMediaResponseDto> {
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<AssetMediaResponseDto> {
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
*/

View file

@ -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<AssetFileUploadResponseDto> {
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);
}
}

View file

@ -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<StreamableFile> {
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);
}
}

View file

@ -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,

View file

@ -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;
}

View file

@ -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()

View file

@ -1,4 +0,0 @@
export class AssetFileUploadResponseDto {
id!: string;
duplicate!: boolean;
}

View file

@ -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;
}

View file

@ -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',
}

View file

@ -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' })

View file

@ -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<AssetEntity | null>;
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
}
export const IAssetRepositoryV1 = 'IAssetRepositoryV1';

View file

@ -153,7 +153,7 @@ export interface IAssetRepository {
): Promise<AssetEntity[]>;
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
getByChecksum(libraryId: string | null, checksum: Buffer): Promise<AssetEntity | null>;
getByChecksum(options: { ownerId: string; checksum: Buffer; libraryId?: string }): Promise<AssetEntity | null>;
getByChecksums(userId: string, checksums: Buffer[]): Promise<AssetEntity[]>;
getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;

View file

@ -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<any>) {
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
const res = context.switchToHttp().getResponse<Response<AssetFileUploadResponseDto>>();
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>();
const checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]);
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum);

View file

@ -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;
}
}

View file

@ -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)

View file

@ -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<AssetEntity>) {}
get(id: string): Promise<AssetEntity | null> {
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<AssetCheck[]> {
return this.assetRepository.find({
select: {
id: true,
checksum: true,
},
where: {
ownerId,
checksum: In(checksums),
},
withDeleted: true,
});
}
}

View file

@ -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<AssetEntity | null> {
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, libraryId: DummyValue.UUID, checksum: DummyValue.BUFFER }] })
getByChecksum({
ownerId,
libraryId,
checksum,
}: {
ownerId: string;
checksum: Buffer;
libraryId?: string;
}): Promise<AssetEntity | null> {
return this.repository.findOne({
where: {
ownerId,
libraryId: libraryId || IsNull(),
checksum,
},

View file

@ -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 },

View file

@ -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<IAssetRepository>;
@ -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');

View file

@ -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<AssetMediaResponseDto | undefined> {
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<AssetMediaResponseDto> {
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<AssetMediaResponseDto> {
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<ImmichFileResponse> {
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<ImmichFileResponse> {
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<ImmichFileResponse> {
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<CheckExistingAssetsResponseDto> {
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<AssetBulkUploadCheckResponseDto> {
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
const results = await this.assetRepository.getByChecksums(auth.user.id, checksums);
const checksumMap: Record<string, string> = {};
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<AssetEntity> {
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<CheckExistingAssetsResponseDto> {
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<AssetBulkUploadCheckResponseDto> {
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
const results = await this.assetRepository.getByChecksums(auth.user.id, checksums);
const checksumMap: Record<string, string> = {};
for (const { id, checksum } of results) {
checksumMap[checksum.toString('hex')] = id;
private async findOrFail(id: string): Promise<AssetEntity> {
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;
}
}

View file

@ -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<IAssetRepositoryV1>;
let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>;
let libraryMock: Mocked<ILibraryRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>;
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),
);
});
});
});

View file

@ -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<AssetFileUploadResponseDto> {
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<ImmichFileResponse> {
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<ImmichFileResponse> {
// 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<AssetEntity> {
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!');
}
}
}

View file

@ -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<IAssetRepository>;
let jobMock: Mocked<IJobRepository>;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>;
let eventMock: Mocked<IEventRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
@ -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();

View file

@ -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<AssetFileUploadResponseDto | undefined> {
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<MemoryLaneResponseDto[]> {
const currentYear = new Date().getFullYear();

View file

@ -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(),

View file

@ -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<ImmichFileResponse> {
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<DownloadResponseDto> {
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
const archives: DownloadArchiveInfo[] = [];

View file

@ -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,

View file

@ -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(

View file

@ -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();

View file

@ -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',
};
}

View file

@ -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));

View file

@ -1,15 +1,13 @@
<script lang="ts">
import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk';
import { getAssetThumbnailUrl } from '$lib/utils';
import { type AlbumResponseDto } from '@immich/sdk';
export let album: AlbumResponseDto | undefined;
export let preload = false;
export let css = '';
$: thumbnailUrl =
album && album.albumThumbnailAssetId
? getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)
: null;
album && album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null;
</script>
<div class="relative aspect-square">

View file

@ -9,7 +9,6 @@
import { isTenMinutesApart } from '$lib/utils/timesince';
import {
ReactionType,
ThumbnailFormat,
createActivity,
deleteActivity,
getActivities,
@ -182,7 +181,7 @@
<a class="aspect-square w-[75px] h-[75px]" href="{AppRoute.ALBUMS}/{albumId}/photos/{reaction.assetId}">
<img
class="rounded-lg w-[75px] h-[75px] object-cover"
src={getAssetThumbnailUrl(reaction.assetId, ThumbnailFormat.Webp)}
src={getAssetThumbnailUrl(reaction.assetId)}
alt="Profile picture of {reaction.user.name}, who commented on this asset"
/>
</a>
@ -235,7 +234,7 @@
>
<img
class="rounded-lg w-[75px] h-[75px] object-cover"
src={getAssetThumbnailUrl(reaction.assetId, ThumbnailFormat.Webp)}
src={getAssetThumbnailUrl(reaction.assetId)}
alt="Profile picture of {reaction.user.name}, who liked this asset"
/>
</a>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { getAssetThumbnailUrl } from '$lib/utils';
import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk';
import { type AlbumResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
import AlbumListItemDetails from './album-list-item-details.svelte';
@ -35,7 +35,7 @@
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)}
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
alt={album.albumName}
class="z-0 h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
data-testid="album-image"

View file

@ -11,7 +11,7 @@
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError, isSharedLink } from '$lib/utils';
import { delay, isFlipped } from '$lib/utils/asset-utils';
import {
ThumbnailFormat,
AssetMediaSize,
getAssetInfo,
updateAsset,
type AlbumResponseDto,
@ -474,7 +474,7 @@
alt={album.albumName}
class="h-[50px] w-[50px] rounded object-cover"
src={album.albumThumbnailAssetId &&
getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Jpeg)}
getAssetThumbnailUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
/>
</div>

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { serveFile, type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
import { getAssetOriginalUrl, getKey } from '$lib/utils';
import { AssetMediaSize, AssetTypeEnum, viewAsset, type AssetResponseDto } from '@immich/sdk';
import type { AdapterConstructor, PluginConstructor } from '@photo-sphere-viewer/core';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { getAssetFileUrl, getKey } from '$lib/utils';
import type { AdapterConstructor, PluginConstructor } from '@photo-sphere-viewer/core';
export let asset: Pick<AssetResponseDto, 'id' | 'type'>;
const photoSphereConfigs =
@ -20,9 +20,9 @@
const loadAssetData = async () => {
if (asset.type === AssetTypeEnum.Video) {
return { source: getAssetFileUrl(asset.id, false, false) };
return { source: getAssetOriginalUrl(asset.id) };
}
const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() });
const data = await viewAsset({ id: asset.id, size: AssetMediaSize.Preview, key: getKey() });
const url = URL.createObjectURL(data);
return url;
};

View file

@ -44,7 +44,7 @@ describe('PhotoViewer component', () => {
expect(downloadRequestMock).toBeCalledWith(
expect.objectContaining({
url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=true&c=${asset.checksum}`,
url: `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.checksum}`,
}),
);
await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument());
@ -61,7 +61,7 @@ describe('PhotoViewer component', () => {
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'url-two'));
expect(downloadRequestMock).toBeCalledWith(
expect.objectContaining({
url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=false&c=${asset.checksum}`,
url: `/api/assets/${asset.id}/original?c=${asset.checksum}`,
}),
);
});
@ -76,7 +76,7 @@ describe('PhotoViewer component', () => {
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'url-two'));
expect(downloadRequestMock).toBeCalledWith(
expect.objectContaining({
url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=true&c=new-checksum`,
url: `/api/assets/${asset.id}/thumbnail?size=preview&c=new-checksum`,
}),
);
});

View file

@ -3,11 +3,11 @@
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { downloadRequest, getAssetFileUrl, handlePromiseError } from '$lib/utils';
import { downloadRequest, getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { shortcuts } from '$lib/actions/shortcut';
import { type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
import { type AssetResponseDto, AssetTypeEnum, AssetMediaSize } from '@immich/sdk';
import { useZoomImageWheel } from '@zoom-image/svelte';
import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition';
@ -62,7 +62,9 @@
// TODO: Use sdk once it supports signals
const res = await downloadRequest({
url: getAssetFileUrl(asset.id, !loadOriginal, false, checksum),
url: loadOriginal
? getAssetOriginalUrl({ id: asset.id, checksum })
: getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview, checksum }),
signal: abortController.signal,
});
@ -76,7 +78,9 @@
for (const preloadAsset of preloadAssets) {
if (preloadAsset.type === AssetTypeEnum.Image) {
await downloadRequest({
url: getAssetFileUrl(preloadAsset.id, !loadOriginal, false),
url: loadOriginal
? getAssetOriginalUrl(preloadAsset.id)
: getAssetThumbnailUrl({ id: preloadAsset.id, size: AssetMediaSize.Preview }),
signal: abortController.signal,
});
}

View file

@ -1,11 +1,11 @@
<script lang="ts">
import { loopVideo as loopVideoPreference, videoViewerVolume, videoViewerMuted } from '$lib/stores/preferences.store';
import { getAssetFileUrl, getAssetThumbnailUrl } from '$lib/utils';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { ThumbnailFormat } from '@immich/sdk';
import { AssetMediaSize } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
export let assetId: string;
export let loopVideo: boolean;
@ -16,7 +16,7 @@
let assetFileUrl: string;
$: {
const next = getAssetFileUrl(assetId, false, true, checksum);
const next = getAssetPlaybackUrl({ id: assetId, checksum });
if (assetFileUrl !== next) {
assetFileUrl = next;
element && element.load();
@ -54,7 +54,7 @@
on:ended={() => dispatch('onVideoEnded')}
bind:muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl(assetId, ThumbnailFormat.Jpeg, checksum)}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })}
>
<source src={assetFileUrl} type="video/mp4" />
<track kind="captions" />

View file

@ -2,11 +2,12 @@
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { ProjectionType } from '$lib/constants';
import { getAssetFileUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { timeToSeconds } from '$lib/utils/date-time';
import { AssetTypeEnum, ThumbnailFormat, type AssetResponseDto } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl } from '$lib/utils';
import {
mdiArchiveArrowDownOutline,
mdiCameraBurst,
@ -33,7 +34,6 @@
export let thumbnailSize: number | undefined = undefined;
export let thumbnailWidth: number | undefined = undefined;
export let thumbnailHeight: number | undefined = undefined;
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected = false;
export let selectionCandidate = false;
export let disabled = false;
@ -181,7 +181,7 @@
{#if asset.resized}
<ImageThumbnail
url={getAssetThumbnailUrl(asset.id, format, asset.checksum)}
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, checksum: asset.checksum })}
altText={getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
@ -197,7 +197,7 @@
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetFileUrl(asset.id, false, true, asset.checksum)}
url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={timeToSeconds(asset.duration)}
@ -209,7 +209,7 @@
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetFileUrl(asset.livePhotoVideoId, false, true, asset.checksum)}
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
showTime={false}

View file

@ -3,7 +3,7 @@
import { photoViewer } from '$lib/stores/assets.store';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
import { AssetTypeEnum, ThumbnailFormat, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
import { AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import { linear } from 'svelte/easing';
@ -43,7 +43,7 @@
if (assetType === AssetTypeEnum.Image) {
image = $photoViewer;
} else if (assetType === AssetTypeEnum.Video) {
const data = getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp);
const data = getAssetThumbnailUrl(assetId);
const img: HTMLImageElement = new Image();
img.src = data;

View file

@ -1,27 +1,27 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { shortcuts } from '$lib/actions/shortcut';
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { type Viewport } from '$lib/stores/assets.store';
import { memoryStore } from '$lib/stores/memory.store';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { shortcuts } from '$lib/actions/shortcut';
import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { ThumbnailFormat, getMemoryLane, type AssetResponseDto } from '@immich/sdk';
import { AssetMediaSize, getMemoryLane, type AssetResponseDto } from '@immich/sdk';
import {
mdiChevronDown,
mdiChevronLeft,
@ -243,7 +243,7 @@
{#if previousMemory}
<img
class="h-full w-full rounded-2xl object-cover"
src={getAssetThumbnailUrl(previousMemory.assets[0].id, ThumbnailFormat.Jpeg)}
src={getAssetThumbnailUrl({ id: previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt="Previous memory"
draggable="false"
/>
@ -275,7 +275,7 @@
<img
transition:fade
class="h-full w-full rounded-2xl object-contain transition-all"
src={getAssetThumbnailUrl(currentAsset.id, ThumbnailFormat.Jpeg)}
src={getAssetThumbnailUrl({ id: currentAsset.id, size: AssetMediaSize.Preview })}
alt={currentAsset.exifInfo?.description}
draggable="false"
/>
@ -321,7 +321,7 @@
{#if nextMemory}
<img
class="h-full w-full rounded-2xl object-cover"
src={getAssetThumbnailUrl(nextMemory.assets[0].id, ThumbnailFormat.Jpeg)}
src={getAssetThumbnailUrl({ id: nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt="Next memory"
draggable="false"
/>

View file

@ -4,7 +4,7 @@
import { memoryStore } from '$lib/stores/memory.store';
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { ThumbnailFormat, getMemoryLane } from '@immich/sdk';
import { getMemoryLane } from '@immich/sdk';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
@ -75,7 +75,7 @@
>
<img
class="h-full w-full rounded-xl object-cover"
src={getAssetThumbnailUrl(memory.assets[0].id, ThumbnailFormat.Webp)}
src={getAssetThumbnailUrl(memory.assets[0].id)}
alt={`Memory Lane ${getAltText(memory.assets[0])}`}
draggable="false"
/>

View file

@ -183,7 +183,7 @@
/>
{:else}
<img
src={getAssetThumbnailUrl(feature.properties?.id, undefined)}
src={getAssetThumbnailUrl(feature.properties?.id)}
class="rounded-full w-[60px] h-[60px] border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
alt={feature.properties?.city && feature.properties.country
? `Map marker for images taken in ${feature.properties.city}, ${feature.properties.country}`

View file

@ -2,7 +2,7 @@
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk';
import { type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk';
import { mdiCheck, mdiTrashCanOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { s } from '$lib/utils';
@ -56,7 +56,7 @@
<button type="button" on:click={() => onSelectAsset(asset)} class="block relative">
<!-- THUMBNAIL-->
<img
src={getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
src={getAssetThumbnailUrl(asset.id)}
alt={asset.id}
title={`${assetData}`}
class={`w-[250px] h-[250px] object-cover rounded-t-xl border-t-[4px] border-l-[4px] border-r-[4px] border-gray-300 ${isSelected ? 'border-immich-primary dark:border-immich-dark-primary' : 'dark:border-gray-800'} transition-all`}

View file

@ -3,10 +3,11 @@ import { locales } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import {
AssetJobName,
AssetMediaSize,
JobName,
ThumbnailFormat,
finishOAuth,
getAssetOriginalPath,
getAssetPlaybackPath,
getAssetThumbnailPath,
getBaseUrl,
getPeopleThumbnailPath,
@ -162,18 +163,28 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
return getBaseUrl() + url.pathname + url.search + url.hash;
};
export const getAssetFileUrl = (
...[assetId, isWeb, isThumb, checksum]:
| [assetId: string, isWeb: boolean, isThumb: boolean]
| [assetId: string, isWeb: boolean, isThumb: boolean, checksum: string]
) => createUrl(getAssetOriginalPath(assetId), { isThumb, isWeb, key: getKey(), c: checksum });
export const getAssetOriginalUrl = (options: string | { id: string; checksum?: string }) => {
if (typeof options === 'string') {
options = { id: options };
}
const { id, checksum } = options;
return createUrl(getAssetOriginalPath(id), { key: getKey(), c: checksum });
};
export const getAssetThumbnailUrl = (
...[assetId, format, checksum]:
| [assetId: string, format: ThumbnailFormat | undefined]
| [assetId: string, format: ThumbnailFormat | undefined, checksum: string]
) => {
return createUrl(getAssetThumbnailPath(assetId), { format, key: getKey(), c: checksum });
export const getAssetThumbnailUrl = (options: string | { id: string; size?: AssetMediaSize; checksum?: string }) => {
if (typeof options === 'string') {
options = { id: options };
}
const { id, size, checksum } = options;
return createUrl(getAssetThumbnailPath(id), { size, key: getKey(), c: checksum });
};
export const getAssetPlaybackUrl = (options: string | { id: string; checksum?: string }) => {
if (typeof options === 'string') {
options = { id: options };
}
const { id, checksum } = options;
return createUrl(getAssetPlaybackPath(id), { key: getKey(), c: checksum });
};
export const getProfileImageUrl = (userId: string) => createUrl(getUserProfileImagePath(userId));

View file

@ -11,6 +11,7 @@ import { asByteUnitString } from '$lib/utils/byte-units';
import { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
import {
addAssetsToAlbum as addAssets,
getAssetInfo,
getBaseUrl,
getDownloadInfo,
updateAssets,
@ -154,11 +155,13 @@ export const downloadFile = async (asset: AssetResponseDto) => {
size: asset.exifInfo?.fileSizeInByte || 0,
},
];
if (asset.livePhotoVideoId) {
const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() });
assets.push({
filename: asset.originalFileName,
filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId,
size: 0,
size: motionAsset.exifInfo?.fileSizeInByte || 0,
});
}
@ -177,8 +180,8 @@ export const downloadFile = async (asset: AssetResponseDto) => {
// TODO use sdk once it supports progress events
const { data } = await downloadRequest({
method: 'POST',
url: getBaseUrl() + `/download/asset/${id}` + (key ? `?key=${key}` : ''),
method: 'GET',
url: getBaseUrl() + `/assets/${id}/original` + (key ? `?key=${key}` : ''),
signal: abort.signal,
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded, event.total),
});

View file

@ -7,9 +7,9 @@ import {
Action,
AssetMediaStatus,
checkBulkUpload,
getAssetOriginalPath,
getBaseUrl,
getSupportedMediaTypes,
type AssetFileUploadResponseDto,
type AssetMediaResponseDto,
} from '@immich/sdk';
import { tick } from 'svelte';
@ -129,26 +129,24 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Uploading...' });
if (replaceAssetId) {
const response = await uploadRequest<AssetMediaResponseDto>({
url: getBaseUrl() + '/asset/' + replaceAssetId + '/file' + (key ? `?key=${key}` : ''),
url: getBaseUrl() + getAssetOriginalPath(replaceAssetId) + (key ? `?key=${key}` : ''),
method: 'PUT',
data: formData,
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
});
({ status, id } = response.data);
} else {
const response = await uploadRequest<AssetFileUploadResponseDto>({
url: getBaseUrl() + '/asset/upload' + (key ? `?key=${key}` : ''),
const response = await uploadRequest<AssetMediaResponseDto>({
url: getBaseUrl() + '/assets' + (key ? `?key=${key}` : ''),
data: formData,
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
});
if (![200, 201].includes(response.status)) {
throw new Error('Failed to upload file');
}
if (response.data.duplicate) {
status = AssetMediaStatus.Duplicate;
} else {
id = response.data.id;
}
({ status, id } = response.data);
}
}

View file

@ -1,7 +1,7 @@
import { getAssetThumbnailUrl, setSharedLink } from '$lib/utils';
import { authenticate } from '$lib/utils/auth';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { ThumbnailFormat, getMySharedLink, isHttpError } from '@immich/sdk';
import { getMySharedLink, isHttpError } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
@ -22,7 +22,7 @@ export const load = (async ({ params }) => {
meta: {
title: sharedLink.album ? sharedLink.album.albumName : 'Public Share',
description: sharedLink.description || `${assetCount} shared photos & videos.`,
imageUrl: assetId ? getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp) : '/feature-panel.png',
imageUrl: assetId ? getAssetThumbnailUrl(assetId) : '/feature-panel.png',
},
};
} catch (error) {