1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-04-10 10:06:25 +02:00

refactor: asset media endpoints ()

* 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 1932 additions and 2456 deletions
cli/src/commands
docs/docs/guides
e2e/src
mobile
open-api
server/src
web/src

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,

View file

@ -93,22 +93,23 @@ Class | Method | HTTP request | Description
*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} |
*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} |
*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} |
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /asset/bulk-upload-check |
*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /asset/exist |
*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /asset |
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} |
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /asset/{id} |
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /asset/statistics |
*AssetsApi* | [**getAssetThumbnail**](doc//AssetsApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} |
*AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /asset/memory-lane |
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /asset/random |
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /asset/{id}/file |
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /asset/jobs |
*AssetsApi* | [**serveFile**](doc//AssetsApi.md#servefile) | **GET** /asset/file/{id} |
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /asset/{id} |
*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /asset |
*AssetsApi* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /asset/stack/parent |
*AssetsApi* | [**uploadFile**](doc//AssetsApi.md#uploadfile) | **POST** /asset/upload |
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check |
*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist |
*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets |
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original |
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} |
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} |
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics |
*AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /assets/memory-lane |
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random |
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback |
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original |
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs |
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} |
*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets |
*AssetsApi* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /assets/stack/parent |
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
*AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
@ -116,7 +117,6 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
*DownloadApi* | [**downloadFile**](doc//DownloadApi.md#downloadfile) | **POST** /download/asset/{id} |
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates |
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces |
@ -260,13 +260,13 @@ Class | Method | HTTP request | Description
- [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md)
- [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md)
- [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md)
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
- [AssetFullSyncDto](doc//AssetFullSyncDto.md)
- [AssetIdsDto](doc//AssetIdsDto.md)
- [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
- [AssetJobName](doc//AssetJobName.md)
- [AssetJobsDto](doc//AssetJobsDto.md)
- [AssetMediaResponseDto](doc//AssetMediaResponseDto.md)
- [AssetMediaSize](doc//AssetMediaSize.md)
- [AssetMediaStatus](doc//AssetMediaStatus.md)
- [AssetOrder](doc//AssetOrder.md)
- [AssetResponseDto](doc//AssetResponseDto.md)
@ -398,7 +398,6 @@ Class | Method | HTTP request | Description
- [SystemConfigUserDto](doc//SystemConfigUserDto.md)
- [TagResponseDto](doc//TagResponseDto.md)
- [TagTypeEnum](doc//TagTypeEnum.md)
- [ThumbnailFormat](doc//ThumbnailFormat.md)
- [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
- [TimeBucketSize](doc//TimeBucketSize.md)
- [ToneMapping](doc//ToneMapping.md)

View file

@ -87,13 +87,13 @@ part 'model/asset_face_response_dto.dart';
part 'model/asset_face_update_dto.dart';
part 'model/asset_face_update_item.dart';
part 'model/asset_face_without_person_response_dto.dart';
part 'model/asset_file_upload_response_dto.dart';
part 'model/asset_full_sync_dto.dart';
part 'model/asset_ids_dto.dart';
part 'model/asset_ids_response_dto.dart';
part 'model/asset_job_name.dart';
part 'model/asset_jobs_dto.dart';
part 'model/asset_media_response_dto.dart';
part 'model/asset_media_size.dart';
part 'model/asset_media_status.dart';
part 'model/asset_order.dart';
part 'model/asset_response_dto.dart';
@ -225,7 +225,6 @@ part 'model/system_config_trash_dto.dart';
part 'model/system_config_user_dto.dart';
part 'model/tag_response_dto.dart';
part 'model/tag_type_enum.dart';
part 'model/thumbnail_format.dart';
part 'model/time_bucket_response_dto.dart';
part 'model/time_bucket_size.dart';
part 'model/tone_mapping.dart';

View file

@ -25,7 +25,7 @@ class AssetsApi {
/// * [AssetBulkUploadCheckDto] assetBulkUploadCheckDto (required):
Future<Response> checkBulkUploadWithHttpInfo(AssetBulkUploadCheckDto assetBulkUploadCheckDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset/bulk-upload-check';
final path = r'/assets/bulk-upload-check';
// ignore: prefer_final_locals
Object? postBody = assetBulkUploadCheckDto;
@ -77,7 +77,7 @@ class AssetsApi {
/// * [CheckExistingAssetsDto] checkExistingAssetsDto (required):
Future<Response> checkExistingAssetsWithHttpInfo(CheckExistingAssetsDto checkExistingAssetsDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset/exist';
final path = r'/assets/exist';
// ignore: prefer_final_locals
Object? postBody = checkExistingAssetsDto;
@ -120,13 +120,13 @@ class AssetsApi {
return null;
}
/// Performs an HTTP 'DELETE /asset' operation and returns the [Response].
/// Performs an HTTP 'DELETE /assets' operation and returns the [Response].
/// Parameters:
///
/// * [AssetBulkDeleteDto] assetBulkDeleteDto (required):
Future<Response> deleteAssetsWithHttpInfo(AssetBulkDeleteDto assetBulkDeleteDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset';
final path = r'/assets';
// ignore: prefer_final_locals
Object? postBody = assetBulkDeleteDto;
@ -159,6 +159,62 @@ class AssetsApi {
}
}
/// Performs an HTTP 'GET /assets/{id}/original' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
Future<Response> downloadAssetWithHttpInfo(String id, { String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/assets/{id}/original'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
Future<MultipartFile?> downloadAsset(String id, { String? key, }) async {
final response = await downloadAssetWithHttpInfo(id, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
}
return null;
}
/// Get all asset of a device that are in the database, ID only.
///
/// Note: This method returns the HTTP [Response].
@ -168,7 +224,7 @@ class AssetsApi {
/// * [String] deviceId (required):
Future<Response> getAllUserAssetsByDeviceIdWithHttpInfo(String deviceId,) async {
// ignore: prefer_const_declarations
final path = r'/asset/device/{deviceId}'
final path = r'/assets/device/{deviceId}'
.replaceAll('{deviceId}', deviceId);
// ignore: prefer_final_locals
@ -215,7 +271,7 @@ class AssetsApi {
return null;
}
/// Performs an HTTP 'GET /asset/{id}' operation and returns the [Response].
/// Performs an HTTP 'GET /assets/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
@ -223,7 +279,7 @@ class AssetsApi {
/// * [String] key:
Future<Response> getAssetInfoWithHttpInfo(String id, { String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/{id}'
final path = r'/assets/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
@ -271,7 +327,7 @@ class AssetsApi {
return null;
}
/// Performs an HTTP 'GET /asset/statistics' operation and returns the [Response].
/// Performs an HTTP 'GET /assets/statistics' operation and returns the [Response].
/// Parameters:
///
/// * [bool] isArchived:
@ -281,7 +337,7 @@ class AssetsApi {
/// * [bool] isTrashed:
Future<Response> getAssetStatisticsWithHttpInfo({ bool? isArchived, bool? isFavorite, bool? isTrashed, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/statistics';
final path = r'/assets/statistics';
// ignore: prefer_final_locals
Object? postBody;
@ -336,70 +392,7 @@ class AssetsApi {
return null;
}
/// Performs an HTTP 'GET /asset/thumbnail/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [ThumbnailFormat] format:
///
/// * [String] key:
Future<Response> getAssetThumbnailWithHttpInfo(String id, { ThumbnailFormat? format, String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/thumbnail/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (format != null) {
queryParams.addAll(_queryParams('', 'format', format));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [ThumbnailFormat] format:
///
/// * [String] key:
Future<MultipartFile?> getAssetThumbnail(String id, { ThumbnailFormat? format, String? key, }) async {
final response = await getAssetThumbnailWithHttpInfo(id, format: format, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
}
return null;
}
/// Performs an HTTP 'GET /asset/memory-lane' operation and returns the [Response].
/// Performs an HTTP 'GET /assets/memory-lane' operation and returns the [Response].
/// Parameters:
///
/// * [int] day (required):
@ -407,7 +400,7 @@ class AssetsApi {
/// * [int] month (required):
Future<Response> getMemoryLaneWithHttpInfo(int day, int month,) async {
// ignore: prefer_const_declarations
final path = r'/asset/memory-lane';
final path = r'/assets/memory-lane';
// ignore: prefer_final_locals
Object? postBody;
@ -456,13 +449,13 @@ class AssetsApi {
return null;
}
/// Performs an HTTP 'GET /asset/random' operation and returns the [Response].
/// Performs an HTTP 'GET /assets/random' operation and returns the [Response].
/// Parameters:
///
/// * [num] count:
Future<Response> getRandomWithHttpInfo({ num? count, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/random';
final path = r'/assets/random';
// ignore: prefer_final_locals
Object? postBody;
@ -510,6 +503,62 @@ class AssetsApi {
return null;
}
/// Performs an HTTP 'GET /assets/{id}/video/playback' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
Future<Response> playAssetVideoWithHttpInfo(String id, { String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/assets/{id}/video/playback'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
Future<MultipartFile?> playAssetVideo(String id, { String? key, }) async {
final response = await playAssetVideoWithHttpInfo(id, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
}
return null;
}
/// Replace the asset with new file, without changing its id
///
/// Note: This method returns the HTTP [Response].
@ -533,7 +582,7 @@ class AssetsApi {
/// * [String] duration:
Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/{id}/file'
final path = r'/assets/{id}/original'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
@ -625,13 +674,13 @@ class AssetsApi {
return null;
}
/// Performs an HTTP 'POST /asset/jobs' operation and returns the [Response].
/// Performs an HTTP 'POST /assets/jobs' operation and returns the [Response].
/// Parameters:
///
/// * [AssetJobsDto] assetJobsDto (required):
Future<Response> runAssetJobsWithHttpInfo(AssetJobsDto assetJobsDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset/jobs';
final path = r'/assets/jobs';
// ignore: prefer_final_locals
Object? postBody = assetJobsDto;
@ -664,77 +713,7 @@ class AssetsApi {
}
}
/// Performs an HTTP 'GET /asset/file/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [bool] isThumb:
///
/// * [bool] isWeb:
///
/// * [String] key:
Future<Response> serveFileWithHttpInfo(String id, { bool? isThumb, bool? isWeb, String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/file/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (isThumb != null) {
queryParams.addAll(_queryParams('', 'isThumb', isThumb));
}
if (isWeb != null) {
queryParams.addAll(_queryParams('', 'isWeb', isWeb));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [bool] isThumb:
///
/// * [bool] isWeb:
///
/// * [String] key:
Future<MultipartFile?> serveFile(String id, { bool? isThumb, bool? isWeb, String? key, }) async {
final response = await serveFileWithHttpInfo(id, isThumb: isThumb, isWeb: isWeb, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
}
return null;
}
/// Performs an HTTP 'PUT /asset/{id}' operation and returns the [Response].
/// Performs an HTTP 'PUT /assets/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
@ -742,7 +721,7 @@ class AssetsApi {
/// * [UpdateAssetDto] updateAssetDto (required):
Future<Response> updateAssetWithHttpInfo(String id, UpdateAssetDto updateAssetDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset/{id}'
final path = r'/assets/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
@ -786,13 +765,13 @@ class AssetsApi {
return null;
}
/// Performs an HTTP 'PUT /asset' operation and returns the [Response].
/// Performs an HTTP 'PUT /assets' operation and returns the [Response].
/// Parameters:
///
/// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
Future<Response> updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset';
final path = r'/assets';
// ignore: prefer_final_locals
Object? postBody = assetBulkUpdateDto;
@ -825,13 +804,13 @@ class AssetsApi {
}
}
/// Performs an HTTP 'PUT /asset/stack/parent' operation and returns the [Response].
/// Performs an HTTP 'PUT /assets/stack/parent' operation and returns the [Response].
/// Parameters:
///
/// * [UpdateStackParentDto] updateStackParentDto (required):
Future<Response> updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset/stack/parent';
final path = r'/assets/stack/parent';
// ignore: prefer_final_locals
Object? postBody = updateStackParentDto;
@ -864,7 +843,7 @@ class AssetsApi {
}
}
/// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
/// Performs an HTTP 'POST /assets' operation and returns the [Response].
/// Parameters:
///
/// * [MultipartFile] assetData (required):
@ -892,12 +871,12 @@ class AssetsApi {
///
/// * [bool] isVisible:
///
/// * [MultipartFile] livePhotoData:
/// * [String] livePhotoVideoId:
///
/// * [MultipartFile] sidecarData:
Future<Response> uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/upload';
final path = r'/assets';
// ignore: prefer_final_locals
Object? postBody;
@ -959,10 +938,9 @@ class AssetsApi {
hasFields = true;
mp.fields[r'isVisible'] = parameterToString(isVisible);
}
if (livePhotoData != null) {
if (livePhotoVideoId != null) {
hasFields = true;
mp.fields[r'livePhotoData'] = livePhotoData.field;
mp.files.add(livePhotoData);
mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId);
}
if (sidecarData != null) {
hasFields = true;
@ -1011,11 +989,11 @@ class AssetsApi {
///
/// * [bool] isVisible:
///
/// * [MultipartFile] livePhotoData:
/// * [String] livePhotoVideoId:
///
/// * [MultipartFile] sidecarData:
Future<AssetFileUploadResponseDto?> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoData: livePhotoData, sidecarData: sidecarData, );
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async {
final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@ -1023,7 +1001,70 @@ class AssetsApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetFileUploadResponseDto',) as AssetFileUploadResponseDto;
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMediaResponseDto',) as AssetMediaResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /assets/{id}/thumbnail' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
///
/// * [AssetMediaSize] size:
Future<Response> viewAssetWithHttpInfo(String id, { String? key, AssetMediaSize? size, }) async {
// ignore: prefer_const_declarations
final path = r'/assets/{id}/thumbnail'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (size != null) {
queryParams.addAll(_queryParams('', 'size', size));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
///
/// * [AssetMediaSize] size:
Future<MultipartFile?> viewAsset(String id, { String? key, AssetMediaSize? size, }) async {
final response = await viewAssetWithHttpInfo(id, key: key, size: size, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
}
return null;

View file

@ -71,62 +71,6 @@ class DownloadApi {
return null;
}
/// Performs an HTTP 'POST /download/asset/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
Future<Response> downloadFileWithHttpInfo(String id, { String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/download/asset/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
Future<MultipartFile?> downloadFile(String id, { String? key, }) async {
final response = await downloadFileWithHttpInfo(id, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
}
return null;
}
/// Performs an HTTP 'POST /download/info' operation and returns the [Response].
/// Parameters:
///

View file

@ -238,8 +238,6 @@ class ApiClient {
return AssetFaceUpdateItem.fromJson(value);
case 'AssetFaceWithoutPersonResponseDto':
return AssetFaceWithoutPersonResponseDto.fromJson(value);
case 'AssetFileUploadResponseDto':
return AssetFileUploadResponseDto.fromJson(value);
case 'AssetFullSyncDto':
return AssetFullSyncDto.fromJson(value);
case 'AssetIdsDto':
@ -252,6 +250,8 @@ class ApiClient {
return AssetJobsDto.fromJson(value);
case 'AssetMediaResponseDto':
return AssetMediaResponseDto.fromJson(value);
case 'AssetMediaSize':
return AssetMediaSizeTypeTransformer().decode(value);
case 'AssetMediaStatus':
return AssetMediaStatusTypeTransformer().decode(value);
case 'AssetOrder':
@ -514,8 +514,6 @@ class ApiClient {
return TagResponseDto.fromJson(value);
case 'TagTypeEnum':
return TagTypeEnumTypeTransformer().decode(value);
case 'ThumbnailFormat':
return ThumbnailFormatTypeTransformer().decode(value);
case 'TimeBucketResponseDto':
return TimeBucketResponseDto.fromJson(value);
case 'TimeBucketSize':

View file

@ -61,6 +61,9 @@ String parameterToString(dynamic value) {
if (value is AssetJobName) {
return AssetJobNameTypeTransformer().encode(value).toString();
}
if (value is AssetMediaSize) {
return AssetMediaSizeTypeTransformer().encode(value).toString();
}
if (value is AssetMediaStatus) {
return AssetMediaStatusTypeTransformer().encode(value).toString();
}
@ -127,9 +130,6 @@ String parameterToString(dynamic value) {
if (value is TagTypeEnum) {
return TagTypeEnumTypeTransformer().encode(value).toString();
}
if (value is ThumbnailFormat) {
return ThumbnailFormatTypeTransformer().encode(value).toString();
}
if (value is TimeBucketSize) {
return TimeBucketSizeTypeTransformer().encode(value).toString();
}

View file

@ -1,106 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetFileUploadResponseDto {
/// Returns a new [AssetFileUploadResponseDto] instance.
AssetFileUploadResponseDto({
required this.duplicate,
required this.id,
});
bool duplicate;
String id;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetFileUploadResponseDto &&
other.duplicate == duplicate &&
other.id == id;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(duplicate.hashCode) +
(id.hashCode);
@override
String toString() => 'AssetFileUploadResponseDto[duplicate=$duplicate, id=$id]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'duplicate'] = this.duplicate;
json[r'id'] = this.id;
return json;
}
/// Returns a new [AssetFileUploadResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFileUploadResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetFileUploadResponseDto(
duplicate: mapValueOfType<bool>(json, r'duplicate')!,
id: mapValueOfType<String>(json, r'id')!,
);
}
return null;
}
static List<AssetFileUploadResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetFileUploadResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetFileUploadResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetFileUploadResponseDto> mapFromJson(dynamic json) {
final map = <String, AssetFileUploadResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetFileUploadResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetFileUploadResponseDto-objects as value to a dart map
static Map<String, List<AssetFileUploadResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetFileUploadResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetFileUploadResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'duplicate',
'id',
};
}

View file

@ -11,9 +11,9 @@
part of openapi.api;
class ThumbnailFormat {
class AssetMediaSize {
/// Instantiate a new enum with the provided [value].
const ThumbnailFormat._(this.value);
const AssetMediaSize._(this.value);
/// The underlying value of this enum member.
final String value;
@ -23,22 +23,22 @@ class ThumbnailFormat {
String toJson() => value;
static const JPEG = ThumbnailFormat._(r'JPEG');
static const WEBP = ThumbnailFormat._(r'WEBP');
static const preview = AssetMediaSize._(r'preview');
static const thumbnail = AssetMediaSize._(r'thumbnail');
/// List of all possible values in this [enum][ThumbnailFormat].
static const values = <ThumbnailFormat>[
JPEG,
WEBP,
/// List of all possible values in this [enum][AssetMediaSize].
static const values = <AssetMediaSize>[
preview,
thumbnail,
];
static ThumbnailFormat? fromJson(dynamic value) => ThumbnailFormatTypeTransformer().decode(value);
static AssetMediaSize? fromJson(dynamic value) => AssetMediaSizeTypeTransformer().decode(value);
static List<ThumbnailFormat> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ThumbnailFormat>[];
static List<AssetMediaSize> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetMediaSize>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ThumbnailFormat.fromJson(row);
final value = AssetMediaSize.fromJson(row);
if (value != null) {
result.add(value);
}
@ -48,16 +48,16 @@ class ThumbnailFormat {
}
}
/// Transformation class that can [encode] an instance of [ThumbnailFormat] to String,
/// and [decode] dynamic data back to [ThumbnailFormat].
class ThumbnailFormatTypeTransformer {
factory ThumbnailFormatTypeTransformer() => _instance ??= const ThumbnailFormatTypeTransformer._();
/// Transformation class that can [encode] an instance of [AssetMediaSize] to String,
/// and [decode] dynamic data back to [AssetMediaSize].
class AssetMediaSizeTypeTransformer {
factory AssetMediaSizeTypeTransformer() => _instance ??= const AssetMediaSizeTypeTransformer._();
const ThumbnailFormatTypeTransformer._();
const AssetMediaSizeTypeTransformer._();
String encode(ThumbnailFormat data) => data.value;
String encode(AssetMediaSize data) => data.value;
/// Decodes a [dynamic value][data] to a ThumbnailFormat.
/// Decodes a [dynamic value][data] to a AssetMediaSize.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@ -65,11 +65,11 @@ class ThumbnailFormatTypeTransformer {
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ThumbnailFormat? decode(dynamic data, {bool allowNull = true}) {
AssetMediaSize? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'JPEG': return ThumbnailFormat.JPEG;
case r'WEBP': return ThumbnailFormat.WEBP;
case r'preview': return AssetMediaSize.preview;
case r'thumbnail': return AssetMediaSize.thumbnail;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
@ -79,7 +79,7 @@ class ThumbnailFormatTypeTransformer {
return null;
}
/// Singleton [ThumbnailFormatTypeTransformer] instance.
static ThumbnailFormatTypeTransformer? _instance;
/// Singleton [AssetMediaSizeTypeTransformer] instance.
static AssetMediaSizeTypeTransformer? _instance;
}

View file

@ -23,11 +23,13 @@ class AssetMediaStatus {
String toJson() => value;
static const created = AssetMediaStatus._(r'created');
static const replaced = AssetMediaStatus._(r'replaced');
static const duplicate = AssetMediaStatus._(r'duplicate');
/// List of all possible values in this [enum][AssetMediaStatus].
static const values = <AssetMediaStatus>[
created,
replaced,
duplicate,
];
@ -68,6 +70,7 @@ class AssetMediaStatusTypeTransformer {
AssetMediaStatus? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'created': return AssetMediaStatus.created;
case r'replaced': return AssetMediaStatus.replaced;
case r'duplicate': return AssetMediaStatus.duplicate;
default:

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