mirror of
https://github.com/immich-app/immich.git
synced 2025-04-10 10:06:25 +02:00
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:
parent
66fced40e7
commit
69d2fcb43e
91 changed files with 1932 additions and 2456 deletions
cli/src/commands
docs/docs/guides
e2e/src
api/specs
activity.e2e-spec.tsalbum.e2e-spec.tsasset.e2e-spec.tsdownload.e2e-spec.tsmap.e2e-spec.tsmemory.e2e-spec.tssearch.e2e-spec.tsshared-link.e2e-spec.tstimeline.e2e-spec.ts
utils.tsweb/specs
mobile
open-api
server/src
controllers
dtos
asset-media-response.dto.tsasset-media.dto.tsasset-v1-response.dto.tsasset-v1.dto.tsasset.dto.tsuser-profile.dto.ts
interfaces
middleware
queries
repositories
services
web/src
lib
components
album-page
asset-viewer
activity-viewer.sveltealbum-list-item.sveltedetail-panel.sveltepanorama-viewer.sveltephoto-viewer.spec.tsphoto-viewer.sveltevideo-native-viewer.svelte
assets/thumbnail
faces-page
memory-page
photos-page
shared-components/map
utilities-page/duplicates
utils
routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]
|
@ -1,7 +1,8 @@
|
|||
import {
|
||||
Action,
|
||||
AssetBulkUploadCheckResult,
|
||||
AssetFileUploadResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetMediaStatus,
|
||||
addAssetsToAlbum,
|
||||
checkBulkUpload,
|
||||
createAlbum,
|
||||
|
@ -167,7 +168,7 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio
|
|||
|
||||
newAssets.push({ id: response.id, filepath });
|
||||
|
||||
if (response.duplicate) {
|
||||
if (response.status === AssetMediaStatus.Duplicate) {
|
||||
duplicateCount++;
|
||||
duplicateSize += stats.size ?? 0;
|
||||
} else {
|
||||
|
@ -192,7 +193,7 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio
|
|||
return newAssets;
|
||||
};
|
||||
|
||||
const uploadFile = async (input: string, stats: Stats): Promise<AssetFileUploadResponseDto> => {
|
||||
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
|
||||
const { baseUrl, headers } = defaults;
|
||||
|
||||
const assetPath = path.parse(input);
|
||||
|
@ -225,7 +226,7 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetFileUploadR
|
|||
formData.append('sidecarData', sidecarData);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/asset/upload`, {
|
||||
const response = await fetch(`${baseUrl}/assets`, {
|
||||
method: 'post',
|
||||
redirect: 'error',
|
||||
headers: headers as Record<string, string>,
|
||||
|
|
|
@ -32,7 +32,7 @@ def upload(file):
|
|||
}
|
||||
|
||||
response = requests.post(
|
||||
f'{BASE_URL}/asset/upload', headers=headers, data=data, files=files)
|
||||
f'{BASE_URL}/assets', headers=headers, data=data, files=files)
|
||||
|
||||
print(response.json())
|
||||
# {'id': 'ef96f635-61c7-4639-9e60-61a11c4bbfba', 'duplicate': False}
|
||||
|
|
|
@ -2,7 +2,7 @@ import {
|
|||
ActivityCreateDto,
|
||||
AlbumResponseDto,
|
||||
AlbumUserRole,
|
||||
AssetFileUploadResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
LoginResponseDto,
|
||||
ReactionType,
|
||||
createActivity as create,
|
||||
|
@ -17,7 +17,7 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|||
describe('/activities', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let nonOwner: LoginResponseDto;
|
||||
let asset: AssetFileUploadResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
|
||||
const createActivity = (dto: ActivityCreateDto, accessToken?: string) =>
|
||||
|
|
|
@ -2,7 +2,7 @@ import {
|
|||
addAssetsToAlbum,
|
||||
AlbumResponseDto,
|
||||
AlbumUserRole,
|
||||
AssetFileUploadResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetOrder,
|
||||
deleteUserAdmin,
|
||||
getAlbumInfo,
|
||||
|
@ -26,8 +26,8 @@ const user2NotShared = 'user2NotShared';
|
|||
describe('/albums', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user1Asset1: AssetFileUploadResponseDto;
|
||||
let user1Asset2: AssetFileUploadResponseDto;
|
||||
let user1Asset1: AssetMediaResponseDto;
|
||||
let user1Asset2: AssetMediaResponseDto;
|
||||
let user1Albums: AlbumResponseDto[];
|
||||
let user2: LoginResponseDto;
|
||||
let user2Albums: AlbumResponseDto[];
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
AssetFileUploadResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetMediaStatus,
|
||||
AssetResponseDto,
|
||||
AssetTypeEnum,
|
||||
LoginResponseDto,
|
||||
|
@ -67,10 +68,10 @@ describe('/asset', () => {
|
|||
let statsUser: LoginResponseDto;
|
||||
let stackUser: LoginResponseDto;
|
||||
|
||||
let user1Assets: AssetFileUploadResponseDto[];
|
||||
let user2Assets: AssetFileUploadResponseDto[];
|
||||
let stackAssets: AssetFileUploadResponseDto[];
|
||||
let locationAsset: AssetFileUploadResponseDto;
|
||||
let user1Assets: AssetMediaResponseDto[];
|
||||
let user2Assets: AssetMediaResponseDto[];
|
||||
let stackAssets: AssetMediaResponseDto[];
|
||||
let locationAsset: AssetMediaResponseDto;
|
||||
|
||||
const setupTests = async () => {
|
||||
await utils.resetDatabase();
|
||||
|
@ -121,7 +122,7 @@ describe('/asset', () => {
|
|||
]);
|
||||
|
||||
for (const asset of [...user1Assets, ...user2Assets]) {
|
||||
expect(asset.duplicate).toBe(false);
|
||||
expect(asset.status).toBe(AssetMediaStatus.Created);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
|
@ -164,16 +165,34 @@ describe('/asset', () => {
|
|||
utils.disconnectWebsocket(websocket);
|
||||
});
|
||||
|
||||
describe('GET /asset/:id', () => {
|
||||
describe('GET /assets/:id/original', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/asset/${uuidDto.notFound}`);
|
||||
const { status, body } = await request(app).get(`/assets/${uuidDto.notFound}/original`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should download the file', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/assets/${user1Assets[0].id}/original`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toEqual('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/assets/${uuidDto.notFound}`);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${uuidDto.invalid}`)
|
||||
.get(`/assets/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||
|
@ -181,7 +200,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should require access', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${user2Assets[0].id}`)
|
||||
.get(`/assets/${user2Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
|
@ -189,7 +208,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should get the asset info', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${user1Assets[0].id}`)
|
||||
.get(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id });
|
||||
|
@ -201,14 +220,14 @@ describe('/asset', () => {
|
|||
assetIds: [user1Assets[0].id],
|
||||
});
|
||||
|
||||
const { status, body } = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
|
||||
const { status, body } = await request(app).get(`/assets/${user1Assets[0].id}?key=${sharedLink.key}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id });
|
||||
});
|
||||
|
||||
it('should not send people data for shared links for un-authenticated users', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${user1Assets[0].id}`)
|
||||
.get(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toEqual(200);
|
||||
|
@ -231,7 +250,7 @@ describe('/asset', () => {
|
|||
assetIds: [user1Assets[0].id],
|
||||
});
|
||||
|
||||
const data = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
|
||||
const data = await request(app).get(`/assets/${user1Assets[0].id}?key=${sharedLink.key}`);
|
||||
expect(data.status).toBe(200);
|
||||
expect(data.body).toMatchObject({ people: [] });
|
||||
});
|
||||
|
@ -239,7 +258,7 @@ describe('/asset', () => {
|
|||
describe('partner assets', () => {
|
||||
it('should get the asset info', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/asset/${user1Assets[0].id}`)
|
||||
.get(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id });
|
||||
|
@ -249,7 +268,7 @@ describe('/asset', () => {
|
|||
const asset = await utils.createAsset(user1.accessToken, { isArchived: true });
|
||||
|
||||
const { status } = await request(app)
|
||||
.get(`/asset/${asset.id}`)
|
||||
.get(`/assets/${asset.id}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
@ -259,16 +278,16 @@ describe('/asset', () => {
|
|||
await utils.deleteAssets(user1.accessToken, [asset.id]);
|
||||
|
||||
const { status } = await request(app)
|
||||
.get(`/asset/${asset.id}`)
|
||||
.get(`/assets/${asset.id}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /asset/statistics', () => {
|
||||
describe('GET /assets/statistics', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/asset/statistics');
|
||||
const { status, body } = await request(app).get('/assets/statistics');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
|
@ -276,7 +295,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should return stats of all assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.get('/assets/statistics')
|
||||
.set('Authorization', `Bearer ${statsUser.accessToken}`);
|
||||
|
||||
expect(body).toEqual({ images: 3, videos: 1, total: 4 });
|
||||
|
@ -285,7 +304,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should return stats of all favored assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.get('/assets/statistics')
|
||||
.set('Authorization', `Bearer ${statsUser.accessToken}`)
|
||||
.query({ isFavorite: true });
|
||||
|
||||
|
@ -295,7 +314,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should return stats of all archived assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.get('/assets/statistics')
|
||||
.set('Authorization', `Bearer ${statsUser.accessToken}`)
|
||||
.query({ isArchived: true });
|
||||
|
||||
|
@ -305,7 +324,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should return stats of all favored and archived assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.get('/assets/statistics')
|
||||
.set('Authorization', `Bearer ${statsUser.accessToken}`)
|
||||
.query({ isFavorite: true, isArchived: true });
|
||||
|
||||
|
@ -315,7 +334,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should return stats of all assets neither favored nor archived', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/statistics')
|
||||
.get('/assets/statistics')
|
||||
.set('Authorization', `Bearer ${statsUser.accessToken}`)
|
||||
.query({ isFavorite: false, isArchived: false });
|
||||
|
||||
|
@ -324,7 +343,7 @@ describe('/asset', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /asset/random', () => {
|
||||
describe('GET /assets/random', () => {
|
||||
beforeAll(async () => {
|
||||
await Promise.all([
|
||||
utils.createAsset(user1.accessToken),
|
||||
|
@ -337,7 +356,7 @@ describe('/asset', () => {
|
|||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/asset/random');
|
||||
const { status, body } = await request(app).get('/assets/random');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
|
@ -345,7 +364,7 @@ describe('/asset', () => {
|
|||
|
||||
it.each(TEN_TIMES)('should return 1 random assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/random')
|
||||
.get('/assets/random')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
@ -357,7 +376,7 @@ describe('/asset', () => {
|
|||
|
||||
it.each(TEN_TIMES)('should return 2 random assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/random?count=2')
|
||||
.get('/assets/random?count=2')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
@ -374,7 +393,7 @@ describe('/asset', () => {
|
|||
'should return 1 asset if there are 10 assets in the database but user 2 only has 1',
|
||||
async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/asset/random')
|
||||
.get('/assets/random')
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
@ -384,23 +403,23 @@ describe('/asset', () => {
|
|||
|
||||
it('should return error', async () => {
|
||||
const { status } = await request(app)
|
||||
.get('/asset/random?count=ABC')
|
||||
.get('/assets/random?count=ABC')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /asset/:id', () => {
|
||||
describe('PUT /assets/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(`/asset/:${uuidDto.notFound}`);
|
||||
const { status, body } = await request(app).put(`/assets/:${uuidDto.notFound}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${uuidDto.invalid}`)
|
||||
.put(`/assets/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||
|
@ -408,7 +427,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should require access', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user2Assets[0].id}`)
|
||||
.put(`/assets/${user2Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
|
@ -419,7 +438,7 @@ describe('/asset', () => {
|
|||
expect(before.isFavorite).toBe(false);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ isFavorite: true });
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true });
|
||||
|
@ -431,7 +450,7 @@ describe('/asset', () => {
|
|||
expect(before.isArchived).toBe(false);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ isArchived: true });
|
||||
expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true });
|
||||
|
@ -440,7 +459,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should update date time original', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||
|
||||
|
@ -467,7 +486,7 @@ describe('/asset', () => {
|
|||
{ latitude: 12, longitude: 181 },
|
||||
]) {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.send(test)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
|
@ -477,7 +496,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should update gps data', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ latitude: 12, longitude: 12 });
|
||||
|
||||
|
@ -490,7 +509,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should set the description', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ description: 'Test asset description' });
|
||||
expect(body).toMatchObject({
|
||||
|
@ -504,7 +523,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should return tagged people', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/asset/${user1Assets[0].id}`)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ isFavorite: true });
|
||||
expect(status).toEqual(200);
|
||||
|
@ -524,10 +543,10 @@ describe('/asset', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('DELETE /asset', () => {
|
||||
describe('DELETE /assets', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/asset`)
|
||||
.delete(`/assets`)
|
||||
.send({ ids: [uuidDto.notFound] });
|
||||
|
||||
expect(status).toBe(401);
|
||||
|
@ -536,7 +555,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/asset`)
|
||||
.delete(`/assets`)
|
||||
.send({ ids: [uuidDto.invalid] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
|
@ -546,7 +565,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should throw an error when the id is not found', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/asset`)
|
||||
.delete(`/assets`)
|
||||
.send({ ids: [uuidDto.notFound] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
|
@ -561,7 +580,7 @@ describe('/asset', () => {
|
|||
expect(before.isTrashed).toBe(false);
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete('/asset')
|
||||
.delete('/assets')
|
||||
.send({ ids: [assetId] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
|
@ -571,9 +590,9 @@ describe('/asset', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /asset/thumbnail/:id', () => {
|
||||
describe('GET /assets/:id/thumbnail', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`);
|
||||
const { status, body } = await request(app).get(`/assets/${locationAsset.id}/thumbnail`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
|
@ -586,7 +605,7 @@ describe('/asset', () => {
|
|||
});
|
||||
|
||||
const { status, body, type } = await request(app)
|
||||
.get(`/asset/thumbnail/${locationAsset.id}?format=WEBP`)
|
||||
.get(`/assets/${locationAsset.id}/thumbnail?format=WEBP`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
@ -598,9 +617,9 @@ describe('/asset', () => {
|
|||
expect(exifData).not.toHaveProperty('GPSLatitude');
|
||||
});
|
||||
|
||||
it('should not include gps data for jpeg thumbnails', async () => {
|
||||
it('should not include gps data for jpeg previews', async () => {
|
||||
const { status, body, type } = await request(app)
|
||||
.get(`/asset/thumbnail/${locationAsset.id}?format=JPEG`)
|
||||
.get(`/assets/${locationAsset.id}/thumbnail?size=preview`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
@ -613,9 +632,9 @@ describe('/asset', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /asset/file/:id', () => {
|
||||
describe('GET /assets/:id/original', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`);
|
||||
const { status, body } = await request(app).get(`/assets/${locationAsset.id}/original`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
|
@ -623,7 +642,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should download the original', async () => {
|
||||
const { status, body, type } = await request(app)
|
||||
.get(`/asset/file/${locationAsset.id}`)
|
||||
.get(`/assets/${locationAsset.id}/original`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
@ -641,9 +660,9 @@ describe('/asset', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PUT /asset', () => {
|
||||
describe('PUT /assets', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put('/asset');
|
||||
const { status, body } = await request(app).put('/assets');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
|
@ -651,7 +670,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should require a valid parent id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put('/asset')
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] });
|
||||
|
||||
|
@ -661,7 +680,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should require access to the parent', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put('/asset')
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] });
|
||||
|
||||
|
@ -671,7 +690,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should add stack children', async () => {
|
||||
const { status } = await request(app)
|
||||
.put('/asset')
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${stackUser.accessToken}`)
|
||||
.send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] });
|
||||
|
||||
|
@ -684,7 +703,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should remove stack children', async () => {
|
||||
const { status } = await request(app)
|
||||
.put('/asset')
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${stackUser.accessToken}`)
|
||||
.send({ removeParent: true, ids: [stackAssets[1].id] });
|
||||
|
||||
|
@ -702,7 +721,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should remove all stack children', async () => {
|
||||
const { status } = await request(app)
|
||||
.put('/asset')
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${stackUser.accessToken}`)
|
||||
.send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] });
|
||||
|
||||
|
@ -720,7 +739,7 @@ describe('/asset', () => {
|
|||
);
|
||||
|
||||
const { status } = await request(app)
|
||||
.put('/asset')
|
||||
.put('/assets')
|
||||
.set('Authorization', `Bearer ${stackUser.accessToken}`)
|
||||
.send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] });
|
||||
|
||||
|
@ -738,9 +757,9 @@ describe('/asset', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PUT /asset/stack/parent', () => {
|
||||
describe('PUT /assets/stack/parent', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put('/asset/stack/parent');
|
||||
const { status, body } = await request(app).put('/assets/stack/parent');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
|
@ -748,7 +767,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put('/asset/stack/parent')
|
||||
.put('/assets/stack/parent')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid });
|
||||
|
||||
|
@ -758,7 +777,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should require access', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put('/asset/stack/parent')
|
||||
.put('/assets/stack/parent')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id });
|
||||
|
||||
|
@ -768,7 +787,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should make old parent child of new parent', async () => {
|
||||
const { status } = await request(app)
|
||||
.put('/asset/stack/parent')
|
||||
.put('/assets/stack/parent')
|
||||
.set('Authorization', `Bearer ${stackUser.accessToken}`)
|
||||
.send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id });
|
||||
|
||||
|
@ -787,11 +806,11 @@ describe('/asset', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
describe('POST /asset/upload', () => {
|
||||
describe('POST /assets', () => {
|
||||
beforeAll(setupTests, 30_000);
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(`/asset/upload`);
|
||||
const { status, body } = await request(app).post(`/assets`);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
@ -807,7 +826,7 @@ describe('/asset', () => {
|
|||
{ should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } },
|
||||
])('should $should', async ({ dto }) => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/asset/upload')
|
||||
.post('/assets')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.attach('assetData', makeRandomImage(), 'example.png')
|
||||
.field(dto);
|
||||
|
@ -1033,11 +1052,11 @@ describe('/asset', () => {
|
|||
},
|
||||
])(`should upload and generate a thumbnail for $input`, async ({ input, expected }) => {
|
||||
const filepath = join(testAssetDir, input);
|
||||
const { id, duplicate } = await utils.createAsset(admin.accessToken, {
|
||||
const { id, status } = await utils.createAsset(admin.accessToken, {
|
||||
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
|
||||
});
|
||||
|
||||
expect(duplicate).toBe(false);
|
||||
expect(status).toBe(AssetMediaStatus.Created);
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id });
|
||||
|
||||
|
@ -1050,19 +1069,19 @@ describe('/asset', () => {
|
|||
|
||||
it('should handle a duplicate', async () => {
|
||||
const filepath = 'formats/jpeg/el_torcal_rocks.jpeg';
|
||||
const { duplicate } = await utils.createAsset(admin.accessToken, {
|
||||
const { status } = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
bytes: await readFile(join(testAssetDir, filepath)),
|
||||
filename: basename(filepath),
|
||||
},
|
||||
});
|
||||
|
||||
expect(duplicate).toBe(true);
|
||||
expect(status).toBe(AssetMediaStatus.Duplicate);
|
||||
});
|
||||
|
||||
it('should update the used quota', async () => {
|
||||
const { body, status } = await request(app)
|
||||
.post('/asset/upload')
|
||||
.post('/assets')
|
||||
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
|
||||
.field('deviceAssetId', 'example-image')
|
||||
.field('deviceId', 'e2e')
|
||||
|
@ -1070,7 +1089,7 @@ describe('/asset', () => {
|
|||
.field('fileModifiedAt', new Date().toISOString())
|
||||
.attach('assetData', makeRandomImage(), 'example.jpg');
|
||||
|
||||
expect(body).toEqual({ id: expect.any(String), duplicate: false });
|
||||
expect(body).toEqual({ id: expect.any(String), status: AssetMediaStatus.Created });
|
||||
expect(status).toBe(201);
|
||||
|
||||
const user = await getMyUser({ headers: asBearerAuth(quotaUser.accessToken) });
|
||||
|
@ -1080,7 +1099,7 @@ describe('/asset', () => {
|
|||
|
||||
it('should not upload an asset if it would exceed the quota', async () => {
|
||||
const { body, status } = await request(app)
|
||||
.post('/asset/upload')
|
||||
.post('/assets')
|
||||
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
|
||||
.field('deviceAssetId', 'example-image')
|
||||
.field('deviceId', 'e2e')
|
||||
|
@ -1120,7 +1139,7 @@ describe('/asset', () => {
|
|||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: response.id });
|
||||
|
||||
expect(response.duplicate).toBe(false);
|
||||
expect(response.status).toBe(AssetMediaStatus.Created);
|
||||
|
||||
const asset = await utils.getAssetInfo(admin.accessToken, response.id);
|
||||
expect(asset.livePhotoVideoId).toBeDefined();
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, tempDir, utils } from 'src/utils';
|
||||
|
@ -7,8 +7,8 @@ import { beforeAll, describe, expect, it } from 'vitest';
|
|||
|
||||
describe('/download', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset1: AssetFileUploadResponseDto;
|
||||
let asset2: AssetFileUploadResponseDto;
|
||||
let asset1: AssetMediaResponseDto;
|
||||
let asset2: AssetMediaResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
|
@ -73,22 +73,4 @@ describe('/download', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /download/asset/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(`/download/asset/${asset1.id}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should download file', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/download/asset/${asset1.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toEqual('image/png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
|
||||
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { Socket } from 'socket.io-client';
|
||||
|
@ -12,7 +12,7 @@ describe('/map', () => {
|
|||
let websocket: Socket;
|
||||
let admin: LoginResponseDto;
|
||||
let nonAdmin: LoginResponseDto;
|
||||
let asset: AssetFileUploadResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {
|
||||
AssetFileUploadResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
LoginResponseDto,
|
||||
MemoryResponseDto,
|
||||
MemoryType,
|
||||
|
@ -15,9 +15,9 @@ import { beforeAll, describe, expect, it } from 'vitest';
|
|||
describe('/memories', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let user: LoginResponseDto;
|
||||
let adminAsset: AssetFileUploadResponseDto;
|
||||
let userAsset1: AssetFileUploadResponseDto;
|
||||
let userAsset2: AssetFileUploadResponseDto;
|
||||
let adminAsset: AssetMediaResponseDto;
|
||||
let userAsset1: AssetMediaResponseDto;
|
||||
let userAsset2: AssetMediaResponseDto;
|
||||
let userMemory: MemoryResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk';
|
||||
import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
@ -13,25 +13,25 @@ describe('/search', () => {
|
|||
let admin: LoginResponseDto;
|
||||
let websocket: Socket;
|
||||
|
||||
let assetFalcon: AssetFileUploadResponseDto;
|
||||
let assetDenali: AssetFileUploadResponseDto;
|
||||
let assetCyclamen: AssetFileUploadResponseDto;
|
||||
let assetNotocactus: AssetFileUploadResponseDto;
|
||||
let assetSilver: AssetFileUploadResponseDto;
|
||||
let assetDensity: AssetFileUploadResponseDto;
|
||||
// let assetPhiladelphia: AssetFileUploadResponseDto;
|
||||
// let assetOrychophragmus: AssetFileUploadResponseDto;
|
||||
// let assetRidge: AssetFileUploadResponseDto;
|
||||
// let assetPolemonium: AssetFileUploadResponseDto;
|
||||
// let assetWood: AssetFileUploadResponseDto;
|
||||
// let assetGlarus: AssetFileUploadResponseDto;
|
||||
let assetHeic: AssetFileUploadResponseDto;
|
||||
let assetRocks: AssetFileUploadResponseDto;
|
||||
let assetOneJpg6: AssetFileUploadResponseDto;
|
||||
let assetOneHeic6: AssetFileUploadResponseDto;
|
||||
let assetOneJpg5: AssetFileUploadResponseDto;
|
||||
let assetSprings: AssetFileUploadResponseDto;
|
||||
let assetLast: AssetFileUploadResponseDto;
|
||||
let assetFalcon: AssetMediaResponseDto;
|
||||
let assetDenali: AssetMediaResponseDto;
|
||||
let assetCyclamen: AssetMediaResponseDto;
|
||||
let assetNotocactus: AssetMediaResponseDto;
|
||||
let assetSilver: AssetMediaResponseDto;
|
||||
let assetDensity: AssetMediaResponseDto;
|
||||
// let assetPhiladelphia: AssetMediaResponseDto;
|
||||
// let assetOrychophragmus: AssetMediaResponseDto;
|
||||
// let assetRidge: AssetMediaResponseDto;
|
||||
// let assetPolemonium: AssetMediaResponseDto;
|
||||
// let assetWood: AssetMediaResponseDto;
|
||||
// let assetGlarus: AssetMediaResponseDto;
|
||||
let assetHeic: AssetMediaResponseDto;
|
||||
let assetRocks: AssetMediaResponseDto;
|
||||
let assetOneJpg6: AssetMediaResponseDto;
|
||||
let assetOneHeic6: AssetMediaResponseDto;
|
||||
let assetOneJpg5: AssetMediaResponseDto;
|
||||
let assetSprings: AssetMediaResponseDto;
|
||||
let assetLast: AssetMediaResponseDto;
|
||||
let cities: string[];
|
||||
let states: string[];
|
||||
let countries: string[];
|
||||
|
@ -66,7 +66,7 @@ describe('/search', () => {
|
|||
// last asset
|
||||
{ filename: '/albums/nature/wood_anemones.jpg' },
|
||||
];
|
||||
const assets: AssetFileUploadResponseDto[] = [];
|
||||
const assets: AssetMediaResponseDto[] = [];
|
||||
for (const { filename, dto } of files) {
|
||||
const bytes = await readFile(join(testAssetDir, filename));
|
||||
assets.push(
|
||||
|
@ -134,7 +134,7 @@ describe('/search', () => {
|
|||
// assetWood,
|
||||
] = assets;
|
||||
|
||||
assetLast = assets.at(-1) as AssetFileUploadResponseDto;
|
||||
assetLast = assets.at(-1) as AssetMediaResponseDto;
|
||||
|
||||
await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {
|
||||
AlbumResponseDto,
|
||||
AssetFileUploadResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
LoginResponseDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkType,
|
||||
|
@ -15,8 +15,8 @@ import { beforeAll, describe, expect, it } from 'vitest';
|
|||
|
||||
describe('/shared-links', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset1: AssetFileUploadResponseDto;
|
||||
let asset2: AssetFileUploadResponseDto;
|
||||
let asset1: AssetMediaResponseDto;
|
||||
let asset2: AssetMediaResponseDto;
|
||||
let user1: LoginResponseDto;
|
||||
let user2: LoginResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk';
|
||||
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
|
@ -19,7 +19,7 @@ describe('/timeline', () => {
|
|||
let user: LoginResponseDto;
|
||||
let timeBucketUser: LoginResponseDto;
|
||||
|
||||
let userAssets: AssetFileUploadResponseDto[];
|
||||
let userAssets: AssetMediaResponseDto[];
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import {
|
||||
AllJobStatusResponseDto,
|
||||
AssetFileUploadResponseDto,
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetResponseDto,
|
||||
CreateAlbumDto,
|
||||
CreateAssetDto,
|
||||
CreateLibraryDto,
|
||||
MetadataSearchDto,
|
||||
PersonCreateDto,
|
||||
|
@ -292,7 +292,7 @@ export const utils = {
|
|||
|
||||
createAsset: async (
|
||||
accessToken: string,
|
||||
dto?: Partial<Omit<CreateAssetDto, 'assetData'>> & { assetData?: AssetData },
|
||||
dto?: Partial<Omit<AssetMediaCreateDto, 'assetData'>> & { assetData?: AssetData },
|
||||
) => {
|
||||
const _dto = {
|
||||
deviceAssetId: 'test-1',
|
||||
|
@ -310,7 +310,7 @@ export const utils = {
|
|||
}
|
||||
|
||||
const builder = request(app)
|
||||
.post(`/asset/upload`)
|
||||
.post(`/assets`)
|
||||
.attach('assetData', assetData, filename)
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
|
@ -320,7 +320,7 @@ export const utils = {
|
|||
|
||||
const { body } = await builder;
|
||||
|
||||
return body as AssetFileUploadResponseDto;
|
||||
return body as AssetMediaResponseDto;
|
||||
},
|
||||
|
||||
createImageFile: (path: string) => {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
|
||||
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe('Detail Panel', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetFileUploadResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
|
||||
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe('Asset Viewer Navbar', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetFileUploadResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {
|
||||
AlbumResponseDto,
|
||||
AssetFileUploadResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
LoginResponseDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkType,
|
||||
|
@ -11,7 +11,7 @@ import { asBearerAuth, utils } from 'src/utils';
|
|||
|
||||
test.describe('Shared Links', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetFileUploadResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
let sharedLink: SharedLinkResponseDto;
|
||||
let sharedLinkPassword: SharedLinkResponseDto;
|
||||
|
|
|
@ -30,7 +30,6 @@ import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.d
|
|||
import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart' show ThumbnailFormat;
|
||||
|
||||
@RoutePage()
|
||||
// ignore: must_be_immutable
|
||||
|
@ -52,9 +51,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
|
||||
final PageController controller;
|
||||
|
||||
static const jpeg = ThumbnailFormat.JPEG;
|
||||
static const webp = ThumbnailFormat.WEBP;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
|
|
|
@ -22,8 +22,8 @@ Future<VideoPlayerController> videoPlayerController(
|
|||
// Use a network URL for the video player controller
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final String videoUrl = asset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/asset/file/${asset.livePhotoVideoId}'
|
||||
: '$serverEndpoint/asset/file/${asset.remoteId}';
|
||||
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
|
||||
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
|
||||
|
||||
final url = Uri.parse(videoUrl);
|
||||
final accessToken = Store.get(StoreKey.accessToken);
|
||||
|
|
|
@ -74,7 +74,7 @@ class ImmichRemoteImageProvider
|
|||
if (_loadPreview) {
|
||||
final preview = getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
type: api.AssetMediaSize.thumbnail,
|
||||
);
|
||||
|
||||
yield await ImageLoader.loadImageFromCache(
|
||||
|
@ -88,7 +88,7 @@ class ImmichRemoteImageProvider
|
|||
// Load the higher resolution version of the image
|
||||
final url = getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
type: api.AssetMediaSize.preview,
|
||||
);
|
||||
final codec = await ImageLoader.loadImageFromCache(
|
||||
url,
|
||||
|
|
|
@ -61,7 +61,7 @@ class ImmichRemoteThumbnailProvider
|
|||
// Load a preview to the chunk events
|
||||
final preview = getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
type: api.AssetMediaSize.thumbnail,
|
||||
);
|
||||
|
||||
yield await ImageLoader.loadImageFromCache(
|
||||
|
|
|
@ -2,26 +2,26 @@ import 'dart:async';
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cancellation_token_http/http.dart' as http;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:cancellation_token_http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final backupServiceProvider = Provider(
|
||||
(ref) => BackupService(
|
||||
|
@ -270,10 +270,12 @@ class BackupService {
|
|||
);
|
||||
|
||||
file = await entity.loadFile(progressHandler: pmProgressHandler);
|
||||
livePhotoFile = await entity.loadFile(
|
||||
withSubtype: true,
|
||||
progressHandler: pmProgressHandler,
|
||||
);
|
||||
if (entity.isLivePhoto) {
|
||||
livePhotoFile = await entity.loadFile(
|
||||
withSubtype: true,
|
||||
progressHandler: pmProgressHandler,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (entity.type == AssetType.video) {
|
||||
file = await entity.originFile;
|
||||
|
@ -288,6 +290,15 @@ class BackupService {
|
|||
|
||||
if (file != null) {
|
||||
String originalFileName = await entity.titleAsync;
|
||||
|
||||
if (entity.isLivePhoto) {
|
||||
if (livePhotoFile == null) {
|
||||
_log.warning(
|
||||
"Failed to obtain motion part of the livePhoto - $originalFileName",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var fileStream = file.openRead();
|
||||
var assetRawUploadData = http.MultipartFile(
|
||||
"assetData",
|
||||
|
@ -296,50 +307,29 @@ class BackupService {
|
|||
filename: originalFileName,
|
||||
);
|
||||
|
||||
var req = MultipartRequest(
|
||||
var baseRequest = MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$savedEndpoint/asset/upload'),
|
||||
Uri.parse('$savedEndpoint/assets'),
|
||||
onProgress: ((bytes, totalBytes) =>
|
||||
uploadProgressCb(bytes, totalBytes)),
|
||||
);
|
||||
req.headers["x-immich-user-token"] = Store.get(StoreKey.accessToken);
|
||||
req.headers["Transfer-Encoding"] = "chunked";
|
||||
baseRequest.headers["x-immich-user-token"] =
|
||||
Store.get(StoreKey.accessToken);
|
||||
baseRequest.headers["Transfer-Encoding"] = "chunked";
|
||||
|
||||
req.fields['deviceAssetId'] = entity.id;
|
||||
req.fields['deviceId'] = deviceId;
|
||||
req.fields['fileCreatedAt'] =
|
||||
baseRequest.fields['deviceAssetId'] = entity.id;
|
||||
baseRequest.fields['deviceId'] = deviceId;
|
||||
baseRequest.fields['fileCreatedAt'] =
|
||||
entity.createDateTime.toUtc().toIso8601String();
|
||||
req.fields['fileModifiedAt'] =
|
||||
baseRequest.fields['fileModifiedAt'] =
|
||||
entity.modifiedDateTime.toUtc().toIso8601String();
|
||||
req.fields['isFavorite'] = entity.isFavorite.toString();
|
||||
req.fields['duration'] = entity.videoDuration.toString();
|
||||
baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
|
||||
baseRequest.fields['duration'] = entity.videoDuration.toString();
|
||||
|
||||
req.files.add(assetRawUploadData);
|
||||
baseRequest.files.add(assetRawUploadData);
|
||||
|
||||
var fileSize = file.lengthSync();
|
||||
|
||||
if (entity.isLivePhoto) {
|
||||
if (livePhotoFile != null) {
|
||||
final livePhotoTitle = p.setExtension(
|
||||
originalFileName,
|
||||
p.extension(livePhotoFile.path),
|
||||
);
|
||||
final fileStream = livePhotoFile.openRead();
|
||||
final livePhotoRawUploadData = http.MultipartFile(
|
||||
"livePhotoData",
|
||||
fileStream,
|
||||
livePhotoFile.lengthSync(),
|
||||
filename: livePhotoTitle,
|
||||
);
|
||||
req.files.add(livePhotoRawUploadData);
|
||||
fileSize += livePhotoFile.lengthSync();
|
||||
} else {
|
||||
_log.warning(
|
||||
"Failed to obtain motion part of the livePhoto - $originalFileName",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentUploadAssetCb(
|
||||
CurrentUploadAsset(
|
||||
id: entity.id,
|
||||
|
@ -353,19 +343,29 @@ class BackupService {
|
|||
),
|
||||
);
|
||||
|
||||
var response =
|
||||
await httpClient.send(req, cancellationToken: cancelToken);
|
||||
String? livePhotoVideoId;
|
||||
if (entity.isLivePhoto && livePhotoFile != null) {
|
||||
livePhotoVideoId = await uploadLivePhotoVideo(
|
||||
originalFileName,
|
||||
livePhotoFile,
|
||||
baseRequest,
|
||||
cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// asset is a duplicate (already exists on the server)
|
||||
duplicatedAssetIds.add(entity.id);
|
||||
uploadSuccessCb(entity.id, deviceId, true);
|
||||
} else if (response.statusCode == 201) {
|
||||
// stored a new asset on the server
|
||||
uploadSuccessCb(entity.id, deviceId, false);
|
||||
} else {
|
||||
var data = await response.stream.bytesToString();
|
||||
var error = jsonDecode(data);
|
||||
if (livePhotoVideoId != null) {
|
||||
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||
}
|
||||
|
||||
var response = await httpClient.send(
|
||||
baseRequest,
|
||||
cancellationToken: cancelToken,
|
||||
);
|
||||
|
||||
var responseBody = jsonDecode(await response.stream.bytesToString());
|
||||
|
||||
if (![200, 201].contains(response.statusCode)) {
|
||||
var error = responseBody;
|
||||
var errorMessage = error['message'] ?? error['error'];
|
||||
|
||||
debugPrint(
|
||||
|
@ -389,6 +389,14 @@ class BackupService {
|
|||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var isDuplicate = false;
|
||||
if (response.statusCode == 200) {
|
||||
isDuplicate = true;
|
||||
duplicatedAssetIds.add(entity.id);
|
||||
}
|
||||
|
||||
uploadSuccessCb(entity.id, deviceId, isDuplicate);
|
||||
}
|
||||
} on http.CancelledException {
|
||||
debugPrint("Backup was cancelled by the user");
|
||||
|
@ -415,6 +423,54 @@ class BackupService {
|
|||
return !anyErrors;
|
||||
}
|
||||
|
||||
Future<String?> uploadLivePhotoVideo(
|
||||
String originalFileName,
|
||||
File? livePhotoVideoFile,
|
||||
MultipartRequest baseRequest,
|
||||
http.CancellationToken cancelToken,
|
||||
) async {
|
||||
if (livePhotoVideoFile == null) {
|
||||
return null;
|
||||
}
|
||||
final livePhotoTitle = p.setExtension(
|
||||
originalFileName,
|
||||
p.extension(livePhotoVideoFile.path),
|
||||
);
|
||||
final fileStream = livePhotoVideoFile.openRead();
|
||||
final livePhotoRawUploadData = http.MultipartFile(
|
||||
"assetData",
|
||||
fileStream,
|
||||
livePhotoVideoFile.lengthSync(),
|
||||
filename: livePhotoTitle,
|
||||
);
|
||||
final livePhotoReq = MultipartRequest(
|
||||
baseRequest.method,
|
||||
baseRequest.url,
|
||||
onProgress: baseRequest.onProgress,
|
||||
)
|
||||
..headers.addAll(baseRequest.headers)
|
||||
..fields.addAll(baseRequest.fields);
|
||||
|
||||
livePhotoReq.files.add(livePhotoRawUploadData);
|
||||
|
||||
var response = await httpClient.send(
|
||||
livePhotoReq,
|
||||
cancellationToken: cancelToken,
|
||||
);
|
||||
|
||||
var responseBody = jsonDecode(await response.stream.bytesToString());
|
||||
|
||||
if (![200, 201].contains(response.statusCode)) {
|
||||
var error = responseBody;
|
||||
|
||||
debugPrint(
|
||||
"Error(${error['statusCode']}) uploading livePhoto for assetId | $livePhotoTitle | ${error['error']}",
|
||||
);
|
||||
}
|
||||
|
||||
return responseBody.containsKey('id') ? responseBody['id'] : null;
|
||||
}
|
||||
|
||||
String _getAssetType(AssetType assetType) {
|
||||
switch (assetType) {
|
||||
case AssetType.audio:
|
||||
|
|
|
@ -165,8 +165,8 @@ class BackupVerificationService {
|
|||
// (skip first few KBs containing metadata)
|
||||
final Uint64List localImage =
|
||||
_fakeDecodeImg(local, await file.readAsBytes());
|
||||
final res = await apiService.downloadApi
|
||||
.downloadFileWithHttpInfo(remote.remoteId!);
|
||||
final res = await apiService.assetsApi
|
||||
.downloadAssetWithHttpInfo(remote.remoteId!);
|
||||
final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes);
|
||||
|
||||
final eq = const ListEquality().equals(remoteImage, localImage);
|
||||
|
|
|
@ -26,19 +26,19 @@ class ImageViewerService {
|
|||
// Download LivePhotos image and motion part
|
||||
if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
|
||||
var imageResponse =
|
||||
await _apiService.downloadApi.downloadFileWithHttpInfo(
|
||||
await _apiService.assetsApi.downloadAssetWithHttpInfo(
|
||||
asset.remoteId!,
|
||||
);
|
||||
|
||||
var motionReponse =
|
||||
await _apiService.downloadApi.downloadFileWithHttpInfo(
|
||||
var motionResponse =
|
||||
await _apiService.assetsApi.downloadAssetWithHttpInfo(
|
||||
asset.livePhotoVideoId!,
|
||||
);
|
||||
|
||||
if (imageResponse.statusCode != 200 ||
|
||||
motionReponse.statusCode != 200) {
|
||||
motionResponse.statusCode != 200) {
|
||||
final failedResponse =
|
||||
imageResponse.statusCode != 200 ? imageResponse : motionReponse;
|
||||
imageResponse.statusCode != 200 ? imageResponse : motionResponse;
|
||||
_log.severe(
|
||||
"Motion asset download failed",
|
||||
failedResponse.toLoggerString(),
|
||||
|
@ -51,7 +51,7 @@ class ImageViewerService {
|
|||
final tempDir = await getTemporaryDirectory();
|
||||
videoFile = await File('${tempDir.path}/livephoto.mov').create();
|
||||
imageFile = await File('${tempDir.path}/livephoto.heic').create();
|
||||
videoFile.writeAsBytesSync(motionReponse.bodyBytes);
|
||||
videoFile.writeAsBytesSync(motionResponse.bodyBytes);
|
||||
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
|
||||
|
||||
entity = await PhotoManager.editor.darwin.saveLivePhoto(
|
||||
|
@ -73,8 +73,8 @@ class ImageViewerService {
|
|||
|
||||
return entity != null;
|
||||
} else {
|
||||
var res = await _apiService.downloadApi
|
||||
.downloadFileWithHttpInfo(asset.remoteId!);
|
||||
var res = await _apiService.assetsApi
|
||||
.downloadAssetWithHttpInfo(asset.remoteId!);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
_log.severe("Asset download failed", res.toLoggerString());
|
||||
|
|
|
@ -37,8 +37,8 @@ class ShareService {
|
|||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName = asset.fileName;
|
||||
final tempFile = await File('${tempDir.path}/$fileName').create();
|
||||
final res = await _apiService.downloadApi
|
||||
.downloadFileWithHttpInfo(asset.remoteId!);
|
||||
final res = await _apiService.assetsApi
|
||||
.downloadAssetWithHttpInfo(asset.remoteId!);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
_log.severe(
|
||||
|
|
|
@ -129,8 +129,8 @@ class _ChewieControllerHookState
|
|||
// Use a network URL for the video player controller
|
||||
final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
|
||||
final String videoUrl = hook.asset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/asset/file/${hook.asset.livePhotoVideoId}'
|
||||
: '$serverEndpoint/asset/file/${hook.asset.remoteId}';
|
||||
? '$serverEndpoint/assets/${hook.asset.livePhotoVideoId}/video/playback'
|
||||
: '$serverEndpoint/assets/${hook.asset.remoteId}/video/playback';
|
||||
|
||||
final url = Uri.parse(videoUrl);
|
||||
final accessToken = store.Store.get(StoreKey.accessToken);
|
||||
|
|
|
@ -6,23 +6,23 @@ import 'package:openapi/api.dart';
|
|||
|
||||
String getThumbnailUrl(
|
||||
final Asset asset, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||
}) {
|
||||
return getThumbnailUrlForRemoteId(asset.remoteId!, type: type);
|
||||
}
|
||||
|
||||
String getThumbnailCacheKey(
|
||||
final Asset asset, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||
}) {
|
||||
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
|
||||
}
|
||||
|
||||
String getThumbnailCacheKeyForRemoteId(
|
||||
final String id, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||
}) {
|
||||
if (type == ThumbnailFormat.WEBP) {
|
||||
if (type == AssetMediaSize.thumbnail) {
|
||||
return 'thumbnail-image-$id';
|
||||
} else {
|
||||
return '${id}_previewStage';
|
||||
|
@ -31,7 +31,7 @@ String getThumbnailCacheKeyForRemoteId(
|
|||
|
||||
String getAlbumThumbnailUrl(
|
||||
final Album album, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||
}) {
|
||||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
|
@ -44,7 +44,7 @@ String getAlbumThumbnailUrl(
|
|||
|
||||
String getAlbumThumbNailCacheKey(
|
||||
final Album album, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||
}) {
|
||||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
|
@ -60,7 +60,7 @@ String getImageUrl(final Asset asset) {
|
|||
}
|
||||
|
||||
String getImageUrlFromId(final String id) {
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/asset/file/$id?isThumb=false';
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=preview';
|
||||
}
|
||||
|
||||
String getImageCacheKey(final Asset asset) {
|
||||
|
@ -71,9 +71,9 @@ String getImageCacheKey(final Asset asset) {
|
|||
|
||||
String getThumbnailUrlForRemoteId(
|
||||
final String id, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||
}) {
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$id?format=${type.value}';
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?format=${type.value}';
|
||||
}
|
||||
|
||||
String getFaceThumbnailUrl(final String personId) {
|
||||
|
|
|
@ -46,12 +46,13 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
|||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageUrl: getAlbumThumbnailUrl(
|
||||
album,
|
||||
type: ThumbnailFormat.WEBP,
|
||||
type: AssetMediaSize.thumbnail,
|
||||
),
|
||||
httpHeaders: {
|
||||
"x-immich-user-token": Store.get(StoreKey.accessToken),
|
||||
},
|
||||
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.WEBP),
|
||||
cacheKey:
|
||||
getAlbumThumbNailCacheKey(album, type: AssetMediaSize.thumbnail),
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
);
|
||||
|
|
|
@ -115,7 +115,7 @@ class CuratedPlacesRow extends CuratedRow {
|
|||
final actualIndex = index - actualContentIndex;
|
||||
final object = content[actualIndex];
|
||||
final thumbnailRequestUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
|
||||
'${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail';
|
||||
return SizedBox(
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
|
|
|
@ -46,7 +46,7 @@ class CuratedRow extends StatelessWidget {
|
|||
itemBuilder: (context, index) {
|
||||
final object = content[index];
|
||||
final thumbnailRequestUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
|
||||
'${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail';
|
||||
return SizedBox(
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
|
|
|
@ -44,7 +44,7 @@ class ExploreGrid extends StatelessWidget {
|
|||
final content = curatedContent[index];
|
||||
final thumbnailRequestUrl = isPeople
|
||||
? getFaceThumbnailUrl(content.id)
|
||||
: '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${content.id}';
|
||||
: '${Store.get(StoreKey.serverEndpoint)}/assets/${content.id}/thumbnail';
|
||||
|
||||
return ThumbnailWithInfo(
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
|
|
37
mobile/openapi/README.md
generated
37
mobile/openapi/README.md
generated
|
@ -93,22 +93,23 @@ Class | Method | HTTP request | Description
|
|||
*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} |
|
||||
*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} |
|
||||
*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} |
|
||||
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /asset/bulk-upload-check |
|
||||
*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /asset/exist |
|
||||
*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /asset |
|
||||
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} |
|
||||
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /asset/{id} |
|
||||
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /asset/statistics |
|
||||
*AssetsApi* | [**getAssetThumbnail**](doc//AssetsApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} |
|
||||
*AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /asset/memory-lane |
|
||||
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /asset/random |
|
||||
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /asset/{id}/file |
|
||||
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /asset/jobs |
|
||||
*AssetsApi* | [**serveFile**](doc//AssetsApi.md#servefile) | **GET** /asset/file/{id} |
|
||||
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /asset/{id} |
|
||||
*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /asset |
|
||||
*AssetsApi* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /asset/stack/parent |
|
||||
*AssetsApi* | [**uploadFile**](doc//AssetsApi.md#uploadfile) | **POST** /asset/upload |
|
||||
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check |
|
||||
*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist |
|
||||
*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets |
|
||||
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original |
|
||||
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} |
|
||||
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} |
|
||||
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics |
|
||||
*AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /assets/memory-lane |
|
||||
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random |
|
||||
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback |
|
||||
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original |
|
||||
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs |
|
||||
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} |
|
||||
*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets |
|
||||
*AssetsApi* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /assets/stack/parent |
|
||||
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
|
||||
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
|
||||
*AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
|
||||
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
|
||||
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
|
||||
|
@ -116,7 +117,6 @@ Class | Method | HTTP request | Description
|
|||
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
|
||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
|
||||
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
|
||||
*DownloadApi* | [**downloadFile**](doc//DownloadApi.md#downloadfile) | **POST** /download/asset/{id} |
|
||||
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |
|
||||
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates |
|
||||
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces |
|
||||
|
@ -260,13 +260,13 @@ Class | Method | HTTP request | Description
|
|||
- [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md)
|
||||
- [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md)
|
||||
- [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md)
|
||||
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
|
||||
- [AssetFullSyncDto](doc//AssetFullSyncDto.md)
|
||||
- [AssetIdsDto](doc//AssetIdsDto.md)
|
||||
- [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
|
||||
- [AssetJobName](doc//AssetJobName.md)
|
||||
- [AssetJobsDto](doc//AssetJobsDto.md)
|
||||
- [AssetMediaResponseDto](doc//AssetMediaResponseDto.md)
|
||||
- [AssetMediaSize](doc//AssetMediaSize.md)
|
||||
- [AssetMediaStatus](doc//AssetMediaStatus.md)
|
||||
- [AssetOrder](doc//AssetOrder.md)
|
||||
- [AssetResponseDto](doc//AssetResponseDto.md)
|
||||
|
@ -398,7 +398,6 @@ Class | Method | HTTP request | Description
|
|||
- [SystemConfigUserDto](doc//SystemConfigUserDto.md)
|
||||
- [TagResponseDto](doc//TagResponseDto.md)
|
||||
- [TagTypeEnum](doc//TagTypeEnum.md)
|
||||
- [ThumbnailFormat](doc//ThumbnailFormat.md)
|
||||
- [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
|
||||
- [TimeBucketSize](doc//TimeBucketSize.md)
|
||||
- [ToneMapping](doc//ToneMapping.md)
|
||||
|
|
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
|
@ -87,13 +87,13 @@ part 'model/asset_face_response_dto.dart';
|
|||
part 'model/asset_face_update_dto.dart';
|
||||
part 'model/asset_face_update_item.dart';
|
||||
part 'model/asset_face_without_person_response_dto.dart';
|
||||
part 'model/asset_file_upload_response_dto.dart';
|
||||
part 'model/asset_full_sync_dto.dart';
|
||||
part 'model/asset_ids_dto.dart';
|
||||
part 'model/asset_ids_response_dto.dart';
|
||||
part 'model/asset_job_name.dart';
|
||||
part 'model/asset_jobs_dto.dart';
|
||||
part 'model/asset_media_response_dto.dart';
|
||||
part 'model/asset_media_size.dart';
|
||||
part 'model/asset_media_status.dart';
|
||||
part 'model/asset_order.dart';
|
||||
part 'model/asset_response_dto.dart';
|
||||
|
@ -225,7 +225,6 @@ part 'model/system_config_trash_dto.dart';
|
|||
part 'model/system_config_user_dto.dart';
|
||||
part 'model/tag_response_dto.dart';
|
||||
part 'model/tag_type_enum.dart';
|
||||
part 'model/thumbnail_format.dart';
|
||||
part 'model/time_bucket_response_dto.dart';
|
||||
part 'model/time_bucket_size.dart';
|
||||
part 'model/tone_mapping.dart';
|
||||
|
|
373
mobile/openapi/lib/api/assets_api.dart
generated
373
mobile/openapi/lib/api/assets_api.dart
generated
|
@ -25,7 +25,7 @@ class AssetsApi {
|
|||
/// * [AssetBulkUploadCheckDto] assetBulkUploadCheckDto (required):
|
||||
Future<Response> checkBulkUploadWithHttpInfo(AssetBulkUploadCheckDto assetBulkUploadCheckDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/bulk-upload-check';
|
||||
final path = r'/assets/bulk-upload-check';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = assetBulkUploadCheckDto;
|
||||
|
@ -77,7 +77,7 @@ class AssetsApi {
|
|||
/// * [CheckExistingAssetsDto] checkExistingAssetsDto (required):
|
||||
Future<Response> checkExistingAssetsWithHttpInfo(CheckExistingAssetsDto checkExistingAssetsDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/exist';
|
||||
final path = r'/assets/exist';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = checkExistingAssetsDto;
|
||||
|
@ -120,13 +120,13 @@ class AssetsApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'DELETE /asset' operation and returns the [Response].
|
||||
/// Performs an HTTP 'DELETE /assets' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [AssetBulkDeleteDto] assetBulkDeleteDto (required):
|
||||
Future<Response> deleteAssetsWithHttpInfo(AssetBulkDeleteDto assetBulkDeleteDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset';
|
||||
final path = r'/assets';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = assetBulkDeleteDto;
|
||||
|
@ -159,6 +159,62 @@ class AssetsApi {
|
|||
}
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /assets/{id}/original' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<Response> downloadAssetWithHttpInfo(String id, { String? key, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/assets/{id}/original'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<MultipartFile?> downloadAsset(String id, { String? key, }) async {
|
||||
final response = await downloadAssetWithHttpInfo(id, key: key, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get all asset of a device that are in the database, ID only.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
|
@ -168,7 +224,7 @@ class AssetsApi {
|
|||
/// * [String] deviceId (required):
|
||||
Future<Response> getAllUserAssetsByDeviceIdWithHttpInfo(String deviceId,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/device/{deviceId}'
|
||||
final path = r'/assets/device/{deviceId}'
|
||||
.replaceAll('{deviceId}', deviceId);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
|
@ -215,7 +271,7 @@ class AssetsApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/{id}' operation and returns the [Response].
|
||||
/// Performs an HTTP 'GET /assets/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
|
@ -223,7 +279,7 @@ class AssetsApi {
|
|||
/// * [String] key:
|
||||
Future<Response> getAssetInfoWithHttpInfo(String id, { String? key, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/{id}'
|
||||
final path = r'/assets/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
|
@ -271,7 +327,7 @@ class AssetsApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/statistics' operation and returns the [Response].
|
||||
/// Performs an HTTP 'GET /assets/statistics' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [bool] isArchived:
|
||||
|
@ -281,7 +337,7 @@ class AssetsApi {
|
|||
/// * [bool] isTrashed:
|
||||
Future<Response> getAssetStatisticsWithHttpInfo({ bool? isArchived, bool? isFavorite, bool? isTrashed, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/statistics';
|
||||
final path = r'/assets/statistics';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
@ -336,70 +392,7 @@ class AssetsApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/thumbnail/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [ThumbnailFormat] format:
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<Response> getAssetThumbnailWithHttpInfo(String id, { ThumbnailFormat? format, String? key, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/thumbnail/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (format != null) {
|
||||
queryParams.addAll(_queryParams('', 'format', format));
|
||||
}
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [ThumbnailFormat] format:
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<MultipartFile?> getAssetThumbnail(String id, { ThumbnailFormat? format, String? key, }) async {
|
||||
final response = await getAssetThumbnailWithHttpInfo(id, format: format, key: key, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/memory-lane' operation and returns the [Response].
|
||||
/// Performs an HTTP 'GET /assets/memory-lane' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [int] day (required):
|
||||
|
@ -407,7 +400,7 @@ class AssetsApi {
|
|||
/// * [int] month (required):
|
||||
Future<Response> getMemoryLaneWithHttpInfo(int day, int month,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/memory-lane';
|
||||
final path = r'/assets/memory-lane';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
@ -456,13 +449,13 @@ class AssetsApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/random' operation and returns the [Response].
|
||||
/// Performs an HTTP 'GET /assets/random' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [num] count:
|
||||
Future<Response> getRandomWithHttpInfo({ num? count, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/random';
|
||||
final path = r'/assets/random';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
@ -510,6 +503,62 @@ class AssetsApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /assets/{id}/video/playback' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<Response> playAssetVideoWithHttpInfo(String id, { String? key, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/assets/{id}/video/playback'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<MultipartFile?> playAssetVideo(String id, { String? key, }) async {
|
||||
final response = await playAssetVideoWithHttpInfo(id, key: key, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Replace the asset with new file, without changing its id
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
|
@ -533,7 +582,7 @@ class AssetsApi {
|
|||
/// * [String] duration:
|
||||
Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/{id}/file'
|
||||
final path = r'/assets/{id}/original'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
|
@ -625,13 +674,13 @@ class AssetsApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'POST /asset/jobs' operation and returns the [Response].
|
||||
/// Performs an HTTP 'POST /assets/jobs' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [AssetJobsDto] assetJobsDto (required):
|
||||
Future<Response> runAssetJobsWithHttpInfo(AssetJobsDto assetJobsDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/jobs';
|
||||
final path = r'/assets/jobs';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = assetJobsDto;
|
||||
|
@ -664,77 +713,7 @@ class AssetsApi {
|
|||
}
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/file/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [bool] isThumb:
|
||||
///
|
||||
/// * [bool] isWeb:
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<Response> serveFileWithHttpInfo(String id, { bool? isThumb, bool? isWeb, String? key, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/file/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (isThumb != null) {
|
||||
queryParams.addAll(_queryParams('', 'isThumb', isThumb));
|
||||
}
|
||||
if (isWeb != null) {
|
||||
queryParams.addAll(_queryParams('', 'isWeb', isWeb));
|
||||
}
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [bool] isThumb:
|
||||
///
|
||||
/// * [bool] isWeb:
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<MultipartFile?> serveFile(String id, { bool? isThumb, bool? isWeb, String? key, }) async {
|
||||
final response = await serveFileWithHttpInfo(id, isThumb: isThumb, isWeb: isWeb, key: key, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'PUT /asset/{id}' operation and returns the [Response].
|
||||
/// Performs an HTTP 'PUT /assets/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
|
@ -742,7 +721,7 @@ class AssetsApi {
|
|||
/// * [UpdateAssetDto] updateAssetDto (required):
|
||||
Future<Response> updateAssetWithHttpInfo(String id, UpdateAssetDto updateAssetDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/{id}'
|
||||
final path = r'/assets/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
|
@ -786,13 +765,13 @@ class AssetsApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'PUT /asset' operation and returns the [Response].
|
||||
/// Performs an HTTP 'PUT /assets' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
|
||||
Future<Response> updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset';
|
||||
final path = r'/assets';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = assetBulkUpdateDto;
|
||||
|
@ -825,13 +804,13 @@ class AssetsApi {
|
|||
}
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'PUT /asset/stack/parent' operation and returns the [Response].
|
||||
/// Performs an HTTP 'PUT /assets/stack/parent' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [UpdateStackParentDto] updateStackParentDto (required):
|
||||
Future<Response> updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/stack/parent';
|
||||
final path = r'/assets/stack/parent';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = updateStackParentDto;
|
||||
|
@ -864,7 +843,7 @@ class AssetsApi {
|
|||
}
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
|
||||
/// Performs an HTTP 'POST /assets' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [MultipartFile] assetData (required):
|
||||
|
@ -892,12 +871,12 @@ class AssetsApi {
|
|||
///
|
||||
/// * [bool] isVisible:
|
||||
///
|
||||
/// * [MultipartFile] livePhotoData:
|
||||
/// * [String] livePhotoVideoId:
|
||||
///
|
||||
/// * [MultipartFile] sidecarData:
|
||||
Future<Response> uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/upload';
|
||||
final path = r'/assets';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
@ -959,10 +938,9 @@ class AssetsApi {
|
|||
hasFields = true;
|
||||
mp.fields[r'isVisible'] = parameterToString(isVisible);
|
||||
}
|
||||
if (livePhotoData != null) {
|
||||
if (livePhotoVideoId != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'livePhotoData'] = livePhotoData.field;
|
||||
mp.files.add(livePhotoData);
|
||||
mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId);
|
||||
}
|
||||
if (sidecarData != null) {
|
||||
hasFields = true;
|
||||
|
@ -1011,11 +989,11 @@ class AssetsApi {
|
|||
///
|
||||
/// * [bool] isVisible:
|
||||
///
|
||||
/// * [MultipartFile] livePhotoData:
|
||||
/// * [String] livePhotoVideoId:
|
||||
///
|
||||
/// * [MultipartFile] sidecarData:
|
||||
Future<AssetFileUploadResponseDto?> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
|
||||
final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoData: livePhotoData, sidecarData: sidecarData, );
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async {
|
||||
final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
@ -1023,7 +1001,70 @@ class AssetsApi {
|
|||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetFileUploadResponseDto',) as AssetFileUploadResponseDto;
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMediaResponseDto',) as AssetMediaResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /assets/{id}/thumbnail' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [AssetMediaSize] size:
|
||||
Future<Response> viewAssetWithHttpInfo(String id, { String? key, AssetMediaSize? size, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/assets/{id}/thumbnail'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (size != null) {
|
||||
queryParams.addAll(_queryParams('', 'size', size));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [AssetMediaSize] size:
|
||||
Future<MultipartFile?> viewAsset(String id, { String? key, AssetMediaSize? size, }) async {
|
||||
final response = await viewAssetWithHttpInfo(id, key: key, size: size, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
|
||||
|
||||
}
|
||||
return null;
|
||||
|
|
56
mobile/openapi/lib/api/download_api.dart
generated
56
mobile/openapi/lib/api/download_api.dart
generated
|
@ -71,62 +71,6 @@ class DownloadApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'POST /download/asset/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<Response> downloadFileWithHttpInfo(String id, { String? key, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/download/asset/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<MultipartFile?> downloadFile(String id, { String? key, }) async {
|
||||
final response = await downloadFileWithHttpInfo(id, key: key, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'POST /download/info' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
|
|
6
mobile/openapi/lib/api_client.dart
generated
6
mobile/openapi/lib/api_client.dart
generated
|
@ -238,8 +238,6 @@ class ApiClient {
|
|||
return AssetFaceUpdateItem.fromJson(value);
|
||||
case 'AssetFaceWithoutPersonResponseDto':
|
||||
return AssetFaceWithoutPersonResponseDto.fromJson(value);
|
||||
case 'AssetFileUploadResponseDto':
|
||||
return AssetFileUploadResponseDto.fromJson(value);
|
||||
case 'AssetFullSyncDto':
|
||||
return AssetFullSyncDto.fromJson(value);
|
||||
case 'AssetIdsDto':
|
||||
|
@ -252,6 +250,8 @@ class ApiClient {
|
|||
return AssetJobsDto.fromJson(value);
|
||||
case 'AssetMediaResponseDto':
|
||||
return AssetMediaResponseDto.fromJson(value);
|
||||
case 'AssetMediaSize':
|
||||
return AssetMediaSizeTypeTransformer().decode(value);
|
||||
case 'AssetMediaStatus':
|
||||
return AssetMediaStatusTypeTransformer().decode(value);
|
||||
case 'AssetOrder':
|
||||
|
@ -514,8 +514,6 @@ class ApiClient {
|
|||
return TagResponseDto.fromJson(value);
|
||||
case 'TagTypeEnum':
|
||||
return TagTypeEnumTypeTransformer().decode(value);
|
||||
case 'ThumbnailFormat':
|
||||
return ThumbnailFormatTypeTransformer().decode(value);
|
||||
case 'TimeBucketResponseDto':
|
||||
return TimeBucketResponseDto.fromJson(value);
|
||||
case 'TimeBucketSize':
|
||||
|
|
6
mobile/openapi/lib/api_helper.dart
generated
6
mobile/openapi/lib/api_helper.dart
generated
|
@ -61,6 +61,9 @@ String parameterToString(dynamic value) {
|
|||
if (value is AssetJobName) {
|
||||
return AssetJobNameTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is AssetMediaSize) {
|
||||
return AssetMediaSizeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is AssetMediaStatus) {
|
||||
return AssetMediaStatusTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
@ -127,9 +130,6 @@ String parameterToString(dynamic value) {
|
|||
if (value is TagTypeEnum) {
|
||||
return TagTypeEnumTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is ThumbnailFormat) {
|
||||
return ThumbnailFormatTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is TimeBucketSize) {
|
||||
return TimeBucketSizeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
@ -11,9 +11,9 @@
|
|||
part of openapi.api;
|
||||
|
||||
|
||||
class ThumbnailFormat {
|
||||
class AssetMediaSize {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const ThumbnailFormat._(this.value);
|
||||
const AssetMediaSize._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
@ -23,22 +23,22 @@ class ThumbnailFormat {
|
|||
|
||||
String toJson() => value;
|
||||
|
||||
static const JPEG = ThumbnailFormat._(r'JPEG');
|
||||
static const WEBP = ThumbnailFormat._(r'WEBP');
|
||||
static const preview = AssetMediaSize._(r'preview');
|
||||
static const thumbnail = AssetMediaSize._(r'thumbnail');
|
||||
|
||||
/// List of all possible values in this [enum][ThumbnailFormat].
|
||||
static const values = <ThumbnailFormat>[
|
||||
JPEG,
|
||||
WEBP,
|
||||
/// List of all possible values in this [enum][AssetMediaSize].
|
||||
static const values = <AssetMediaSize>[
|
||||
preview,
|
||||
thumbnail,
|
||||
];
|
||||
|
||||
static ThumbnailFormat? fromJson(dynamic value) => ThumbnailFormatTypeTransformer().decode(value);
|
||||
static AssetMediaSize? fromJson(dynamic value) => AssetMediaSizeTypeTransformer().decode(value);
|
||||
|
||||
static List<ThumbnailFormat> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ThumbnailFormat>[];
|
||||
static List<AssetMediaSize> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetMediaSize>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = ThumbnailFormat.fromJson(row);
|
||||
final value = AssetMediaSize.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
|
@ -48,16 +48,16 @@ class ThumbnailFormat {
|
|||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [ThumbnailFormat] to String,
|
||||
/// and [decode] dynamic data back to [ThumbnailFormat].
|
||||
class ThumbnailFormatTypeTransformer {
|
||||
factory ThumbnailFormatTypeTransformer() => _instance ??= const ThumbnailFormatTypeTransformer._();
|
||||
/// Transformation class that can [encode] an instance of [AssetMediaSize] to String,
|
||||
/// and [decode] dynamic data back to [AssetMediaSize].
|
||||
class AssetMediaSizeTypeTransformer {
|
||||
factory AssetMediaSizeTypeTransformer() => _instance ??= const AssetMediaSizeTypeTransformer._();
|
||||
|
||||
const ThumbnailFormatTypeTransformer._();
|
||||
const AssetMediaSizeTypeTransformer._();
|
||||
|
||||
String encode(ThumbnailFormat data) => data.value;
|
||||
String encode(AssetMediaSize data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a ThumbnailFormat.
|
||||
/// Decodes a [dynamic value][data] to a AssetMediaSize.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
|
@ -65,11 +65,11 @@ class ThumbnailFormatTypeTransformer {
|
|||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
ThumbnailFormat? decode(dynamic data, {bool allowNull = true}) {
|
||||
AssetMediaSize? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'JPEG': return ThumbnailFormat.JPEG;
|
||||
case r'WEBP': return ThumbnailFormat.WEBP;
|
||||
case r'preview': return AssetMediaSize.preview;
|
||||
case r'thumbnail': return AssetMediaSize.thumbnail;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
@ -79,7 +79,7 @@ class ThumbnailFormatTypeTransformer {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [ThumbnailFormatTypeTransformer] instance.
|
||||
static ThumbnailFormatTypeTransformer? _instance;
|
||||
/// Singleton [AssetMediaSizeTypeTransformer] instance.
|
||||
static AssetMediaSizeTypeTransformer? _instance;
|
||||
}
|
||||
|
3
mobile/openapi/lib/model/asset_media_status.dart
generated
3
mobile/openapi/lib/model/asset_media_status.dart
generated
|
@ -23,11 +23,13 @@ class AssetMediaStatus {
|
|||
|
||||
String toJson() => value;
|
||||
|
||||
static const created = AssetMediaStatus._(r'created');
|
||||
static const replaced = AssetMediaStatus._(r'replaced');
|
||||
static const duplicate = AssetMediaStatus._(r'duplicate');
|
||||
|
||||
/// List of all possible values in this [enum][AssetMediaStatus].
|
||||
static const values = <AssetMediaStatus>[
|
||||
created,
|
||||
replaced,
|
||||
duplicate,
|
||||
];
|
||||
|
@ -68,6 +70,7 @@ class AssetMediaStatusTypeTransformer {
|
|||
AssetMediaStatus? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'created': return AssetMediaStatus.created;
|
||||
case r'replaced': return AssetMediaStatus.replaced;
|
||||
case r'duplicate': return AssetMediaStatus.duplicate;
|
||||
default:
|
||||
|
|
|
@ -1805,4 +1805,4 @@ packages:
|
|||
version: "3.1.2"
|
||||
sdks:
|
||||
dart: ">=3.3.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.22.1"
|
||||
|
|
|
@ -1295,7 +1295,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/asset": {
|
||||
"/assets": {
|
||||
"delete": {
|
||||
"operationId": "deleteAssets",
|
||||
"parameters": [],
|
||||
|
@ -1329,6 +1329,65 @@
|
|||
"Assets"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "uploadAsset",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-immich-checksum",
|
||||
"in": "header",
|
||||
"description": "sha1 checksum that can be used for duplicate detection before the file is uploaded",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AssetMediaCreateDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Asset Upload Information",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AssetMediaResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Assets"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "updateAssets",
|
||||
"parameters": [],
|
||||
|
@ -1363,7 +1422,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/asset/bulk-upload-check": {
|
||||
"/assets/bulk-upload-check": {
|
||||
"post": {
|
||||
"description": "Checks if assets exist by checksums",
|
||||
"operationId": "checkBulkUpload",
|
||||
|
@ -1406,7 +1465,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/asset/device/{deviceId}": {
|
||||
"/assets/device/{deviceId}": {
|
||||
"get": {
|
||||
"description": "Get all asset of a device that are in the database, ID only.",
|
||||
"operationId": "getAllUserAssetsByDeviceId",
|
||||
|
@ -1451,7 +1510,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/asset/exist": {
|
||||
"/assets/exist": {
|
||||
"post": {
|
||||
"description": "Checks if multiple assets exist on the server and returns all existing - used by background backup",
|
||||
"operationId": "checkExistingAssets",
|
||||
|
@ -1494,76 +1553,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/asset/file/{id}": {
|
||||
"get": {
|
||||
"operationId": "serveFile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "isThumb",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"title": "Is serve thumbnail (resize) file",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "isWeb",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"title": "Is request made from web",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Assets"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/jobs": {
|
||||
"/assets/jobs": {
|
||||
"post": {
|
||||
"operationId": "runAssetJobs",
|
||||
"parameters": [],
|
||||
|
@ -1598,7 +1588,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/asset/memory-lane": {
|
||||
"/assets/memory-lane": {
|
||||
"get": {
|
||||
"operationId": "getMemoryLane",
|
||||
"parameters": [
|
||||
|
@ -1654,7 +1644,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/asset/random": {
|
||||
"/assets/random": {
|
||||
"get": {
|
||||
"operationId": "getRandom",
|
||||
"parameters": [
|
||||
|
@ -1699,7 +1689,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/asset/stack/parent": {
|
||||
"/assets/stack/parent": {
|
||||
"put": {
|
||||
"operationId": "updateStackParent",
|
||||
"parameters": [],
|
||||
|
@ -1734,7 +1724,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/asset/statistics": {
|
||||
"/assets/statistics": {
|
||||
"get": {
|
||||
"operationId": "getAssetStatistics",
|
||||
"parameters": [
|
||||
|
@ -1791,127 +1781,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/asset/thumbnail/{id}": {
|
||||
"get": {
|
||||
"operationId": "getAssetThumbnail",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "format",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ThumbnailFormat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Assets"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/upload": {
|
||||
"post": {
|
||||
"operationId": "uploadFile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-immich-checksum",
|
||||
"in": "header",
|
||||
"description": "sha1 checksum that can be used for duplicate detection before the file is uploaded",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateAssetDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Asset Upload Information",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AssetFileUploadResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Assets"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/{id}": {
|
||||
"/assets/{id}": {
|
||||
"get": {
|
||||
"operationId": "getAssetInfo",
|
||||
"parameters": [
|
||||
|
@ -2011,7 +1881,56 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/asset/{id}/file": {
|
||||
"/assets/{id}/original": {
|
||||
"get": {
|
||||
"operationId": "downloadAsset",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Assets"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Replace the asset with new file, without changing its id",
|
||||
"operationId": "replaceAsset",
|
||||
|
@ -2075,6 +1994,116 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/assets/{id}/thumbnail": {
|
||||
"get": {
|
||||
"operationId": "viewAsset",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AssetMediaSize"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Assets"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/assets/{id}/video/playback": {
|
||||
"get": {
|
||||
"operationId": "playAssetVideo",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Assets"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/audit/deletes": {
|
||||
"get": {
|
||||
"operationId": "getAuditDeletes",
|
||||
|
@ -2354,57 +2383,6 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/download/asset/{id}": {
|
||||
"post": {
|
||||
"operationId": "downloadFile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Download"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/download/info": {
|
||||
"post": {
|
||||
"operationId": "getDownloadInfo",
|
||||
|
@ -7417,21 +7395,6 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AssetFileUploadResponseDto": {
|
||||
"properties": {
|
||||
"duplicate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"duplicate",
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AssetFullSyncDto": {
|
||||
"properties": {
|
||||
"lastCreationDate": {
|
||||
|
@ -7526,6 +7489,59 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AssetMediaCreateDto": {
|
||||
"properties": {
|
||||
"assetData": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
},
|
||||
"deviceAssetId": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration": {
|
||||
"type": "string"
|
||||
},
|
||||
"fileCreatedAt": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"fileModifiedAt": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"isArchived": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isOffline": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isVisible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"livePhotoVideoId": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecarData": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetData",
|
||||
"deviceAssetId",
|
||||
"deviceId",
|
||||
"fileCreatedAt",
|
||||
"fileModifiedAt"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AssetMediaReplaceDto": {
|
||||
"properties": {
|
||||
"assetData": {
|
||||
|
@ -7574,8 +7590,16 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AssetMediaSize": {
|
||||
"enum": [
|
||||
"preview",
|
||||
"thumbnail"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AssetMediaStatus": {
|
||||
"enum": [
|
||||
"created",
|
||||
"replaced",
|
||||
"duplicate"
|
||||
],
|
||||
|
@ -7963,59 +7987,6 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CreateAssetDto": {
|
||||
"properties": {
|
||||
"assetData": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
},
|
||||
"deviceAssetId": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration": {
|
||||
"type": "string"
|
||||
},
|
||||
"fileCreatedAt": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"fileModifiedAt": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"isArchived": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isOffline": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isVisible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"livePhotoData": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecarData": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetData",
|
||||
"deviceAssetId",
|
||||
"deviceId",
|
||||
"fileCreatedAt",
|
||||
"fileModifiedAt"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CreateLibraryDto": {
|
||||
"properties": {
|
||||
"exclusionPatterns": {
|
||||
|
@ -10872,13 +10843,6 @@
|
|||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThumbnailFormat": {
|
||||
"enum": [
|
||||
"JPEG",
|
||||
"WEBP"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"TimeBucketResponseDto": {
|
||||
"properties": {
|
||||
"count": {
|
||||
|
|
|
@ -264,6 +264,24 @@ export type AssetBulkDeleteDto = {
|
|||
force?: boolean;
|
||||
ids: string[];
|
||||
};
|
||||
export type AssetMediaCreateDto = {
|
||||
assetData: Blob;
|
||||
deviceAssetId: string;
|
||||
deviceId: string;
|
||||
duration?: string;
|
||||
fileCreatedAt: string;
|
||||
fileModifiedAt: string;
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
isOffline?: boolean;
|
||||
isVisible?: boolean;
|
||||
livePhotoVideoId?: string;
|
||||
sidecarData?: Blob;
|
||||
};
|
||||
export type AssetMediaResponseDto = {
|
||||
id: string;
|
||||
status: AssetMediaStatus;
|
||||
};
|
||||
export type AssetBulkUpdateDto = {
|
||||
dateTimeOriginal?: string;
|
||||
duplicateId?: string | null;
|
||||
|
@ -316,24 +334,6 @@ export type AssetStatsResponseDto = {
|
|||
total: number;
|
||||
videos: number;
|
||||
};
|
||||
export type CreateAssetDto = {
|
||||
assetData: Blob;
|
||||
deviceAssetId: string;
|
||||
deviceId: string;
|
||||
duration?: string;
|
||||
fileCreatedAt: string;
|
||||
fileModifiedAt: string;
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
isOffline?: boolean;
|
||||
isVisible?: boolean;
|
||||
livePhotoData?: Blob;
|
||||
sidecarData?: Blob;
|
||||
};
|
||||
export type AssetFileUploadResponseDto = {
|
||||
duplicate: boolean;
|
||||
id: string;
|
||||
};
|
||||
export type UpdateAssetDto = {
|
||||
dateTimeOriginal?: string;
|
||||
description?: string;
|
||||
|
@ -350,10 +350,6 @@ export type AssetMediaReplaceDto = {
|
|||
fileCreatedAt: string;
|
||||
fileModifiedAt: string;
|
||||
};
|
||||
export type AssetMediaResponseDto = {
|
||||
id: string;
|
||||
status: AssetMediaStatus;
|
||||
};
|
||||
export type AuditDeletesResponseDto = {
|
||||
ids: string[];
|
||||
needsFullSync: boolean;
|
||||
|
@ -1434,16 +1430,35 @@ export function updateApiKey({ id, apiKeyUpdateDto }: {
|
|||
export function deleteAssets({ assetBulkDeleteDto }: {
|
||||
assetBulkDeleteDto: AssetBulkDeleteDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText("/asset", oazapfts.json({
|
||||
return oazapfts.ok(oazapfts.fetchText("/assets", oazapfts.json({
|
||||
...opts,
|
||||
method: "DELETE",
|
||||
body: assetBulkDeleteDto
|
||||
})));
|
||||
}
|
||||
export function uploadAsset({ key, xImmichChecksum, assetMediaCreateDto }: {
|
||||
key?: string;
|
||||
xImmichChecksum?: string;
|
||||
assetMediaCreateDto: AssetMediaCreateDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 201;
|
||||
data: AssetMediaResponseDto;
|
||||
}>(`/assets${QS.query(QS.explode({
|
||||
key
|
||||
}))}`, oazapfts.multipart({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: assetMediaCreateDto,
|
||||
headers: oazapfts.mergeHeaders(opts?.headers, {
|
||||
"x-immich-checksum": xImmichChecksum
|
||||
})
|
||||
})));
|
||||
}
|
||||
export function updateAssets({ assetBulkUpdateDto }: {
|
||||
assetBulkUpdateDto: AssetBulkUpdateDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText("/asset", oazapfts.json({
|
||||
return oazapfts.ok(oazapfts.fetchText("/assets", oazapfts.json({
|
||||
...opts,
|
||||
method: "PUT",
|
||||
body: assetBulkUpdateDto
|
||||
|
@ -1458,7 +1473,7 @@ export function checkBulkUpload({ assetBulkUploadCheckDto }: {
|
|||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: AssetBulkUploadCheckResponseDto;
|
||||
}>("/asset/bulk-upload-check", oazapfts.json({
|
||||
}>("/assets/bulk-upload-check", oazapfts.json({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: assetBulkUploadCheckDto
|
||||
|
@ -1473,7 +1488,7 @@ export function getAllUserAssetsByDeviceId({ deviceId }: {
|
|||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: string[];
|
||||
}>(`/asset/device/${encodeURIComponent(deviceId)}`, {
|
||||
}>(`/assets/device/${encodeURIComponent(deviceId)}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
|
@ -1486,33 +1501,16 @@ export function checkExistingAssets({ checkExistingAssetsDto }: {
|
|||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: CheckExistingAssetsResponseDto;
|
||||
}>("/asset/exist", oazapfts.json({
|
||||
}>("/assets/exist", oazapfts.json({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: checkExistingAssetsDto
|
||||
})));
|
||||
}
|
||||
export function serveFile({ id, isThumb, isWeb, key }: {
|
||||
id: string;
|
||||
isThumb?: boolean;
|
||||
isWeb?: boolean;
|
||||
key?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/asset/file/${encodeURIComponent(id)}${QS.query(QS.explode({
|
||||
isThumb,
|
||||
isWeb,
|
||||
key
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
export function runAssetJobs({ assetJobsDto }: {
|
||||
assetJobsDto: AssetJobsDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText("/asset/jobs", oazapfts.json({
|
||||
return oazapfts.ok(oazapfts.fetchText("/assets/jobs", oazapfts.json({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: assetJobsDto
|
||||
|
@ -1525,7 +1523,7 @@ export function getMemoryLane({ day, month }: {
|
|||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: MemoryLaneResponseDto[];
|
||||
}>(`/asset/memory-lane${QS.query(QS.explode({
|
||||
}>(`/assets/memory-lane${QS.query(QS.explode({
|
||||
day,
|
||||
month
|
||||
}))}`, {
|
||||
|
@ -1538,7 +1536,7 @@ export function getRandom({ count }: {
|
|||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: AssetResponseDto[];
|
||||
}>(`/asset/random${QS.query(QS.explode({
|
||||
}>(`/assets/random${QS.query(QS.explode({
|
||||
count
|
||||
}))}`, {
|
||||
...opts
|
||||
|
@ -1547,7 +1545,7 @@ export function getRandom({ count }: {
|
|||
export function updateStackParent({ updateStackParentDto }: {
|
||||
updateStackParentDto: UpdateStackParentDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText("/asset/stack/parent", oazapfts.json({
|
||||
return oazapfts.ok(oazapfts.fetchText("/assets/stack/parent", oazapfts.json({
|
||||
...opts,
|
||||
method: "PUT",
|
||||
body: updateStackParentDto
|
||||
|
@ -1561,7 +1559,7 @@ export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: {
|
|||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: AssetStatsResponseDto;
|
||||
}>(`/asset/statistics${QS.query(QS.explode({
|
||||
}>(`/assets/statistics${QS.query(QS.explode({
|
||||
isArchived,
|
||||
isFavorite,
|
||||
isTrashed
|
||||
|
@ -1569,40 +1567,6 @@ export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: {
|
|||
...opts
|
||||
}));
|
||||
}
|
||||
export function getAssetThumbnail({ format, id, key }: {
|
||||
format?: ThumbnailFormat;
|
||||
id: string;
|
||||
key?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/asset/thumbnail/${encodeURIComponent(id)}${QS.query(QS.explode({
|
||||
format,
|
||||
key
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
export function uploadFile({ key, xImmichChecksum, createAssetDto }: {
|
||||
key?: string;
|
||||
xImmichChecksum?: string;
|
||||
createAssetDto: CreateAssetDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 201;
|
||||
data: AssetFileUploadResponseDto;
|
||||
}>(`/asset/upload${QS.query(QS.explode({
|
||||
key
|
||||
}))}`, oazapfts.multipart({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: createAssetDto,
|
||||
headers: oazapfts.mergeHeaders(opts?.headers, {
|
||||
"x-immich-checksum": xImmichChecksum
|
||||
})
|
||||
})));
|
||||
}
|
||||
export function getAssetInfo({ id, key }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
|
@ -1610,7 +1574,7 @@ export function getAssetInfo({ id, key }: {
|
|||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: AssetResponseDto;
|
||||
}>(`/asset/${encodeURIComponent(id)}${QS.query(QS.explode({
|
||||
}>(`/assets/${encodeURIComponent(id)}${QS.query(QS.explode({
|
||||
key
|
||||
}))}`, {
|
||||
...opts
|
||||
|
@ -1623,12 +1587,25 @@ export function updateAsset({ id, updateAssetDto }: {
|
|||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: AssetResponseDto;
|
||||
}>(`/asset/${encodeURIComponent(id)}`, oazapfts.json({
|
||||
}>(`/assets/${encodeURIComponent(id)}`, oazapfts.json({
|
||||
...opts,
|
||||
method: "PUT",
|
||||
body: updateAssetDto
|
||||
})));
|
||||
}
|
||||
export function downloadAsset({ id, key }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({
|
||||
key
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Replace the asset with new file, without changing its id
|
||||
*/
|
||||
|
@ -1640,7 +1617,7 @@ export function replaceAsset({ id, key, assetMediaReplaceDto }: {
|
|||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: AssetMediaResponseDto;
|
||||
}>(`/asset/${encodeURIComponent(id)}/file${QS.query(QS.explode({
|
||||
}>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({
|
||||
key
|
||||
}))}`, oazapfts.multipart({
|
||||
...opts,
|
||||
|
@ -1648,6 +1625,34 @@ export function replaceAsset({ id, key, assetMediaReplaceDto }: {
|
|||
body: assetMediaReplaceDto
|
||||
})));
|
||||
}
|
||||
export function viewAsset({ id, key, size }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
size?: AssetMediaSize;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/assets/${encodeURIComponent(id)}/thumbnail${QS.query(QS.explode({
|
||||
key,
|
||||
size
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
export function playAssetVideo({ id, key }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/assets/${encodeURIComponent(id)}/video/playback${QS.query(QS.explode({
|
||||
key
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
export function getAuditDeletes({ after, entityType, userId }: {
|
||||
after: string;
|
||||
entityType: EntityType;
|
||||
|
@ -1733,20 +1738,6 @@ export function downloadArchive({ key, assetIdsDto }: {
|
|||
body: assetIdsDto
|
||||
})));
|
||||
}
|
||||
export function downloadFile({ id, key }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/download/asset/${encodeURIComponent(id)}${QS.query(QS.explode({
|
||||
key
|
||||
}))}`, {
|
||||
...opts,
|
||||
method: "POST"
|
||||
}));
|
||||
}
|
||||
export function getDownloadInfo({ key, downloadInfoDto }: {
|
||||
key?: string;
|
||||
downloadInfoDto: DownloadInfoDto;
|
||||
|
@ -2929,6 +2920,11 @@ export enum Error {
|
|||
NotFound = "not_found",
|
||||
Unknown = "unknown"
|
||||
}
|
||||
export enum AssetMediaStatus {
|
||||
Created = "created",
|
||||
Replaced = "replaced",
|
||||
Duplicate = "duplicate"
|
||||
}
|
||||
export enum Action {
|
||||
Accept = "accept",
|
||||
Reject = "reject"
|
||||
|
@ -2942,13 +2938,9 @@ export enum AssetJobName {
|
|||
RefreshMetadata = "refresh-metadata",
|
||||
TranscodeVideo = "transcode-video"
|
||||
}
|
||||
export enum ThumbnailFormat {
|
||||
Jpeg = "JPEG",
|
||||
Webp = "WEBP"
|
||||
}
|
||||
export enum AssetMediaStatus {
|
||||
Replaced = "replaced",
|
||||
Duplicate = "duplicate"
|
||||
export enum AssetMediaSize {
|
||||
Preview = "preview",
|
||||
Thumbnail = "thumbnail"
|
||||
}
|
||||
export enum EntityType {
|
||||
Asset = "ASSET",
|
||||
|
|
|
@ -24,9 +24,12 @@ export const setApiKey = (apiKey: string) => {
|
|||
defaults.headers['x-api-key'] = apiKey;
|
||||
};
|
||||
|
||||
export const getAssetOriginalPath = (id: string) => `/asset/file/${id}`;
|
||||
export const getAssetOriginalPath = (id: string) => `/assets/${id}/original`;
|
||||
|
||||
export const getAssetThumbnailPath = (id: string) => `/asset/thumbnail/${id}`;
|
||||
export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`;
|
||||
|
||||
export const getAssetPlaybackPath = (id: string) =>
|
||||
`/assets/${id}/video/playback`;
|
||||
|
||||
export const getUserProfileImagePath = (userId: string) =>
|
||||
`/users/${userId}/profile-image`;
|
||||
|
|
|
@ -1,37 +1,44 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Next,
|
||||
Param,
|
||||
ParseFilePipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { EndpointLifecycle } from 'src/decorators';
|
||||
import {
|
||||
AssetBulkUploadCheckResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetMediaStatusEnum,
|
||||
AssetMediaStatus,
|
||||
CheckExistingAssetsResponseDto,
|
||||
} from 'src/dtos/asset-media-response.dto';
|
||||
import {
|
||||
AssetBulkUploadCheckDto,
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaReplaceDto,
|
||||
CheckExistingAssetsDto,
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor, Route, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('Assets')
|
||||
|
@ -42,10 +49,48 @@ export class AssetMediaController {
|
|||
private service: AssetMediaService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiHeader({
|
||||
name: ImmichHeader.CHECKSUM,
|
||||
description: 'sha1 checksum that can be used for duplicate detection before the file is uploaded',
|
||||
required: false,
|
||||
})
|
||||
@ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto })
|
||||
@Authenticated({ sharedLink: true })
|
||||
async uploadAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
|
||||
@Body() dto: AssetMediaCreateDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
const { file, sidecarFile } = getFiles(files);
|
||||
const responseDto = await this.service.uploadAsset(auth, dto, file, sidecarFile);
|
||||
|
||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
|
||||
return responseDto;
|
||||
}
|
||||
|
||||
@Get(':id/original')
|
||||
@FileResponse()
|
||||
@Authenticated({ sharedLink: true })
|
||||
async downloadAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.downloadOriginal(auth, id), this.logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the asset with new file, without changing its id
|
||||
*/
|
||||
@Put(':id/file')
|
||||
@Put(':id/original')
|
||||
@UseInterceptors(FileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@Authenticated({ sharedLink: true })
|
||||
|
@ -60,12 +105,37 @@ export class AssetMediaController {
|
|||
): Promise<AssetMediaResponseDto> {
|
||||
const { file } = getFiles(files);
|
||||
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
|
||||
if (responseDto.status === AssetMediaStatusEnum.DUPLICATE) {
|
||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
return responseDto;
|
||||
}
|
||||
|
||||
@Get(':id/thumbnail')
|
||||
@FileResponse()
|
||||
@Authenticated({ sharedLink: true })
|
||||
async viewAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: AssetMediaOptionsDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.viewThumbnail(auth, id, dto), this.logger);
|
||||
}
|
||||
|
||||
@Get(':id/video/playback')
|
||||
@FileResponse()
|
||||
@Authenticated({ sharedLink: true })
|
||||
async playAssetVideo(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if multiple assets exist on the server and returns all existing - used by background backup
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -1,22 +1,16 @@
|
|||
import { Body, Controller, HttpCode, HttpStatus, Inject, Next, Param, Post, Res, StreamableFile } from '@nestjs/common';
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import { asStreamableFile, sendFile } from 'src/utils/file';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
import { asStreamableFile } from 'src/utils/file';
|
||||
|
||||
@ApiTags('Download')
|
||||
@Controller('download')
|
||||
export class DownloadController {
|
||||
constructor(
|
||||
private service: DownloadService,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {}
|
||||
constructor(private service: DownloadService) {}
|
||||
|
||||
@Post('info')
|
||||
@Authenticated({ sharedLink: true })
|
||||
|
@ -31,17 +25,4 @@ export class DownloadController {
|
|||
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
|
||||
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
|
||||
}
|
||||
|
||||
@Post('asset/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@FileResponse()
|
||||
@Authenticated({ sharedLink: true })
|
||||
async downloadFile(
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.downloadFile(auth, id), this.logger);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ import { AlbumController } from 'src/controllers/album.controller';
|
|||
import { APIKeyController } from 'src/controllers/api-key.controller';
|
||||
import { AppController } from 'src/controllers/app.controller';
|
||||
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetControllerV1 } from 'src/controllers/asset-v1.controller';
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AuditController } from 'src/controllers/audit.controller';
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
|
@ -37,7 +36,6 @@ export const controllers = [
|
|||
AlbumController,
|
||||
AppController,
|
||||
AssetController,
|
||||
AssetControllerV1,
|
||||
AssetMediaController,
|
||||
AuditController,
|
||||
AuthController,
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export enum AssetMediaStatusEnum {
|
||||
export enum AssetMediaStatus {
|
||||
CREATED = 'created',
|
||||
REPLACED = 'replaced',
|
||||
DUPLICATE = 'duplicate',
|
||||
}
|
||||
export class AssetMediaResponseDto {
|
||||
@ApiProperty({ enum: AssetMediaStatusEnum, enumName: 'AssetMediaStatus' })
|
||||
status!: AssetMediaStatusEnum;
|
||||
@ApiProperty({ enum: AssetMediaStatus, enumName: 'AssetMediaStatus' })
|
||||
status!: AssetMediaStatus;
|
||||
id!: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,16 +1,27 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import { Optional, ValidateDate } from 'src/validation';
|
||||
import { ArrayNotEmpty, IsArray, IsEnum, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum AssetMediaSize {
|
||||
PREVIEW = 'preview',
|
||||
THUMBNAIL = 'thumbnail',
|
||||
}
|
||||
|
||||
export class AssetMediaOptionsDto {
|
||||
@Optional()
|
||||
@IsEnum(AssetMediaSize)
|
||||
@ApiProperty({ enumName: 'AssetMediaSize', enum: AssetMediaSize })
|
||||
size?: AssetMediaSize;
|
||||
}
|
||||
|
||||
export enum UploadFieldName {
|
||||
ASSET_DATA = 'assetData',
|
||||
LIVE_PHOTO_DATA = 'livePhotoData',
|
||||
SIDECAR_DATA = 'sidecarData',
|
||||
PROFILE_DATA = 'file',
|
||||
}
|
||||
|
||||
export class AssetMediaReplaceDto {
|
||||
class AssetMediaBase {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceAssetId!: string;
|
||||
|
@ -35,6 +46,28 @@ export class AssetMediaReplaceDto {
|
|||
[UploadFieldName.ASSET_DATA]!: any;
|
||||
}
|
||||
|
||||
export class AssetMediaCreateDto extends AssetMediaBase {
|
||||
@ValidateBoolean({ optional: true })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isArchived?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isVisible?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isOffline?: boolean;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
livePhotoVideoId?: string;
|
||||
|
||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||
[UploadFieldName.SIDECAR_DATA]?: any;
|
||||
}
|
||||
|
||||
export class AssetMediaReplaceDto extends AssetMediaBase {}
|
||||
|
||||
export class AssetBulkUploadCheckItem {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
export class AssetFileUploadResponseDto {
|
||||
id!: string;
|
||||
duplicate!: boolean;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -127,9 +127,3 @@ export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
|
|||
total: Object.values(stats).reduce((total, value) => total + value, 0),
|
||||
};
|
||||
};
|
||||
export enum UploadFieldName {
|
||||
ASSET_DATA = 'assetData',
|
||||
LIVE_PHOTO_DATA = 'livePhotoData',
|
||||
SIDECAR_DATA = 'sidecarData',
|
||||
PROFILE_DATA = 'file',
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { UploadFieldName } from 'src/dtos/asset.dto';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
|
||||
export class CreateProfileImageDto {
|
||||
@ApiProperty({ type: 'string', format: 'binary' })
|
||||
|
|
|
@ -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';
|
|
@ -153,7 +153,7 @@ export interface IAssetRepository {
|
|||
): Promise<AssetEntity[]>;
|
||||
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
|
||||
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
|
||||
getByChecksum(libraryId: string | null, checksum: Buffer): Promise<AssetEntity | null>;
|
||||
getByChecksum(options: { ownerId: string; checksum: Buffer; libraryId?: string }): Promise<AssetEntity | null>;
|
||||
getByChecksums(userId: string, checksums: Buffer[]): Promise<AssetEntity[]>;
|
||||
getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>;
|
||||
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { AssetFileUploadResponseDto } from 'src/dtos/asset-v1-response.dto';
|
||||
import { AssetMediaResponseDto } from 'src/dtos/asset-media-response.dto';
|
||||
import { ImmichHeader } from 'src/dtos/auth.dto';
|
||||
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { fromMaybeArray } from 'src/utils/request';
|
||||
|
||||
@Injectable()
|
||||
export class AssetUploadInterceptor implements NestInterceptor {
|
||||
constructor(private service: AssetService) {}
|
||||
constructor(private service: AssetMediaService) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler<any>) {
|
||||
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const res = context.switchToHttp().getResponse<Response<AssetFileUploadResponseDto>>();
|
||||
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>();
|
||||
|
||||
const checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]);
|
||||
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum);
|
||||
|
|
|
@ -9,16 +9,14 @@ import { Observable } from 'rxjs';
|
|||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { UploadFile } from 'src/services/asset-media.service';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AssetMediaService, UploadFile } from 'src/services/asset-media.service';
|
||||
|
||||
export interface UploadFiles {
|
||||
assetData: ImmichFile[];
|
||||
livePhotoData?: ImmichFile[];
|
||||
sidecarData: ImmichFile[];
|
||||
}
|
||||
|
||||
export function getFile(files: UploadFiles, property: 'assetData' | 'livePhotoData' | 'sidecarData') {
|
||||
export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') {
|
||||
const file = files[property]?.[0];
|
||||
return file ? mapToUploadFile(file) : file;
|
||||
}
|
||||
|
@ -26,13 +24,12 @@ export function getFile(files: UploadFiles, property: 'assetData' | 'livePhotoDa
|
|||
export function getFiles(files: UploadFiles) {
|
||||
return {
|
||||
file: getFile(files, 'assetData') as UploadFile,
|
||||
livePhotoFile: getFile(files, 'livePhotoData'),
|
||||
sidecarFile: getFile(files, 'sidecarData'),
|
||||
};
|
||||
}
|
||||
|
||||
export enum Route {
|
||||
ASSET = 'asset',
|
||||
ASSET = 'assets',
|
||||
USER = 'users',
|
||||
}
|
||||
|
||||
|
@ -87,7 +84,7 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||
|
||||
constructor(
|
||||
private reflect: Reflector,
|
||||
private assetService: AssetService,
|
||||
private assetService: AssetMediaService,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.logger.setContext(FileUploadInterceptor.name);
|
||||
|
@ -109,7 +106,6 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||
userProfile: instance.single(UploadFieldName.PROFILE_DATA),
|
||||
assetUpload: instance.fields([
|
||||
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
|
||||
{ name: UploadFieldName.LIVE_PHOTO_DATA, maxCount: 1 },
|
||||
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
|
||||
]),
|
||||
};
|
||||
|
@ -172,8 +168,7 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||
|
||||
private isAssetUploadFile(file: Express.Multer.File) {
|
||||
switch (file.fieldname as UploadFieldName) {
|
||||
case UploadFieldName.ASSET_DATA:
|
||||
case UploadFieldName.LIVE_PHOTO_DATA: {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -474,8 +474,9 @@ FROM
|
|||
WHERE
|
||||
(
|
||||
(
|
||||
("AssetEntity"."libraryId" = $1)
|
||||
AND ("AssetEntity"."checksum" = $2)
|
||||
("AssetEntity"."ownerId" = $1)
|
||||
AND ("AssetEntity"."libraryId" = $2)
|
||||
AND ("AssetEntity"."checksum" = $3)
|
||||
)
|
||||
)
|
||||
AND ("AssetEntity"."deletedAt" IS NULL)
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -300,10 +300,19 @@ export class AssetRepository implements IAssetRepository {
|
|||
await this.repository.remove(asset);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
|
||||
getByChecksum(libraryId: string | null, checksum: Buffer): Promise<AssetEntity | null> {
|
||||
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, libraryId: DummyValue.UUID, checksum: DummyValue.BUFFER }] })
|
||||
getByChecksum({
|
||||
ownerId,
|
||||
libraryId,
|
||||
checksum,
|
||||
}: {
|
||||
ownerId: string;
|
||||
checksum: Buffer;
|
||||
libraryId?: string;
|
||||
}): Promise<AssetEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
ownerId,
|
||||
libraryId: libraryId || IsNull(),
|
||||
checksum,
|
||||
},
|
||||
|
|
|
@ -4,7 +4,6 @@ import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
|
|||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IAuditRepository } from 'src/interfaces/audit.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
|
@ -37,7 +36,6 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
|||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
||||
import { AssetStackRepository } from 'src/repositories/asset-stack.repository';
|
||||
import { AssetRepositoryV1 } from 'src/repositories/asset-v1.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { AuditRepository } from 'src/repositories/audit.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
|
@ -71,7 +69,6 @@ export const repositories = [
|
|||
{ provide: IAlbumRepository, useClass: AlbumRepository },
|
||||
{ provide: IAlbumUserRepository, useClass: AlbumUserRepository },
|
||||
{ provide: IAssetRepository, useClass: AssetRepository },
|
||||
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
|
||||
{ provide: IAssetStackRepository, useClass: AssetStackRepository },
|
||||
{ provide: IAuditRepository, useClass: AuditRepository },
|
||||
{ provide: ICryptoRepository, useClass: CryptoRepository },
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Stats } from 'node:fs';
|
||||
import { AssetMediaStatusEnum, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaReplaceDto } from 'src/dtos/asset-media.dto';
|
||||
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AssetMediaService, UploadFile } from 'src/services/asset-media.service';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { fileStub } from 'test/fixtures/file.stub';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
|
@ -23,65 +24,169 @@ import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
|||
import { QueryFailedError } from 'typeorm';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
const _getUpdateAssetDto = (): AssetMediaReplaceDto => {
|
||||
return Object.assign(new AssetMediaReplaceDto(), {
|
||||
deviceAssetId: 'deviceAssetId',
|
||||
deviceId: 'deviceId',
|
||||
fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
updatedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
});
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
|
||||
const uploadFile = {
|
||||
nullAuth: {
|
||||
auth: null,
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: 'upload/admin/image.jpeg',
|
||||
originalName: 'image.jpeg',
|
||||
size: 1000,
|
||||
},
|
||||
},
|
||||
filename: (fieldName: UploadFieldName, filename: string) => {
|
||||
return {
|
||||
auth: authStub.admin,
|
||||
fieldName,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: `upload/admin/${filename}`,
|
||||
originalName: filename,
|
||||
size: 1000,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const _getAsset_1 = () => {
|
||||
const asset_1 = new AssetEntity();
|
||||
const validImages = [
|
||||
'.3fr',
|
||||
'.ari',
|
||||
'.arw',
|
||||
'.avif',
|
||||
'.cap',
|
||||
'.cin',
|
||||
'.cr2',
|
||||
'.cr3',
|
||||
'.crw',
|
||||
'.dcr',
|
||||
'.dng',
|
||||
'.erf',
|
||||
'.fff',
|
||||
'.gif',
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.iiq',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.jxl',
|
||||
'.k25',
|
||||
'.kdc',
|
||||
'.mrw',
|
||||
'.nef',
|
||||
'.orf',
|
||||
'.ori',
|
||||
'.pef',
|
||||
'.png',
|
||||
'.psd',
|
||||
'.raf',
|
||||
'.raw',
|
||||
'.rwl',
|
||||
'.sr2',
|
||||
'.srf',
|
||||
'.srw',
|
||||
'.svg',
|
||||
'.tiff',
|
||||
'.webp',
|
||||
'.x3f',
|
||||
];
|
||||
|
||||
asset_1.id = 'id_1';
|
||||
asset_1.ownerId = 'user_id_1';
|
||||
asset_1.deviceAssetId = 'device_asset_id_1';
|
||||
asset_1.deviceId = 'device_id_1';
|
||||
asset_1.type = AssetType.VIDEO;
|
||||
asset_1.originalPath = 'fake_path/asset_1.jpeg';
|
||||
asset_1.previewPath = '';
|
||||
asset_1.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.isFavorite = false;
|
||||
asset_1.isArchived = false;
|
||||
asset_1.thumbnailPath = '';
|
||||
asset_1.encodedVideoPath = '';
|
||||
asset_1.duration = '0:00:00.000000';
|
||||
asset_1.exifInfo = new ExifEntity();
|
||||
asset_1.exifInfo.latitude = 49.533_547;
|
||||
asset_1.exifInfo.longitude = 10.703_075;
|
||||
asset_1.livePhotoVideoId = null;
|
||||
asset_1.sidecarPath = null;
|
||||
return asset_1;
|
||||
};
|
||||
const _getExistingAsset = () => {
|
||||
return {
|
||||
..._getAsset_1(),
|
||||
duration: null,
|
||||
type: AssetType.IMAGE,
|
||||
checksum: Buffer.from('_getExistingAsset', 'utf8'),
|
||||
libraryId: 'libraryId',
|
||||
} as AssetEntity;
|
||||
};
|
||||
const _getExistingAssetWithSideCar = () => {
|
||||
return {
|
||||
..._getExistingAsset(),
|
||||
sidecarPath: 'sidecar-path',
|
||||
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
|
||||
} as AssetEntity;
|
||||
};
|
||||
const _getCopiedAsset = () => {
|
||||
return {
|
||||
id: 'copied-asset',
|
||||
originalPath: 'copied-path',
|
||||
} as AssetEntity;
|
||||
};
|
||||
const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv'];
|
||||
|
||||
describe('AssetMediaService', () => {
|
||||
const uploadTests = [
|
||||
{
|
||||
label: 'asset images',
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
valid: validImages,
|
||||
invalid: ['.html', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'asset videos',
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
valid: validVideos,
|
||||
invalid: ['.html', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'sidecar',
|
||||
fieldName: UploadFieldName.SIDECAR_DATA,
|
||||
valid: ['.xmp'],
|
||||
invalid: ['.html', '.jpeg', '.jpg', '.mov', '.mp4', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'profile',
|
||||
fieldName: UploadFieldName.PROFILE_DATA,
|
||||
valid: ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'],
|
||||
invalid: ['.arf', '.cr2', '.html', '.mov', '.mp4', '.xml'],
|
||||
},
|
||||
];
|
||||
|
||||
const createDto = Object.freeze({
|
||||
deviceAssetId: 'deviceAssetId',
|
||||
deviceId: 'deviceId',
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
duration: '0:00:00.000000',
|
||||
}) as AssetMediaCreateDto;
|
||||
|
||||
const replaceDto = Object.freeze({
|
||||
deviceAssetId: 'deviceAssetId',
|
||||
deviceId: 'deviceId',
|
||||
fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
}) as AssetMediaReplaceDto;
|
||||
|
||||
const assetEntity = Object.freeze({
|
||||
id: 'id_1',
|
||||
ownerId: 'user_id_1',
|
||||
deviceAssetId: 'device_asset_id_1',
|
||||
deviceId: 'device_id_1',
|
||||
type: AssetType.VIDEO,
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
previewPath: '',
|
||||
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
thumbnailPath: '',
|
||||
encodedVideoPath: '',
|
||||
duration: '0:00:00.000000',
|
||||
exifInfo: {
|
||||
latitude: 49.533_547,
|
||||
longitude: 10.703_075,
|
||||
},
|
||||
livePhotoVideoId: null,
|
||||
sidecarPath: null,
|
||||
}) as AssetEntity;
|
||||
|
||||
const existingAsset = Object.freeze({
|
||||
...assetEntity,
|
||||
duration: null,
|
||||
type: AssetType.IMAGE,
|
||||
checksum: Buffer.from('_getExistingAsset', 'utf8'),
|
||||
libraryId: 'libraryId',
|
||||
originalFileName: 'existing-filename.jpeg',
|
||||
}) as AssetEntity;
|
||||
|
||||
const sidecarAsset = Object.freeze({
|
||||
...existingAsset,
|
||||
sidecarPath: 'sidecar-path',
|
||||
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
|
||||
}) as AssetEntity;
|
||||
|
||||
const copiedAsset = Object.freeze({
|
||||
id: 'copied-asset',
|
||||
originalPath: 'copied-path',
|
||||
}) as AssetEntity;
|
||||
|
||||
describe(AssetMediaService.name, () => {
|
||||
let sut: AssetMediaService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
|
@ -103,171 +208,359 @@ describe('AssetMediaService', () => {
|
|||
sut = new AssetMediaService(accessMock, assetMock, jobMock, storageMock, userMock, eventMock, loggerMock);
|
||||
});
|
||||
|
||||
describe('getUploadAssetIdByChecksum', () => {
|
||||
it('should handle a non-existent asset', async () => {
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
|
||||
it('should find an existing asset', async () => {
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
|
||||
it('should find an existing asset by base64', async () => {
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUpload', () => {
|
||||
it('should require an authenticated user', () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
for (const { fieldName, valid, invalid } of uploadTests) {
|
||||
describe(fieldName, () => {
|
||||
for (const filetype of valid) {
|
||||
it(`should accept ${filetype}`, () => {
|
||||
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const filetype of invalid) {
|
||||
it(`should reject ${filetype}`, () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('should be sorted (valid)', () => {
|
||||
// TODO: use toSorted in NodeJS 20.
|
||||
expect(valid).toEqual([...valid].sort());
|
||||
});
|
||||
|
||||
it('should be sorted (invalid)', () => {
|
||||
// TODO: use toSorted in NodeJS 20.
|
||||
expect(invalid).toEqual([...invalid].sort());
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getUploadFilename', () => {
|
||||
it('should require authentication', () => {
|
||||
expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should be the original extension for asset upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
||||
'random-uuid.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the xmp extension for sidecar upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual(
|
||||
'random-uuid.xmp',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the original extension for profile upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
||||
'random-uuid.jpg',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUploadFolder', () => {
|
||||
it('should require authentication', () => {
|
||||
expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return profile for profile uploads', () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
||||
'upload/profile/admin_id',
|
||||
);
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
|
||||
});
|
||||
|
||||
it('should return upload for everything else', () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
||||
'upload/upload/admin_id/ra/nd',
|
||||
);
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAsset', () => {
|
||||
it('should handle a file upload', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 42,
|
||||
};
|
||||
|
||||
assetMock.create.mockResolvedValue(assetEntity);
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
|
||||
id: 'id_1',
|
||||
status: AssetMediaStatus.CREATED,
|
||||
});
|
||||
|
||||
expect(assetMock.create).toHaveBeenCalled();
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
file.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(createDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a duplicate', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 0,
|
||||
};
|
||||
const error = new QueryFailedError('', [], new Error('unique key violation'));
|
||||
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
assetMock.create.mockRejectedValue(error);
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id);
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
|
||||
id: 'id_1',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: ['fake_path/asset_1.jpeg', undefined] },
|
||||
});
|
||||
expect(userMock.updateUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a live photo', async () => {
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
|
||||
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(
|
||||
authStub.user1,
|
||||
{ ...createDto, livePhotoVideoId: 'live-photo-motion-asset' },
|
||||
fileStub.livePhotoStill,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: AssetMediaStatus.CREATED,
|
||||
id: 'live-photo-still-asset',
|
||||
});
|
||||
|
||||
expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset');
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hide the linked motion asset', async () => {
|
||||
assetMock.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true });
|
||||
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(
|
||||
authStub.user1,
|
||||
{ ...createDto, livePhotoVideoId: 'live-photo-motion-asset' },
|
||||
fileStub.livePhotoStill,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: AssetMediaStatus.CREATED,
|
||||
id: 'live-photo-still-asset',
|
||||
});
|
||||
|
||||
expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset');
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadOriginal', () => {
|
||||
it('should require the asset.download permission', async () => {
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is not found', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(assetMock.getById).toHaveBeenCalledWith('asset-1');
|
||||
});
|
||||
|
||||
it('should download a file', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/original/path.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceAsset', () => {
|
||||
const expectAssetUpdate = (
|
||||
existingAsset: AssetEntity,
|
||||
uploadFile: UploadFile,
|
||||
dto: AssetMediaReplaceDto,
|
||||
livePhotoVideo?: AssetEntity,
|
||||
sidecarPath?: UploadFile,
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
) => {
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: existingAsset.id,
|
||||
checksum: uploadFile.checksum,
|
||||
originalFileName: uploadFile.originalName,
|
||||
originalPath: uploadFile.originalPath,
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
type: mimeTypes.assetType(uploadFile.originalPath),
|
||||
duration: dto.duration || null,
|
||||
livePhotoVideo: livePhotoVideo ? { id: livePhotoVideo?.id } : null,
|
||||
sidecarPath: sidecarPath?.originalPath || null,
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
const expectAssetCreateCopy = (existingAsset: AssetEntity) => {
|
||||
expect(assetMock.create).toHaveBeenCalledWith({
|
||||
ownerId: existingAsset.ownerId,
|
||||
originalPath: existingAsset.originalPath,
|
||||
originalFileName: existingAsset.originalFileName,
|
||||
libraryId: existingAsset.libraryId,
|
||||
deviceAssetId: existingAsset.deviceAssetId,
|
||||
deviceId: existingAsset.deviceId,
|
||||
type: existingAsset.type,
|
||||
checksum: existingAsset.checksum,
|
||||
fileCreatedAt: existingAsset.fileCreatedAt,
|
||||
localDateTime: existingAsset.localDateTime,
|
||||
fileModifiedAt: existingAsset.fileModifiedAt,
|
||||
livePhotoVideoId: existingAsset.livePhotoVideoId || null,
|
||||
sidecarPath: existingAsset.sidecarPath || null,
|
||||
});
|
||||
};
|
||||
|
||||
it('should error when update photo does not exist', async () => {
|
||||
const dto = _getUpdateAssetDto();
|
||||
assetMock.getById.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, 'id', dto, fileStub.photo)).rejects.toThrow(
|
||||
await expect(sut.replaceAsset(authStub.user1, 'id', replaceDto, fileStub.photo)).rejects.toThrow(
|
||||
'Not found or no asset.update access',
|
||||
);
|
||||
|
||||
expect(assetMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a photo with no sidecar to photo with no sidecar', async () => {
|
||||
const existingAsset = _getExistingAsset();
|
||||
const updatedFile = fileStub.photo;
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
const dto = _getUpdateAssetDto();
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.REPLACED,
|
||||
id: _getCopiedAsset().id,
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto);
|
||||
expectAssetCreateCopy(existingAsset);
|
||||
expect(assetMock.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: existingAsset.id,
|
||||
sidecarPath: null,
|
||||
originalFileName: 'photo1.jpeg',
|
||||
originalPath: 'fake_path/photo1.jpeg',
|
||||
}),
|
||||
);
|
||||
expect(assetMock.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sidecarPath: null,
|
||||
originalFileName: 'existing-filename.jpeg',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([copiedAsset.id]);
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
it('should update a photo with sidecar to photo with sidecar', async () => {
|
||||
const existingAsset = _getExistingAssetWithSideCar();
|
||||
|
||||
it('should update a photo with sidecar to photo with sidecar', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
const sidecarFile = fileStub.photoSidecar;
|
||||
const dto = _getUpdateAssetDto();
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile, sidecarFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.REPLACED,
|
||||
id: _getCopiedAsset().id,
|
||||
await expect(
|
||||
sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile),
|
||||
).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto, undefined, sidecarFile);
|
||||
expectAssetCreateCopy(existingAsset);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a photo with a sidecar to photo with no sidecar', async () => {
|
||||
const existingAsset = _getExistingAssetWithSideCar();
|
||||
const updatedFile = fileStub.photo;
|
||||
|
||||
const dto = _getUpdateAssetDto();
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
||||
assetMock.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the copy call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.REPLACED,
|
||||
id: _getCopiedAsset().id,
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto);
|
||||
expectAssetCreateCopy(existingAsset);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a photo with sidecar to duplicate photo ', async () => {
|
||||
const existingAsset = _getExistingAssetWithSideCar();
|
||||
const updatedFile = fileStub.photo;
|
||||
const dto = _getUpdateAssetDto();
|
||||
const error = new QueryFailedError('', [], new Error('unique key violation'));
|
||||
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
assetMock.update.mockRejectedValue(error);
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue(existingAsset.id);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
assetMock.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.DUPLICATE,
|
||||
id: existingAsset.id,
|
||||
await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
id: sidecarAsset.id,
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto);
|
||||
expect(assetMock.create).not.toHaveBeenCalled();
|
||||
expect(assetMock.softDeleteAll).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
|
@ -277,6 +570,7 @@ describe('AssetMediaService', () => {
|
|||
expect(userMock.updateUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUploadCheck', () => {
|
||||
it('should accept hex and base64 checksums', async () => {
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
|
|
|
@ -1,21 +1,33 @@
|
|||
import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { extname } from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import {
|
||||
AssetBulkUploadCheckResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetMediaStatusEnum,
|
||||
AssetMediaStatus,
|
||||
AssetRejectReason,
|
||||
AssetUploadAction,
|
||||
CheckExistingAssetsResponseDto,
|
||||
} from 'src/dtos/asset-media-response.dto';
|
||||
import {
|
||||
AssetBulkUploadCheckDto,
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaReplaceDto,
|
||||
AssetMediaSize,
|
||||
CheckExistingAssetsDto,
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
|
@ -23,6 +35,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
|||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
|
@ -57,7 +70,121 @@ export class AssetMediaService {
|
|||
this.access = AccessCore.create(accessRepository);
|
||||
}
|
||||
|
||||
public async replaceAsset(
|
||||
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
|
||||
if (!checksum) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
|
||||
if (!assetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { id: assetId, status: AssetMediaStatus.DUPLICATE };
|
||||
}
|
||||
|
||||
canUploadFile({ auth, fieldName, file }: UploadRequest): true {
|
||||
this.access.requireUploadAccess(auth);
|
||||
|
||||
const filename = file.originalName;
|
||||
|
||||
switch (fieldName) {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
if (mimeTypes.isAsset(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.SIDECAR_DATA: {
|
||||
if (mimeTypes.isSidecar(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.PROFILE_DATA: {
|
||||
if (mimeTypes.isProfile(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error(`Unsupported file type ${filename}`);
|
||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
||||
}
|
||||
|
||||
getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
|
||||
this.access.requireUploadAccess(auth);
|
||||
|
||||
const originalExtension = extname(file.originalName);
|
||||
|
||||
const lookup = {
|
||||
[UploadFieldName.ASSET_DATA]: originalExtension,
|
||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
||||
[UploadFieldName.PROFILE_DATA]: originalExtension,
|
||||
};
|
||||
|
||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
||||
}
|
||||
|
||||
getUploadFolder({ auth, fieldName, file }: UploadRequest): string {
|
||||
auth = this.access.requireUploadAccess(auth);
|
||||
|
||||
let folder = StorageCore.getNestedFolder(StorageFolder.UPLOAD, auth.user.id, file.uuid);
|
||||
if (fieldName === UploadFieldName.PROFILE_DATA) {
|
||||
folder = StorageCore.getFolderLocation(StorageFolder.PROFILE, auth.user.id);
|
||||
}
|
||||
|
||||
this.storageRepository.mkdirSync(folder);
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
async uploadAsset(
|
||||
auth: AuthDto,
|
||||
dto: AssetMediaCreateDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
try {
|
||||
await this.access.requirePermission(
|
||||
auth,
|
||||
Permission.ASSET_UPLOAD,
|
||||
// do not need an id here, but the interface requires it
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
|
||||
if (dto.livePhotoVideoId) {
|
||||
const motionAsset = await this.assetRepository.getById(dto.livePhotoVideoId);
|
||||
if (!motionAsset) {
|
||||
throw new BadRequestException('Live photo video not found');
|
||||
}
|
||||
if (motionAsset.type !== AssetType.VIDEO) {
|
||||
throw new BadRequestException('Live photo vide must be a video');
|
||||
}
|
||||
if (motionAsset.ownerId !== auth.user.id) {
|
||||
throw new BadRequestException('Live photo video does not belong to the user');
|
||||
}
|
||||
if (motionAsset.isVisible) {
|
||||
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
||||
}
|
||||
}
|
||||
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
|
||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||
} catch (error: any) {
|
||||
return this.handleUploadError(error, auth, file, sidecarFile);
|
||||
}
|
||||
}
|
||||
|
||||
async replaceAsset(
|
||||
auth: AuthDto,
|
||||
id: string,
|
||||
dto: AssetMediaReplaceDto,
|
||||
|
@ -66,27 +193,131 @@ export class AssetMediaService {
|
|||
): Promise<AssetMediaResponseDto> {
|
||||
try {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id);
|
||||
const existingAssetEntity = (await this.assetRepository.getById(id)) as AssetEntity;
|
||||
const asset = (await this.assetRepository.getById(id)) as AssetEntity;
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
|
||||
await this.replaceFileData(existingAssetEntity.id, dto, file, sidecarFile?.originalPath);
|
||||
await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath);
|
||||
|
||||
// Next, create a backup copy of the existing record. The db record has already been updated above,
|
||||
// but the local variable holds the original file data paths.
|
||||
const copiedPhoto = await this.createCopy(existingAssetEntity);
|
||||
const copiedPhoto = await this.createCopy(asset);
|
||||
// and immediate trash it
|
||||
await this.assetRepository.softDeleteAll([copiedPhoto.id]);
|
||||
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]);
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
|
||||
return { status: AssetMediaStatusEnum.REPLACED, id: copiedPhoto.id };
|
||||
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
|
||||
} catch (error: any) {
|
||||
return await this.handleUploadError(error, auth, file, sidecarFile);
|
||||
return this.handleUploadError(error, auth, file, sidecarFile);
|
||||
}
|
||||
}
|
||||
|
||||
async downloadOriginal(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id);
|
||||
|
||||
const asset = await this.findOrFail(id);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset does not exist');
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: asset.originalPath,
|
||||
contentType: mimeTypes.lookup(asset.originalPath),
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
});
|
||||
}
|
||||
|
||||
async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_VIEW, id);
|
||||
|
||||
const asset = await this.findOrFail(id);
|
||||
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
|
||||
|
||||
let filepath = asset.previewPath;
|
||||
if (size === AssetMediaSize.THUMBNAIL && asset.thumbnailPath) {
|
||||
filepath = asset.thumbnailPath;
|
||||
}
|
||||
|
||||
if (!filepath) {
|
||||
throw new NotFoundException('Asset media not found');
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: filepath,
|
||||
contentType: mimeTypes.lookup(filepath),
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
});
|
||||
}
|
||||
|
||||
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_VIEW, id);
|
||||
|
||||
const asset = await this.findOrFail(id);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset does not exist');
|
||||
}
|
||||
|
||||
if (asset.type !== AssetType.VIDEO) {
|
||||
throw new BadRequestException('Asset is not a video');
|
||||
}
|
||||
|
||||
const filepath = asset.encodedVideoPath || asset.originalPath;
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: filepath,
|
||||
contentType: mimeTypes.lookup(filepath),
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
});
|
||||
}
|
||||
|
||||
async checkExistingAssets(
|
||||
auth: AuthDto,
|
||||
checkExistingAssetsDto: CheckExistingAssetsDto,
|
||||
): Promise<CheckExistingAssetsResponseDto> {
|
||||
const assets = await this.assetRepository.getByDeviceIds(
|
||||
auth.user.id,
|
||||
checkExistingAssetsDto.deviceId,
|
||||
checkExistingAssetsDto.deviceAssetIds,
|
||||
);
|
||||
return {
|
||||
existingIds: assets.map((asset) => asset.id),
|
||||
};
|
||||
}
|
||||
|
||||
async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
|
||||
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
|
||||
const results = await this.assetRepository.getByChecksums(auth.user.id, checksums);
|
||||
const checksumMap: Record<string, string> = {};
|
||||
|
||||
for (const { id, checksum } of results) {
|
||||
checksumMap[checksum.toString('hex')] = id;
|
||||
}
|
||||
|
||||
return {
|
||||
results: dto.assets.map(({ id, checksum }) => {
|
||||
const duplicate = checksumMap[fromChecksum(checksum).toString('hex')];
|
||||
if (duplicate) {
|
||||
return {
|
||||
id,
|
||||
assetId: duplicate,
|
||||
action: AssetUploadAction.REJECT,
|
||||
reason: AssetRejectReason.DUPLICATE,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO mime-check
|
||||
|
||||
return {
|
||||
id,
|
||||
action: AssetUploadAction.ACCEPT,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async handleUploadError(
|
||||
error: any,
|
||||
auth: AuthDto,
|
||||
|
@ -106,7 +337,7 @@ export class AssetMediaService {
|
|||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
return { status: AssetMediaStatusEnum.DUPLICATE, id: duplicateId };
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
|
@ -181,54 +412,59 @@ export class AssetMediaService {
|
|||
return created;
|
||||
}
|
||||
|
||||
private async create(
|
||||
ownerId: string,
|
||||
dto: AssetMediaCreateDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
): Promise<AssetEntity> {
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
isFavorite: dto.isFavorite,
|
||||
isArchived: dto.isArchived ?? false,
|
||||
duration: dto.duration || null,
|
||||
isVisible: dto.isVisible ?? true,
|
||||
livePhotoVideoId: dto.livePhotoVideoId,
|
||||
originalFileName: file.originalName,
|
||||
sidecarPath: sidecarFile?.originalPath,
|
||||
isOffline: dto.isOffline ?? false,
|
||||
});
|
||||
|
||||
if (sidecarFile) {
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
private requireQuota(auth: AuthDto, size: number) {
|
||||
if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
||||
throw new BadRequestException('Quota has been exceeded!');
|
||||
}
|
||||
}
|
||||
|
||||
async checkExistingAssets(
|
||||
auth: AuthDto,
|
||||
checkExistingAssetsDto: CheckExistingAssetsDto,
|
||||
): Promise<CheckExistingAssetsResponseDto> {
|
||||
const assets = await this.assetRepository.getByDeviceIds(
|
||||
auth.user.id,
|
||||
checkExistingAssetsDto.deviceId,
|
||||
checkExistingAssetsDto.deviceAssetIds,
|
||||
);
|
||||
return {
|
||||
existingIds: assets.map((asset) => asset.id),
|
||||
};
|
||||
}
|
||||
|
||||
async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
|
||||
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
|
||||
const results = await this.assetRepository.getByChecksums(auth.user.id, checksums);
|
||||
const checksumMap: Record<string, string> = {};
|
||||
|
||||
for (const { id, checksum } of results) {
|
||||
checksumMap[checksum.toString('hex')] = id;
|
||||
private async findOrFail(id: string): Promise<AssetEntity> {
|
||||
const asset = await this.assetRepository.getById(id);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
}
|
||||
|
||||
return {
|
||||
results: dto.assets.map(({ id, checksum }) => {
|
||||
const duplicate = checksumMap[fromChecksum(checksum).toString('hex')];
|
||||
if (duplicate) {
|
||||
return {
|
||||
id,
|
||||
assetId: duplicate,
|
||||
action: AssetUploadAction.REJECT,
|
||||
reason: AssetRejectReason.DUPLICATE,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO mime-check
|
||||
|
||||
return {
|
||||
id,
|
||||
action: AssetUploadAction.ACCEPT,
|
||||
};
|
||||
}),
|
||||
};
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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!');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/asset.dto';
|
||||
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
||||
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
|
@ -8,7 +8,6 @@ import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
|||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
|
@ -24,13 +23,10 @@ import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'
|
|||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
||||
import { Mocked, vitest } from 'vitest';
|
||||
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
|
||||
const stats: AssetStats = {
|
||||
[AssetType.IMAGE]: 10,
|
||||
[AssetType.VIDEO]: 23,
|
||||
|
@ -44,117 +40,11 @@ const statResponse: AssetStatsResponseDto = {
|
|||
total: 33,
|
||||
};
|
||||
|
||||
const uploadFile = {
|
||||
nullAuth: {
|
||||
auth: null,
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: 'upload/admin/image.jpeg',
|
||||
originalName: 'image.jpeg',
|
||||
size: 1000,
|
||||
},
|
||||
},
|
||||
filename: (fieldName: UploadFieldName, filename: string) => {
|
||||
return {
|
||||
auth: authStub.admin,
|
||||
fieldName,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: `upload/admin/${filename}`,
|
||||
originalName: filename,
|
||||
size: 1000,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const validImages = [
|
||||
'.3fr',
|
||||
'.ari',
|
||||
'.arw',
|
||||
'.avif',
|
||||
'.cap',
|
||||
'.cin',
|
||||
'.cr2',
|
||||
'.cr3',
|
||||
'.crw',
|
||||
'.dcr',
|
||||
'.dng',
|
||||
'.erf',
|
||||
'.fff',
|
||||
'.gif',
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.iiq',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.jxl',
|
||||
'.k25',
|
||||
'.kdc',
|
||||
'.mrw',
|
||||
'.nef',
|
||||
'.orf',
|
||||
'.ori',
|
||||
'.pef',
|
||||
'.png',
|
||||
'.psd',
|
||||
'.raf',
|
||||
'.raw',
|
||||
'.rwl',
|
||||
'.sr2',
|
||||
'.srf',
|
||||
'.srw',
|
||||
'.svg',
|
||||
'.tiff',
|
||||
'.webp',
|
||||
'.x3f',
|
||||
];
|
||||
|
||||
const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv'];
|
||||
|
||||
const uploadTests = [
|
||||
{
|
||||
label: 'asset images',
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
valid: validImages,
|
||||
invalid: ['.html', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'asset videos',
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
valid: validVideos,
|
||||
invalid: ['.html', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'live photo',
|
||||
fieldName: UploadFieldName.LIVE_PHOTO_DATA,
|
||||
valid: validVideos,
|
||||
invalid: ['.html', '.jpeg', '.jpg', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'sidecar',
|
||||
fieldName: UploadFieldName.SIDECAR_DATA,
|
||||
valid: ['.xmp'],
|
||||
invalid: ['.html', '.jpeg', '.jpg', '.mov', '.mp4', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'profile',
|
||||
fieldName: UploadFieldName.PROFILE_DATA,
|
||||
valid: ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'],
|
||||
invalid: ['.arf', '.cr2', '.html', '.mov', '.mp4', '.xml'],
|
||||
},
|
||||
];
|
||||
|
||||
describe(AssetService.name, () => {
|
||||
let sut: AssetService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
|
@ -177,7 +67,6 @@ describe(AssetService.name, () => {
|
|||
assetMock = newAssetRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
systemMock = newSystemMetadataRepositoryMock();
|
||||
partnerMock = newPartnerRepositoryMock();
|
||||
|
@ -189,7 +78,6 @@ describe(AssetService.name, () => {
|
|||
assetMock,
|
||||
jobMock,
|
||||
systemMock,
|
||||
storageMock,
|
||||
userMock,
|
||||
eventMock,
|
||||
partnerMock,
|
||||
|
@ -200,115 +88,6 @@ describe(AssetService.name, () => {
|
|||
mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]);
|
||||
});
|
||||
|
||||
describe('getUploadAssetIdByChecksum', () => {
|
||||
it('should handle a non-existent asset', async () => {
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
|
||||
it('should find an existing asset', async () => {
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
duplicate: true,
|
||||
});
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
|
||||
it('should find an existing asset by base64', async () => {
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
duplicate: true,
|
||||
});
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUpload', () => {
|
||||
it('should require an authenticated user', () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
for (const { fieldName, valid, invalid } of uploadTests) {
|
||||
describe(fieldName, () => {
|
||||
for (const filetype of valid) {
|
||||
it(`should accept ${filetype}`, () => {
|
||||
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const filetype of invalid) {
|
||||
it(`should reject ${filetype}`, () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('should be sorted (valid)', () => {
|
||||
// TODO: use toSorted in NodeJS 20.
|
||||
expect(valid).toEqual([...valid].sort());
|
||||
});
|
||||
|
||||
it('should be sorted (invalid)', () => {
|
||||
// TODO: use toSorted in NodeJS 20.
|
||||
expect(invalid).toEqual([...invalid].sort());
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getUploadFilename', () => {
|
||||
it('should require authentication', () => {
|
||||
expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should be the original extension for asset upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
||||
'random-uuid.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the mov extension for live photo upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual(
|
||||
'random-uuid.mov',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the xmp extension for sidecar upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual(
|
||||
'random-uuid.xmp',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the original extension for profile upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
||||
'random-uuid.jpg',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUploadFolder', () => {
|
||||
it('should require authentication', () => {
|
||||
expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return profile for profile uploads', () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
||||
'upload/profile/admin_id',
|
||||
);
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
|
||||
});
|
||||
|
||||
it('should return upload for everything else', () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
||||
'upload/upload/admin_id/ra/nd',
|
||||
);
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMemoryLane', () => {
|
||||
beforeAll(() => {
|
||||
vitest.useFakeTimers();
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import { BadRequestException, Inject } from '@nestjs/common';
|
||||
import _ from 'lodash';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { extname } from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import {
|
||||
AssetResponseDto,
|
||||
|
@ -12,7 +9,6 @@ import {
|
|||
SanitizedAssetResponseDto,
|
||||
mapAsset,
|
||||
} from 'src/dtos/asset-response.dto';
|
||||
import { AssetFileUploadResponseDto } from 'src/dtos/asset-v1-response.dto';
|
||||
import {
|
||||
AssetBulkDeleteDto,
|
||||
AssetBulkUpdateDto,
|
||||
|
@ -20,7 +16,6 @@ import {
|
|||
AssetJobsDto,
|
||||
AssetStatsDto,
|
||||
UpdateAssetDto,
|
||||
UploadFieldName,
|
||||
mapStats,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
|
@ -42,13 +37,9 @@ import {
|
|||
} from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { UploadRequest } from 'src/services/asset-media.service';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
export class AssetService {
|
||||
private access: AccessCore;
|
||||
|
@ -59,7 +50,6 @@ export class AssetService {
|
|||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
||||
|
@ -71,86 +61,6 @@ export class AssetService {
|
|||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||
}
|
||||
|
||||
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetFileUploadResponseDto | undefined> {
|
||||
if (!checksum) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
|
||||
if (!assetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { id: assetId, duplicate: true };
|
||||
}
|
||||
|
||||
canUploadFile({ auth, fieldName, file }: UploadRequest): true {
|
||||
this.access.requireUploadAccess(auth);
|
||||
|
||||
const filename = file.originalName;
|
||||
|
||||
switch (fieldName) {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
if (mimeTypes.isAsset(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.LIVE_PHOTO_DATA: {
|
||||
if (mimeTypes.isVideo(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.SIDECAR_DATA: {
|
||||
if (mimeTypes.isSidecar(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.PROFILE_DATA: {
|
||||
if (mimeTypes.isProfile(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error(`Unsupported file type ${filename}`);
|
||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
||||
}
|
||||
|
||||
getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
|
||||
this.access.requireUploadAccess(auth);
|
||||
|
||||
const originalExtension = extname(file.originalName);
|
||||
|
||||
const lookup = {
|
||||
[UploadFieldName.ASSET_DATA]: originalExtension,
|
||||
[UploadFieldName.LIVE_PHOTO_DATA]: '.mov',
|
||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
||||
[UploadFieldName.PROFILE_DATA]: originalExtension,
|
||||
};
|
||||
|
||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
||||
}
|
||||
|
||||
getUploadFolder({ auth, fieldName, file }: UploadRequest): string {
|
||||
auth = this.access.requireUploadAccess(auth);
|
||||
|
||||
let folder = StorageCore.getNestedFolder(StorageFolder.UPLOAD, auth.user.id, file.uuid);
|
||||
if (fieldName === UploadFieldName.PROFILE_DATA) {
|
||||
folder = StorageCore.getFolderLocation(StorageFolder.PROFILE, auth.user.id);
|
||||
}
|
||||
|
||||
this.storageRepository.mkdirSync(folder);
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
|
|
|
@ -4,7 +4,6 @@ import { AssetEntity } from 'src/entities/asset.entity';
|
|||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
|
@ -41,46 +40,7 @@ describe(DownloadService.name, () => {
|
|||
sut = new DownloadService(accessMock, assetMock, storageMock);
|
||||
});
|
||||
|
||||
describe('downloadFile', () => {
|
||||
it('should require the asset.download permission', async () => {
|
||||
await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is not found', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getByIds.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is offline', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.offline]);
|
||||
|
||||
await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
|
||||
});
|
||||
|
||||
it('should download a file', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
|
||||
await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/original/path.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.NONE,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('downloadArchive', () => {
|
||||
it('should download an archive', async () => {
|
||||
const archiveMock = {
|
||||
addFile: vitest.fn(),
|
||||
|
|
|
@ -9,8 +9,6 @@ import { IAccessRepository } from 'src/interfaces/access.interface';
|
|||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IStorageRepository, ImmichReadStream } from 'src/interfaces/storage.interface';
|
||||
import { HumanReadableSize } from 'src/utils/bytes';
|
||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
@Injectable()
|
||||
|
@ -25,25 +23,6 @@ export class DownloadService {
|
|||
this.access = AccessCore.create(accessRepository);
|
||||
}
|
||||
|
||||
async downloadFile(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id);
|
||||
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
throw new BadRequestException('Asset not found');
|
||||
}
|
||||
|
||||
if (asset.isOffline) {
|
||||
throw new BadRequestException('Asset is offline');
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: asset.originalPath,
|
||||
contentType: mimeTypes.lookup(asset.originalPath),
|
||||
cacheControl: CacheControl.NONE,
|
||||
});
|
||||
}
|
||||
|
||||
async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
|
||||
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
|
||||
const archives: DownloadArchiveInfo[] = [];
|
||||
|
|
|
@ -3,7 +3,6 @@ import { AlbumService } from 'src/services/album.service';
|
|||
import { APIKeyService } from 'src/services/api-key.service';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { AssetServiceV1 } from 'src/services/asset-v1.service';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AuditService } from 'src/services/audit.service';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
|
@ -45,7 +44,6 @@ export const services = [
|
|||
ApiService,
|
||||
AssetMediaService,
|
||||
AssetService,
|
||||
AssetServiceV1,
|
||||
AuditService,
|
||||
AuthService,
|
||||
CliService,
|
||||
|
|
|
@ -411,7 +411,11 @@ export class MetadataService {
|
|||
}
|
||||
const checksum = this.cryptoRepository.hashSha1(video);
|
||||
|
||||
let motionAsset = await this.assetRepository.getByChecksum(asset.libraryId ?? null, checksum);
|
||||
let motionAsset = await this.assetRepository.getByChecksum({
|
||||
ownerId: asset.ownerId,
|
||||
libraryId: asset.libraryId ?? undefined,
|
||||
checksum,
|
||||
});
|
||||
if (motionAsset) {
|
||||
this.logger.debug(
|
||||
`Asset ${asset.id}'s motion photo video with checksum ${checksum.toString(
|
||||
|
|
|
@ -271,7 +271,7 @@ describe(SharedLinkService.name, () => {
|
|||
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
|
||||
description: '1 shared photos & videos',
|
||||
imageUrl:
|
||||
'/api/asset/thumbnail/asset-id?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0',
|
||||
'/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0',
|
||||
title: 'Public Share',
|
||||
});
|
||||
expect(shareMock.get).toHaveBeenCalled();
|
||||
|
|
|
@ -191,7 +191,7 @@ export class SharedLinkService {
|
|||
title: sharedLink.album ? sharedLink.album.albumName : 'Public Share',
|
||||
description: sharedLink.description || `${assetCount} shared photos & videos`,
|
||||
imageUrl: assetId
|
||||
? `/api/asset/thumbnail/${assetId}?key=${sharedLink.key.toString('base64url')}`
|
||||
? `/api/assets/${assetId}/thumbnail?key=${sharedLink.key.toString('base64url')}`
|
||||
: '/feature-panel.png',
|
||||
};
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ describe('AlbumCard component', () => {
|
|||
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
|
||||
|
||||
expect(albumImgElement).toHaveAttribute('alt', album.albumName);
|
||||
expect(sdkMock.getAssetThumbnail).not.toHaveBeenCalled();
|
||||
expect(sdkMock.viewAsset).not.toHaveBeenCalled();
|
||||
|
||||
expect(albumNameElement).toHaveTextContent(album.albumName);
|
||||
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { type AlbumResponseDto } from '@immich/sdk';
|
||||
|
||||
export let album: AlbumResponseDto | undefined;
|
||||
export let preload = false;
|
||||
export let css = '';
|
||||
|
||||
$: thumbnailUrl =
|
||||
album && album.albumThumbnailAssetId
|
||||
? getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)
|
||||
: null;
|
||||
album && album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null;
|
||||
</script>
|
||||
|
||||
<div class="relative aspect-square">
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import { isTenMinutesApart } from '$lib/utils/timesince';
|
||||
import {
|
||||
ReactionType,
|
||||
ThumbnailFormat,
|
||||
createActivity,
|
||||
deleteActivity,
|
||||
getActivities,
|
||||
|
@ -182,7 +181,7 @@
|
|||
<a class="aspect-square w-[75px] h-[75px]" href="{AppRoute.ALBUMS}/{albumId}/photos/{reaction.assetId}">
|
||||
<img
|
||||
class="rounded-lg w-[75px] h-[75px] object-cover"
|
||||
src={getAssetThumbnailUrl(reaction.assetId, ThumbnailFormat.Webp)}
|
||||
src={getAssetThumbnailUrl(reaction.assetId)}
|
||||
alt="Profile picture of {reaction.user.name}, who commented on this asset"
|
||||
/>
|
||||
</a>
|
||||
|
@ -235,7 +234,7 @@
|
|||
>
|
||||
<img
|
||||
class="rounded-lg w-[75px] h-[75px] object-cover"
|
||||
src={getAssetThumbnailUrl(reaction.assetId, ThumbnailFormat.Webp)}
|
||||
src={getAssetThumbnailUrl(reaction.assetId)}
|
||||
alt="Profile picture of {reaction.user.name}, who liked this asset"
|
||||
/>
|
||||
</a>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { type AlbumResponseDto } from '@immich/sdk';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils.js';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
|
@ -35,7 +35,7 @@
|
|||
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
|
||||
{#if album.albumThumbnailAssetId}
|
||||
<img
|
||||
src={getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)}
|
||||
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
|
||||
alt={album.albumName}
|
||||
class="z-0 h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
|
||||
data-testid="album-image"
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError, isSharedLink } from '$lib/utils';
|
||||
import { delay, isFlipped } from '$lib/utils/asset-utils';
|
||||
import {
|
||||
ThumbnailFormat,
|
||||
AssetMediaSize,
|
||||
getAssetInfo,
|
||||
updateAsset,
|
||||
type AlbumResponseDto,
|
||||
|
@ -474,7 +474,7 @@
|
|||
alt={album.albumName}
|
||||
class="h-[50px] w-[50px] rounded object-cover"
|
||||
src={album.albumThumbnailAssetId &&
|
||||
getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Jpeg)}
|
||||
getAssetThumbnailUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { serveFile, type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
|
||||
import { getAssetOriginalUrl, getKey } from '$lib/utils';
|
||||
import { AssetMediaSize, AssetTypeEnum, viewAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import type { AdapterConstructor, PluginConstructor } from '@photo-sphere-viewer/core';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { getAssetFileUrl, getKey } from '$lib/utils';
|
||||
import type { AdapterConstructor, PluginConstructor } from '@photo-sphere-viewer/core';
|
||||
export let asset: Pick<AssetResponseDto, 'id' | 'type'>;
|
||||
|
||||
const photoSphereConfigs =
|
||||
|
@ -20,9 +20,9 @@
|
|||
|
||||
const loadAssetData = async () => {
|
||||
if (asset.type === AssetTypeEnum.Video) {
|
||||
return { source: getAssetFileUrl(asset.id, false, false) };
|
||||
return { source: getAssetOriginalUrl(asset.id) };
|
||||
}
|
||||
const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() });
|
||||
const data = await viewAsset({ id: asset.id, size: AssetMediaSize.Preview, key: getKey() });
|
||||
const url = URL.createObjectURL(data);
|
||||
return url;
|
||||
};
|
||||
|
|
|
@ -44,7 +44,7 @@ describe('PhotoViewer component', () => {
|
|||
|
||||
expect(downloadRequestMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=true&c=${asset.checksum}`,
|
||||
url: `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.checksum}`,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument());
|
||||
|
@ -61,7 +61,7 @@ describe('PhotoViewer component', () => {
|
|||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'url-two'));
|
||||
expect(downloadRequestMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=false&c=${asset.checksum}`,
|
||||
url: `/api/assets/${asset.id}/original?c=${asset.checksum}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
@ -76,7 +76,7 @@ describe('PhotoViewer component', () => {
|
|||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'url-two'));
|
||||
expect(downloadRequestMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=true&c=new-checksum`,
|
||||
url: `/api/assets/${asset.id}/thumbnail?size=preview&c=new-checksum`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
@ -3,11 +3,11 @@
|
|||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { downloadRequest, getAssetFileUrl, handlePromiseError } from '$lib/utils';
|
||||
import { downloadRequest, getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
|
||||
import { type AssetResponseDto, AssetTypeEnum, AssetMediaSize } from '@immich/sdk';
|
||||
import { useZoomImageWheel } from '@zoom-image/svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
@ -62,7 +62,9 @@
|
|||
|
||||
// TODO: Use sdk once it supports signals
|
||||
const res = await downloadRequest({
|
||||
url: getAssetFileUrl(asset.id, !loadOriginal, false, checksum),
|
||||
url: loadOriginal
|
||||
? getAssetOriginalUrl({ id: asset.id, checksum })
|
||||
: getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview, checksum }),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
|
@ -76,7 +78,9 @@
|
|||
for (const preloadAsset of preloadAssets) {
|
||||
if (preloadAsset.type === AssetTypeEnum.Image) {
|
||||
await downloadRequest({
|
||||
url: getAssetFileUrl(preloadAsset.id, !loadOriginal, false),
|
||||
url: loadOriginal
|
||||
? getAssetOriginalUrl(preloadAsset.id)
|
||||
: getAssetThumbnailUrl({ id: preloadAsset.id, size: AssetMediaSize.Preview }),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { loopVideo as loopVideoPreference, videoViewerVolume, videoViewerMuted } from '$lib/stores/preferences.store';
|
||||
import { getAssetFileUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { ThumbnailFormat } from '@immich/sdk';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
|
||||
export let assetId: string;
|
||||
export let loopVideo: boolean;
|
||||
|
@ -16,7 +16,7 @@
|
|||
let assetFileUrl: string;
|
||||
|
||||
$: {
|
||||
const next = getAssetFileUrl(assetId, false, true, checksum);
|
||||
const next = getAssetPlaybackUrl({ id: assetId, checksum });
|
||||
if (assetFileUrl !== next) {
|
||||
assetFileUrl = next;
|
||||
element && element.load();
|
||||
|
@ -54,7 +54,7 @@
|
|||
on:ended={() => dispatch('onVideoEnded')}
|
||||
bind:muted={$videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetThumbnailUrl(assetId, ThumbnailFormat.Jpeg, checksum)}
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })}
|
||||
>
|
||||
<source src={assetFileUrl} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
|
|
|
@ -2,11 +2,12 @@
|
|||
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import { getAssetFileUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
|
||||
import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { timeToSeconds } from '$lib/utils/date-time';
|
||||
import { AssetTypeEnum, ThumbnailFormat, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
|
||||
import { getAssetPlaybackUrl } from '$lib/utils';
|
||||
import {
|
||||
mdiArchiveArrowDownOutline,
|
||||
mdiCameraBurst,
|
||||
|
@ -33,7 +34,6 @@
|
|||
export let thumbnailSize: number | undefined = undefined;
|
||||
export let thumbnailWidth: number | undefined = undefined;
|
||||
export let thumbnailHeight: number | undefined = undefined;
|
||||
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
||||
export let selected = false;
|
||||
export let selectionCandidate = false;
|
||||
export let disabled = false;
|
||||
|
@ -181,7 +181,7 @@
|
|||
|
||||
{#if asset.resized}
|
||||
<ImageThumbnail
|
||||
url={getAssetThumbnailUrl(asset.id, format, asset.checksum)}
|
||||
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, checksum: asset.checksum })}
|
||||
altText={getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
|
@ -197,7 +197,7 @@
|
|||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<div class="absolute top-0 h-full w-full">
|
||||
<VideoThumbnail
|
||||
url={getAssetFileUrl(asset.id, false, true, asset.checksum)}
|
||||
url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
curve={selected}
|
||||
durationInSeconds={timeToSeconds(asset.duration)}
|
||||
|
@ -209,7 +209,7 @@
|
|||
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
||||
<div class="absolute top-0 h-full w-full">
|
||||
<VideoThumbnail
|
||||
url={getAssetFileUrl(asset.livePhotoVideoId, false, true, asset.checksum)}
|
||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })}
|
||||
pauseIcon={mdiMotionPauseOutline}
|
||||
playIcon={mdiMotionPlayOutline}
|
||||
showTime={false}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import { photoViewer } from '$lib/stores/assets.store';
|
||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
|
||||
import { AssetTypeEnum, ThumbnailFormat, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { linear } from 'svelte/easing';
|
||||
|
@ -43,7 +43,7 @@
|
|||
if (assetType === AssetTypeEnum.Image) {
|
||||
image = $photoViewer;
|
||||
} else if (assetType === AssetTypeEnum.Video) {
|
||||
const data = getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp);
|
||||
const data = getAssetThumbnailUrl(assetId);
|
||||
const img: HTMLImageElement = new Image();
|
||||
img.src = data;
|
||||
|
||||
|
|
|
@ -1,27 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
||||
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
|
||||
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
|
||||
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
|
||||
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
|
||||
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
|
||||
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
||||
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
|
||||
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { type Viewport } from '$lib/stores/assets.store';
|
||||
import { memoryStore } from '$lib/stores/memory.store';
|
||||
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { fromLocalDateTime } from '$lib/utils/timeline-util';
|
||||
import { ThumbnailFormat, getMemoryLane, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetMediaSize, getMemoryLane, type AssetResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
mdiChevronDown,
|
||||
mdiChevronLeft,
|
||||
|
@ -243,7 +243,7 @@
|
|||
{#if previousMemory}
|
||||
<img
|
||||
class="h-full w-full rounded-2xl object-cover"
|
||||
src={getAssetThumbnailUrl(previousMemory.assets[0].id, ThumbnailFormat.Jpeg)}
|
||||
src={getAssetThumbnailUrl({ id: previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||
alt="Previous memory"
|
||||
draggable="false"
|
||||
/>
|
||||
|
@ -275,7 +275,7 @@
|
|||
<img
|
||||
transition:fade
|
||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
||||
src={getAssetThumbnailUrl(currentAsset.id, ThumbnailFormat.Jpeg)}
|
||||
src={getAssetThumbnailUrl({ id: currentAsset.id, size: AssetMediaSize.Preview })}
|
||||
alt={currentAsset.exifInfo?.description}
|
||||
draggable="false"
|
||||
/>
|
||||
|
@ -321,7 +321,7 @@
|
|||
{#if nextMemory}
|
||||
<img
|
||||
class="h-full w-full rounded-2xl object-cover"
|
||||
src={getAssetThumbnailUrl(nextMemory.assets[0].id, ThumbnailFormat.Jpeg)}
|
||||
src={getAssetThumbnailUrl({ id: nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||
alt="Next memory"
|
||||
draggable="false"
|
||||
/>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
import { memoryStore } from '$lib/stores/memory.store';
|
||||
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { ThumbnailFormat, getMemoryLane } from '@immich/sdk';
|
||||
import { getMemoryLane } from '@immich/sdk';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
@ -75,7 +75,7 @@
|
|||
>
|
||||
<img
|
||||
class="h-full w-full rounded-xl object-cover"
|
||||
src={getAssetThumbnailUrl(memory.assets[0].id, ThumbnailFormat.Webp)}
|
||||
src={getAssetThumbnailUrl(memory.assets[0].id)}
|
||||
alt={`Memory Lane ${getAltText(memory.assets[0])}`}
|
||||
draggable="false"
|
||||
/>
|
||||
|
|
|
@ -183,7 +183,7 @@
|
|||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={getAssetThumbnailUrl(feature.properties?.id, undefined)}
|
||||
src={getAssetThumbnailUrl(feature.properties?.id)}
|
||||
class="rounded-full w-[60px] h-[60px] border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
|
||||
alt={feature.properties?.city && feature.properties.country
|
||||
? `Map marker for images taken in ${feature.properties.city}, ${feature.properties.country}`
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk';
|
||||
import { type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk';
|
||||
import { mdiCheck, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { s } from '$lib/utils';
|
||||
|
@ -56,7 +56,7 @@
|
|||
<button type="button" on:click={() => onSelectAsset(asset)} class="block relative">
|
||||
<!-- THUMBNAIL-->
|
||||
<img
|
||||
src={getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
|
||||
src={getAssetThumbnailUrl(asset.id)}
|
||||
alt={asset.id}
|
||||
title={`${assetData}`}
|
||||
class={`w-[250px] h-[250px] object-cover rounded-t-xl border-t-[4px] border-l-[4px] border-r-[4px] border-gray-300 ${isSelected ? 'border-immich-primary dark:border-immich-dark-primary' : 'dark:border-gray-800'} transition-all`}
|
||||
|
|
|
@ -3,10 +3,11 @@ import { locales } from '$lib/constants';
|
|||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
AssetJobName,
|
||||
AssetMediaSize,
|
||||
JobName,
|
||||
ThumbnailFormat,
|
||||
finishOAuth,
|
||||
getAssetOriginalPath,
|
||||
getAssetPlaybackPath,
|
||||
getAssetThumbnailPath,
|
||||
getBaseUrl,
|
||||
getPeopleThumbnailPath,
|
||||
|
@ -162,18 +163,28 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
|
|||
return getBaseUrl() + url.pathname + url.search + url.hash;
|
||||
};
|
||||
|
||||
export const getAssetFileUrl = (
|
||||
...[assetId, isWeb, isThumb, checksum]:
|
||||
| [assetId: string, isWeb: boolean, isThumb: boolean]
|
||||
| [assetId: string, isWeb: boolean, isThumb: boolean, checksum: string]
|
||||
) => createUrl(getAssetOriginalPath(assetId), { isThumb, isWeb, key: getKey(), c: checksum });
|
||||
export const getAssetOriginalUrl = (options: string | { id: string; checksum?: string }) => {
|
||||
if (typeof options === 'string') {
|
||||
options = { id: options };
|
||||
}
|
||||
const { id, checksum } = options;
|
||||
return createUrl(getAssetOriginalPath(id), { key: getKey(), c: checksum });
|
||||
};
|
||||
|
||||
export const getAssetThumbnailUrl = (
|
||||
...[assetId, format, checksum]:
|
||||
| [assetId: string, format: ThumbnailFormat | undefined]
|
||||
| [assetId: string, format: ThumbnailFormat | undefined, checksum: string]
|
||||
) => {
|
||||
return createUrl(getAssetThumbnailPath(assetId), { format, key: getKey(), c: checksum });
|
||||
export const getAssetThumbnailUrl = (options: string | { id: string; size?: AssetMediaSize; checksum?: string }) => {
|
||||
if (typeof options === 'string') {
|
||||
options = { id: options };
|
||||
}
|
||||
const { id, size, checksum } = options;
|
||||
return createUrl(getAssetThumbnailPath(id), { size, key: getKey(), c: checksum });
|
||||
};
|
||||
|
||||
export const getAssetPlaybackUrl = (options: string | { id: string; checksum?: string }) => {
|
||||
if (typeof options === 'string') {
|
||||
options = { id: options };
|
||||
}
|
||||
const { id, checksum } = options;
|
||||
return createUrl(getAssetPlaybackPath(id), { key: getKey(), c: checksum });
|
||||
};
|
||||
|
||||
export const getProfileImageUrl = (userId: string) => createUrl(getUserProfileImagePath(userId));
|
||||
|
|
|
@ -11,6 +11,7 @@ import { asByteUnitString } from '$lib/utils/byte-units';
|
|||
import { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
|
||||
import {
|
||||
addAssetsToAlbum as addAssets,
|
||||
getAssetInfo,
|
||||
getBaseUrl,
|
||||
getDownloadInfo,
|
||||
updateAssets,
|
||||
|
@ -154,11 +155,13 @@ export const downloadFile = async (asset: AssetResponseDto) => {
|
|||
size: asset.exifInfo?.fileSizeInByte || 0,
|
||||
},
|
||||
];
|
||||
|
||||
if (asset.livePhotoVideoId) {
|
||||
const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() });
|
||||
assets.push({
|
||||
filename: asset.originalFileName,
|
||||
filename: motionAsset.originalFileName,
|
||||
id: asset.livePhotoVideoId,
|
||||
size: 0,
|
||||
size: motionAsset.exifInfo?.fileSizeInByte || 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -177,8 +180,8 @@ export const downloadFile = async (asset: AssetResponseDto) => {
|
|||
|
||||
// TODO use sdk once it supports progress events
|
||||
const { data } = await downloadRequest({
|
||||
method: 'POST',
|
||||
url: getBaseUrl() + `/download/asset/${id}` + (key ? `?key=${key}` : ''),
|
||||
method: 'GET',
|
||||
url: getBaseUrl() + `/assets/${id}/original` + (key ? `?key=${key}` : ''),
|
||||
signal: abort.signal,
|
||||
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded, event.total),
|
||||
});
|
||||
|
|
|
@ -7,9 +7,9 @@ import {
|
|||
Action,
|
||||
AssetMediaStatus,
|
||||
checkBulkUpload,
|
||||
getAssetOriginalPath,
|
||||
getBaseUrl,
|
||||
getSupportedMediaTypes,
|
||||
type AssetFileUploadResponseDto,
|
||||
type AssetMediaResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { tick } from 'svelte';
|
||||
|
@ -129,26 +129,24 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
|
|||
uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Uploading...' });
|
||||
if (replaceAssetId) {
|
||||
const response = await uploadRequest<AssetMediaResponseDto>({
|
||||
url: getBaseUrl() + '/asset/' + replaceAssetId + '/file' + (key ? `?key=${key}` : ''),
|
||||
url: getBaseUrl() + getAssetOriginalPath(replaceAssetId) + (key ? `?key=${key}` : ''),
|
||||
method: 'PUT',
|
||||
data: formData,
|
||||
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
|
||||
});
|
||||
({ status, id } = response.data);
|
||||
} else {
|
||||
const response = await uploadRequest<AssetFileUploadResponseDto>({
|
||||
url: getBaseUrl() + '/asset/upload' + (key ? `?key=${key}` : ''),
|
||||
const response = await uploadRequest<AssetMediaResponseDto>({
|
||||
url: getBaseUrl() + '/assets' + (key ? `?key=${key}` : ''),
|
||||
data: formData,
|
||||
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
|
||||
});
|
||||
|
||||
if (![200, 201].includes(response.status)) {
|
||||
throw new Error('Failed to upload file');
|
||||
}
|
||||
if (response.data.duplicate) {
|
||||
status = AssetMediaStatus.Duplicate;
|
||||
} else {
|
||||
id = response.data.id;
|
||||
}
|
||||
|
||||
({ status, id } = response.data);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { getAssetThumbnailUrl, setSharedLink } from '$lib/utils';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import { ThumbnailFormat, getMySharedLink, isHttpError } from '@immich/sdk';
|
||||
import { getMySharedLink, isHttpError } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
|
@ -22,7 +22,7 @@ export const load = (async ({ params }) => {
|
|||
meta: {
|
||||
title: sharedLink.album ? sharedLink.album.albumName : 'Public Share',
|
||||
description: sharedLink.description || `${assetCount} shared photos & videos.`,
|
||||
imageUrl: assetId ? getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp) : '/feature-panel.png',
|
||||
imageUrl: assetId ? getAssetThumbnailUrl(assetId) : '/feature-panel.png',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
Loading…
Add table
Reference in a new issue