refactor: asset media endpoints (#9831)

* refactor: asset media endpoints

* refactor: mobile upload livePhoto as separate request

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

* chore: format and analyze dart code

* feat: mark motion as hidden when linked

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

* fix: incorrect assetApi calls in mobile code

* fix: download asset

---------

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

View File

@ -1,7 +1,8 @@
import { import {
Action, Action,
AssetBulkUploadCheckResult, AssetBulkUploadCheckResult,
AssetFileUploadResponseDto, AssetMediaResponseDto,
AssetMediaStatus,
addAssetsToAlbum, addAssetsToAlbum,
checkBulkUpload, checkBulkUpload,
createAlbum, createAlbum,
@ -167,7 +168,7 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio
newAssets.push({ id: response.id, filepath }); newAssets.push({ id: response.id, filepath });
if (response.duplicate) { if (response.status === AssetMediaStatus.Duplicate) {
duplicateCount++; duplicateCount++;
duplicateSize += stats.size ?? 0; duplicateSize += stats.size ?? 0;
} else { } else {
@ -192,7 +193,7 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio
return newAssets; 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 { baseUrl, headers } = defaults;
const assetPath = path.parse(input); const assetPath = path.parse(input);
@ -225,7 +226,7 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetFileUploadR
formData.append('sidecarData', sidecarData); formData.append('sidecarData', sidecarData);
} }
const response = await fetch(`${baseUrl}/asset/upload`, { const response = await fetch(`${baseUrl}/assets`, {
method: 'post', method: 'post',
redirect: 'error', redirect: 'error',
headers: headers as Record<string, string>, headers: headers as Record<string, string>,

View File

@ -32,7 +32,7 @@ def upload(file):
} }
response = requests.post( 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()) print(response.json())
# {'id': 'ef96f635-61c7-4639-9e60-61a11c4bbfba', 'duplicate': False} # {'id': 'ef96f635-61c7-4639-9e60-61a11c4bbfba', 'duplicate': False}

View File

@ -2,7 +2,7 @@ import {
ActivityCreateDto, ActivityCreateDto,
AlbumResponseDto, AlbumResponseDto,
AlbumUserRole, AlbumUserRole,
AssetFileUploadResponseDto, AssetMediaResponseDto,
LoginResponseDto, LoginResponseDto,
ReactionType, ReactionType,
createActivity as create, createActivity as create,
@ -17,7 +17,7 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/activities', () => { describe('/activities', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let nonOwner: LoginResponseDto; let nonOwner: LoginResponseDto;
let asset: AssetFileUploadResponseDto; let asset: AssetMediaResponseDto;
let album: AlbumResponseDto; let album: AlbumResponseDto;
const createActivity = (dto: ActivityCreateDto, accessToken?: string) => const createActivity = (dto: ActivityCreateDto, accessToken?: string) =>

View File

@ -2,7 +2,7 @@ import {
addAssetsToAlbum, addAssetsToAlbum,
AlbumResponseDto, AlbumResponseDto,
AlbumUserRole, AlbumUserRole,
AssetFileUploadResponseDto, AssetMediaResponseDto,
AssetOrder, AssetOrder,
deleteUserAdmin, deleteUserAdmin,
getAlbumInfo, getAlbumInfo,
@ -26,8 +26,8 @@ const user2NotShared = 'user2NotShared';
describe('/albums', () => { describe('/albums', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let user1: LoginResponseDto; let user1: LoginResponseDto;
let user1Asset1: AssetFileUploadResponseDto; let user1Asset1: AssetMediaResponseDto;
let user1Asset2: AssetFileUploadResponseDto; let user1Asset2: AssetMediaResponseDto;
let user1Albums: AlbumResponseDto[]; let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto; let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[]; let user2Albums: AlbumResponseDto[];

View File

@ -1,5 +1,6 @@
import { import {
AssetFileUploadResponseDto, AssetMediaResponseDto,
AssetMediaStatus,
AssetResponseDto, AssetResponseDto,
AssetTypeEnum, AssetTypeEnum,
LoginResponseDto, LoginResponseDto,
@ -67,10 +68,10 @@ describe('/asset', () => {
let statsUser: LoginResponseDto; let statsUser: LoginResponseDto;
let stackUser: LoginResponseDto; let stackUser: LoginResponseDto;
let user1Assets: AssetFileUploadResponseDto[]; let user1Assets: AssetMediaResponseDto[];
let user2Assets: AssetFileUploadResponseDto[]; let user2Assets: AssetMediaResponseDto[];
let stackAssets: AssetFileUploadResponseDto[]; let stackAssets: AssetMediaResponseDto[];
let locationAsset: AssetFileUploadResponseDto; let locationAsset: AssetMediaResponseDto;
const setupTests = async () => { const setupTests = async () => {
await utils.resetDatabase(); await utils.resetDatabase();
@ -121,7 +122,7 @@ describe('/asset', () => {
]); ]);
for (const asset of [...user1Assets, ...user2Assets]) { for (const asset of [...user1Assets, ...user2Assets]) {
expect(asset.duplicate).toBe(false); expect(asset.status).toBe(AssetMediaStatus.Created);
} }
await Promise.all([ await Promise.all([
@ -164,16 +165,34 @@ describe('/asset', () => {
utils.disconnectWebsocket(websocket); utils.disconnectWebsocket(websocket);
}); });
describe('GET /asset/:id', () => { describe('GET /assets/:id/original', () => {
it('should require authentication', async () => { 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(body).toEqual(errorDto.unauthorized);
expect(status).toBe(401); expect(status).toBe(401);
}); });
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/asset/${uuidDto.invalid}`) .get(`/assets/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
@ -181,7 +200,7 @@ describe('/asset', () => {
it('should require access', async () => { it('should require access', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/asset/${user2Assets[0].id}`) .get(`/assets/${user2Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission); expect(body).toEqual(errorDto.noPermission);
@ -189,7 +208,7 @@ describe('/asset', () => {
it('should get the asset info', async () => { it('should get the asset info', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/asset/${user1Assets[0].id}`) .get(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toMatchObject({ id: user1Assets[0].id }); expect(body).toMatchObject({ id: user1Assets[0].id });
@ -201,14 +220,14 @@ describe('/asset', () => {
assetIds: [user1Assets[0].id], 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(status).toBe(200);
expect(body).toMatchObject({ id: user1Assets[0].id }); expect(body).toMatchObject({ id: user1Assets[0].id });
}); });
it('should not send people data for shared links for un-authenticated users', async () => { it('should not send people data for shared links for un-authenticated users', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/asset/${user1Assets[0].id}`) .get(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(200); expect(status).toEqual(200);
@ -231,7 +250,7 @@ describe('/asset', () => {
assetIds: [user1Assets[0].id], 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.status).toBe(200);
expect(data.body).toMatchObject({ people: [] }); expect(data.body).toMatchObject({ people: [] });
}); });
@ -239,7 +258,7 @@ describe('/asset', () => {
describe('partner assets', () => { describe('partner assets', () => {
it('should get the asset info', async () => { it('should get the asset info', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/asset/${user1Assets[0].id}`) .get(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user2.accessToken}`); .set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toMatchObject({ id: user1Assets[0].id }); expect(body).toMatchObject({ id: user1Assets[0].id });
@ -249,7 +268,7 @@ describe('/asset', () => {
const asset = await utils.createAsset(user1.accessToken, { isArchived: true }); const asset = await utils.createAsset(user1.accessToken, { isArchived: true });
const { status } = await request(app) const { status } = await request(app)
.get(`/asset/${asset.id}`) .get(`/assets/${asset.id}`)
.set('Authorization', `Bearer ${user2.accessToken}`); .set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
}); });
@ -259,16 +278,16 @@ describe('/asset', () => {
await utils.deleteAssets(user1.accessToken, [asset.id]); await utils.deleteAssets(user1.accessToken, [asset.id]);
const { status } = await request(app) const { status } = await request(app)
.get(`/asset/${asset.id}`) .get(`/assets/${asset.id}`)
.set('Authorization', `Bearer ${user2.accessToken}`); .set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
}); });
}); });
}); });
describe('GET /asset/statistics', () => { describe('GET /assets/statistics', () => {
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
@ -276,7 +295,7 @@ describe('/asset', () => {
it('should return stats of all assets', async () => { it('should return stats of all assets', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/asset/statistics') .get('/assets/statistics')
.set('Authorization', `Bearer ${statsUser.accessToken}`); .set('Authorization', `Bearer ${statsUser.accessToken}`);
expect(body).toEqual({ images: 3, videos: 1, total: 4 }); expect(body).toEqual({ images: 3, videos: 1, total: 4 });
@ -285,7 +304,7 @@ describe('/asset', () => {
it('should return stats of all favored assets', async () => { it('should return stats of all favored assets', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/asset/statistics') .get('/assets/statistics')
.set('Authorization', `Bearer ${statsUser.accessToken}`) .set('Authorization', `Bearer ${statsUser.accessToken}`)
.query({ isFavorite: true }); .query({ isFavorite: true });
@ -295,7 +314,7 @@ describe('/asset', () => {
it('should return stats of all archived assets', async () => { it('should return stats of all archived assets', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/asset/statistics') .get('/assets/statistics')
.set('Authorization', `Bearer ${statsUser.accessToken}`) .set('Authorization', `Bearer ${statsUser.accessToken}`)
.query({ isArchived: true }); .query({ isArchived: true });
@ -305,7 +324,7 @@ describe('/asset', () => {
it('should return stats of all favored and archived assets', async () => { it('should return stats of all favored and archived assets', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/asset/statistics') .get('/assets/statistics')
.set('Authorization', `Bearer ${statsUser.accessToken}`) .set('Authorization', `Bearer ${statsUser.accessToken}`)
.query({ isFavorite: true, isArchived: true }); .query({ isFavorite: true, isArchived: true });
@ -315,7 +334,7 @@ describe('/asset', () => {
it('should return stats of all assets neither favored nor archived', async () => { it('should return stats of all assets neither favored nor archived', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/asset/statistics') .get('/assets/statistics')
.set('Authorization', `Bearer ${statsUser.accessToken}`) .set('Authorization', `Bearer ${statsUser.accessToken}`)
.query({ isFavorite: false, isArchived: false }); .query({ isFavorite: false, isArchived: false });
@ -324,7 +343,7 @@ describe('/asset', () => {
}); });
}); });
describe('GET /asset/random', () => { describe('GET /assets/random', () => {
beforeAll(async () => { beforeAll(async () => {
await Promise.all([ await Promise.all([
utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken),
@ -337,7 +356,7 @@ describe('/asset', () => {
}); });
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
@ -345,7 +364,7 @@ describe('/asset', () => {
it.each(TEN_TIMES)('should return 1 random assets', async () => { it.each(TEN_TIMES)('should return 1 random assets', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/asset/random') .get('/assets/random')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
@ -357,7 +376,7 @@ describe('/asset', () => {
it.each(TEN_TIMES)('should return 2 random assets', async () => { it.each(TEN_TIMES)('should return 2 random assets', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/asset/random?count=2') .get('/assets/random?count=2')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); 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', 'should return 1 asset if there are 10 assets in the database but user 2 only has 1',
async () => { async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/asset/random') .get('/assets/random')
.set('Authorization', `Bearer ${user2.accessToken}`); .set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
@ -384,23 +403,23 @@ describe('/asset', () => {
it('should return error', async () => { it('should return error', async () => {
const { status } = await request(app) const { status } = await request(app)
.get('/asset/random?count=ABC') .get('/assets/random?count=ABC')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
}); });
}); });
describe('PUT /asset/:id', () => { describe('PUT /assets/:id', () => {
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/asset/${uuidDto.invalid}`) .put(`/assets/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
@ -408,7 +427,7 @@ describe('/asset', () => {
it('should require access', async () => { it('should require access', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/asset/${user2Assets[0].id}`) .put(`/assets/${user2Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission); expect(body).toEqual(errorDto.noPermission);
@ -419,7 +438,7 @@ describe('/asset', () => {
expect(before.isFavorite).toBe(false); expect(before.isFavorite).toBe(false);
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`) .put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true }); .send({ isFavorite: true });
expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true }); expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true });
@ -431,7 +450,7 @@ describe('/asset', () => {
expect(before.isArchived).toBe(false); expect(before.isArchived).toBe(false);
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`) .put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isArchived: true }); .send({ isArchived: true });
expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true }); expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true });
@ -440,7 +459,7 @@ describe('/asset', () => {
it('should update date time original', async () => { it('should update date time original', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`) .put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
@ -467,7 +486,7 @@ describe('/asset', () => {
{ latitude: 12, longitude: 181 }, { latitude: 12, longitude: 181 },
]) { ]) {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`) .put(`/assets/${user1Assets[0].id}`)
.send(test) .send(test)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
@ -477,7 +496,7 @@ describe('/asset', () => {
it('should update gps data', async () => { it('should update gps data', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`) .put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ latitude: 12, longitude: 12 }); .send({ latitude: 12, longitude: 12 });
@ -490,7 +509,7 @@ describe('/asset', () => {
it('should set the description', async () => { it('should set the description', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`) .put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'Test asset description' }); .send({ description: 'Test asset description' });
expect(body).toMatchObject({ expect(body).toMatchObject({
@ -504,7 +523,7 @@ describe('/asset', () => {
it('should return tagged people', async () => { it('should return tagged people', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`) .put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true }); .send({ isFavorite: true });
expect(status).toEqual(200); expect(status).toEqual(200);
@ -524,10 +543,10 @@ describe('/asset', () => {
}); });
}); });
describe('DELETE /asset', () => { describe('DELETE /assets', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/asset`) .delete(`/assets`)
.send({ ids: [uuidDto.notFound] }); .send({ ids: [uuidDto.notFound] });
expect(status).toBe(401); expect(status).toBe(401);
@ -536,7 +555,7 @@ describe('/asset', () => {
it('should require a valid uuid', async () => { it('should require a valid uuid', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/asset`) .delete(`/assets`)
.send({ ids: [uuidDto.invalid] }) .send({ ids: [uuidDto.invalid] })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@ -546,7 +565,7 @@ describe('/asset', () => {
it('should throw an error when the id is not found', async () => { it('should throw an error when the id is not found', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/asset`) .delete(`/assets`)
.send({ ids: [uuidDto.notFound] }) .send({ ids: [uuidDto.notFound] })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
@ -561,7 +580,7 @@ describe('/asset', () => {
expect(before.isTrashed).toBe(false); expect(before.isTrashed).toBe(false);
const { status } = await request(app) const { status } = await request(app)
.delete('/asset') .delete('/assets')
.send({ ids: [assetId] }) .send({ ids: [assetId] })
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204); 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 () => { 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(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
@ -586,7 +605,7 @@ describe('/asset', () => {
}); });
const { status, body, type } = await request(app) 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}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
@ -598,9 +617,9 @@ describe('/asset', () => {
expect(exifData).not.toHaveProperty('GPSLatitude'); 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) 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}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); 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 () => { 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(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
@ -623,7 +642,7 @@ describe('/asset', () => {
it('should download the original', async () => { it('should download the original', async () => {
const { status, body, type } = await request(app) const { status, body, type } = await request(app)
.get(`/asset/file/${locationAsset.id}`) .get(`/assets/${locationAsset.id}/original`)
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
@ -641,9 +660,9 @@ describe('/asset', () => {
}); });
}); });
describe('PUT /asset', () => { describe('PUT /assets', () => {
it('should require authentication', async () => { 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(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
@ -651,7 +670,7 @@ describe('/asset', () => {
it('should require a valid parent id', async () => { it('should require a valid parent id', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put('/asset') .put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] });
@ -661,7 +680,7 @@ describe('/asset', () => {
it('should require access to the parent', async () => { it('should require access to the parent', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put('/asset') .put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] });
@ -671,7 +690,7 @@ describe('/asset', () => {
it('should add stack children', async () => { it('should add stack children', async () => {
const { status } = await request(app) const { status } = await request(app)
.put('/asset') .put('/assets')
.set('Authorization', `Bearer ${stackUser.accessToken}`) .set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] });
@ -684,7 +703,7 @@ describe('/asset', () => {
it('should remove stack children', async () => { it('should remove stack children', async () => {
const { status } = await request(app) const { status } = await request(app)
.put('/asset') .put('/assets')
.set('Authorization', `Bearer ${stackUser.accessToken}`) .set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ removeParent: true, ids: [stackAssets[1].id] }); .send({ removeParent: true, ids: [stackAssets[1].id] });
@ -702,7 +721,7 @@ describe('/asset', () => {
it('should remove all stack children', async () => { it('should remove all stack children', async () => {
const { status } = await request(app) const { status } = await request(app)
.put('/asset') .put('/assets')
.set('Authorization', `Bearer ${stackUser.accessToken}`) .set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] });
@ -720,7 +739,7 @@ describe('/asset', () => {
); );
const { status } = await request(app) const { status } = await request(app)
.put('/asset') .put('/assets')
.set('Authorization', `Bearer ${stackUser.accessToken}`) .set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); .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 () => { 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(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
@ -748,7 +767,7 @@ describe('/asset', () => {
it('should require a valid id', async () => { it('should require a valid id', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put('/asset/stack/parent') .put('/assets/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid }); .send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid });
@ -758,7 +777,7 @@ describe('/asset', () => {
it('should require access', async () => { it('should require access', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put('/asset/stack/parent') .put('/assets/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); .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 () => { it('should make old parent child of new parent', async () => {
const { status } = await request(app) const { status } = await request(app)
.put('/asset/stack/parent') .put('/assets/stack/parent')
.set('Authorization', `Bearer ${stackUser.accessToken}`) .set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); .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); beforeAll(setupTests, 30_000);
it('should require authentication', async () => { 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(body).toEqual(errorDto.unauthorized);
expect(status).toBe(401); 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: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } },
])('should $should', async ({ dto }) => { ])('should $should', async ({ dto }) => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post('/asset/upload') .post('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.attach('assetData', makeRandomImage(), 'example.png') .attach('assetData', makeRandomImage(), 'example.png')
.field(dto); .field(dto);
@ -1033,11 +1052,11 @@ describe('/asset', () => {
}, },
])(`should upload and generate a thumbnail for $input`, async ({ input, expected }) => { ])(`should upload and generate a thumbnail for $input`, async ({ input, expected }) => {
const filepath = join(testAssetDir, input); 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) }, assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
}); });
expect(duplicate).toBe(false); expect(status).toBe(AssetMediaStatus.Created);
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id }); await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id });
@ -1050,19 +1069,19 @@ describe('/asset', () => {
it('should handle a duplicate', async () => { it('should handle a duplicate', async () => {
const filepath = 'formats/jpeg/el_torcal_rocks.jpeg'; const filepath = 'formats/jpeg/el_torcal_rocks.jpeg';
const { duplicate } = await utils.createAsset(admin.accessToken, { const { status } = await utils.createAsset(admin.accessToken, {
assetData: { assetData: {
bytes: await readFile(join(testAssetDir, filepath)), bytes: await readFile(join(testAssetDir, filepath)),
filename: basename(filepath), filename: basename(filepath),
}, },
}); });
expect(duplicate).toBe(true); expect(status).toBe(AssetMediaStatus.Duplicate);
}); });
it('should update the used quota', async () => { it('should update the used quota', async () => {
const { body, status } = await request(app) const { body, status } = await request(app)
.post('/asset/upload') .post('/assets')
.set('Authorization', `Bearer ${quotaUser.accessToken}`) .set('Authorization', `Bearer ${quotaUser.accessToken}`)
.field('deviceAssetId', 'example-image') .field('deviceAssetId', 'example-image')
.field('deviceId', 'e2e') .field('deviceId', 'e2e')
@ -1070,7 +1089,7 @@ describe('/asset', () => {
.field('fileModifiedAt', new Date().toISOString()) .field('fileModifiedAt', new Date().toISOString())
.attach('assetData', makeRandomImage(), 'example.jpg'); .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); expect(status).toBe(201);
const user = await getMyUser({ headers: asBearerAuth(quotaUser.accessToken) }); 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 () => { it('should not upload an asset if it would exceed the quota', async () => {
const { body, status } = await request(app) const { body, status } = await request(app)
.post('/asset/upload') .post('/assets')
.set('Authorization', `Bearer ${quotaUser.accessToken}`) .set('Authorization', `Bearer ${quotaUser.accessToken}`)
.field('deviceAssetId', 'example-image') .field('deviceAssetId', 'example-image')
.field('deviceId', 'e2e') .field('deviceId', 'e2e')
@ -1120,7 +1139,7 @@ describe('/asset', () => {
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: response.id }); 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); const asset = await utils.getAssetInfo(admin.accessToken, response.id);
expect(asset.livePhotoVideoId).toBeDefined(); 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 { readFile, writeFile } from 'node:fs/promises';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, tempDir, utils } from 'src/utils'; import { app, tempDir, utils } from 'src/utils';
@ -7,8 +7,8 @@ import { beforeAll, describe, expect, it } from 'vitest';
describe('/download', () => { describe('/download', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset1: AssetFileUploadResponseDto; let asset1: AssetMediaResponseDto;
let asset2: AssetFileUploadResponseDto; let asset2: AssetMediaResponseDto;
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); 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 { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path'; import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
@ -12,7 +12,7 @@ describe('/map', () => {
let websocket: Socket; let websocket: Socket;
let admin: LoginResponseDto; let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto; let nonAdmin: LoginResponseDto;
let asset: AssetFileUploadResponseDto; let asset: AssetMediaResponseDto;
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();

View File

@ -1,5 +1,5 @@
import { import {
AssetFileUploadResponseDto, AssetMediaResponseDto,
LoginResponseDto, LoginResponseDto,
MemoryResponseDto, MemoryResponseDto,
MemoryType, MemoryType,
@ -15,9 +15,9 @@ import { beforeAll, describe, expect, it } from 'vitest';
describe('/memories', () => { describe('/memories', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let user: LoginResponseDto; let user: LoginResponseDto;
let adminAsset: AssetFileUploadResponseDto; let adminAsset: AssetMediaResponseDto;
let userAsset1: AssetFileUploadResponseDto; let userAsset1: AssetMediaResponseDto;
let userAsset2: AssetFileUploadResponseDto; let userAsset2: AssetMediaResponseDto;
let userMemory: MemoryResponseDto; let userMemory: MemoryResponseDto;
beforeAll(async () => { 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 { DateTime } from 'luxon';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
@ -13,25 +13,25 @@ describe('/search', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let websocket: Socket; let websocket: Socket;
let assetFalcon: AssetFileUploadResponseDto; let assetFalcon: AssetMediaResponseDto;
let assetDenali: AssetFileUploadResponseDto; let assetDenali: AssetMediaResponseDto;
let assetCyclamen: AssetFileUploadResponseDto; let assetCyclamen: AssetMediaResponseDto;
let assetNotocactus: AssetFileUploadResponseDto; let assetNotocactus: AssetMediaResponseDto;
let assetSilver: AssetFileUploadResponseDto; let assetSilver: AssetMediaResponseDto;
let assetDensity: AssetFileUploadResponseDto; let assetDensity: AssetMediaResponseDto;
// let assetPhiladelphia: AssetFileUploadResponseDto; // let assetPhiladelphia: AssetMediaResponseDto;
// let assetOrychophragmus: AssetFileUploadResponseDto; // let assetOrychophragmus: AssetMediaResponseDto;
// let assetRidge: AssetFileUploadResponseDto; // let assetRidge: AssetMediaResponseDto;
// let assetPolemonium: AssetFileUploadResponseDto; // let assetPolemonium: AssetMediaResponseDto;
// let assetWood: AssetFileUploadResponseDto; // let assetWood: AssetMediaResponseDto;
// let assetGlarus: AssetFileUploadResponseDto; // let assetGlarus: AssetMediaResponseDto;
let assetHeic: AssetFileUploadResponseDto; let assetHeic: AssetMediaResponseDto;
let assetRocks: AssetFileUploadResponseDto; let assetRocks: AssetMediaResponseDto;
let assetOneJpg6: AssetFileUploadResponseDto; let assetOneJpg6: AssetMediaResponseDto;
let assetOneHeic6: AssetFileUploadResponseDto; let assetOneHeic6: AssetMediaResponseDto;
let assetOneJpg5: AssetFileUploadResponseDto; let assetOneJpg5: AssetMediaResponseDto;
let assetSprings: AssetFileUploadResponseDto; let assetSprings: AssetMediaResponseDto;
let assetLast: AssetFileUploadResponseDto; let assetLast: AssetMediaResponseDto;
let cities: string[]; let cities: string[];
let states: string[]; let states: string[];
let countries: string[]; let countries: string[];
@ -66,7 +66,7 @@ describe('/search', () => {
// last asset // last asset
{ filename: '/albums/nature/wood_anemones.jpg' }, { filename: '/albums/nature/wood_anemones.jpg' },
]; ];
const assets: AssetFileUploadResponseDto[] = []; const assets: AssetMediaResponseDto[] = [];
for (const { filename, dto } of files) { for (const { filename, dto } of files) {
const bytes = await readFile(join(testAssetDir, filename)); const bytes = await readFile(join(testAssetDir, filename));
assets.push( assets.push(
@ -134,7 +134,7 @@ describe('/search', () => {
// assetWood, // assetWood,
] = assets; ] = assets;
assetLast = assets.at(-1) as AssetFileUploadResponseDto; assetLast = assets.at(-1) as AssetMediaResponseDto;
await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) }); await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });

View File

@ -1,6 +1,6 @@
import { import {
AlbumResponseDto, AlbumResponseDto,
AssetFileUploadResponseDto, AssetMediaResponseDto,
LoginResponseDto, LoginResponseDto,
SharedLinkResponseDto, SharedLinkResponseDto,
SharedLinkType, SharedLinkType,
@ -15,8 +15,8 @@ import { beforeAll, describe, expect, it } from 'vitest';
describe('/shared-links', () => { describe('/shared-links', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset1: AssetFileUploadResponseDto; let asset1: AssetMediaResponseDto;
let asset2: AssetFileUploadResponseDto; let asset2: AssetMediaResponseDto;
let user1: LoginResponseDto; let user1: LoginResponseDto;
let user2: LoginResponseDto; let user2: LoginResponseDto;
let album: AlbumResponseDto; 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 { DateTime } from 'luxon';
import { createUserDto } from 'src/fixtures'; import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
@ -19,7 +19,7 @@ describe('/timeline', () => {
let user: LoginResponseDto; let user: LoginResponseDto;
let timeBucketUser: LoginResponseDto; let timeBucketUser: LoginResponseDto;
let userAssets: AssetFileUploadResponseDto[]; let userAssets: AssetMediaResponseDto[];
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();

View File

@ -1,9 +1,9 @@
import { import {
AllJobStatusResponseDto, AllJobStatusResponseDto,
AssetFileUploadResponseDto, AssetMediaCreateDto,
AssetMediaResponseDto,
AssetResponseDto, AssetResponseDto,
CreateAlbumDto, CreateAlbumDto,
CreateAssetDto,
CreateLibraryDto, CreateLibraryDto,
MetadataSearchDto, MetadataSearchDto,
PersonCreateDto, PersonCreateDto,
@ -292,7 +292,7 @@ export const utils = {
createAsset: async ( createAsset: async (
accessToken: string, accessToken: string,
dto?: Partial<Omit<CreateAssetDto, 'assetData'>> & { assetData?: AssetData }, dto?: Partial<Omit<AssetMediaCreateDto, 'assetData'>> & { assetData?: AssetData },
) => { ) => {
const _dto = { const _dto = {
deviceAssetId: 'test-1', deviceAssetId: 'test-1',
@ -310,7 +310,7 @@ export const utils = {
} }
const builder = request(app) const builder = request(app)
.post(`/asset/upload`) .post(`/assets`)
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.set('Authorization', `Bearer ${accessToken}`); .set('Authorization', `Bearer ${accessToken}`);
@ -320,7 +320,7 @@ export const utils = {
const { body } = await builder; const { body } = await builder;
return body as AssetFileUploadResponseDto; return body as AssetMediaResponseDto;
}, },
createImageFile: (path: string) => { 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 { expect, test } from '@playwright/test';
import { utils } from 'src/utils'; import { utils } from 'src/utils';
test.describe('Detail Panel', () => { test.describe('Detail Panel', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetFileUploadResponseDto; let asset: AssetMediaResponseDto;
test.beforeAll(async () => { test.beforeAll(async () => {
utils.initSdk(); 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 { expect, test } from '@playwright/test';
import { utils } from 'src/utils'; import { utils } from 'src/utils';
test.describe('Asset Viewer Navbar', () => { test.describe('Asset Viewer Navbar', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetFileUploadResponseDto; let asset: AssetMediaResponseDto;
test.beforeAll(async () => { test.beforeAll(async () => {
utils.initSdk(); utils.initSdk();

View File

@ -1,6 +1,6 @@
import { import {
AlbumResponseDto, AlbumResponseDto,
AssetFileUploadResponseDto, AssetMediaResponseDto,
LoginResponseDto, LoginResponseDto,
SharedLinkResponseDto, SharedLinkResponseDto,
SharedLinkType, SharedLinkType,
@ -11,7 +11,7 @@ import { asBearerAuth, utils } from 'src/utils';
test.describe('Shared Links', () => { test.describe('Shared Links', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetFileUploadResponseDto; let asset: AssetMediaResponseDto;
let album: AlbumResponseDto; let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto; let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: 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/photo_view_scale_state.dart';
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:openapi/api.dart' show ThumbnailFormat;
@RoutePage() @RoutePage()
// ignore: must_be_immutable // ignore: must_be_immutable
@ -52,9 +51,6 @@ class GalleryViewerPage extends HookConsumerWidget {
final PageController controller; final PageController controller;
static const jpeg = ThumbnailFormat.JPEG;
static const webp = ThumbnailFormat.WEBP;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsServiceProvider); final settings = ref.watch(appSettingsServiceProvider);

View File

@ -22,8 +22,8 @@ Future<VideoPlayerController> videoPlayerController(
// Use a network URL for the video player controller // Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final String videoUrl = asset.livePhotoVideoId != null final String videoUrl = asset.livePhotoVideoId != null
? '$serverEndpoint/asset/file/${asset.livePhotoVideoId}' ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
: '$serverEndpoint/asset/file/${asset.remoteId}'; : '$serverEndpoint/assets/${asset.remoteId}/video/playback';
final url = Uri.parse(videoUrl); final url = Uri.parse(videoUrl);
final accessToken = Store.get(StoreKey.accessToken); final accessToken = Store.get(StoreKey.accessToken);

View File

@ -74,7 +74,7 @@ class ImmichRemoteImageProvider
if (_loadPreview) { if (_loadPreview) {
final preview = getThumbnailUrlForRemoteId( final preview = getThumbnailUrlForRemoteId(
key.assetId, key.assetId,
type: api.ThumbnailFormat.WEBP, type: api.AssetMediaSize.thumbnail,
); );
yield await ImageLoader.loadImageFromCache( yield await ImageLoader.loadImageFromCache(
@ -88,7 +88,7 @@ class ImmichRemoteImageProvider
// Load the higher resolution version of the image // Load the higher resolution version of the image
final url = getThumbnailUrlForRemoteId( final url = getThumbnailUrlForRemoteId(
key.assetId, key.assetId,
type: api.ThumbnailFormat.JPEG, type: api.AssetMediaSize.preview,
); );
final codec = await ImageLoader.loadImageFromCache( final codec = await ImageLoader.loadImageFromCache(
url, url,

View File

@ -61,7 +61,7 @@ class ImmichRemoteThumbnailProvider
// Load a preview to the chunk events // Load a preview to the chunk events
final preview = getThumbnailUrlForRemoteId( final preview = getThumbnailUrlForRemoteId(
key.assetId, key.assetId,
type: api.ThumbnailFormat.WEBP, type: api.AssetMediaSize.thumbnail,
); );
yield await ImageLoader.loadImageFromCache( yield await ImageLoader.loadImageFromCache(

View File

@ -2,26 +2,26 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:cancellation_token_http/http.dart' as http;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/backup_album.entity.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/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/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/api.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/services/api.service.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:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.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( final backupServiceProvider = Provider(
(ref) => BackupService( (ref) => BackupService(
@ -270,10 +270,12 @@ class BackupService {
); );
file = await entity.loadFile(progressHandler: pmProgressHandler); file = await entity.loadFile(progressHandler: pmProgressHandler);
livePhotoFile = await entity.loadFile( if (entity.isLivePhoto) {
withSubtype: true, livePhotoFile = await entity.loadFile(
progressHandler: pmProgressHandler, withSubtype: true,
); progressHandler: pmProgressHandler,
);
}
} else { } else {
if (entity.type == AssetType.video) { if (entity.type == AssetType.video) {
file = await entity.originFile; file = await entity.originFile;
@ -288,6 +290,15 @@ class BackupService {
if (file != null) { if (file != null) {
String originalFileName = await entity.titleAsync; 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 fileStream = file.openRead();
var assetRawUploadData = http.MultipartFile( var assetRawUploadData = http.MultipartFile(
"assetData", "assetData",
@ -296,50 +307,29 @@ class BackupService {
filename: originalFileName, filename: originalFileName,
); );
var req = MultipartRequest( var baseRequest = MultipartRequest(
'POST', 'POST',
Uri.parse('$savedEndpoint/asset/upload'), Uri.parse('$savedEndpoint/assets'),
onProgress: ((bytes, totalBytes) => onProgress: ((bytes, totalBytes) =>
uploadProgressCb(bytes, totalBytes)), uploadProgressCb(bytes, totalBytes)),
); );
req.headers["x-immich-user-token"] = Store.get(StoreKey.accessToken); baseRequest.headers["x-immich-user-token"] =
req.headers["Transfer-Encoding"] = "chunked"; Store.get(StoreKey.accessToken);
baseRequest.headers["Transfer-Encoding"] = "chunked";
req.fields['deviceAssetId'] = entity.id; baseRequest.fields['deviceAssetId'] = entity.id;
req.fields['deviceId'] = deviceId; baseRequest.fields['deviceId'] = deviceId;
req.fields['fileCreatedAt'] = baseRequest.fields['fileCreatedAt'] =
entity.createDateTime.toUtc().toIso8601String(); entity.createDateTime.toUtc().toIso8601String();
req.fields['fileModifiedAt'] = baseRequest.fields['fileModifiedAt'] =
entity.modifiedDateTime.toUtc().toIso8601String(); entity.modifiedDateTime.toUtc().toIso8601String();
req.fields['isFavorite'] = entity.isFavorite.toString(); baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
req.fields['duration'] = entity.videoDuration.toString(); baseRequest.fields['duration'] = entity.videoDuration.toString();
req.files.add(assetRawUploadData); baseRequest.files.add(assetRawUploadData);
var fileSize = file.lengthSync(); 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( setCurrentUploadAssetCb(
CurrentUploadAsset( CurrentUploadAsset(
id: entity.id, id: entity.id,
@ -353,19 +343,29 @@ class BackupService {
), ),
); );
var response = String? livePhotoVideoId;
await httpClient.send(req, cancellationToken: cancelToken); if (entity.isLivePhoto && livePhotoFile != null) {
livePhotoVideoId = await uploadLivePhotoVideo(
originalFileName,
livePhotoFile,
baseRequest,
cancelToken,
);
}
if (response.statusCode == 200) { if (livePhotoVideoId != null) {
// asset is a duplicate (already exists on the server) baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
duplicatedAssetIds.add(entity.id); }
uploadSuccessCb(entity.id, deviceId, true);
} else if (response.statusCode == 201) { var response = await httpClient.send(
// stored a new asset on the server baseRequest,
uploadSuccessCb(entity.id, deviceId, false); cancellationToken: cancelToken,
} else { );
var data = await response.stream.bytesToString();
var error = jsonDecode(data); var responseBody = jsonDecode(await response.stream.bytesToString());
if (![200, 201].contains(response.statusCode)) {
var error = responseBody;
var errorMessage = error['message'] ?? error['error']; var errorMessage = error['message'] ?? error['error'];
debugPrint( debugPrint(
@ -389,6 +389,14 @@ class BackupService {
} }
continue; continue;
} }
var isDuplicate = false;
if (response.statusCode == 200) {
isDuplicate = true;
duplicatedAssetIds.add(entity.id);
}
uploadSuccessCb(entity.id, deviceId, isDuplicate);
} }
} on http.CancelledException { } on http.CancelledException {
debugPrint("Backup was cancelled by the user"); debugPrint("Backup was cancelled by the user");
@ -415,6 +423,54 @@ class BackupService {
return !anyErrors; 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) { String _getAssetType(AssetType assetType) {
switch (assetType) { switch (assetType) {
case AssetType.audio: case AssetType.audio:

View File

@ -165,8 +165,8 @@ class BackupVerificationService {
// (skip first few KBs containing metadata) // (skip first few KBs containing metadata)
final Uint64List localImage = final Uint64List localImage =
_fakeDecodeImg(local, await file.readAsBytes()); _fakeDecodeImg(local, await file.readAsBytes());
final res = await apiService.downloadApi final res = await apiService.assetsApi
.downloadFileWithHttpInfo(remote.remoteId!); .downloadAssetWithHttpInfo(remote.remoteId!);
final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes); final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes);
final eq = const ListEquality().equals(remoteImage, localImage); final eq = const ListEquality().equals(remoteImage, localImage);

View File

@ -26,19 +26,19 @@ class ImageViewerService {
// Download LivePhotos image and motion part // Download LivePhotos image and motion part
if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
var imageResponse = var imageResponse =
await _apiService.downloadApi.downloadFileWithHttpInfo( await _apiService.assetsApi.downloadAssetWithHttpInfo(
asset.remoteId!, asset.remoteId!,
); );
var motionReponse = var motionResponse =
await _apiService.downloadApi.downloadFileWithHttpInfo( await _apiService.assetsApi.downloadAssetWithHttpInfo(
asset.livePhotoVideoId!, asset.livePhotoVideoId!,
); );
if (imageResponse.statusCode != 200 || if (imageResponse.statusCode != 200 ||
motionReponse.statusCode != 200) { motionResponse.statusCode != 200) {
final failedResponse = final failedResponse =
imageResponse.statusCode != 200 ? imageResponse : motionReponse; imageResponse.statusCode != 200 ? imageResponse : motionResponse;
_log.severe( _log.severe(
"Motion asset download failed", "Motion asset download failed",
failedResponse.toLoggerString(), failedResponse.toLoggerString(),
@ -51,7 +51,7 @@ class ImageViewerService {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/livephoto.mov').create(); videoFile = await File('${tempDir.path}/livephoto.mov').create();
imageFile = await File('${tempDir.path}/livephoto.heic').create(); imageFile = await File('${tempDir.path}/livephoto.heic').create();
videoFile.writeAsBytesSync(motionReponse.bodyBytes); videoFile.writeAsBytesSync(motionResponse.bodyBytes);
imageFile.writeAsBytesSync(imageResponse.bodyBytes); imageFile.writeAsBytesSync(imageResponse.bodyBytes);
entity = await PhotoManager.editor.darwin.saveLivePhoto( entity = await PhotoManager.editor.darwin.saveLivePhoto(
@ -73,8 +73,8 @@ class ImageViewerService {
return entity != null; return entity != null;
} else { } else {
var res = await _apiService.downloadApi var res = await _apiService.assetsApi
.downloadFileWithHttpInfo(asset.remoteId!); .downloadAssetWithHttpInfo(asset.remoteId!);
if (res.statusCode != 200) { if (res.statusCode != 200) {
_log.severe("Asset download failed", res.toLoggerString()); _log.severe("Asset download failed", res.toLoggerString());

View File

@ -37,8 +37,8 @@ class ShareService {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final fileName = asset.fileName; final fileName = asset.fileName;
final tempFile = await File('${tempDir.path}/$fileName').create(); final tempFile = await File('${tempDir.path}/$fileName').create();
final res = await _apiService.downloadApi final res = await _apiService.assetsApi
.downloadFileWithHttpInfo(asset.remoteId!); .downloadAssetWithHttpInfo(asset.remoteId!);
if (res.statusCode != 200) { if (res.statusCode != 200) {
_log.severe( _log.severe(

View File

@ -129,8 +129,8 @@ class _ChewieControllerHookState
// Use a network URL for the video player controller // Use a network URL for the video player controller
final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint); final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
final String videoUrl = hook.asset.livePhotoVideoId != null final String videoUrl = hook.asset.livePhotoVideoId != null
? '$serverEndpoint/asset/file/${hook.asset.livePhotoVideoId}' ? '$serverEndpoint/assets/${hook.asset.livePhotoVideoId}/video/playback'
: '$serverEndpoint/asset/file/${hook.asset.remoteId}'; : '$serverEndpoint/assets/${hook.asset.remoteId}/video/playback';
final url = Uri.parse(videoUrl); final url = Uri.parse(videoUrl);
final accessToken = store.Store.get(StoreKey.accessToken); final accessToken = store.Store.get(StoreKey.accessToken);

View File

@ -6,23 +6,23 @@ import 'package:openapi/api.dart';
String getThumbnailUrl( String getThumbnailUrl(
final Asset asset, { final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP, AssetMediaSize type = AssetMediaSize.thumbnail,
}) { }) {
return getThumbnailUrlForRemoteId(asset.remoteId!, type: type); return getThumbnailUrlForRemoteId(asset.remoteId!, type: type);
} }
String getThumbnailCacheKey( String getThumbnailCacheKey(
final Asset asset, { final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP, AssetMediaSize type = AssetMediaSize.thumbnail,
}) { }) {
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type); return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
} }
String getThumbnailCacheKeyForRemoteId( String getThumbnailCacheKeyForRemoteId(
final String id, { final String id, {
ThumbnailFormat type = ThumbnailFormat.WEBP, AssetMediaSize type = AssetMediaSize.thumbnail,
}) { }) {
if (type == ThumbnailFormat.WEBP) { if (type == AssetMediaSize.thumbnail) {
return 'thumbnail-image-$id'; return 'thumbnail-image-$id';
} else { } else {
return '${id}_previewStage'; return '${id}_previewStage';
@ -31,7 +31,7 @@ String getThumbnailCacheKeyForRemoteId(
String getAlbumThumbnailUrl( String getAlbumThumbnailUrl(
final Album album, { final Album album, {
ThumbnailFormat type = ThumbnailFormat.WEBP, AssetMediaSize type = AssetMediaSize.thumbnail,
}) { }) {
if (album.thumbnail.value?.remoteId == null) { if (album.thumbnail.value?.remoteId == null) {
return ''; return '';
@ -44,7 +44,7 @@ String getAlbumThumbnailUrl(
String getAlbumThumbNailCacheKey( String getAlbumThumbNailCacheKey(
final Album album, { final Album album, {
ThumbnailFormat type = ThumbnailFormat.WEBP, AssetMediaSize type = AssetMediaSize.thumbnail,
}) { }) {
if (album.thumbnail.value?.remoteId == null) { if (album.thumbnail.value?.remoteId == null) {
return ''; return '';
@ -60,7 +60,7 @@ String getImageUrl(final Asset asset) {
} }
String getImageUrlFromId(final String id) { 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) { String getImageCacheKey(final Asset asset) {
@ -71,9 +71,9 @@ String getImageCacheKey(final Asset asset) {
String getThumbnailUrlForRemoteId( String getThumbnailUrlForRemoteId(
final String id, { 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) { String getFaceThumbnailUrl(final String personId) {

View File

@ -46,12 +46,13 @@ class AlbumThumbnailListTile extends StatelessWidget {
fadeInDuration: const Duration(milliseconds: 200), fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl( imageUrl: getAlbumThumbnailUrl(
album, album,
type: ThumbnailFormat.WEBP, type: AssetMediaSize.thumbnail,
), ),
httpHeaders: { httpHeaders: {
"x-immich-user-token": Store.get(StoreKey.accessToken), "x-immich-user-token": Store.get(StoreKey.accessToken),
}, },
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.WEBP), cacheKey:
getAlbumThumbNailCacheKey(album, type: AssetMediaSize.thumbnail),
errorWidget: (context, url, error) => errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined), const Icon(Icons.image_not_supported_outlined),
); );

View File

@ -115,7 +115,7 @@ class CuratedPlacesRow extends CuratedRow {
final actualIndex = index - actualContentIndex; final actualIndex = index - actualContentIndex;
final object = content[actualIndex]; final object = content[actualIndex];
final thumbnailRequestUrl = final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}'; '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail';
return SizedBox( return SizedBox(
width: imageSize, width: imageSize,
height: imageSize, height: imageSize,

View File

@ -46,7 +46,7 @@ class CuratedRow extends StatelessWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final object = content[index]; final object = content[index];
final thumbnailRequestUrl = final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}'; '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail';
return SizedBox( return SizedBox(
width: imageSize, width: imageSize,
height: imageSize, height: imageSize,

View File

@ -44,7 +44,7 @@ class ExploreGrid extends StatelessWidget {
final content = curatedContent[index]; final content = curatedContent[index];
final thumbnailRequestUrl = isPeople final thumbnailRequestUrl = isPeople
? getFaceThumbnailUrl(content.id) ? getFaceThumbnailUrl(content.id)
: '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${content.id}'; : '${Store.get(StoreKey.serverEndpoint)}/assets/${content.id}/thumbnail';
return ThumbnailWithInfo( return ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl, 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* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} |
*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | *AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} |
*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | *AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} |
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /asset/bulk-upload-check | *AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check |
*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /asset/exist | *AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist |
*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /asset | *AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets |
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | *AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original |
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /asset/{id} | *AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} |
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /asset/statistics | *AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} |
*AssetsApi* | [**getAssetThumbnail**](doc//AssetsApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | *AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics |
*AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /asset/memory-lane | *AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /assets/memory-lane |
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /asset/random | *AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random |
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /asset/{id}/file | *AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback |
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /asset/jobs | *AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original |
*AssetsApi* | [**serveFile**](doc//AssetsApi.md#servefile) | **GET** /asset/file/{id} | *AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs |
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /asset/{id} | *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} |
*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /asset | *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets |
*AssetsApi* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /asset/stack/parent | *AssetsApi* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /assets/stack/parent |
*AssetsApi* | [**uploadFile**](doc//AssetsApi.md#uploadfile) | **POST** /asset/upload | *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 | *AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *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* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *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 | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates |
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | *FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces |
@ -260,13 +260,13 @@ Class | Method | HTTP request | Description
- [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md) - [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md)
- [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md) - [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md)
- [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md) - [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md)
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
- [AssetFullSyncDto](doc//AssetFullSyncDto.md) - [AssetFullSyncDto](doc//AssetFullSyncDto.md)
- [AssetIdsDto](doc//AssetIdsDto.md) - [AssetIdsDto](doc//AssetIdsDto.md)
- [AssetIdsResponseDto](doc//AssetIdsResponseDto.md) - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
- [AssetJobName](doc//AssetJobName.md) - [AssetJobName](doc//AssetJobName.md)
- [AssetJobsDto](doc//AssetJobsDto.md) - [AssetJobsDto](doc//AssetJobsDto.md)
- [AssetMediaResponseDto](doc//AssetMediaResponseDto.md) - [AssetMediaResponseDto](doc//AssetMediaResponseDto.md)
- [AssetMediaSize](doc//AssetMediaSize.md)
- [AssetMediaStatus](doc//AssetMediaStatus.md) - [AssetMediaStatus](doc//AssetMediaStatus.md)
- [AssetOrder](doc//AssetOrder.md) - [AssetOrder](doc//AssetOrder.md)
- [AssetResponseDto](doc//AssetResponseDto.md) - [AssetResponseDto](doc//AssetResponseDto.md)
@ -398,7 +398,6 @@ Class | Method | HTTP request | Description
- [SystemConfigUserDto](doc//SystemConfigUserDto.md) - [SystemConfigUserDto](doc//SystemConfigUserDto.md)
- [TagResponseDto](doc//TagResponseDto.md) - [TagResponseDto](doc//TagResponseDto.md)
- [TagTypeEnum](doc//TagTypeEnum.md) - [TagTypeEnum](doc//TagTypeEnum.md)
- [ThumbnailFormat](doc//ThumbnailFormat.md)
- [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
- [TimeBucketSize](doc//TimeBucketSize.md) - [TimeBucketSize](doc//TimeBucketSize.md)
- [ToneMapping](doc//ToneMapping.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_dto.dart';
part 'model/asset_face_update_item.dart'; part 'model/asset_face_update_item.dart';
part 'model/asset_face_without_person_response_dto.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_full_sync_dto.dart';
part 'model/asset_ids_dto.dart'; part 'model/asset_ids_dto.dart';
part 'model/asset_ids_response_dto.dart'; part 'model/asset_ids_response_dto.dart';
part 'model/asset_job_name.dart'; part 'model/asset_job_name.dart';
part 'model/asset_jobs_dto.dart'; part 'model/asset_jobs_dto.dart';
part 'model/asset_media_response_dto.dart'; part 'model/asset_media_response_dto.dart';
part 'model/asset_media_size.dart';
part 'model/asset_media_status.dart'; part 'model/asset_media_status.dart';
part 'model/asset_order.dart'; part 'model/asset_order.dart';
part 'model/asset_response_dto.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/system_config_user_dto.dart';
part 'model/tag_response_dto.dart'; part 'model/tag_response_dto.dart';
part 'model/tag_type_enum.dart'; part 'model/tag_type_enum.dart';
part 'model/thumbnail_format.dart';
part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_response_dto.dart';
part 'model/time_bucket_size.dart'; part 'model/time_bucket_size.dart';
part 'model/tone_mapping.dart'; part 'model/tone_mapping.dart';

View File

@ -25,7 +25,7 @@ class AssetsApi {
/// * [AssetBulkUploadCheckDto] assetBulkUploadCheckDto (required): /// * [AssetBulkUploadCheckDto] assetBulkUploadCheckDto (required):
Future<Response> checkBulkUploadWithHttpInfo(AssetBulkUploadCheckDto assetBulkUploadCheckDto,) async { Future<Response> checkBulkUploadWithHttpInfo(AssetBulkUploadCheckDto assetBulkUploadCheckDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/bulk-upload-check'; final path = r'/assets/bulk-upload-check';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody = assetBulkUploadCheckDto; Object? postBody = assetBulkUploadCheckDto;
@ -77,7 +77,7 @@ class AssetsApi {
/// * [CheckExistingAssetsDto] checkExistingAssetsDto (required): /// * [CheckExistingAssetsDto] checkExistingAssetsDto (required):
Future<Response> checkExistingAssetsWithHttpInfo(CheckExistingAssetsDto checkExistingAssetsDto,) async { Future<Response> checkExistingAssetsWithHttpInfo(CheckExistingAssetsDto checkExistingAssetsDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/exist'; final path = r'/assets/exist';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody = checkExistingAssetsDto; Object? postBody = checkExistingAssetsDto;
@ -120,13 +120,13 @@ class AssetsApi {
return null; return null;
} }
/// Performs an HTTP 'DELETE /asset' operation and returns the [Response]. /// Performs an HTTP 'DELETE /assets' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
/// * [AssetBulkDeleteDto] assetBulkDeleteDto (required): /// * [AssetBulkDeleteDto] assetBulkDeleteDto (required):
Future<Response> deleteAssetsWithHttpInfo(AssetBulkDeleteDto assetBulkDeleteDto,) async { Future<Response> deleteAssetsWithHttpInfo(AssetBulkDeleteDto assetBulkDeleteDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset'; final path = r'/assets';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody = assetBulkDeleteDto; 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. /// Get all asset of a device that are in the database, ID only.
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
@ -168,7 +224,7 @@ class AssetsApi {
/// * [String] deviceId (required): /// * [String] deviceId (required):
Future<Response> getAllUserAssetsByDeviceIdWithHttpInfo(String deviceId,) async { Future<Response> getAllUserAssetsByDeviceIdWithHttpInfo(String deviceId,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/device/{deviceId}' final path = r'/assets/device/{deviceId}'
.replaceAll('{deviceId}', deviceId); .replaceAll('{deviceId}', deviceId);
// ignore: prefer_final_locals // ignore: prefer_final_locals
@ -215,7 +271,7 @@ class AssetsApi {
return null; 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: /// Parameters:
/// ///
/// * [String] id (required): /// * [String] id (required):
@ -223,7 +279,7 @@ class AssetsApi {
/// * [String] key: /// * [String] key:
Future<Response> getAssetInfoWithHttpInfo(String id, { String? key, }) async { Future<Response> getAssetInfoWithHttpInfo(String id, { String? key, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/{id}' final path = r'/assets/{id}'
.replaceAll('{id}', id); .replaceAll('{id}', id);
// ignore: prefer_final_locals // ignore: prefer_final_locals
@ -271,7 +327,7 @@ class AssetsApi {
return null; 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: /// Parameters:
/// ///
/// * [bool] isArchived: /// * [bool] isArchived:
@ -281,7 +337,7 @@ class AssetsApi {
/// * [bool] isTrashed: /// * [bool] isTrashed:
Future<Response> getAssetStatisticsWithHttpInfo({ bool? isArchived, bool? isFavorite, bool? isTrashed, }) async { Future<Response> getAssetStatisticsWithHttpInfo({ bool? isArchived, bool? isFavorite, bool? isTrashed, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/statistics'; final path = r'/assets/statistics';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody; Object? postBody;
@ -336,70 +392,7 @@ class AssetsApi {
return null; return null;
} }
/// Performs an HTTP 'GET /asset/thumbnail/{id}' operation and returns the [Response]. /// Performs an HTTP 'GET /assets/memory-lane' 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].
/// Parameters: /// Parameters:
/// ///
/// * [int] day (required): /// * [int] day (required):
@ -407,7 +400,7 @@ class AssetsApi {
/// * [int] month (required): /// * [int] month (required):
Future<Response> getMemoryLaneWithHttpInfo(int day, int month,) async { Future<Response> getMemoryLaneWithHttpInfo(int day, int month,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/memory-lane'; final path = r'/assets/memory-lane';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody; Object? postBody;
@ -456,13 +449,13 @@ class AssetsApi {
return null; 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: /// Parameters:
/// ///
/// * [num] count: /// * [num] count:
Future<Response> getRandomWithHttpInfo({ num? count, }) async { Future<Response> getRandomWithHttpInfo({ num? count, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/random'; final path = r'/assets/random';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody; Object? postBody;
@ -510,6 +503,62 @@ class AssetsApi {
return null; 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 /// Replace the asset with new file, without changing its id
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
@ -533,7 +582,7 @@ class AssetsApi {
/// * [String] duration: /// * [String] duration:
Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, }) async { Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/{id}/file' final path = r'/assets/{id}/original'
.replaceAll('{id}', id); .replaceAll('{id}', id);
// ignore: prefer_final_locals // ignore: prefer_final_locals
@ -625,13 +674,13 @@ class AssetsApi {
return null; 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: /// Parameters:
/// ///
/// * [AssetJobsDto] assetJobsDto (required): /// * [AssetJobsDto] assetJobsDto (required):
Future<Response> runAssetJobsWithHttpInfo(AssetJobsDto assetJobsDto,) async { Future<Response> runAssetJobsWithHttpInfo(AssetJobsDto assetJobsDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/jobs'; final path = r'/assets/jobs';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody = assetJobsDto; Object? postBody = assetJobsDto;
@ -664,77 +713,7 @@ class AssetsApi {
} }
} }
/// Performs an HTTP 'GET /asset/file/{id}' operation and returns the [Response]. /// Performs an HTTP 'PUT /assets/{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].
/// Parameters: /// Parameters:
/// ///
/// * [String] id (required): /// * [String] id (required):
@ -742,7 +721,7 @@ class AssetsApi {
/// * [UpdateAssetDto] updateAssetDto (required): /// * [UpdateAssetDto] updateAssetDto (required):
Future<Response> updateAssetWithHttpInfo(String id, UpdateAssetDto updateAssetDto,) async { Future<Response> updateAssetWithHttpInfo(String id, UpdateAssetDto updateAssetDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/{id}' final path = r'/assets/{id}'
.replaceAll('{id}', id); .replaceAll('{id}', id);
// ignore: prefer_final_locals // ignore: prefer_final_locals
@ -786,13 +765,13 @@ class AssetsApi {
return null; return null;
} }
/// Performs an HTTP 'PUT /asset' operation and returns the [Response]. /// Performs an HTTP 'PUT /assets' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
/// * [AssetBulkUpdateDto] assetBulkUpdateDto (required): /// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
Future<Response> updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto,) async { Future<Response> updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset'; final path = r'/assets';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody = assetBulkUpdateDto; 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: /// Parameters:
/// ///
/// * [UpdateStackParentDto] updateStackParentDto (required): /// * [UpdateStackParentDto] updateStackParentDto (required):
Future<Response> updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async { Future<Response> updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/stack/parent'; final path = r'/assets/stack/parent';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody = updateStackParentDto; 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: /// Parameters:
/// ///
/// * [MultipartFile] assetData (required): /// * [MultipartFile] assetData (required):
@ -892,12 +871,12 @@ class AssetsApi {
/// ///
/// * [bool] isVisible: /// * [bool] isVisible:
/// ///
/// * [MultipartFile] livePhotoData: /// * [String] livePhotoVideoId:
/// ///
/// * [MultipartFile] sidecarData: /// * [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 // ignore: prefer_const_declarations
final path = r'/asset/upload'; final path = r'/assets';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody; Object? postBody;
@ -959,10 +938,9 @@ class AssetsApi {
hasFields = true; hasFields = true;
mp.fields[r'isVisible'] = parameterToString(isVisible); mp.fields[r'isVisible'] = parameterToString(isVisible);
} }
if (livePhotoData != null) { if (livePhotoVideoId != null) {
hasFields = true; hasFields = true;
mp.fields[r'livePhotoData'] = livePhotoData.field; mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId);
mp.files.add(livePhotoData);
} }
if (sidecarData != null) { if (sidecarData != null) {
hasFields = true; hasFields = true;
@ -1011,11 +989,11 @@ class AssetsApi {
/// ///
/// * [bool] isVisible: /// * [bool] isVisible:
/// ///
/// * [MultipartFile] livePhotoData: /// * [String] livePhotoVideoId:
/// ///
/// * [MultipartFile] sidecarData: /// * [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 { 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 uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoData: livePhotoData, sidecarData: sidecarData, ); 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) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); 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" // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string. // FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { 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; return null;

View File

@ -71,62 +71,6 @@ class DownloadApi {
return null; 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]. /// Performs an HTTP 'POST /download/info' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

View File

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

View File

@ -61,6 +61,9 @@ String parameterToString(dynamic value) {
if (value is AssetJobName) { if (value is AssetJobName) {
return AssetJobNameTypeTransformer().encode(value).toString(); return AssetJobNameTypeTransformer().encode(value).toString();
} }
if (value is AssetMediaSize) {
return AssetMediaSizeTypeTransformer().encode(value).toString();
}
if (value is AssetMediaStatus) { if (value is AssetMediaStatus) {
return AssetMediaStatusTypeTransformer().encode(value).toString(); return AssetMediaStatusTypeTransformer().encode(value).toString();
} }
@ -127,9 +130,6 @@ String parameterToString(dynamic value) {
if (value is TagTypeEnum) { if (value is TagTypeEnum) {
return TagTypeEnumTypeTransformer().encode(value).toString(); return TagTypeEnumTypeTransformer().encode(value).toString();
} }
if (value is ThumbnailFormat) {
return ThumbnailFormatTypeTransformer().encode(value).toString();
}
if (value is TimeBucketSize) { if (value is TimeBucketSize) {
return TimeBucketSizeTypeTransformer().encode(value).toString(); 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; part of openapi.api;
class ThumbnailFormat { class AssetMediaSize {
/// Instantiate a new enum with the provided [value]. /// Instantiate a new enum with the provided [value].
const ThumbnailFormat._(this.value); const AssetMediaSize._(this.value);
/// The underlying value of this enum member. /// The underlying value of this enum member.
final String value; final String value;
@ -23,22 +23,22 @@ class ThumbnailFormat {
String toJson() => value; String toJson() => value;
static const JPEG = ThumbnailFormat._(r'JPEG'); static const preview = AssetMediaSize._(r'preview');
static const WEBP = ThumbnailFormat._(r'WEBP'); static const thumbnail = AssetMediaSize._(r'thumbnail');
/// List of all possible values in this [enum][ThumbnailFormat]. /// List of all possible values in this [enum][AssetMediaSize].
static const values = <ThumbnailFormat>[ static const values = <AssetMediaSize>[
JPEG, preview,
WEBP, 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,}) { static List<AssetMediaSize> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ThumbnailFormat>[]; final result = <AssetMediaSize>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
final value = ThumbnailFormat.fromJson(row); final value = AssetMediaSize.fromJson(row);
if (value != null) { if (value != null) {
result.add(value); result.add(value);
} }
@ -48,16 +48,16 @@ class ThumbnailFormat {
} }
} }
/// Transformation class that can [encode] an instance of [ThumbnailFormat] to String, /// Transformation class that can [encode] an instance of [AssetMediaSize] to String,
/// and [decode] dynamic data back to [ThumbnailFormat]. /// and [decode] dynamic data back to [AssetMediaSize].
class ThumbnailFormatTypeTransformer { class AssetMediaSizeTypeTransformer {
factory ThumbnailFormatTypeTransformer() => _instance ??= const ThumbnailFormatTypeTransformer._(); 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, /// 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] /// 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, /// 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. /// 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) { if (data != null) {
switch (data) { switch (data) {
case r'JPEG': return ThumbnailFormat.JPEG; case r'preview': return AssetMediaSize.preview;
case r'WEBP': return ThumbnailFormat.WEBP; case r'thumbnail': return AssetMediaSize.thumbnail;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
@ -79,7 +79,7 @@ class ThumbnailFormatTypeTransformer {
return null; return null;
} }
/// Singleton [ThumbnailFormatTypeTransformer] instance. /// Singleton [AssetMediaSizeTypeTransformer] instance.
static ThumbnailFormatTypeTransformer? _instance; static AssetMediaSizeTypeTransformer? _instance;
} }

View File

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

View File

@ -1805,4 +1805,4 @@ packages:
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.3.0 <4.0.0" 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": { "delete": {
"operationId": "deleteAssets", "operationId": "deleteAssets",
"parameters": [], "parameters": [],
@ -1329,6 +1329,65 @@
"Assets" "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": { "put": {
"operationId": "updateAssets", "operationId": "updateAssets",
"parameters": [], "parameters": [],
@ -1363,7 +1422,7 @@
] ]
} }
}, },
"/asset/bulk-upload-check": { "/assets/bulk-upload-check": {
"post": { "post": {
"description": "Checks if assets exist by checksums", "description": "Checks if assets exist by checksums",
"operationId": "checkBulkUpload", "operationId": "checkBulkUpload",
@ -1406,7 +1465,7 @@
] ]
} }
}, },
"/asset/device/{deviceId}": { "/assets/device/{deviceId}": {
"get": { "get": {
"description": "Get all asset of a device that are in the database, ID only.", "description": "Get all asset of a device that are in the database, ID only.",
"operationId": "getAllUserAssetsByDeviceId", "operationId": "getAllUserAssetsByDeviceId",
@ -1451,7 +1510,7 @@
] ]
} }
}, },
"/asset/exist": { "/assets/exist": {
"post": { "post": {
"description": "Checks if multiple assets exist on the server and returns all existing - used by background backup", "description": "Checks if multiple assets exist on the server and returns all existing - used by background backup",
"operationId": "checkExistingAssets", "operationId": "checkExistingAssets",
@ -1494,76 +1553,7 @@
] ]
} }
}, },
"/asset/file/{id}": { "/assets/jobs": {
"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": {
"post": { "post": {
"operationId": "runAssetJobs", "operationId": "runAssetJobs",
"parameters": [], "parameters": [],
@ -1598,7 +1588,7 @@
] ]
} }
}, },
"/asset/memory-lane": { "/assets/memory-lane": {
"get": { "get": {
"operationId": "getMemoryLane", "operationId": "getMemoryLane",
"parameters": [ "parameters": [
@ -1654,7 +1644,7 @@
] ]
} }
}, },
"/asset/random": { "/assets/random": {
"get": { "get": {
"operationId": "getRandom", "operationId": "getRandom",
"parameters": [ "parameters": [
@ -1699,7 +1689,7 @@
] ]
} }
}, },
"/asset/stack/parent": { "/assets/stack/parent": {
"put": { "put": {
"operationId": "updateStackParent", "operationId": "updateStackParent",
"parameters": [], "parameters": [],
@ -1734,7 +1724,7 @@
] ]
} }
}, },
"/asset/statistics": { "/assets/statistics": {
"get": { "get": {
"operationId": "getAssetStatistics", "operationId": "getAssetStatistics",
"parameters": [ "parameters": [
@ -1791,127 +1781,7 @@
] ]
} }
}, },
"/asset/thumbnail/{id}": { "/assets/{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}": {
"get": { "get": {
"operationId": "getAssetInfo", "operationId": "getAssetInfo",
"parameters": [ "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": { "put": {
"description": "Replace the asset with new file, without changing its id", "description": "Replace the asset with new file, without changing its id",
"operationId": "replaceAsset", "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": { "/audit/deletes": {
"get": { "get": {
"operationId": "getAuditDeletes", "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": { "/download/info": {
"post": { "post": {
"operationId": "getDownloadInfo", "operationId": "getDownloadInfo",
@ -7417,21 +7395,6 @@
], ],
"type": "object" "type": "object"
}, },
"AssetFileUploadResponseDto": {
"properties": {
"duplicate": {
"type": "boolean"
},
"id": {
"type": "string"
}
},
"required": [
"duplicate",
"id"
],
"type": "object"
},
"AssetFullSyncDto": { "AssetFullSyncDto": {
"properties": { "properties": {
"lastCreationDate": { "lastCreationDate": {
@ -7526,6 +7489,59 @@
], ],
"type": "object" "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": { "AssetMediaReplaceDto": {
"properties": { "properties": {
"assetData": { "assetData": {
@ -7574,8 +7590,16 @@
], ],
"type": "object" "type": "object"
}, },
"AssetMediaSize": {
"enum": [
"preview",
"thumbnail"
],
"type": "string"
},
"AssetMediaStatus": { "AssetMediaStatus": {
"enum": [ "enum": [
"created",
"replaced", "replaced",
"duplicate" "duplicate"
], ],
@ -7963,59 +7987,6 @@
], ],
"type": "object" "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": { "CreateLibraryDto": {
"properties": { "properties": {
"exclusionPatterns": { "exclusionPatterns": {
@ -10872,13 +10843,6 @@
], ],
"type": "string" "type": "string"
}, },
"ThumbnailFormat": {
"enum": [
"JPEG",
"WEBP"
],
"type": "string"
},
"TimeBucketResponseDto": { "TimeBucketResponseDto": {
"properties": { "properties": {
"count": { "count": {

View File

@ -264,6 +264,24 @@ export type AssetBulkDeleteDto = {
force?: boolean; force?: boolean;
ids: string[]; 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 = { export type AssetBulkUpdateDto = {
dateTimeOriginal?: string; dateTimeOriginal?: string;
duplicateId?: string | null; duplicateId?: string | null;
@ -316,24 +334,6 @@ export type AssetStatsResponseDto = {
total: number; total: number;
videos: 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 = { export type UpdateAssetDto = {
dateTimeOriginal?: string; dateTimeOriginal?: string;
description?: string; description?: string;
@ -350,10 +350,6 @@ export type AssetMediaReplaceDto = {
fileCreatedAt: string; fileCreatedAt: string;
fileModifiedAt: string; fileModifiedAt: string;
}; };
export type AssetMediaResponseDto = {
id: string;
status: AssetMediaStatus;
};
export type AuditDeletesResponseDto = { export type AuditDeletesResponseDto = {
ids: string[]; ids: string[];
needsFullSync: boolean; needsFullSync: boolean;
@ -1434,16 +1430,35 @@ export function updateApiKey({ id, apiKeyUpdateDto }: {
export function deleteAssets({ assetBulkDeleteDto }: { export function deleteAssets({ assetBulkDeleteDto }: {
assetBulkDeleteDto: AssetBulkDeleteDto; assetBulkDeleteDto: AssetBulkDeleteDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/asset", oazapfts.json({ return oazapfts.ok(oazapfts.fetchText("/assets", oazapfts.json({
...opts, ...opts,
method: "DELETE", method: "DELETE",
body: assetBulkDeleteDto 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 }: { export function updateAssets({ assetBulkUpdateDto }: {
assetBulkUpdateDto: AssetBulkUpdateDto; assetBulkUpdateDto: AssetBulkUpdateDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/asset", oazapfts.json({ return oazapfts.ok(oazapfts.fetchText("/assets", oazapfts.json({
...opts, ...opts,
method: "PUT", method: "PUT",
body: assetBulkUpdateDto body: assetBulkUpdateDto
@ -1458,7 +1473,7 @@ export function checkBulkUpload({ assetBulkUploadCheckDto }: {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetBulkUploadCheckResponseDto; data: AssetBulkUploadCheckResponseDto;
}>("/asset/bulk-upload-check", oazapfts.json({ }>("/assets/bulk-upload-check", oazapfts.json({
...opts, ...opts,
method: "POST", method: "POST",
body: assetBulkUploadCheckDto body: assetBulkUploadCheckDto
@ -1473,7 +1488,7 @@ export function getAllUserAssetsByDeviceId({ deviceId }: {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: string[]; data: string[];
}>(`/asset/device/${encodeURIComponent(deviceId)}`, { }>(`/assets/device/${encodeURIComponent(deviceId)}`, {
...opts ...opts
})); }));
} }
@ -1486,33 +1501,16 @@ export function checkExistingAssets({ checkExistingAssetsDto }: {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: CheckExistingAssetsResponseDto; data: CheckExistingAssetsResponseDto;
}>("/asset/exist", oazapfts.json({ }>("/assets/exist", oazapfts.json({
...opts, ...opts,
method: "POST", method: "POST",
body: checkExistingAssetsDto 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 }: { export function runAssetJobs({ assetJobsDto }: {
assetJobsDto: AssetJobsDto; assetJobsDto: AssetJobsDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/asset/jobs", oazapfts.json({ return oazapfts.ok(oazapfts.fetchText("/assets/jobs", oazapfts.json({
...opts, ...opts,
method: "POST", method: "POST",
body: assetJobsDto body: assetJobsDto
@ -1525,7 +1523,7 @@ export function getMemoryLane({ day, month }: {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: MemoryLaneResponseDto[]; data: MemoryLaneResponseDto[];
}>(`/asset/memory-lane${QS.query(QS.explode({ }>(`/assets/memory-lane${QS.query(QS.explode({
day, day,
month month
}))}`, { }))}`, {
@ -1538,7 +1536,7 @@ export function getRandom({ count }: {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetResponseDto[]; data: AssetResponseDto[];
}>(`/asset/random${QS.query(QS.explode({ }>(`/assets/random${QS.query(QS.explode({
count count
}))}`, { }))}`, {
...opts ...opts
@ -1547,7 +1545,7 @@ export function getRandom({ count }: {
export function updateStackParent({ updateStackParentDto }: { export function updateStackParent({ updateStackParentDto }: {
updateStackParentDto: UpdateStackParentDto; updateStackParentDto: UpdateStackParentDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/asset/stack/parent", oazapfts.json({ return oazapfts.ok(oazapfts.fetchText("/assets/stack/parent", oazapfts.json({
...opts, ...opts,
method: "PUT", method: "PUT",
body: updateStackParentDto body: updateStackParentDto
@ -1561,7 +1559,7 @@ export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetStatsResponseDto; data: AssetStatsResponseDto;
}>(`/asset/statistics${QS.query(QS.explode({ }>(`/assets/statistics${QS.query(QS.explode({
isArchived, isArchived,
isFavorite, isFavorite,
isTrashed isTrashed
@ -1569,40 +1567,6 @@ export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: {
...opts ...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 }: { export function getAssetInfo({ id, key }: {
id: string; id: string;
key?: string; key?: string;
@ -1610,7 +1574,7 @@ export function getAssetInfo({ id, key }: {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetResponseDto; data: AssetResponseDto;
}>(`/asset/${encodeURIComponent(id)}${QS.query(QS.explode({ }>(`/assets/${encodeURIComponent(id)}${QS.query(QS.explode({
key key
}))}`, { }))}`, {
...opts ...opts
@ -1623,12 +1587,25 @@ export function updateAsset({ id, updateAssetDto }: {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetResponseDto; data: AssetResponseDto;
}>(`/asset/${encodeURIComponent(id)}`, oazapfts.json({ }>(`/assets/${encodeURIComponent(id)}`, oazapfts.json({
...opts, ...opts,
method: "PUT", method: "PUT",
body: updateAssetDto 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 * 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<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetMediaResponseDto; data: AssetMediaResponseDto;
}>(`/asset/${encodeURIComponent(id)}/file${QS.query(QS.explode({ }>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({
key key
}))}`, oazapfts.multipart({ }))}`, oazapfts.multipart({
...opts, ...opts,
@ -1648,6 +1625,34 @@ export function replaceAsset({ id, key, assetMediaReplaceDto }: {
body: 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 }: { export function getAuditDeletes({ after, entityType, userId }: {
after: string; after: string;
entityType: EntityType; entityType: EntityType;
@ -1733,20 +1738,6 @@ export function downloadArchive({ key, assetIdsDto }: {
body: 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 }: { export function getDownloadInfo({ key, downloadInfoDto }: {
key?: string; key?: string;
downloadInfoDto: DownloadInfoDto; downloadInfoDto: DownloadInfoDto;
@ -2929,6 +2920,11 @@ export enum Error {
NotFound = "not_found", NotFound = "not_found",
Unknown = "unknown" Unknown = "unknown"
} }
export enum AssetMediaStatus {
Created = "created",
Replaced = "replaced",
Duplicate = "duplicate"
}
export enum Action { export enum Action {
Accept = "accept", Accept = "accept",
Reject = "reject" Reject = "reject"
@ -2942,13 +2938,9 @@ export enum AssetJobName {
RefreshMetadata = "refresh-metadata", RefreshMetadata = "refresh-metadata",
TranscodeVideo = "transcode-video" TranscodeVideo = "transcode-video"
} }
export enum ThumbnailFormat { export enum AssetMediaSize {
Jpeg = "JPEG", Preview = "preview",
Webp = "WEBP" Thumbnail = "thumbnail"
}
export enum AssetMediaStatus {
Replaced = "replaced",
Duplicate = "duplicate"
} }
export enum EntityType { export enum EntityType {
Asset = "ASSET", Asset = "ASSET",

View File

@ -24,9 +24,12 @@ export const setApiKey = (apiKey: string) => {
defaults.headers['x-api-key'] = apiKey; 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) => export const getUserProfileImagePath = (userId: string) =>
`/users/${userId}/profile-image`; `/users/${userId}/profile-image`;

View File

@ -1,37 +1,44 @@
import { import {
Body, Body,
Controller, Controller,
Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Inject, Inject,
Next,
Param, Param,
ParseFilePipe, ParseFilePipe,
Post, Post,
Put, Put,
Query,
Res, Res,
UploadedFiles, UploadedFiles,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { Response } from 'express'; import { NextFunction, Response } from 'express';
import { EndpointLifecycle } from 'src/decorators'; import { EndpointLifecycle } from 'src/decorators';
import { import {
AssetBulkUploadCheckResponseDto, AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto, AssetMediaResponseDto,
AssetMediaStatusEnum, AssetMediaStatus,
CheckExistingAssetsResponseDto, CheckExistingAssetsResponseDto,
} from 'src/dtos/asset-media-response.dto'; } from 'src/dtos/asset-media-response.dto';
import { import {
AssetBulkUploadCheckDto, AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto, AssetMediaReplaceDto,
CheckExistingAssetsDto, CheckExistingAssetsDto,
UploadFieldName, UploadFieldName,
} from 'src/dtos/asset-media.dto'; } 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 { 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 { FileUploadInterceptor, Route, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { sendFile } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags('Assets') @ApiTags('Assets')
@ -42,10 +49,48 @@ export class AssetMediaController {
private service: AssetMediaService, 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 * Replace the asset with new file, without changing its id
*/ */
@Put(':id/file') @Put(':id/original')
@UseInterceptors(FileUploadInterceptor) @UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@Authenticated({ sharedLink: true }) @Authenticated({ sharedLink: true })
@ -60,12 +105,37 @@ export class AssetMediaController {
): Promise<AssetMediaResponseDto> { ): Promise<AssetMediaResponseDto> {
const { file } = getFiles(files); const { file } = getFiles(files);
const responseDto = await this.service.replaceAsset(auth, id, dto, file); const responseDto = await this.service.replaceAsset(auth, id, dto, file);
if (responseDto.status === AssetMediaStatusEnum.DUPLICATE) { if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK); res.status(HttpStatus.OK);
} }
return responseDto; 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 * 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 { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.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 { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { DownloadService } from 'src/services/download.service'; import { DownloadService } from 'src/services/download.service';
import { asStreamableFile, sendFile } from 'src/utils/file'; import { asStreamableFile } from 'src/utils/file';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Download') @ApiTags('Download')
@Controller('download') @Controller('download')
export class DownloadController { export class DownloadController {
constructor( constructor(private service: DownloadService) {}
private service: DownloadService,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {}
@Post('info') @Post('info')
@Authenticated({ sharedLink: true }) @Authenticated({ sharedLink: true })
@ -31,17 +25,4 @@ export class DownloadController {
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> { downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
return this.service.downloadArchive(auth, dto).then(asStreamableFile); 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 { APIKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller'; import { AppController } from 'src/controllers/app.controller';
import { AssetMediaController } from 'src/controllers/asset-media.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 { AssetController } from 'src/controllers/asset.controller';
import { AuditController } from 'src/controllers/audit.controller'; import { AuditController } from 'src/controllers/audit.controller';
import { AuthController } from 'src/controllers/auth.controller'; import { AuthController } from 'src/controllers/auth.controller';
@ -37,7 +36,6 @@ export const controllers = [
AlbumController, AlbumController,
AppController, AppController,
AssetController, AssetController,
AssetControllerV1,
AssetMediaController, AssetMediaController,
AuditController, AuditController,
AuthController, AuthController,

View File

@ -1,12 +1,13 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export enum AssetMediaStatusEnum { export enum AssetMediaStatus {
CREATED = 'created',
REPLACED = 'replaced', REPLACED = 'replaced',
DUPLICATE = 'duplicate', DUPLICATE = 'duplicate',
} }
export class AssetMediaResponseDto { export class AssetMediaResponseDto {
@ApiProperty({ enum: AssetMediaStatusEnum, enumName: 'AssetMediaStatus' }) @ApiProperty({ enum: AssetMediaStatus, enumName: 'AssetMediaStatus' })
status!: AssetMediaStatusEnum; status!: AssetMediaStatus;
id!: string; id!: string;
} }

View File

@ -1,16 +1,27 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsEnum, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { Optional, ValidateDate } from 'src/validation'; 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 { export enum UploadFieldName {
ASSET_DATA = 'assetData', ASSET_DATA = 'assetData',
LIVE_PHOTO_DATA = 'livePhotoData',
SIDECAR_DATA = 'sidecarData', SIDECAR_DATA = 'sidecarData',
PROFILE_DATA = 'file', PROFILE_DATA = 'file',
} }
export class AssetMediaReplaceDto { class AssetMediaBase {
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
deviceAssetId!: string; deviceAssetId!: string;
@ -35,6 +46,28 @@ export class AssetMediaReplaceDto {
[UploadFieldName.ASSET_DATA]!: any; [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 { export class AssetBulkUploadCheckItem {
@IsString() @IsString()
@IsNotEmpty() @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), 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 { ApiProperty } from '@nestjs/swagger';
import { UploadFieldName } from 'src/dtos/asset.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto';
export class CreateProfileImageDto { export class CreateProfileImageDto {
@ApiProperty({ type: 'string', format: 'binary' }) @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[]>; ): Promise<AssetEntity[]>;
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>; getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): 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[]>; getByChecksums(userId: string, checksums: Buffer[]): Promise<AssetEntity[]>;
getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>; getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;

View File

@ -1,18 +1,18 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Response } from 'express'; 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 { ImmichHeader } from 'src/dtos/auth.dto';
import { AuthenticatedRequest } from 'src/middleware/auth.guard'; 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'; import { fromMaybeArray } from 'src/utils/request';
@Injectable() @Injectable()
export class AssetUploadInterceptor implements NestInterceptor { export class AssetUploadInterceptor implements NestInterceptor {
constructor(private service: AssetService) {} constructor(private service: AssetMediaService) {}
async intercept(context: ExecutionContext, next: CallHandler<any>) { async intercept(context: ExecutionContext, next: CallHandler<any>) {
const req = context.switchToHttp().getRequest<AuthenticatedRequest>(); 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 checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]);
const response = await this.service.getUploadAssetIdByChecksum(req.user, 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 { UploadFieldName } from 'src/dtos/asset-media.dto';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthRequest } from 'src/middleware/auth.guard'; import { AuthRequest } from 'src/middleware/auth.guard';
import { UploadFile } from 'src/services/asset-media.service'; import { AssetMediaService, UploadFile } from 'src/services/asset-media.service';
import { AssetService } from 'src/services/asset.service';
export interface UploadFiles { export interface UploadFiles {
assetData: ImmichFile[]; assetData: ImmichFile[];
livePhotoData?: ImmichFile[];
sidecarData: 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]; const file = files[property]?.[0];
return file ? mapToUploadFile(file) : file; return file ? mapToUploadFile(file) : file;
} }
@ -26,13 +24,12 @@ export function getFile(files: UploadFiles, property: 'assetData' | 'livePhotoDa
export function getFiles(files: UploadFiles) { export function getFiles(files: UploadFiles) {
return { return {
file: getFile(files, 'assetData') as UploadFile, file: getFile(files, 'assetData') as UploadFile,
livePhotoFile: getFile(files, 'livePhotoData'),
sidecarFile: getFile(files, 'sidecarData'), sidecarFile: getFile(files, 'sidecarData'),
}; };
} }
export enum Route { export enum Route {
ASSET = 'asset', ASSET = 'assets',
USER = 'users', USER = 'users',
} }
@ -87,7 +84,7 @@ export class FileUploadInterceptor implements NestInterceptor {
constructor( constructor(
private reflect: Reflector, private reflect: Reflector,
private assetService: AssetService, private assetService: AssetMediaService,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(FileUploadInterceptor.name); this.logger.setContext(FileUploadInterceptor.name);
@ -109,7 +106,6 @@ export class FileUploadInterceptor implements NestInterceptor {
userProfile: instance.single(UploadFieldName.PROFILE_DATA), userProfile: instance.single(UploadFieldName.PROFILE_DATA),
assetUpload: instance.fields([ assetUpload: instance.fields([
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 }, { name: UploadFieldName.ASSET_DATA, maxCount: 1 },
{ name: UploadFieldName.LIVE_PHOTO_DATA, maxCount: 1 },
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 }, { name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
]), ]),
}; };
@ -172,8 +168,7 @@ export class FileUploadInterceptor implements NestInterceptor {
private isAssetUploadFile(file: Express.Multer.File) { private isAssetUploadFile(file: Express.Multer.File) {
switch (file.fieldname as UploadFieldName) { switch (file.fieldname as UploadFieldName) {
case UploadFieldName.ASSET_DATA: case UploadFieldName.ASSET_DATA: {
case UploadFieldName.LIVE_PHOTO_DATA: {
return true; return true;
} }
} }

View File

@ -474,8 +474,9 @@ FROM
WHERE WHERE
( (
( (
("AssetEntity"."libraryId" = $1) ("AssetEntity"."ownerId" = $1)
AND ("AssetEntity"."checksum" = $2) AND ("AssetEntity"."libraryId" = $2)
AND ("AssetEntity"."checksum" = $3)
) )
) )
AND ("AssetEntity"."deletedAt" IS NULL) 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); await this.repository.remove(asset);
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) @GenerateSql({ params: [{ ownerId: DummyValue.UUID, libraryId: DummyValue.UUID, checksum: DummyValue.BUFFER }] })
getByChecksum(libraryId: string | null, checksum: Buffer): Promise<AssetEntity | null> { getByChecksum({
ownerId,
libraryId,
checksum,
}: {
ownerId: string;
checksum: Buffer;
libraryId?: string;
}): Promise<AssetEntity | null> {
return this.repository.findOne({ return this.repository.findOne({
where: { where: {
ownerId,
libraryId: libraryId || IsNull(), libraryId: libraryId || IsNull(),
checksum, checksum,
}, },

View File

@ -4,7 +4,6 @@ import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.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 { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.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 { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AssetStackRepository } from 'src/repositories/asset-stack.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 { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository'; import { AuditRepository } from 'src/repositories/audit.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository';
@ -71,7 +69,6 @@ export const repositories = [
{ provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAlbumRepository, useClass: AlbumRepository },
{ provide: IAlbumUserRepository, useClass: AlbumUserRepository }, { provide: IAlbumUserRepository, useClass: AlbumUserRepository },
{ provide: IAssetRepository, useClass: AssetRepository }, { provide: IAssetRepository, useClass: AssetRepository },
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
{ provide: IAssetStackRepository, useClass: AssetStackRepository }, { provide: IAssetStackRepository, useClass: AssetStackRepository },
{ provide: IAuditRepository, useClass: AuditRepository }, { provide: IAuditRepository, useClass: AuditRepository },
{ provide: ICryptoRepository, useClass: CryptoRepository }, { provide: ICryptoRepository, useClass: CryptoRepository },

View File

@ -1,16 +1,17 @@
import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { AssetMediaStatusEnum, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
import { AssetMediaReplaceDto } from 'src/dtos/asset-media.dto'; import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; 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 { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AssetMediaService, UploadFile } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { mimeTypes } from 'src/utils/mime-types'; import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub'; import { fileStub } from 'test/fixtures/file.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; 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 { QueryFailedError } from 'typeorm';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
const _getUpdateAssetDto = (): AssetMediaReplaceDto => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
return Object.assign(new AssetMediaReplaceDto(), {
deviceAssetId: 'deviceAssetId', const uploadFile = {
deviceId: 'deviceId', nullAuth: {
fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'), auth: null,
fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'), fieldName: UploadFieldName.ASSET_DATA,
updatedAt: new Date('2024-04-15T23:41:36.910Z'), 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 validImages = [
const asset_1 = new AssetEntity(); '.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'; const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv'];
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;
};
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 sut: AssetMediaService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
@ -103,171 +208,359 @@ describe('AssetMediaService', () => {
sut = new AssetMediaService(accessMock, assetMock, jobMock, storageMock, userMock, eventMock, loggerMock); 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', () => { 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 () => { it('should error when update photo does not exist', async () => {
const dto = _getUpdateAssetDto();
assetMock.getById.mockResolvedValueOnce(null); 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', 'Not found or no asset.update access',
); );
expect(assetMock.create).not.toHaveBeenCalled(); expect(assetMock.create).not.toHaveBeenCalled();
}); });
it('should update a photo with no sidecar to photo with no sidecar', async () => { it('should update a photo with no sidecar to photo with no sidecar', async () => {
const existingAsset = _getExistingAsset();
const updatedFile = fileStub.photo; const updatedFile = fileStub.photo;
const updatedAsset = { ...existingAsset, ...updatedFile }; const updatedAsset = { ...existingAsset, ...updatedFile };
const dto = _getUpdateAssetDto();
assetMock.getById.mockResolvedValueOnce(existingAsset); assetMock.getById.mockResolvedValueOnce(existingAsset);
assetMock.getById.mockResolvedValueOnce(updatedAsset); assetMock.getById.mockResolvedValueOnce(updatedAsset);
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
// this is the original file size // this is the original file size
storageMock.stat.mockResolvedValue({ size: 0 } as Stats); storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call // 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({ await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatusEnum.REPLACED, status: AssetMediaStatus.REPLACED,
id: _getCopiedAsset().id, id: 'copied-asset',
}); });
expectAssetUpdate(existingAsset, updatedFile, dto); expect(assetMock.update).toHaveBeenCalledWith(
expectAssetCreateCopy(existingAsset); 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(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(storageMock.utimes).toHaveBeenCalledWith( expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath, updatedFile.originalPath,
expect.any(Date), 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 updatedFile = fileStub.photo;
const sidecarFile = fileStub.photoSidecar; const sidecarFile = fileStub.photoSidecar;
const dto = _getUpdateAssetDto(); const updatedAsset = { ...sidecarAsset, ...updatedFile };
const updatedAsset = { ...existingAsset, ...updatedFile };
assetMock.getById.mockResolvedValueOnce(existingAsset); assetMock.getById.mockResolvedValueOnce(existingAsset);
assetMock.getById.mockResolvedValueOnce(updatedAsset); 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 // this is the original file size
storageMock.stat.mockResolvedValue({ size: 0 } as Stats); storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call // 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({ await expect(
status: AssetMediaStatusEnum.REPLACED, sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile),
id: _getCopiedAsset().id, ).resolves.toEqual({
status: AssetMediaStatus.REPLACED,
id: 'copied-asset',
}); });
expectAssetUpdate(existingAsset, updatedFile, dto, undefined, sidecarFile); expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
expectAssetCreateCopy(existingAsset);
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]);
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(storageMock.utimes).toHaveBeenCalledWith( expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath, updatedFile.originalPath,
expect.any(Date), 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 () => { it('should update a photo with a sidecar to photo with no sidecar', async () => {
const existingAsset = _getExistingAssetWithSideCar();
const updatedFile = fileStub.photo; const updatedFile = fileStub.photo;
const dto = _getUpdateAssetDto(); const updatedAsset = { ...sidecarAsset, ...updatedFile };
const updatedAsset = { ...existingAsset, ...updatedFile }; assetMock.getById.mockResolvedValueOnce(sidecarAsset);
assetMock.getById.mockResolvedValueOnce(existingAsset);
assetMock.getById.mockResolvedValueOnce(updatedAsset); 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 // this is the original file size
storageMock.stat.mockResolvedValue({ size: 0 } as Stats); storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the copy call // 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({ await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatusEnum.REPLACED, status: AssetMediaStatus.REPLACED,
id: _getCopiedAsset().id, id: 'copied-asset',
}); });
expectAssetUpdate(existingAsset, updatedFile, dto); expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
expectAssetCreateCopy(existingAsset);
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]);
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(storageMock.utimes).toHaveBeenCalledWith( expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath, updatedFile.originalPath,
expect.any(Date), expect.any(Date),
new Date(dto.fileModifiedAt), new Date(replaceDto.fileModifiedAt),
); );
}); });
it('should handle a photo with sidecar to duplicate photo ', async () => { it('should handle a photo with sidecar to duplicate photo ', async () => {
const existingAsset = _getExistingAssetWithSideCar();
const updatedFile = fileStub.photo; const updatedFile = fileStub.photo;
const dto = _getUpdateAssetDto();
const error = new QueryFailedError('', [], new Error('unique key violation')); const error = new QueryFailedError('', [], new Error('unique key violation'));
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
assetMock.update.mockRejectedValue(error); assetMock.update.mockRejectedValue(error);
assetMock.getById.mockResolvedValueOnce(existingAsset); assetMock.getById.mockResolvedValueOnce(sidecarAsset);
assetMock.getUploadAssetIdByChecksum.mockResolvedValue(existingAsset.id); assetMock.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id);
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
// this is the original file size // this is the original file size
storageMock.stat.mockResolvedValue({ size: 0 } as Stats); storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call // 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({ await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatusEnum.DUPLICATE, status: AssetMediaStatus.DUPLICATE,
id: existingAsset.id, id: sidecarAsset.id,
}); });
expectAssetUpdate(existingAsset, updatedFile, dto);
expect(assetMock.create).not.toHaveBeenCalled(); expect(assetMock.create).not.toHaveBeenCalled();
expect(assetMock.softDeleteAll).not.toHaveBeenCalled(); expect(assetMock.softDeleteAll).not.toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
@ -277,6 +570,7 @@ describe('AssetMediaService', () => {
expect(userMock.updateUsage).not.toHaveBeenCalled(); expect(userMock.updateUsage).not.toHaveBeenCalled();
}); });
}); });
describe('bulkUploadCheck', () => { describe('bulkUploadCheck', () => {
it('should accept hex and base64 checksums', async () => { it('should accept hex and base64 checksums', async () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); 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 { AccessCore, Permission } from 'src/cores/access.core';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { import {
AssetBulkUploadCheckResponseDto, AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto, AssetMediaResponseDto,
AssetMediaStatusEnum, AssetMediaStatus,
AssetRejectReason, AssetRejectReason,
AssetUploadAction, AssetUploadAction,
CheckExistingAssetsResponseDto, CheckExistingAssetsResponseDto,
} from 'src/dtos/asset-media-response.dto'; } from 'src/dtos/asset-media-response.dto';
import { import {
AssetBulkUploadCheckDto, AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto, AssetMediaReplaceDto,
AssetMediaSize,
CheckExistingAssetsDto, CheckExistingAssetsDto,
UploadFieldName, UploadFieldName,
} from 'src/dtos/asset-media.dto'; } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.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 { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.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 { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request'; import { fromChecksum } from 'src/utils/request';
import { QueryFailedError } from 'typeorm'; import { QueryFailedError } from 'typeorm';
@ -57,7 +70,121 @@ export class AssetMediaService {
this.access = AccessCore.create(accessRepository); 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, auth: AuthDto,
id: string, id: string,
dto: AssetMediaReplaceDto, dto: AssetMediaReplaceDto,
@ -66,27 +193,131 @@ export class AssetMediaService {
): Promise<AssetMediaResponseDto> { ): Promise<AssetMediaResponseDto> {
try { try {
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); 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); 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, // 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. // 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 // and immediate trash it
await this.assetRepository.softDeleteAll([copiedPhoto.id]); await this.assetRepository.softDeleteAll([copiedPhoto.id]);
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]); this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]);
await this.userRepository.updateUsage(auth.user.id, file.size); 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) { } 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( private async handleUploadError(
error: any, error: any,
auth: AuthDto, auth: AuthDto,
@ -106,7 +337,7 @@ export class AssetMediaService {
this.logger.error(`Error locating duplicate for checksum constraint`); this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException(); throw new InternalServerErrorException();
} }
return { status: AssetMediaStatusEnum.DUPLICATE, id: duplicateId }; return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
} }
this.logger.error(`Error uploading file ${error}`, error?.stack); this.logger.error(`Error uploading file ${error}`, error?.stack);
@ -181,54 +412,59 @@ export class AssetMediaService {
return created; 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) { private requireQuota(auth: AuthDto, size: number) {
if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) { if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
throw new BadRequestException('Quota has been exceeded!'); throw new BadRequestException('Quota has been exceeded!');
} }
} }
async checkExistingAssets( private async findOrFail(id: string): Promise<AssetEntity> {
auth: AuthDto, const asset = await this.assetRepository.getById(id);
checkExistingAssetsDto: CheckExistingAssetsDto, if (!asset) {
): Promise<CheckExistingAssetsResponseDto> { throw new NotFoundException('Asset not found');
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 { return asset;
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,
};
}),
};
} }
} }

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 { 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 { AssetEntity, AssetType } from 'src/entities/asset.entity';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.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 { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AssetService } from 'src/services/asset.service'; 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 { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.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 { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const stats: AssetStats = { const stats: AssetStats = {
[AssetType.IMAGE]: 10, [AssetType.IMAGE]: 10,
[AssetType.VIDEO]: 23, [AssetType.VIDEO]: 23,
@ -44,117 +40,11 @@ const statResponse: AssetStatsResponseDto = {
total: 33, 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, () => { describe(AssetService.name, () => {
let sut: AssetService; let sut: AssetService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
@ -177,7 +67,6 @@ describe(AssetService.name, () => {
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
eventMock = newEventRepositoryMock(); eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
systemMock = newSystemMetadataRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
partnerMock = newPartnerRepositoryMock(); partnerMock = newPartnerRepositoryMock();
@ -189,7 +78,6 @@ describe(AssetService.name, () => {
assetMock, assetMock,
jobMock, jobMock,
systemMock, systemMock,
storageMock,
userMock, userMock,
eventMock, eventMock,
partnerMock, partnerMock,
@ -200,115 +88,6 @@ describe(AssetService.name, () => {
mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); 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', () => { describe('getMemoryLane', () => {
beforeAll(() => { beforeAll(() => {
vitest.useFakeTimers(); vitest.useFakeTimers();

View File

@ -1,10 +1,7 @@
import { BadRequestException, Inject } from '@nestjs/common'; import { BadRequestException, Inject } from '@nestjs/common';
import _ from 'lodash'; import _ from 'lodash';
import { DateTime, Duration } from 'luxon'; import { DateTime, Duration } from 'luxon';
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { AccessCore, Permission } from 'src/cores/access.core'; 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 { SystemConfigCore } from 'src/cores/system-config.core';
import { import {
AssetResponseDto, AssetResponseDto,
@ -12,7 +9,6 @@ import {
SanitizedAssetResponseDto, SanitizedAssetResponseDto,
mapAsset, mapAsset,
} from 'src/dtos/asset-response.dto'; } from 'src/dtos/asset-response.dto';
import { AssetFileUploadResponseDto } from 'src/dtos/asset-v1-response.dto';
import { import {
AssetBulkDeleteDto, AssetBulkDeleteDto,
AssetBulkUpdateDto, AssetBulkUpdateDto,
@ -20,7 +16,6 @@ import {
AssetJobsDto, AssetJobsDto,
AssetStatsDto, AssetStatsDto,
UpdateAssetDto, UpdateAssetDto,
UploadFieldName,
mapStats, mapStats,
} from 'src/dtos/asset.dto'; } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@ -42,13 +37,9 @@ import {
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.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 { usePagination } from 'src/utils/pagination';
import { fromChecksum } from 'src/utils/request';
export class AssetService { export class AssetService {
private access: AccessCore; private access: AccessCore;
@ -59,7 +50,6 @@ export class AssetService {
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@ -71,86 +61,6 @@ export class AssetService {
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); 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[]> { async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
const currentYear = new Date().getFullYear(); 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 { IAssetRepository } from 'src/interfaces/asset.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { DownloadService } from 'src/services/download.service'; import { DownloadService } from 'src/services/download.service';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
@ -41,46 +40,7 @@ describe(DownloadService.name, () => {
sut = new DownloadService(accessMock, assetMock, storageMock); sut = new DownloadService(accessMock, assetMock, storageMock);
}); });
describe('downloadFile', () => { describe('downloadArchive', () => {
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,
}),
);
});
it('should download an archive', async () => { it('should download an archive', async () => {
const archiveMock = { const archiveMock = {
addFile: vitest.fn(), addFile: vitest.fn(),

View File

@ -9,8 +9,6 @@ import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IStorageRepository, ImmichReadStream } from 'src/interfaces/storage.interface'; import { IStorageRepository, ImmichReadStream } from 'src/interfaces/storage.interface';
import { HumanReadableSize } from 'src/utils/bytes'; 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'; import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
@ -25,25 +23,6 @@ export class DownloadService {
this.access = AccessCore.create(accessRepository); 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> { async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
const archives: DownloadArchiveInfo[] = []; 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 { APIKeyService } from 'src/services/api-key.service';
import { ApiService } from 'src/services/api.service'; import { ApiService } from 'src/services/api.service';
import { AssetMediaService } from 'src/services/asset-media.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 { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.service'; import { AuditService } from 'src/services/audit.service';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
@ -45,7 +44,6 @@ export const services = [
ApiService, ApiService,
AssetMediaService, AssetMediaService,
AssetService, AssetService,
AssetServiceV1,
AuditService, AuditService,
AuthService, AuthService,
CliService, CliService,

View File

@ -411,7 +411,11 @@ export class MetadataService {
} }
const checksum = this.cryptoRepository.hashSha1(video); 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) { if (motionAsset) {
this.logger.debug( this.logger.debug(
`Asset ${asset.id}'s motion photo video with checksum ${checksum.toString( `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({ await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
description: '1 shared photos & videos', description: '1 shared photos & videos',
imageUrl: imageUrl:
'/api/asset/thumbnail/asset-id?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0', '/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0',
title: 'Public Share', title: 'Public Share',
}); });
expect(shareMock.get).toHaveBeenCalled(); expect(shareMock.get).toHaveBeenCalled();

View File

@ -191,7 +191,7 @@ export class SharedLinkService {
title: sharedLink.album ? sharedLink.album.albumName : 'Public Share', title: sharedLink.album ? sharedLink.album.albumName : 'Public Share',
description: sharedLink.description || `${assetCount} shared photos & videos`, description: sharedLink.description || `${assetCount} shared photos & videos`,
imageUrl: assetId imageUrl: assetId
? `/api/asset/thumbnail/${assetId}?key=${sharedLink.key.toString('base64url')}` ? `/api/assets/${assetId}/thumbnail?key=${sharedLink.key.toString('base64url')}`
: '/feature-panel.png', : '/feature-panel.png',
}; };
} }

View File

@ -44,7 +44,7 @@ describe('AlbumCard component', () => {
await waitFor(() => expect(albumImgElement).toHaveAttribute('src')); await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
expect(albumImgElement).toHaveAttribute('alt', album.albumName); expect(albumImgElement).toHaveAttribute('alt', album.albumName);
expect(sdkMock.getAssetThumbnail).not.toHaveBeenCalled(); expect(sdkMock.viewAsset).not.toHaveBeenCalled();
expect(albumNameElement).toHaveTextContent(album.albumName); expect(albumNameElement).toHaveTextContent(album.albumName);
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText)); expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
<script lang="ts"> <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 { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; 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'>; export let asset: Pick<AssetResponseDto, 'id' | 'type'>;
const photoSphereConfigs = const photoSphereConfigs =
@ -20,9 +20,9 @@
const loadAssetData = async () => { const loadAssetData = async () => {
if (asset.type === AssetTypeEnum.Video) { 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); const url = URL.createObjectURL(data);
return url; return url;
}; };

View File

@ -44,7 +44,7 @@ describe('PhotoViewer component', () => {
expect(downloadRequestMock).toBeCalledWith( expect(downloadRequestMock).toBeCalledWith(
expect.objectContaining({ 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()); await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument());
@ -61,7 +61,7 @@ describe('PhotoViewer component', () => {
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'url-two')); await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'url-two'));
expect(downloadRequestMock).toBeCalledWith( expect(downloadRequestMock).toBeCalledWith(
expect.objectContaining({ 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')); await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'url-two'));
expect(downloadRequestMock).toBeCalledWith( expect(downloadRequestMock).toBeCalledWith(
expect.objectContaining({ 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 { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { photoZoomState } from '$lib/stores/zoom-image.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 { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { getBoundingBox } from '$lib/utils/people-utils'; import { getBoundingBox } from '$lib/utils/people-utils';
import { shortcuts } from '$lib/actions/shortcut'; 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 { useZoomImageWheel } from '@zoom-image/svelte';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@ -62,7 +62,9 @@
// TODO: Use sdk once it supports signals // TODO: Use sdk once it supports signals
const res = await downloadRequest({ 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, signal: abortController.signal,
}); });
@ -76,7 +78,9 @@
for (const preloadAsset of preloadAssets) { for (const preloadAsset of preloadAssets) {
if (preloadAsset.type === AssetTypeEnum.Image) { if (preloadAsset.type === AssetTypeEnum.Image) {
await downloadRequest({ await downloadRequest({
url: getAssetFileUrl(preloadAsset.id, !loadOriginal, false), url: loadOriginal
? getAssetOriginalUrl(preloadAsset.id)
: getAssetThumbnailUrl({ id: preloadAsset.id, size: AssetMediaSize.Preview }),
signal: abortController.signal, signal: abortController.signal,
}); });
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -183,7 +183,7 @@
/> />
{:else} {:else}
<img <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" 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 alt={feature.properties?.city && feature.properties.country
? `Map marker for images taken in ${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 Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { getAssetThumbnailUrl } from '$lib/utils'; 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 { mdiCheck, mdiTrashCanOutline } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { s } from '$lib/utils'; import { s } from '$lib/utils';
@ -56,7 +56,7 @@
<button type="button" on:click={() => onSelectAsset(asset)} class="block relative"> <button type="button" on:click={() => onSelectAsset(asset)} class="block relative">
<!-- THUMBNAIL--> <!-- THUMBNAIL-->
<img <img
src={getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)} src={getAssetThumbnailUrl(asset.id)}
alt={asset.id} alt={asset.id}
title={`${assetData}`} 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`} 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 { handleError } from '$lib/utils/handle-error';
import { import {
AssetJobName, AssetJobName,
AssetMediaSize,
JobName, JobName,
ThumbnailFormat,
finishOAuth, finishOAuth,
getAssetOriginalPath, getAssetOriginalPath,
getAssetPlaybackPath,
getAssetThumbnailPath, getAssetThumbnailPath,
getBaseUrl, getBaseUrl,
getPeopleThumbnailPath, getPeopleThumbnailPath,
@ -162,18 +163,28 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
return getBaseUrl() + url.pathname + url.search + url.hash; return getBaseUrl() + url.pathname + url.search + url.hash;
}; };
export const getAssetFileUrl = ( export const getAssetOriginalUrl = (options: string | { id: string; checksum?: string }) => {
...[assetId, isWeb, isThumb, checksum]: if (typeof options === 'string') {
| [assetId: string, isWeb: boolean, isThumb: boolean] options = { id: options };
| [assetId: string, isWeb: boolean, isThumb: boolean, checksum: string] }
) => createUrl(getAssetOriginalPath(assetId), { isThumb, isWeb, key: getKey(), c: checksum }); const { id, checksum } = options;
return createUrl(getAssetOriginalPath(id), { key: getKey(), c: checksum });
};
export const getAssetThumbnailUrl = ( export const getAssetThumbnailUrl = (options: string | { id: string; size?: AssetMediaSize; checksum?: string }) => {
...[assetId, format, checksum]: if (typeof options === 'string') {
| [assetId: string, format: ThumbnailFormat | undefined] options = { id: options };
| [assetId: string, format: ThumbnailFormat | undefined, checksum: string] }
) => { const { id, size, checksum } = options;
return createUrl(getAssetThumbnailPath(assetId), { format, key: getKey(), c: checksum }); 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)); 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 { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
import { import {
addAssetsToAlbum as addAssets, addAssetsToAlbum as addAssets,
getAssetInfo,
getBaseUrl, getBaseUrl,
getDownloadInfo, getDownloadInfo,
updateAssets, updateAssets,
@ -154,11 +155,13 @@ export const downloadFile = async (asset: AssetResponseDto) => {
size: asset.exifInfo?.fileSizeInByte || 0, size: asset.exifInfo?.fileSizeInByte || 0,
}, },
]; ];
if (asset.livePhotoVideoId) { if (asset.livePhotoVideoId) {
const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() });
assets.push({ assets.push({
filename: asset.originalFileName, filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId, 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 // TODO use sdk once it supports progress events
const { data } = await downloadRequest({ const { data } = await downloadRequest({
method: 'POST', method: 'GET',
url: getBaseUrl() + `/download/asset/${id}` + (key ? `?key=${key}` : ''), url: getBaseUrl() + `/assets/${id}/original` + (key ? `?key=${key}` : ''),
signal: abort.signal, signal: abort.signal,
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded, event.total), onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded, event.total),
}); });

View File

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

View File

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