mirror of
https://github.com/immich-app/immich.git
synced 2026-05-13 19:12:13 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c02edcdb7 | |||
| f16a83f65b | |||
| 2325a359a6 |
@@ -392,8 +392,6 @@ jobs:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
|
||||
- name: Run pnpm install
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
- name: Run medium tests
|
||||
|
||||
Vendored
+1
-2
@@ -26,8 +26,7 @@
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.defaultFormatter": "svelte.svelte-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
|
||||
@@ -81,7 +81,7 @@ VectorChord is the successor extension to pgvecto.rs, allowing for higher perfor
|
||||
|
||||
### Migrating from pgvecto.rs
|
||||
|
||||
Support for pgvecto.rs has been dropped as of 3.0, hence all users currently using pgvecto.rs should migrate to VectorChord. There are two primary approaches to do so.
|
||||
Support for pgvecto.rs will be dropped in a later release, hence we recommend all users currently using pgvecto.rs to migrate to VectorChord at their convenience. There are two primary approaches to do so.
|
||||
|
||||
The easiest option is to have both extensions installed during the migration:
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ You can learn how to set up Tailscale together with Immich with the [tutorial vi
|
||||
### Cons
|
||||
|
||||
- The Tailscale client usually needs to run as root on your devices and it increases the attack surface slightly compared to a minimal Wireguard server. e.g., an [RCE vulnerability](https://github.com/tailscale/tailscale/security/advisories/GHSA-vqp6-rc3h-83cp) was discovered in the Windows Tailscale client in November 2022.
|
||||
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) suitable for personal use.
|
||||
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) that permits up to 3 users and up to 100 devices.
|
||||
- Tailscale needs to be installed and running on both server-side and client-side.
|
||||
|
||||
## Option 3: Reverse Proxy
|
||||
|
||||
@@ -81,7 +81,7 @@ Information on the current workers can be found [here](/administration/jobs-work
|
||||
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
|
||||
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
|
||||
| `DB_SSL_MODE` | Database SSL mode | | server |
|
||||
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`]) | | server |
|
||||
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
|
||||
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
||||
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
|
||||
|
||||
|
||||
@@ -130,3 +130,7 @@ These storage mediums have different performance characteristics. As a result, t
|
||||
#### Can I use the new database image as a general PostgreSQL image outside of Immich?
|
||||
|
||||
It’s a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
|
||||
|
||||
#### If pgvecto.rs and pgvector still work, why should I switch to VectorChord?
|
||||
|
||||
VectorChord is faster, more stable, uses less RAM, and (with the settings Immich uses) offers higher-quality results than pgvector and pgvecto.rs. This translates to better search and facial recognition experiences. In addition, pgvecto.rs support will be dropped in the future, so changing it sooner will avoid disruption.
|
||||
|
||||
@@ -28,10 +28,6 @@ export const errorDto = {
|
||||
badRequest: (message: any = null) => ({
|
||||
message: message ?? expect.anything(),
|
||||
}),
|
||||
validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>) => ({
|
||||
message: 'Validation failed',
|
||||
errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array),
|
||||
}),
|
||||
noPermission: {
|
||||
message: expect.stringContaining('Not found or no'),
|
||||
},
|
||||
@@ -41,6 +37,9 @@ export const errorDto = {
|
||||
alreadyHasAdmin: {
|
||||
message: 'The server already has an admin',
|
||||
},
|
||||
invalidEmail: {
|
||||
message: ['email must be an email'],
|
||||
},
|
||||
};
|
||||
|
||||
export const signupResponseDto = {
|
||||
|
||||
@@ -566,20 +566,6 @@ describe('/asset', () => {
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should set the negative rating', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ rating: -1 });
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
exifInfo: expect.objectContaining({
|
||||
rating: -1,
|
||||
}),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should return tagged people', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
|
||||
@@ -110,9 +110,7 @@ describe('/libraries', () => {
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
|
||||
});
|
||||
|
||||
it('should not create an external library with duplicate exclusion patterns', async () => {
|
||||
@@ -127,9 +125,7 @@ describe('/libraries', () => {
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,9 +157,7 @@ describe('/libraries', () => {
|
||||
.send({ name: '' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['name'], message: 'Too small: expected string to have >=1 characters' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[name] Too small: expected string to have >=1 characters']));
|
||||
});
|
||||
|
||||
it('should change the import paths', async () => {
|
||||
@@ -187,9 +181,7 @@ describe('/libraries', () => {
|
||||
.send({ importPaths: [''] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array items must not be empty']));
|
||||
});
|
||||
|
||||
it('should reject duplicate import paths', async () => {
|
||||
@@ -199,9 +191,7 @@ describe('/libraries', () => {
|
||||
.send({ importPaths: ['/path', '/path'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
|
||||
});
|
||||
|
||||
it('should change the exclusion pattern', async () => {
|
||||
@@ -225,9 +215,7 @@ describe('/libraries', () => {
|
||||
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
|
||||
});
|
||||
|
||||
it('should reject an empty exclusion pattern', async () => {
|
||||
@@ -237,9 +225,7 @@ describe('/libraries', () => {
|
||||
.send({ exclusionPatterns: [''] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array items must not be empty']));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -109,9 +109,7 @@ describe('/map', () => {
|
||||
.get('/map/reverse-geocode?lon=123')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
|
||||
});
|
||||
|
||||
it('should throw an error if a lat is not a number', async () => {
|
||||
@@ -119,9 +117,7 @@ describe('/map', () => {
|
||||
.get('/map/reverse-geocode?lat=abc&lon=123.456')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
|
||||
});
|
||||
|
||||
it('should throw an error if a lat is out of range', async () => {
|
||||
@@ -129,9 +125,7 @@ describe('/map', () => {
|
||||
.get('/map/reverse-geocode?lat=91&lon=123.456')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[lat] Too big: expected number to be <=90']));
|
||||
});
|
||||
|
||||
it('should throw an error if a lon is not provided', async () => {
|
||||
@@ -139,9 +133,7 @@ describe('/map', () => {
|
||||
.get('/map/reverse-geocode?lat=75')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[lon] Invalid input: expected number, received NaN']));
|
||||
});
|
||||
|
||||
const reverseGeocodeTestCases = [
|
||||
|
||||
@@ -105,11 +105,7 @@ describe(`/oauth`, () => {
|
||||
it(`should throw an error if a redirect uri is not provided`, async () => {
|
||||
const { status, body } = await request(app).post('/oauth/authorize').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[redirectUri] Invalid input: expected string, received undefined']));
|
||||
});
|
||||
|
||||
it('should return a redirect uri', async () => {
|
||||
@@ -168,17 +164,13 @@ describe(`/oauth`, () => {
|
||||
it(`should throw an error if a url is not provided`, async () => {
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['url'], message: 'Invalid input: expected string, received undefined' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[url] Invalid input: expected string, received undefined']));
|
||||
});
|
||||
|
||||
it(`should throw an error if the url is empty`, async () => {
|
||||
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['url'], message: 'Too small: expected string to have >=1 characters' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[url] Too small: expected string to have >=1 characters']));
|
||||
});
|
||||
|
||||
it(`should throw an error if the state is not provided`, async () => {
|
||||
@@ -359,7 +351,7 @@ describe(`/oauth`, () => {
|
||||
const callbackParams = await loginWithOAuth('oauth-no-auto-register');
|
||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('OAuth authentication failed'));
|
||||
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
|
||||
});
|
||||
|
||||
it('should link to an existing user by email', async () => {
|
||||
@@ -383,11 +375,7 @@ describe(`/oauth`, () => {
|
||||
it(`should throw an error if the logout_token is not provided`, async () => {
|
||||
const { status, body } = await request(app).post('/oauth/backchannel-logout').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['logout_token'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[logout_token] Invalid input: expected string, received undefined']));
|
||||
});
|
||||
|
||||
it(`should throw an error if an invalid logout token is provided`, async () => {
|
||||
|
||||
@@ -341,9 +341,7 @@ describe('/shared-links', () => {
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should require an asset/album id', async () => {
|
||||
|
||||
@@ -41,9 +41,7 @@ describe('/stacks', () => {
|
||||
.send({ assetIds: [asset.id] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['assetIds'], message: 'Too small: expected array to have >=2 items' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
@@ -53,12 +51,7 @@ describe('/stacks', () => {
|
||||
.send({ assetIds: [uuidDto.invalid, uuidDto.invalid] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['assetIds', 0], message: 'Invalid UUID' },
|
||||
{ path: ['assetIds', 1], message: 'Invalid UUID' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should require access', async () => {
|
||||
|
||||
@@ -309,7 +309,7 @@ describe('/tags', () => {
|
||||
.get(`/tags/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
|
||||
it('should get tag details', async () => {
|
||||
@@ -427,7 +427,7 @@ describe('/tags', () => {
|
||||
.delete(`/tags/${uuidDto.invalid}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
|
||||
it('should delete a tag', async () => {
|
||||
|
||||
@@ -108,20 +108,14 @@ describe('/admin/users', () => {
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
for (const [key, message] of [
|
||||
['password', 'Invalid input: expected string, received null'],
|
||||
['email', 'Invalid input: expected email, received object'],
|
||||
['name', 'Invalid input: expected string, received null'],
|
||||
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
|
||||
['notify', 'Invalid input: expected boolean, received null'],
|
||||
] as const) {
|
||||
for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/admin/users`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ...createUserDto.user1, [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,19 +153,14 @@ describe('/admin/users', () => {
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
for (const [key, message] of [
|
||||
['password', 'Invalid input: expected string, received null'],
|
||||
['email', 'Invalid input: expected email, received object'],
|
||||
['name', 'Invalid input: expected string, received null'],
|
||||
['shouldChangePassword', 'Invalid input: expected boolean, received null'],
|
||||
] as const) {
|
||||
for (const key of ['password', 'email', 'name', 'shouldChangePassword']) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/admin/users/${uuidDto.notFound}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('/users', () => {
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toMatchObject(errorDto.badRequest('Email is not available'));
|
||||
expect(body).toMatchObject(errorDto.badRequest('Email already in use by another account'));
|
||||
});
|
||||
|
||||
it('should update my email', async () => {
|
||||
@@ -179,9 +179,7 @@ describe('/users', () => {
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' },
|
||||
]),
|
||||
errorDto.badRequest(['[download.archiveSize] Invalid input: expected int, received number']),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -209,9 +207,7 @@ describe('/users', () => {
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['download', 'includeEmbeddedVideos'], message: 'Invalid input: expected boolean, received number' },
|
||||
]),
|
||||
errorDto.badRequest(['[download.includeEmbeddedVideos] Invalid input: expected boolean, received number']),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,12 +32,8 @@ export function generateThumbhash(rng: SeededRandom): string {
|
||||
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function generateDuration(rng: SeededRandom): number {
|
||||
return (
|
||||
rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS) *
|
||||
1000 +
|
||||
rng.nextInt(0, 1000)
|
||||
);
|
||||
export function generateDuration(rng: SeededRandom): string {
|
||||
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
export function generateUUID(): string {
|
||||
|
||||
@@ -43,7 +43,7 @@ export type MockTimelineAsset = {
|
||||
isTrashed: boolean;
|
||||
isVideo: boolean;
|
||||
isImage: boolean;
|
||||
duration: number | null;
|
||||
duration: string | null;
|
||||
projectionType: string | null;
|
||||
livePhotoVideoId: string | null;
|
||||
city: string | null;
|
||||
|
||||
@@ -304,7 +304,7 @@ test.describe('Timeline', () => {
|
||||
await page.keyboard.down('Shift');
|
||||
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
|
||||
await expect(
|
||||
thumbnailUtils.locator(page).locator('.absolute.top-0.size-full.bg-immich-primary.opacity-40'),
|
||||
thumbnailUtils.locator(page).locator('.absolute.top-0.h-full.w-full.bg-immich-primary.opacity-40'),
|
||||
).toHaveCount(3);
|
||||
await thumbnailUtils.selectButton(page, assets[2].id).click();
|
||||
await page.keyboard.up('Shift');
|
||||
|
||||
+1
-1
Submodule e2e/test-assets updated: 6742055402...0eac5a3738
@@ -1761,7 +1761,6 @@
|
||||
"play_original_video": "Play original video",
|
||||
"play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
|
||||
"play_transcoded_video": "Play transcoded video",
|
||||
"playback_speed": "Playback speed",
|
||||
"please_auth_to_access": "Please authenticate to access",
|
||||
"port": "Port",
|
||||
"preferences_settings_subtitle": "Manage the app's preferences",
|
||||
@@ -2437,7 +2436,6 @@
|
||||
"workflows": "Workflows",
|
||||
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
|
||||
"wrong_pin_code": "Wrong PIN code",
|
||||
"x_of_total": "{x}/{total}",
|
||||
"year": "Year",
|
||||
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
||||
"yes": "Yes",
|
||||
|
||||
@@ -68,7 +68,7 @@ ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
|
||||
RUN apt-get update && \
|
||||
# Pascal support was dropped in 9.11
|
||||
apt-get install --no-install-recommends -yqq libcudnn9-cuda-12=9.10.2.21-1 tzdata && \
|
||||
apt-get install --no-install-recommends -yqq libcudnn9-cuda-12=9.10.2.21-1 && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -112,7 +112,7 @@ ARG RKNN_TOOLKIT_VERSION="v2.3.0"
|
||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
ADD --chmod=644 --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
|
||||
ADD --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
|
||||
|
||||
FROM prod-${DEVICE} AS prod
|
||||
|
||||
|
||||
@@ -15,26 +15,17 @@ config_roots = [
|
||||
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
flutter = "3.41.7"
|
||||
flutter = "3.41.6"
|
||||
pnpm = "10.33.1"
|
||||
terragrunt = "1.0.2"
|
||||
opentofu = "1.11.6"
|
||||
java = "21.0.2"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.37.0"
|
||||
version = "1.35.1"
|
||||
bin = "dcm"
|
||||
postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"]
|
||||
version = "7.1.3-6"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".platforms]
|
||||
linux-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" }
|
||||
linux-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" }
|
||||
macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" }
|
||||
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
|
||||
|
||||
[settings]
|
||||
experimental = true
|
||||
pin = true
|
||||
|
||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.41.9",
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.41.7",
|
||||
"dart.lineLength": 120,
|
||||
"[dart]": {
|
||||
"editor.rulers": [
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: '>=1.29.0 <=1.37.0'
|
||||
version: '>=1.29.0 <=1.36.0'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
app_identifier "app.alextran.immich" # The bundle identifier of your app
|
||||
apple_id "altran@futo.org" # Your Apple email address
|
||||
apple_id "alex.tran1502@gmail.com" # Your Apple email address
|
||||
|
||||
|
||||
# For more information about the Appfile, see:
|
||||
|
||||
@@ -17,11 +17,10 @@ default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
# Constants
|
||||
TEAM_ID = "2W7AC6T8T5"
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
|
||||
TEAM_ID = "2F67MQ8R79"
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
|
||||
BASE_BUNDLE_ID = "app.alextran.immich"
|
||||
DEV_BUNDLE_ID = "tech.futo.immich.testflight"
|
||||
|
||||
|
||||
# Helper method to get App Store Connect API key
|
||||
def get_api_key
|
||||
app_store_connect_api_key(
|
||||
@@ -45,45 +44,47 @@ def get_version_from_pubspec
|
||||
end
|
||||
|
||||
# Helper method to configure code signing for all targets
|
||||
def configure_code_signing(base_bundle_id:, profile_name_main:, profile_name_share:, profile_name_widget:)
|
||||
def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
|
||||
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
||||
|
||||
# Runner (main app)
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
path: "./Runner.xcodeproj",
|
||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||
bundle_identifier: base_bundle_id,
|
||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
|
||||
profile_name: profile_name_main,
|
||||
targets: ["Runner"]
|
||||
)
|
||||
|
||||
|
||||
# ShareExtension
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
path: "./Runner.xcodeproj",
|
||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||
bundle_identifier: "#{base_bundle_id}.ShareExtension",
|
||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
|
||||
profile_name: profile_name_share,
|
||||
targets: ["ShareExtension"]
|
||||
)
|
||||
|
||||
|
||||
# WidgetExtension
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
path: "./Runner.xcodeproj",
|
||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||
bundle_identifier: "#{base_bundle_id}.Widget",
|
||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
|
||||
profile_name: profile_name_widget,
|
||||
targets: ["WidgetExtension"]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
# Helper method to build and upload to TestFlight
|
||||
def build_and_upload(
|
||||
api_key:,
|
||||
base_bundle_id:,
|
||||
bundle_id_suffix: "",
|
||||
configuration: "Release",
|
||||
distribute_external: true,
|
||||
version_number: nil,
|
||||
@@ -91,8 +92,9 @@ end
|
||||
profile_name_share:,
|
||||
profile_name_widget:
|
||||
)
|
||||
app_identifier = base_bundle_id
|
||||
|
||||
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
||||
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
|
||||
|
||||
# Set version number if provided
|
||||
if version_number
|
||||
increment_version_number(version_number: version_number)
|
||||
@@ -136,31 +138,31 @@ end
|
||||
desc "iOS Development Build to TestFlight (requires separate bundle ID)"
|
||||
lane :gha_testflight_dev do
|
||||
api_key = get_api_key
|
||||
|
||||
|
||||
# Download and install provisioning profiles from App Store Connect
|
||||
# Certificate is imported by GHA workflow into build.keychain
|
||||
# Capture profile names after each sigh call
|
||||
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
|
||||
main_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
|
||||
share_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
|
||||
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
|
||||
# Configure code signing for dev bundle IDs using the downloaded profile names
|
||||
configure_code_signing(
|
||||
base_bundle_id: DEV_BUNDLE_ID,
|
||||
bundle_id_suffix: "development",
|
||||
profile_name_main: main_profile_name,
|
||||
profile_name_share: share_profile_name,
|
||||
profile_name_widget: widget_profile_name
|
||||
)
|
||||
|
||||
|
||||
# Build and upload
|
||||
build_and_upload(
|
||||
api_key: api_key,
|
||||
base_bundle_id: DEV_BUNDLE_ID,
|
||||
bundle_id_suffix: "development",
|
||||
configuration: "Profile",
|
||||
distribute_external: false,
|
||||
profile_name_main: main_profile_name,
|
||||
@@ -187,7 +189,6 @@ end
|
||||
|
||||
# Configure code signing for production bundle IDs
|
||||
configure_code_signing(
|
||||
base_bundle_id: BASE_BUNDLE_ID,
|
||||
profile_name_main: main_profile_name,
|
||||
profile_name_share: share_profile_name,
|
||||
profile_name_widget: widget_profile_name
|
||||
@@ -196,7 +197,6 @@ end
|
||||
# Build and upload with version number
|
||||
build_and_upload(
|
||||
api_key: api_key,
|
||||
base_bundle_id: BASE_BUNDLE_ID,
|
||||
version_number: get_version_from_pubspec,
|
||||
distribute_external: false,
|
||||
profile_name_main: main_profile_name,
|
||||
@@ -243,30 +243,30 @@ end
|
||||
|
||||
desc "iOS Build Only (no TestFlight upload)"
|
||||
lane :gha_build_only do
|
||||
# Use the same build process as the dev TestFlight lane, just skip the upload
|
||||
# This ensures PR builds validate the same way as dev TestFlight builds
|
||||
|
||||
# Use the same build process as production, just skip the upload
|
||||
# This ensures PR builds validate the same way as production builds
|
||||
|
||||
api_key = get_api_key
|
||||
|
||||
|
||||
# Download and install provisioning profiles from App Store Connect
|
||||
# Certificate is imported by GHA workflow into build.keychain
|
||||
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
|
||||
main_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
|
||||
share_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
|
||||
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
|
||||
# Configure code signing for dev bundle IDs
|
||||
configure_code_signing(
|
||||
base_bundle_id: DEV_BUNDLE_ID,
|
||||
bundle_id_suffix: "development",
|
||||
profile_name_main: main_profile_name,
|
||||
profile_name_share: share_profile_name,
|
||||
profile_name_widget: widget_profile_name
|
||||
)
|
||||
|
||||
|
||||
# Build the app (same as gha_testflight_dev but without upload)
|
||||
build_app(
|
||||
scheme: "Runner",
|
||||
@@ -277,9 +277,9 @@ end
|
||||
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||
export_options: {
|
||||
provisioningProfiles: {
|
||||
DEV_BUNDLE_ID => main_profile_name,
|
||||
"#{DEV_BUNDLE_ID}.ShareExtension" => share_profile_name,
|
||||
"#{DEV_BUNDLE_ID}.Widget" => widget_profile_name
|
||||
"#{BASE_BUNDLE_ID}.development" => main_profile_name,
|
||||
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
|
||||
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
|
||||
},
|
||||
signingStyle: "manual",
|
||||
signingCertificate: CODE_SIGN_IDENTITY
|
||||
|
||||
@@ -192,23 +192,23 @@ class SyncStreamService {
|
||||
case SyncEntityType.assetV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV1>();
|
||||
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
return;
|
||||
case SyncEntityType.assetV2:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV2>();
|
||||
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
await _runWithManageMediaPermission(
|
||||
logContext: "Trashed Assets",
|
||||
action: () async {
|
||||
await _handleRemoteDeleted(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id));
|
||||
await _applyRemoteRestoreToLocal();
|
||||
},
|
||||
);
|
||||
return;
|
||||
case SyncEntityType.assetDeleteV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
|
||||
}
|
||||
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
|
||||
await _runWithManageMediaPermission(
|
||||
logContext: "Deleted Assets",
|
||||
action: () async {
|
||||
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
|
||||
await _handleRemoteDeleted(remoteSyncAssets.map((e) => e.assetId));
|
||||
},
|
||||
);
|
||||
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
||||
case SyncEntityType.assetExifV1:
|
||||
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
||||
case SyncEntityType.assetEditV1:
|
||||
@@ -221,12 +221,8 @@ class SyncStreamService {
|
||||
return _syncStreamRepository.deleteAssetsMetadataV1(data.cast());
|
||||
case SyncEntityType.partnerAssetV1:
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner');
|
||||
case SyncEntityType.partnerAssetV2:
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner');
|
||||
case SyncEntityType.partnerAssetBackfillV1:
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner backfill');
|
||||
case SyncEntityType.partnerAssetBackfillV2:
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner backfill');
|
||||
case SyncEntityType.partnerAssetDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetsV1(data.cast(), debugLabel: "partner");
|
||||
case SyncEntityType.partnerAssetExifV1:
|
||||
@@ -247,16 +243,10 @@ class SyncStreamService {
|
||||
return _syncStreamRepository.deleteAlbumUsersV1(data.cast());
|
||||
case SyncEntityType.albumAssetCreateV1:
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset create');
|
||||
case SyncEntityType.albumAssetCreateV2:
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset create');
|
||||
case SyncEntityType.albumAssetUpdateV1:
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset update');
|
||||
case SyncEntityType.albumAssetUpdateV2:
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset update');
|
||||
case SyncEntityType.albumAssetBackfillV1:
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset backfill');
|
||||
case SyncEntityType.albumAssetBackfillV2:
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset backfill');
|
||||
case SyncEntityType.albumAssetExifCreateV1:
|
||||
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif create');
|
||||
case SyncEntityType.albumAssetExifUpdateV1:
|
||||
@@ -358,47 +348,6 @@ class SyncStreamService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) async {
|
||||
if (batchData.isEmpty) return;
|
||||
|
||||
_logger.info('Processing batch of ${batchData.length} AssetUploadReadyV2 events');
|
||||
|
||||
final List<SyncAssetV2> assets = [];
|
||||
final List<SyncAssetExifV1> exifs = [];
|
||||
|
||||
try {
|
||||
for (final data in batchData) {
|
||||
if (data is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final payload = data;
|
||||
final assetData = payload['asset'];
|
||||
final exifData = payload['exif'];
|
||||
|
||||
if (assetData == null || exifData == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final asset = SyncAssetV2.fromJson(assetData);
|
||||
final exif = SyncAssetExifV1.fromJson(exifData);
|
||||
|
||||
if (asset != null && exif != null) {
|
||||
assets.add(asset);
|
||||
exifs.add(exif);
|
||||
}
|
||||
}
|
||||
|
||||
if (assets.isNotEmpty && exifs.isNotEmpty) {
|
||||
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch');
|
||||
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
|
||||
_logger.info('Successfully processed ${assets.length} assets in batch');
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe("Error processing AssetUploadReadyV2 websocket batch events", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleWsAssetEditReadyV1(dynamic data) async {
|
||||
_logger.info('Processing AssetEditReadyV1 event');
|
||||
|
||||
@@ -439,41 +388,6 @@ class SyncStreamService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleWsAssetEditReadyV2(dynamic data) async {
|
||||
_logger.info('Processing AssetEditReadyV2 event');
|
||||
|
||||
try {
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ArgumentError("Invalid data format for AssetEditReadyV2 event");
|
||||
}
|
||||
|
||||
final payload = data;
|
||||
|
||||
if (payload['asset'] == null) {
|
||||
throw ArgumentError("Missing 'asset' field in AssetEditReadyV2 event data");
|
||||
}
|
||||
|
||||
final asset = SyncAssetV2.fromJson(payload['asset']);
|
||||
if (asset == null) {
|
||||
throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV2 event data");
|
||||
}
|
||||
|
||||
final assetEdits = (payload['edit'] as List<dynamic>)
|
||||
.map((e) => SyncAssetEditV1.fromJson(e))
|
||||
.whereType<SyncAssetEditV1>()
|
||||
.toList();
|
||||
|
||||
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit');
|
||||
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
||||
|
||||
_logger.info(
|
||||
'Successfully processed AssetEditReadyV2 event for asset ${asset.id} with ${assetEdits.length} edits',
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe("Error processing AssetEditReadyV2 websocket event", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return Future.value();
|
||||
@@ -510,22 +424,20 @@ class SyncStreamService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
Future<void> _runWithManageMediaPermission({
|
||||
required String logContext,
|
||||
required Future<void> Function() action,
|
||||
}) async {
|
||||
if (!CurrentPlatform.isAndroid || !Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _handleRemoteDeleted(remoteIds);
|
||||
await _applyRemoteRestoreToLocal();
|
||||
}
|
||||
|
||||
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
_logger.warning("sync $logContext cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
await _handleRemoteDeleted(remoteIds);
|
||||
await action();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ class BackgroundSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
||||
Future<void> syncWebsocketBatch(List<dynamic> batchData) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
}
|
||||
@@ -196,17 +196,7 @@ class BackgroundSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketBatchV2(List<dynamic> batchData) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
}
|
||||
_syncWebsocketTask = _handleWsAssetUploadReadyV2Batch(batchData);
|
||||
return _syncWebsocketTask!.whenComplete(() {
|
||||
_syncWebsocketTask = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketEditV1(dynamic data) {
|
||||
Future<void> syncWebsocketEdit(dynamic data) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
}
|
||||
@@ -216,16 +206,6 @@ class BackgroundSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketEditV2(dynamic data) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
}
|
||||
_syncWebsocketTask = _handleWsAssetEditReadyV2(data);
|
||||
return _syncWebsocketTask!.whenComplete(() {
|
||||
_syncWebsocketTask = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncLinkedAlbum() {
|
||||
if (_linkedAlbumSyncTask != null) {
|
||||
return _linkedAlbumSyncTask!.future;
|
||||
@@ -262,17 +242,7 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
|
||||
debugLabel: 'websocket-batch',
|
||||
);
|
||||
|
||||
Cancelable<void> _handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) => runInIsolateGentle(
|
||||
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV2Batch(batchData),
|
||||
debugLabel: 'websocket-batch',
|
||||
);
|
||||
|
||||
Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
|
||||
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
|
||||
debugLabel: 'websocket-edit',
|
||||
);
|
||||
|
||||
Cancelable<void> _handleWsAssetEditReadyV2(dynamic data) => runInIsolateGentle(
|
||||
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV2(data),
|
||||
debugLabel: 'websocket-edit',
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
||||
import 'package:openapi/api.dart' as api;
|
||||
|
||||
@@ -13,7 +14,7 @@ extension DTOToAsset on api.AssetResponseDto {
|
||||
updatedAt: updatedAt,
|
||||
ownerId: ownerId,
|
||||
visibility: visibility.toAssetVisibility(),
|
||||
durationMs: duration,
|
||||
durationMs: duration?.toDuration()?.inMilliseconds ?? 0,
|
||||
height: height?.toInt(),
|
||||
width: width?.toInt(),
|
||||
isFavorite: isFavorite,
|
||||
@@ -35,7 +36,7 @@ extension DTOToAsset on api.AssetResponseDto {
|
||||
updatedAt: updatedAt,
|
||||
ownerId: ownerId,
|
||||
visibility: visibility.toAssetVisibility(),
|
||||
durationMs: duration,
|
||||
durationMs: duration?.toDuration()?.inMilliseconds ?? 0,
|
||||
height: height?.toInt(),
|
||||
width: width?.toInt(),
|
||||
isFavorite: isFavorite,
|
||||
|
||||
@@ -46,25 +46,19 @@ class SyncApiRepository {
|
||||
types: [
|
||||
SyncRequestType.authUsersV1,
|
||||
SyncRequestType.usersV1,
|
||||
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
|
||||
? SyncRequestType.assetsV2
|
||||
: SyncRequestType.assetsV1,
|
||||
SyncRequestType.assetsV1,
|
||||
SyncRequestType.assetExifsV1,
|
||||
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
|
||||
SyncRequestType.assetMetadataV1,
|
||||
SyncRequestType.partnersV1,
|
||||
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
|
||||
? SyncRequestType.partnerAssetsV2
|
||||
: SyncRequestType.partnerAssetsV1,
|
||||
SyncRequestType.partnerAssetsV1,
|
||||
SyncRequestType.partnerAssetExifsV1,
|
||||
if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0))
|
||||
SyncRequestType.albumsV1
|
||||
else
|
||||
SyncRequestType.albumsV2,
|
||||
SyncRequestType.albumUsersV1,
|
||||
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
|
||||
? SyncRequestType.albumAssetsV2
|
||||
: SyncRequestType.albumAssetsV1,
|
||||
SyncRequestType.albumAssetsV1,
|
||||
SyncRequestType.albumAssetExifsV1,
|
||||
SyncRequestType.albumToAssetsV1,
|
||||
SyncRequestType.memoriesV1,
|
||||
@@ -73,9 +67,8 @@ class SyncApiRepository {
|
||||
SyncRequestType.partnerStacksV1,
|
||||
SyncRequestType.userMetadataV1,
|
||||
SyncRequestType.peopleV1,
|
||||
serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)
|
||||
? SyncRequestType.assetFacesV2
|
||||
: SyncRequestType.assetFacesV1,
|
||||
if (serverVersion < const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV1,
|
||||
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV2,
|
||||
],
|
||||
reset: shouldReset,
|
||||
).toJson(),
|
||||
@@ -160,7 +153,6 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
|
||||
SyncEntityType.partnerV1: SyncPartnerV1.fromJson,
|
||||
SyncEntityType.partnerDeleteV1: SyncPartnerDeleteV1.fromJson,
|
||||
SyncEntityType.assetV1: SyncAssetV1.fromJson,
|
||||
SyncEntityType.assetV2: SyncAssetV2.fromJson,
|
||||
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
|
||||
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
|
||||
SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson,
|
||||
@@ -168,9 +160,7 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
|
||||
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
|
||||
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
|
||||
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
|
||||
SyncEntityType.partnerAssetV2: SyncAssetV2.fromJson,
|
||||
SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson,
|
||||
SyncEntityType.partnerAssetBackfillV2: SyncAssetV2.fromJson,
|
||||
SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson,
|
||||
SyncEntityType.partnerAssetExifV1: SyncAssetExifV1.fromJson,
|
||||
SyncEntityType.partnerAssetExifBackfillV1: SyncAssetExifV1.fromJson,
|
||||
@@ -181,11 +171,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
|
||||
SyncEntityType.albumUserBackfillV1: SyncAlbumUserV1.fromJson,
|
||||
SyncEntityType.albumUserDeleteV1: SyncAlbumUserDeleteV1.fromJson,
|
||||
SyncEntityType.albumAssetCreateV1: SyncAssetV1.fromJson,
|
||||
SyncEntityType.albumAssetCreateV2: SyncAssetV2.fromJson,
|
||||
SyncEntityType.albumAssetUpdateV1: SyncAssetV1.fromJson,
|
||||
SyncEntityType.albumAssetUpdateV2: SyncAssetV2.fromJson,
|
||||
SyncEntityType.albumAssetBackfillV1: SyncAssetV1.fromJson,
|
||||
SyncEntityType.albumAssetBackfillV2: SyncAssetV2.fromJson,
|
||||
SyncEntityType.albumAssetExifCreateV1: SyncAssetExifV1.fromJson,
|
||||
SyncEntityType.albumAssetExifUpdateV1: SyncAssetExifV1.fromJson,
|
||||
SyncEntityType.albumAssetExifBackfillV1: SyncAssetExifV1.fromJson,
|
||||
|
||||
@@ -220,44 +220,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetsV2(Iterable<SyncAssetV2> data, {String debugLabel = 'user'}) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final asset in data) {
|
||||
final companion = RemoteAssetEntityCompanion(
|
||||
name: Value(asset.originalFileName),
|
||||
type: Value(asset.type.toAssetType()),
|
||||
createdAt: Value.absentIfNull(asset.fileCreatedAt),
|
||||
updatedAt: Value.absentIfNull(asset.fileModifiedAt),
|
||||
durationMs: Value(asset.duration),
|
||||
checksum: Value(asset.checksum),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
ownerId: Value(asset.ownerId),
|
||||
localDateTime: Value(asset.localDateTime),
|
||||
thumbHash: Value(asset.thumbhash),
|
||||
deletedAt: Value(asset.deletedAt),
|
||||
visibility: Value(asset.visibility.toAssetVisibility()),
|
||||
livePhotoVideoId: Value(asset.livePhotoVideoId),
|
||||
stackId: Value(asset.stackId),
|
||||
libraryId: Value(asset.libraryId),
|
||||
width: Value(asset.width),
|
||||
height: Value(asset.height),
|
||||
isEdited: Value(asset.isEdited),
|
||||
);
|
||||
|
||||
batch.insert(
|
||||
_db.remoteAssetEntity,
|
||||
companion.copyWith(id: Value(asset.id)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Error: updateAssetsV2 - $debugLabel', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetsExifV1(Iterable<SyncAssetExifV1> data, {String debugLabel = 'user'}) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
|
||||
@@ -94,10 +94,8 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
state = const WebsocketState(isConnected: false, socket: null);
|
||||
});
|
||||
|
||||
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReadyV1);
|
||||
socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2);
|
||||
socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1);
|
||||
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
|
||||
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
|
||||
socket.on('AssetEditReadyV1', _handleSyncAssetEditReady);
|
||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||
socket.on('on_new_release', _handleReleaseUpdates);
|
||||
} catch (e) {
|
||||
@@ -165,25 +163,16 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
_ref.read(serverInfoProvider.notifier).handleReleaseInfo(serverVersion, releaseVersion);
|
||||
}
|
||||
|
||||
void _handleSyncAssetUploadReadyV1(dynamic data) {
|
||||
void _handleSyncAssetUploadReady(dynamic data) {
|
||||
_batchedAssetUploadReady.add(data);
|
||||
_batchDebouncer.run(_processBatchedAssetUploadReadyV1);
|
||||
_batchDebouncer.run(_processBatchedAssetUploadReady);
|
||||
}
|
||||
|
||||
void _handleSyncAssetUploadReadyV2(dynamic data) {
|
||||
_batchedAssetUploadReady.add(data);
|
||||
_batchDebouncer.run(_processBatchedAssetUploadReadyV2);
|
||||
void _handleSyncAssetEditReady(dynamic data) {
|
||||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEdit(data));
|
||||
}
|
||||
|
||||
void _handleSyncAssetEditReadyV1(dynamic data) {
|
||||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data));
|
||||
}
|
||||
|
||||
void _handleSyncAssetEditReadyV2(dynamic data) {
|
||||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
|
||||
}
|
||||
|
||||
void _processBatchedAssetUploadReadyV1() {
|
||||
void _processBatchedAssetUploadReady() {
|
||||
if (_batchedAssetUploadReady.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -191,7 +180,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
|
||||
try {
|
||||
unawaited(
|
||||
_ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) {
|
||||
_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) {
|
||||
if (isSyncAlbumEnabled) {
|
||||
_ref.read(backgroundSyncProvider).syncLinkedAlbum();
|
||||
}
|
||||
@@ -203,27 +192,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
|
||||
_batchedAssetUploadReady.clear();
|
||||
}
|
||||
|
||||
void _processBatchedAssetUploadReadyV2() {
|
||||
if (_batchedAssetUploadReady.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
|
||||
try {
|
||||
unawaited(
|
||||
_ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) {
|
||||
if (isSyncAlbumEnabled) {
|
||||
_ref.read(backgroundSyncProvider).syncLinkedAlbum();
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
_log.severe("Error processing batched AssetUploadReadyV2 events: $error");
|
||||
}
|
||||
|
||||
_batchedAssetUploadReady.clear();
|
||||
}
|
||||
}
|
||||
|
||||
final websocketProvider = StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
flutter = "3.41.9"
|
||||
flutter = "3.41.7"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.30.0"
|
||||
|
||||
Generated
+1
-2
@@ -146,7 +146,7 @@ Class | Method | HTTP request | Description
|
||||
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
|
||||
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
|
||||
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information
|
||||
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Dismiss a duplicate group
|
||||
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
|
||||
*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates
|
||||
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates
|
||||
*DuplicatesApi* | [**resolveDuplicates**](doc//DuplicatesApi.md#resolveduplicates) | **POST** /duplicates/resolve | Resolve duplicate groups
|
||||
@@ -579,7 +579,6 @@ Class | Method | HTTP request | Description
|
||||
- [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md)
|
||||
- [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md)
|
||||
- [SyncAssetV1](doc//SyncAssetV1.md)
|
||||
- [SyncAssetV2](doc//SyncAssetV2.md)
|
||||
- [SyncAuthUserV1](doc//SyncAuthUserV1.md)
|
||||
- [SyncEntityType](doc//SyncEntityType.md)
|
||||
- [SyncMemoryAssetDeleteV1](doc//SyncMemoryAssetDeleteV1.md)
|
||||
|
||||
Generated
-1
@@ -327,7 +327,6 @@ part 'model/sync_asset_face_v2.dart';
|
||||
part 'model/sync_asset_metadata_delete_v1.dart';
|
||||
part 'model/sync_asset_metadata_v1.dart';
|
||||
part 'model/sync_asset_v1.dart';
|
||||
part 'model/sync_asset_v2.dart';
|
||||
part 'model/sync_auth_user_v1.dart';
|
||||
part 'model/sync_entity_type.dart';
|
||||
part 'model/sync_memory_asset_delete_v1.dart';
|
||||
|
||||
Generated
+6
-6
@@ -1234,8 +1234,8 @@ class AssetsApi {
|
||||
/// * [String] xImmichChecksum:
|
||||
/// sha1 checksum that can be used for duplicate detection before the file is uploaded
|
||||
///
|
||||
/// * [int] duration:
|
||||
/// Duration in milliseconds (for videos)
|
||||
/// * [String] duration:
|
||||
/// Duration (for videos)
|
||||
///
|
||||
/// * [String] filename:
|
||||
/// Filename
|
||||
@@ -1253,7 +1253,7 @@ class AssetsApi {
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [AssetVisibility] visibility:
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets';
|
||||
|
||||
@@ -1358,8 +1358,8 @@ class AssetsApi {
|
||||
/// * [String] xImmichChecksum:
|
||||
/// sha1 checksum that can be used for duplicate detection before the file is uploaded
|
||||
///
|
||||
/// * [int] duration:
|
||||
/// Duration in milliseconds (for videos)
|
||||
/// * [String] duration:
|
||||
/// Duration (for videos)
|
||||
///
|
||||
/// * [String] filename:
|
||||
/// Filename
|
||||
@@ -1377,7 +1377,7 @@ class AssetsApi {
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [AssetVisibility] visibility:
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
|
||||
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
|
||||
+4
-4
@@ -16,9 +16,9 @@ class DuplicatesApi {
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Dismiss a duplicate group
|
||||
/// Delete a duplicate
|
||||
///
|
||||
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
|
||||
/// Delete a single duplicate asset specified by its ID.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -51,9 +51,9 @@ class DuplicatesApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// Dismiss a duplicate group
|
||||
/// Delete a duplicate
|
||||
///
|
||||
/// Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.
|
||||
/// Delete a single duplicate asset specified by its ID.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
Generated
-2
@@ -700,8 +700,6 @@ class ApiClient {
|
||||
return SyncAssetMetadataV1.fromJson(value);
|
||||
case 'SyncAssetV1':
|
||||
return SyncAssetV1.fromJson(value);
|
||||
case 'SyncAssetV2':
|
||||
return SyncAssetV2.fromJson(value);
|
||||
case 'SyncAuthUserV1':
|
||||
return SyncAuthUserV1.fromJson(value);
|
||||
case 'SyncEntityType':
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ class AssetBulkUpdateDto {
|
||||
|
||||
/// Rating in range [1-5], or null for unrated
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 5
|
||||
int? rating;
|
||||
|
||||
|
||||
+3
-6
@@ -57,11 +57,8 @@ class AssetResponseDto {
|
||||
/// Duplicate group ID
|
||||
String? duplicateId;
|
||||
|
||||
/// Video/gif duration in milliseconds (null for static images)
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 2147483647
|
||||
int? duration;
|
||||
/// Video/gif duration in hh:mm:ss.SSS format (null for static images)
|
||||
String? duration;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
@@ -346,7 +343,7 @@ class AssetResponseDto {
|
||||
checksum: mapValueOfType<String>(json, r'checksum')!,
|
||||
createdAt: mapDateTime(json, r'createdAt', r'')!,
|
||||
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
||||
duration: mapValueOfType<int>(json, r'duration'),
|
||||
duration: mapValueOfType<String>(json, r'duration'),
|
||||
exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']),
|
||||
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
|
||||
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
|
||||
|
||||
+2
-2
@@ -108,8 +108,8 @@ class ExifResponseDto {
|
||||
|
||||
/// Rating
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 5
|
||||
int? rating;
|
||||
|
||||
/// State/province name
|
||||
|
||||
+1
-1
@@ -238,7 +238,7 @@ class MetadataSearchDto {
|
||||
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 5
|
||||
int? rating;
|
||||
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ class RandomSearchDto {
|
||||
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 5
|
||||
int? rating;
|
||||
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ class SmartSearchDto {
|
||||
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 5
|
||||
int? rating;
|
||||
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@ class StatisticsSearchDto {
|
||||
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 5
|
||||
int? rating;
|
||||
|
||||
|
||||
-321
@@ -1,321 +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 SyncAssetV2 {
|
||||
/// Returns a new [SyncAssetV2] instance.
|
||||
SyncAssetV2({
|
||||
required this.checksum,
|
||||
required this.deletedAt,
|
||||
required this.duration,
|
||||
required this.fileCreatedAt,
|
||||
required this.fileModifiedAt,
|
||||
required this.height,
|
||||
required this.id,
|
||||
required this.isEdited,
|
||||
required this.isFavorite,
|
||||
required this.libraryId,
|
||||
required this.livePhotoVideoId,
|
||||
required this.localDateTime,
|
||||
required this.originalFileName,
|
||||
required this.ownerId,
|
||||
required this.stackId,
|
||||
required this.thumbhash,
|
||||
required this.type,
|
||||
required this.visibility,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
/// Checksum
|
||||
String checksum;
|
||||
|
||||
/// Deleted at
|
||||
DateTime? deletedAt;
|
||||
|
||||
/// Duration
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 2147483647
|
||||
int? duration;
|
||||
|
||||
/// File created at
|
||||
DateTime? fileCreatedAt;
|
||||
|
||||
/// File modified at
|
||||
DateTime? fileModifiedAt;
|
||||
|
||||
/// Asset height
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int? height;
|
||||
|
||||
/// Asset ID
|
||||
String id;
|
||||
|
||||
/// Is edited
|
||||
bool isEdited;
|
||||
|
||||
/// Is favorite
|
||||
bool isFavorite;
|
||||
|
||||
/// Library ID
|
||||
String? libraryId;
|
||||
|
||||
/// Live photo video ID
|
||||
String? livePhotoVideoId;
|
||||
|
||||
/// Local date time
|
||||
DateTime? localDateTime;
|
||||
|
||||
/// Original file name
|
||||
String originalFileName;
|
||||
|
||||
/// Owner ID
|
||||
String ownerId;
|
||||
|
||||
/// Stack ID
|
||||
String? stackId;
|
||||
|
||||
/// Thumbhash
|
||||
String? thumbhash;
|
||||
|
||||
AssetTypeEnum type;
|
||||
|
||||
AssetVisibility visibility;
|
||||
|
||||
/// Asset width
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int? width;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SyncAssetV2 &&
|
||||
other.checksum == checksum &&
|
||||
other.deletedAt == deletedAt &&
|
||||
other.duration == duration &&
|
||||
other.fileCreatedAt == fileCreatedAt &&
|
||||
other.fileModifiedAt == fileModifiedAt &&
|
||||
other.height == height &&
|
||||
other.id == id &&
|
||||
other.isEdited == isEdited &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.libraryId == libraryId &&
|
||||
other.livePhotoVideoId == livePhotoVideoId &&
|
||||
other.localDateTime == localDateTime &&
|
||||
other.originalFileName == originalFileName &&
|
||||
other.ownerId == ownerId &&
|
||||
other.stackId == stackId &&
|
||||
other.thumbhash == thumbhash &&
|
||||
other.type == type &&
|
||||
other.visibility == visibility &&
|
||||
other.width == width;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(checksum.hashCode) +
|
||||
(deletedAt == null ? 0 : deletedAt!.hashCode) +
|
||||
(duration == null ? 0 : duration!.hashCode) +
|
||||
(fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) +
|
||||
(fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
|
||||
(height == null ? 0 : height!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isEdited.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(libraryId == null ? 0 : libraryId!.hashCode) +
|
||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
||||
(localDateTime == null ? 0 : localDateTime!.hashCode) +
|
||||
(originalFileName.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(stackId == null ? 0 : stackId!.hashCode) +
|
||||
(thumbhash == null ? 0 : thumbhash!.hashCode) +
|
||||
(type.hashCode) +
|
||||
(visibility.hashCode) +
|
||||
(width == null ? 0 : width!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SyncAssetV2[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isEdited=$isEdited, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'checksum'] = this.checksum;
|
||||
if (this.deletedAt != null) {
|
||||
json[r'deletedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
|
||||
? this.deletedAt!.millisecondsSinceEpoch
|
||||
: this.deletedAt!.toUtc().toIso8601String();
|
||||
} else {
|
||||
// json[r'deletedAt'] = null;
|
||||
}
|
||||
if (this.duration != null) {
|
||||
json[r'duration'] = this.duration;
|
||||
} else {
|
||||
// json[r'duration'] = null;
|
||||
}
|
||||
if (this.fileCreatedAt != null) {
|
||||
json[r'fileCreatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
|
||||
? this.fileCreatedAt!.millisecondsSinceEpoch
|
||||
: this.fileCreatedAt!.toUtc().toIso8601String();
|
||||
} else {
|
||||
// json[r'fileCreatedAt'] = null;
|
||||
}
|
||||
if (this.fileModifiedAt != null) {
|
||||
json[r'fileModifiedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
|
||||
? this.fileModifiedAt!.millisecondsSinceEpoch
|
||||
: this.fileModifiedAt!.toUtc().toIso8601String();
|
||||
} else {
|
||||
// json[r'fileModifiedAt'] = null;
|
||||
}
|
||||
if (this.height != null) {
|
||||
json[r'height'] = this.height;
|
||||
} else {
|
||||
// json[r'height'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
json[r'isEdited'] = this.isEdited;
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
if (this.libraryId != null) {
|
||||
json[r'libraryId'] = this.libraryId;
|
||||
} else {
|
||||
// json[r'libraryId'] = null;
|
||||
}
|
||||
if (this.livePhotoVideoId != null) {
|
||||
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
|
||||
} else {
|
||||
// json[r'livePhotoVideoId'] = null;
|
||||
}
|
||||
if (this.localDateTime != null) {
|
||||
json[r'localDateTime'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
|
||||
? this.localDateTime!.millisecondsSinceEpoch
|
||||
: this.localDateTime!.toUtc().toIso8601String();
|
||||
} else {
|
||||
// json[r'localDateTime'] = null;
|
||||
}
|
||||
json[r'originalFileName'] = this.originalFileName;
|
||||
json[r'ownerId'] = this.ownerId;
|
||||
if (this.stackId != null) {
|
||||
json[r'stackId'] = this.stackId;
|
||||
} else {
|
||||
// json[r'stackId'] = null;
|
||||
}
|
||||
if (this.thumbhash != null) {
|
||||
json[r'thumbhash'] = this.thumbhash;
|
||||
} else {
|
||||
// json[r'thumbhash'] = null;
|
||||
}
|
||||
json[r'type'] = this.type;
|
||||
json[r'visibility'] = this.visibility;
|
||||
if (this.width != null) {
|
||||
json[r'width'] = this.width;
|
||||
} else {
|
||||
// json[r'width'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SyncAssetV2] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SyncAssetV2? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SyncAssetV2");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SyncAssetV2(
|
||||
checksum: mapValueOfType<String>(json, r'checksum')!,
|
||||
deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
|
||||
duration: mapValueOfType<int>(json, r'duration'),
|
||||
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
|
||||
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
|
||||
height: mapValueOfType<int>(json, r'height'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
|
||||
libraryId: mapValueOfType<String>(json, r'libraryId'),
|
||||
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
||||
localDateTime: mapDateTime(json, r'localDateTime', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
|
||||
originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
|
||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
stackId: mapValueOfType<String>(json, r'stackId'),
|
||||
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
|
||||
type: AssetTypeEnum.fromJson(json[r'type'])!,
|
||||
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
|
||||
width: mapValueOfType<int>(json, r'width'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SyncAssetV2> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SyncAssetV2>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SyncAssetV2.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SyncAssetV2> mapFromJson(dynamic json) {
|
||||
final map = <String, SyncAssetV2>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SyncAssetV2.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SyncAssetV2-objects as value to a dart map
|
||||
static Map<String, List<SyncAssetV2>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SyncAssetV2>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SyncAssetV2.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'checksum',
|
||||
'deletedAt',
|
||||
'duration',
|
||||
'fileCreatedAt',
|
||||
'fileModifiedAt',
|
||||
'height',
|
||||
'id',
|
||||
'isEdited',
|
||||
'isFavorite',
|
||||
'libraryId',
|
||||
'livePhotoVideoId',
|
||||
'localDateTime',
|
||||
'originalFileName',
|
||||
'ownerId',
|
||||
'stackId',
|
||||
'thumbhash',
|
||||
'type',
|
||||
'visibility',
|
||||
'width',
|
||||
};
|
||||
}
|
||||
|
||||
-18
@@ -27,7 +27,6 @@ class SyncEntityType {
|
||||
static const userV1 = SyncEntityType._(r'UserV1');
|
||||
static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1');
|
||||
static const assetV1 = SyncEntityType._(r'AssetV1');
|
||||
static const assetV2 = SyncEntityType._(r'AssetV2');
|
||||
static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
|
||||
static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
|
||||
static const assetEditV1 = SyncEntityType._(r'AssetEditV1');
|
||||
@@ -37,9 +36,7 @@ class SyncEntityType {
|
||||
static const partnerV1 = SyncEntityType._(r'PartnerV1');
|
||||
static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1');
|
||||
static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1');
|
||||
static const partnerAssetV2 = SyncEntityType._(r'PartnerAssetV2');
|
||||
static const partnerAssetBackfillV1 = SyncEntityType._(r'PartnerAssetBackfillV1');
|
||||
static const partnerAssetBackfillV2 = SyncEntityType._(r'PartnerAssetBackfillV2');
|
||||
static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1');
|
||||
static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1');
|
||||
static const partnerAssetExifBackfillV1 = SyncEntityType._(r'PartnerAssetExifBackfillV1');
|
||||
@@ -53,11 +50,8 @@ class SyncEntityType {
|
||||
static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1');
|
||||
static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1');
|
||||
static const albumAssetCreateV1 = SyncEntityType._(r'AlbumAssetCreateV1');
|
||||
static const albumAssetCreateV2 = SyncEntityType._(r'AlbumAssetCreateV2');
|
||||
static const albumAssetUpdateV1 = SyncEntityType._(r'AlbumAssetUpdateV1');
|
||||
static const albumAssetUpdateV2 = SyncEntityType._(r'AlbumAssetUpdateV2');
|
||||
static const albumAssetBackfillV1 = SyncEntityType._(r'AlbumAssetBackfillV1');
|
||||
static const albumAssetBackfillV2 = SyncEntityType._(r'AlbumAssetBackfillV2');
|
||||
static const albumAssetExifCreateV1 = SyncEntityType._(r'AlbumAssetExifCreateV1');
|
||||
static const albumAssetExifUpdateV1 = SyncEntityType._(r'AlbumAssetExifUpdateV1');
|
||||
static const albumAssetExifBackfillV1 = SyncEntityType._(r'AlbumAssetExifBackfillV1');
|
||||
@@ -87,7 +81,6 @@ class SyncEntityType {
|
||||
userV1,
|
||||
userDeleteV1,
|
||||
assetV1,
|
||||
assetV2,
|
||||
assetDeleteV1,
|
||||
assetExifV1,
|
||||
assetEditV1,
|
||||
@@ -97,9 +90,7 @@ class SyncEntityType {
|
||||
partnerV1,
|
||||
partnerDeleteV1,
|
||||
partnerAssetV1,
|
||||
partnerAssetV2,
|
||||
partnerAssetBackfillV1,
|
||||
partnerAssetBackfillV2,
|
||||
partnerAssetDeleteV1,
|
||||
partnerAssetExifV1,
|
||||
partnerAssetExifBackfillV1,
|
||||
@@ -113,11 +104,8 @@ class SyncEntityType {
|
||||
albumUserBackfillV1,
|
||||
albumUserDeleteV1,
|
||||
albumAssetCreateV1,
|
||||
albumAssetCreateV2,
|
||||
albumAssetUpdateV1,
|
||||
albumAssetUpdateV2,
|
||||
albumAssetBackfillV1,
|
||||
albumAssetBackfillV2,
|
||||
albumAssetExifCreateV1,
|
||||
albumAssetExifUpdateV1,
|
||||
albumAssetExifBackfillV1,
|
||||
@@ -182,7 +170,6 @@ class SyncEntityTypeTypeTransformer {
|
||||
case r'UserV1': return SyncEntityType.userV1;
|
||||
case r'UserDeleteV1': return SyncEntityType.userDeleteV1;
|
||||
case r'AssetV1': return SyncEntityType.assetV1;
|
||||
case r'AssetV2': return SyncEntityType.assetV2;
|
||||
case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
|
||||
case r'AssetExifV1': return SyncEntityType.assetExifV1;
|
||||
case r'AssetEditV1': return SyncEntityType.assetEditV1;
|
||||
@@ -192,9 +179,7 @@ class SyncEntityTypeTypeTransformer {
|
||||
case r'PartnerV1': return SyncEntityType.partnerV1;
|
||||
case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1;
|
||||
case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1;
|
||||
case r'PartnerAssetV2': return SyncEntityType.partnerAssetV2;
|
||||
case r'PartnerAssetBackfillV1': return SyncEntityType.partnerAssetBackfillV1;
|
||||
case r'PartnerAssetBackfillV2': return SyncEntityType.partnerAssetBackfillV2;
|
||||
case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1;
|
||||
case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1;
|
||||
case r'PartnerAssetExifBackfillV1': return SyncEntityType.partnerAssetExifBackfillV1;
|
||||
@@ -208,11 +193,8 @@ class SyncEntityTypeTypeTransformer {
|
||||
case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1;
|
||||
case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1;
|
||||
case r'AlbumAssetCreateV1': return SyncEntityType.albumAssetCreateV1;
|
||||
case r'AlbumAssetCreateV2': return SyncEntityType.albumAssetCreateV2;
|
||||
case r'AlbumAssetUpdateV1': return SyncEntityType.albumAssetUpdateV1;
|
||||
case r'AlbumAssetUpdateV2': return SyncEntityType.albumAssetUpdateV2;
|
||||
case r'AlbumAssetBackfillV1': return SyncEntityType.albumAssetBackfillV1;
|
||||
case r'AlbumAssetBackfillV2': return SyncEntityType.albumAssetBackfillV2;
|
||||
case r'AlbumAssetExifCreateV1': return SyncEntityType.albumAssetExifCreateV1;
|
||||
case r'AlbumAssetExifUpdateV1': return SyncEntityType.albumAssetExifUpdateV1;
|
||||
case r'AlbumAssetExifBackfillV1': return SyncEntityType.albumAssetExifBackfillV1;
|
||||
|
||||
-9
@@ -28,10 +28,8 @@ class SyncRequestType {
|
||||
static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1');
|
||||
static const albumToAssetsV1 = SyncRequestType._(r'AlbumToAssetsV1');
|
||||
static const albumAssetsV1 = SyncRequestType._(r'AlbumAssetsV1');
|
||||
static const albumAssetsV2 = SyncRequestType._(r'AlbumAssetsV2');
|
||||
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
|
||||
static const assetsV1 = SyncRequestType._(r'AssetsV1');
|
||||
static const assetsV2 = SyncRequestType._(r'AssetsV2');
|
||||
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
|
||||
static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1');
|
||||
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
|
||||
@@ -40,7 +38,6 @@ class SyncRequestType {
|
||||
static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1');
|
||||
static const partnersV1 = SyncRequestType._(r'PartnersV1');
|
||||
static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1');
|
||||
static const partnerAssetsV2 = SyncRequestType._(r'PartnerAssetsV2');
|
||||
static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1');
|
||||
static const partnerStacksV1 = SyncRequestType._(r'PartnerStacksV1');
|
||||
static const stacksV1 = SyncRequestType._(r'StacksV1');
|
||||
@@ -57,10 +54,8 @@ class SyncRequestType {
|
||||
albumUsersV1,
|
||||
albumToAssetsV1,
|
||||
albumAssetsV1,
|
||||
albumAssetsV2,
|
||||
albumAssetExifsV1,
|
||||
assetsV1,
|
||||
assetsV2,
|
||||
assetExifsV1,
|
||||
assetEditsV1,
|
||||
assetMetadataV1,
|
||||
@@ -69,7 +64,6 @@ class SyncRequestType {
|
||||
memoryToAssetsV1,
|
||||
partnersV1,
|
||||
partnerAssetsV1,
|
||||
partnerAssetsV2,
|
||||
partnerAssetExifsV1,
|
||||
partnerStacksV1,
|
||||
stacksV1,
|
||||
@@ -121,10 +115,8 @@ class SyncRequestTypeTypeTransformer {
|
||||
case r'AlbumUsersV1': return SyncRequestType.albumUsersV1;
|
||||
case r'AlbumToAssetsV1': return SyncRequestType.albumToAssetsV1;
|
||||
case r'AlbumAssetsV1': return SyncRequestType.albumAssetsV1;
|
||||
case r'AlbumAssetsV2': return SyncRequestType.albumAssetsV2;
|
||||
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
|
||||
case r'AssetsV1': return SyncRequestType.assetsV1;
|
||||
case r'AssetsV2': return SyncRequestType.assetsV2;
|
||||
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
|
||||
case r'AssetEditsV1': return SyncRequestType.assetEditsV1;
|
||||
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
|
||||
@@ -133,7 +125,6 @@ class SyncRequestTypeTypeTransformer {
|
||||
case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1;
|
||||
case r'PartnersV1': return SyncRequestType.partnersV1;
|
||||
case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1;
|
||||
case r'PartnerAssetsV2': return SyncRequestType.partnerAssetsV2;
|
||||
case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1;
|
||||
case r'PartnerStacksV1': return SyncRequestType.partnerStacksV1;
|
||||
case r'StacksV1': return SyncRequestType.stacksV1;
|
||||
|
||||
@@ -39,8 +39,8 @@ class TimeBucketAssetResponseDto {
|
||||
/// Array of country names extracted from EXIF GPS data
|
||||
List<String?> country;
|
||||
|
||||
/// Array of video/gif durations in milliseconds (null for static images)
|
||||
List<int?> duration;
|
||||
/// Array of video/gif durations in hh:mm:ss.SSS format (null for static images)
|
||||
List<String?> duration;
|
||||
|
||||
/// Array of file creation timestamps in UTC
|
||||
List<String> fileCreatedAt;
|
||||
@@ -172,7 +172,7 @@ class TimeBucketAssetResponseDto {
|
||||
? (json[r'country'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
duration: json[r'duration'] is Iterable
|
||||
? (json[r'duration'] as Iterable).cast<int>().toList(growable: false)
|
||||
? (json[r'duration'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
fileCreatedAt: json[r'fileCreatedAt'] is Iterable
|
||||
? (json[r'fileCreatedAt'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ class UpdateAssetDto {
|
||||
|
||||
/// Rating in range [1-5], or null for unrated
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 5
|
||||
int? rating;
|
||||
|
||||
|
||||
+1
-1
@@ -1989,4 +1989,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: "3.41.9"
|
||||
flutter: "3.41.7"
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ version: 2.7.5+3046
|
||||
|
||||
environment:
|
||||
sdk: '>=3.11.0 <4.0.0'
|
||||
flutter: 3.41.9
|
||||
flutter: 3.41.7
|
||||
|
||||
dependencies:
|
||||
async: ^2.13.1
|
||||
|
||||
@@ -5172,7 +5172,7 @@
|
||||
},
|
||||
"/duplicates/{id}": {
|
||||
"delete": {
|
||||
"description": "Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.",
|
||||
"description": "Delete a single duplicate asset specified by its ID.",
|
||||
"operationId": "deleteDuplicate",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5202,7 +5202,7 @@
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Dismiss a duplicate group",
|
||||
"summary": "Delete a duplicate",
|
||||
"tags": [
|
||||
"Duplicates"
|
||||
],
|
||||
@@ -9369,12 +9369,17 @@
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is no longer valid."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": -1,
|
||||
"minimum": 0,
|
||||
"maximum": 5,
|
||||
"nullable": true
|
||||
}
|
||||
@@ -15678,7 +15683,7 @@
|
||||
"rating": {
|
||||
"description": "Rating in range [1-5], or null for unrated",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
@@ -15694,6 +15699,11 @@
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is no longer valid."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
@@ -16261,10 +16271,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"duration": {
|
||||
"description": "Duration in milliseconds (for videos)",
|
||||
"maximum": 2147483647,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
"description": "Duration (for videos)",
|
||||
"type": "string"
|
||||
},
|
||||
"fileCreatedAt": {
|
||||
"description": "File creation date",
|
||||
@@ -16632,11 +16640,9 @@
|
||||
"type": "string"
|
||||
},
|
||||
"duration": {
|
||||
"description": "Video/gif duration in milliseconds (null for static images)",
|
||||
"maximum": 2147483647,
|
||||
"minimum": 0,
|
||||
"description": "Video/gif duration in hh:mm:ss.SSS format (null for static images)",
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
},
|
||||
"exifInfo": {
|
||||
"$ref": "#/components/schemas/ExifResponseDto"
|
||||
@@ -17742,8 +17748,8 @@
|
||||
"rating": {
|
||||
"default": null,
|
||||
"description": "Rating",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 5,
|
||||
"minimum": 1,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -18773,7 +18779,7 @@
|
||||
"rating": {
|
||||
"description": "Filter by rating [1-5], or null for unrated",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
@@ -18789,6 +18795,11 @@
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is no longer valid."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
@@ -20626,7 +20637,7 @@
|
||||
"rating": {
|
||||
"description": "Filter by rating [1-5], or null for unrated",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
@@ -20642,6 +20653,11 @@
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is no longer valid."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
@@ -22010,7 +22026,7 @@
|
||||
"rating": {
|
||||
"description": "Filter by rating [1-5], or null for unrated",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
@@ -22026,6 +22042,11 @@
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is no longer valid."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
@@ -22270,7 +22291,7 @@
|
||||
"rating": {
|
||||
"description": "Filter by rating [1-5], or null for unrated",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
@@ -22286,6 +22307,11 @@
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is no longer valid."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
@@ -23179,135 +23205,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAssetV2": {
|
||||
"properties": {
|
||||
"checksum": {
|
||||
"description": "Checksum",
|
||||
"type": "string"
|
||||
},
|
||||
"deletedAt": {
|
||||
"description": "Deleted at",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"type": "string"
|
||||
},
|
||||
"duration": {
|
||||
"description": "Duration",
|
||||
"maximum": 2147483647,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
},
|
||||
"fileCreatedAt": {
|
||||
"description": "File created at",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"type": "string"
|
||||
},
|
||||
"fileModifiedAt": {
|
||||
"description": "File modified at",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"type": "string"
|
||||
},
|
||||
"height": {
|
||||
"description": "Asset height",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"description": "Asset ID",
|
||||
"type": "string"
|
||||
},
|
||||
"isEdited": {
|
||||
"description": "Is edited",
|
||||
"type": "boolean"
|
||||
},
|
||||
"isFavorite": {
|
||||
"description": "Is favorite",
|
||||
"type": "boolean"
|
||||
},
|
||||
"libraryId": {
|
||||
"description": "Library ID",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"livePhotoVideoId": {
|
||||
"description": "Live photo video ID",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"localDateTime": {
|
||||
"description": "Local date time",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"type": "string"
|
||||
},
|
||||
"originalFileName": {
|
||||
"description": "Original file name",
|
||||
"type": "string"
|
||||
},
|
||||
"ownerId": {
|
||||
"description": "Owner ID",
|
||||
"type": "string"
|
||||
},
|
||||
"stackId": {
|
||||
"description": "Stack ID",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"thumbhash": {
|
||||
"description": "Thumbhash",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/components/schemas/AssetTypeEnum"
|
||||
},
|
||||
"visibility": {
|
||||
"$ref": "#/components/schemas/AssetVisibility"
|
||||
},
|
||||
"width": {
|
||||
"description": "Asset width",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"checksum",
|
||||
"deletedAt",
|
||||
"duration",
|
||||
"fileCreatedAt",
|
||||
"fileModifiedAt",
|
||||
"height",
|
||||
"id",
|
||||
"isEdited",
|
||||
"isFavorite",
|
||||
"libraryId",
|
||||
"livePhotoVideoId",
|
||||
"localDateTime",
|
||||
"originalFileName",
|
||||
"ownerId",
|
||||
"stackId",
|
||||
"thumbhash",
|
||||
"type",
|
||||
"visibility",
|
||||
"width"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAuthUserV1": {
|
||||
"properties": {
|
||||
"avatarColor": {
|
||||
@@ -23408,7 +23305,6 @@
|
||||
"UserV1",
|
||||
"UserDeleteV1",
|
||||
"AssetV1",
|
||||
"AssetV2",
|
||||
"AssetDeleteV1",
|
||||
"AssetExifV1",
|
||||
"AssetEditV1",
|
||||
@@ -23418,9 +23314,7 @@
|
||||
"PartnerV1",
|
||||
"PartnerDeleteV1",
|
||||
"PartnerAssetV1",
|
||||
"PartnerAssetV2",
|
||||
"PartnerAssetBackfillV1",
|
||||
"PartnerAssetBackfillV2",
|
||||
"PartnerAssetDeleteV1",
|
||||
"PartnerAssetExifV1",
|
||||
"PartnerAssetExifBackfillV1",
|
||||
@@ -23434,11 +23328,8 @@
|
||||
"AlbumUserBackfillV1",
|
||||
"AlbumUserDeleteV1",
|
||||
"AlbumAssetCreateV1",
|
||||
"AlbumAssetCreateV2",
|
||||
"AlbumAssetUpdateV1",
|
||||
"AlbumAssetUpdateV2",
|
||||
"AlbumAssetBackfillV1",
|
||||
"AlbumAssetBackfillV2",
|
||||
"AlbumAssetExifCreateV1",
|
||||
"AlbumAssetExifUpdateV1",
|
||||
"AlbumAssetExifBackfillV1",
|
||||
@@ -23730,10 +23621,8 @@
|
||||
"AlbumUsersV1",
|
||||
"AlbumToAssetsV1",
|
||||
"AlbumAssetsV1",
|
||||
"AlbumAssetsV2",
|
||||
"AlbumAssetExifsV1",
|
||||
"AssetsV1",
|
||||
"AssetsV2",
|
||||
"AssetExifsV1",
|
||||
"AssetEditsV1",
|
||||
"AssetMetadataV1",
|
||||
@@ -23742,7 +23631,6 @@
|
||||
"MemoryToAssetsV1",
|
||||
"PartnersV1",
|
||||
"PartnerAssetsV1",
|
||||
"PartnerAssetsV2",
|
||||
"PartnerAssetExifsV1",
|
||||
"PartnerStacksV1",
|
||||
"StacksV1",
|
||||
@@ -25136,12 +25024,10 @@
|
||||
"type": "array"
|
||||
},
|
||||
"duration": {
|
||||
"description": "Array of video/gif durations in milliseconds (null for static images)",
|
||||
"description": "Array of video/gif durations in hh:mm:ss.SSS format (null for static images)",
|
||||
"items": {
|
||||
"maximum": 2147483647,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
@@ -25421,7 +25307,7 @@
|
||||
"rating": {
|
||||
"description": "Rating in range [1-5], or null for unrated",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
@@ -25437,6 +25323,11 @@
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is no longer valid."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
|
||||
@@ -614,8 +614,8 @@ export type AssetMetadataUpsertItemDto = {
|
||||
export type AssetMediaCreateDto = {
|
||||
/** Asset file data */
|
||||
assetData: Blob;
|
||||
/** Duration in milliseconds (for videos) */
|
||||
duration?: number;
|
||||
/** Duration (for videos) */
|
||||
duration?: string;
|
||||
/** File creation date */
|
||||
fileCreatedAt: string;
|
||||
/** File modification date */
|
||||
@@ -854,8 +854,8 @@ export type AssetResponseDto = {
|
||||
createdAt: string;
|
||||
/** Duplicate group ID */
|
||||
duplicateId?: string | null;
|
||||
/** Video/gif duration in milliseconds (null for static images) */
|
||||
duration: number | null;
|
||||
/** Video/gif duration in hh:mm:ss.SSS format (null for static images) */
|
||||
duration: string | null;
|
||||
exifInfo?: ExifResponseDto;
|
||||
/** The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken. */
|
||||
fileCreatedAt: string;
|
||||
@@ -2673,8 +2673,8 @@ export type TimeBucketAssetResponseDto = {
|
||||
city: (string | null)[];
|
||||
/** Array of country names extracted from EXIF GPS data */
|
||||
country: (string | null)[];
|
||||
/** Array of video/gif durations in milliseconds (null for static images) */
|
||||
duration: (number | null)[];
|
||||
/** Array of video/gif durations in hh:mm:ss.SSS format (null for static images) */
|
||||
duration: (string | null)[];
|
||||
/** Array of file creation timestamps in UTC */
|
||||
fileCreatedAt: string[];
|
||||
/** Array of asset IDs in the time bucket */
|
||||
@@ -3075,44 +3075,6 @@ export type SyncAssetV1 = {
|
||||
/** Asset width */
|
||||
width: number | null;
|
||||
};
|
||||
export type SyncAssetV2 = {
|
||||
/** Checksum */
|
||||
checksum: string;
|
||||
/** Deleted at */
|
||||
deletedAt: string | null;
|
||||
/** Duration */
|
||||
duration: number | null;
|
||||
/** File created at */
|
||||
fileCreatedAt: string | null;
|
||||
/** File modified at */
|
||||
fileModifiedAt: string | null;
|
||||
/** Asset height */
|
||||
height: number | null;
|
||||
/** Asset ID */
|
||||
id: string;
|
||||
/** Is edited */
|
||||
isEdited: boolean;
|
||||
/** Is favorite */
|
||||
isFavorite: boolean;
|
||||
/** Library ID */
|
||||
libraryId: string | null;
|
||||
/** Live photo video ID */
|
||||
livePhotoVideoId: string | null;
|
||||
/** Local date time */
|
||||
localDateTime: string | null;
|
||||
/** Original file name */
|
||||
originalFileName: string;
|
||||
/** Owner ID */
|
||||
ownerId: string;
|
||||
/** Stack ID */
|
||||
stackId: string | null;
|
||||
/** Thumbhash */
|
||||
thumbhash: string | null;
|
||||
"type": AssetTypeEnum;
|
||||
visibility: AssetVisibility;
|
||||
/** Asset width */
|
||||
width: number | null;
|
||||
};
|
||||
export type SyncAuthUserV1 = {
|
||||
avatarColor?: (UserAvatarColor) | null;
|
||||
/** User deleted at */
|
||||
@@ -4480,7 +4442,7 @@ export function resolveDuplicates({ duplicateResolveDto }: {
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Dismiss a duplicate group
|
||||
* Delete a duplicate
|
||||
*/
|
||||
export function deleteDuplicate({ id }: {
|
||||
id: string;
|
||||
@@ -7147,7 +7109,6 @@ export enum SyncEntityType {
|
||||
UserV1 = "UserV1",
|
||||
UserDeleteV1 = "UserDeleteV1",
|
||||
AssetV1 = "AssetV1",
|
||||
AssetV2 = "AssetV2",
|
||||
AssetDeleteV1 = "AssetDeleteV1",
|
||||
AssetExifV1 = "AssetExifV1",
|
||||
AssetEditV1 = "AssetEditV1",
|
||||
@@ -7157,9 +7118,7 @@ export enum SyncEntityType {
|
||||
PartnerV1 = "PartnerV1",
|
||||
PartnerDeleteV1 = "PartnerDeleteV1",
|
||||
PartnerAssetV1 = "PartnerAssetV1",
|
||||
PartnerAssetV2 = "PartnerAssetV2",
|
||||
PartnerAssetBackfillV1 = "PartnerAssetBackfillV1",
|
||||
PartnerAssetBackfillV2 = "PartnerAssetBackfillV2",
|
||||
PartnerAssetDeleteV1 = "PartnerAssetDeleteV1",
|
||||
PartnerAssetExifV1 = "PartnerAssetExifV1",
|
||||
PartnerAssetExifBackfillV1 = "PartnerAssetExifBackfillV1",
|
||||
@@ -7173,11 +7132,8 @@ export enum SyncEntityType {
|
||||
AlbumUserBackfillV1 = "AlbumUserBackfillV1",
|
||||
AlbumUserDeleteV1 = "AlbumUserDeleteV1",
|
||||
AlbumAssetCreateV1 = "AlbumAssetCreateV1",
|
||||
AlbumAssetCreateV2 = "AlbumAssetCreateV2",
|
||||
AlbumAssetUpdateV1 = "AlbumAssetUpdateV1",
|
||||
AlbumAssetUpdateV2 = "AlbumAssetUpdateV2",
|
||||
AlbumAssetBackfillV1 = "AlbumAssetBackfillV1",
|
||||
AlbumAssetBackfillV2 = "AlbumAssetBackfillV2",
|
||||
AlbumAssetExifCreateV1 = "AlbumAssetExifCreateV1",
|
||||
AlbumAssetExifUpdateV1 = "AlbumAssetExifUpdateV1",
|
||||
AlbumAssetExifBackfillV1 = "AlbumAssetExifBackfillV1",
|
||||
@@ -7207,10 +7163,8 @@ export enum SyncRequestType {
|
||||
AlbumUsersV1 = "AlbumUsersV1",
|
||||
AlbumToAssetsV1 = "AlbumToAssetsV1",
|
||||
AlbumAssetsV1 = "AlbumAssetsV1",
|
||||
AlbumAssetsV2 = "AlbumAssetsV2",
|
||||
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
|
||||
AssetsV1 = "AssetsV1",
|
||||
AssetsV2 = "AssetsV2",
|
||||
AssetExifsV1 = "AssetExifsV1",
|
||||
AssetEditsV1 = "AssetEditsV1",
|
||||
AssetMetadataV1 = "AssetMetadataV1",
|
||||
@@ -7219,7 +7173,6 @@ export enum SyncRequestType {
|
||||
MemoryToAssetsV1 = "MemoryToAssetsV1",
|
||||
PartnersV1 = "PartnersV1",
|
||||
PartnerAssetsV1 = "PartnerAssetsV1",
|
||||
PartnerAssetsV2 = "PartnerAssetsV2",
|
||||
PartnerAssetExifsV1 = "PartnerAssetExifsV1",
|
||||
PartnerStacksV1 = "PartnerStacksV1",
|
||||
StacksV1 = "StacksV1",
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { HttpError } from '@oazapfts/runtime';
|
||||
|
||||
export interface ApiValidationError {
|
||||
code: string;
|
||||
path: (string | number)[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApiExceptionResponse {
|
||||
message: string;
|
||||
error?: string;
|
||||
statusCode: number;
|
||||
errors?: ApiValidationError[];
|
||||
}
|
||||
|
||||
export interface ApiHttpError extends HttpError {
|
||||
|
||||
Generated
+3
-103
@@ -804,9 +804,6 @@ importers:
|
||||
maplibre-gl:
|
||||
specifier: ^5.6.2
|
||||
version: 5.24.0
|
||||
media-chrome:
|
||||
specifier: ^4.19.0
|
||||
version: 4.19.0(react@19.2.5)
|
||||
pmtiles:
|
||||
specifier: ^4.3.0
|
||||
version: 4.4.1
|
||||
@@ -878,7 +875,7 @@ importers:
|
||||
specifier: 7.0.0
|
||||
version: 7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.4
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.4.2
|
||||
@@ -922,9 +919,6 @@ importers:
|
||||
eslint-config-prettier:
|
||||
specifier: ^10.1.8
|
||||
version: 10.1.8(eslint@10.2.1(jiti@2.6.1))
|
||||
eslint-plugin-better-tailwindcss:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0(eslint@10.2.1(jiti@2.6.1))(tailwindcss@4.2.4)(typescript@6.0.3)
|
||||
eslint-plugin-compat:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.1(eslint@10.2.1(jiti@2.6.1))
|
||||
@@ -962,7 +956,7 @@ importers:
|
||||
specifier: ^1.3.3
|
||||
version: 1.6.0(svelte@5.55.2)
|
||||
tailwindcss:
|
||||
specifier: ^4.2.4
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.4
|
||||
typescript:
|
||||
specifier: ^6.0.0
|
||||
@@ -2736,10 +2730,6 @@ packages:
|
||||
resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@eslint/css-tree@4.0.2':
|
||||
resolution: {integrity: sha512-eqSkC3mka2tiqOuPZKqvxNJoRzpxMss3Np3Yqi4sW7nTTRCpTKB2hzrY4JRsi0ZP3QbVfp23sgEm7VCoOjesmw==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@eslint/js@10.0.1':
|
||||
resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
@@ -5478,11 +5468,6 @@ packages:
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
'@valibot/to-json-schema@1.6.0':
|
||||
resolution: {integrity: sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A==}
|
||||
peerDependencies:
|
||||
valibot: ^1.3.0
|
||||
|
||||
'@vercel/oidc@3.0.5':
|
||||
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -6178,11 +6163,6 @@ packages:
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
ce-la-react@0.3.2:
|
||||
resolution: {integrity: sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==}
|
||||
peerDependencies:
|
||||
react: '>=17.0.0'
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -7349,19 +7329,6 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: '>=7.0.0'
|
||||
|
||||
eslint-plugin-better-tailwindcss@4.5.0:
|
||||
resolution: {integrity: sha512-EBNTx6OJYaWv7uUxHWTy1fhiNz2rZVkoeOHZzAJFwWaEPideBf04CMshrJ7YntG0KQzadlbRhHKYr32q5aBX4w==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
oxlint: ^1.35.0
|
||||
tailwindcss: ^3.3.0 || ^4.1.17
|
||||
peerDependenciesMeta:
|
||||
eslint:
|
||||
optional: true
|
||||
oxlint:
|
||||
optional: true
|
||||
|
||||
eslint-plugin-compat@7.0.1:
|
||||
resolution: {integrity: sha512-wDID2fVIAfxV9R1uSkCn5HscnNu8yMxDF1IaQGyD1C6XuWwJbuaDgMOSkVgOom0LzY8z0fXXXCy7AQQTERQUvQ==}
|
||||
engines: {node: '>=18.x'}
|
||||
@@ -9085,12 +9052,6 @@ packages:
|
||||
mdn-data@2.0.30:
|
||||
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
|
||||
|
||||
mdn-data@2.27.1:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
|
||||
media-chrome@4.19.0:
|
||||
resolution: {integrity: sha512-HWhDTwts+BSbdPkkB1VsJXp5kvL0IxY7xFT5tBwliM2+89kTPVTnHnev+9it2f9PweANjT/C8/C/S0PW9oyZbA==}
|
||||
|
||||
media-typer@0.3.0:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -11592,15 +11553,6 @@ packages:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tailwind-csstree@0.3.1:
|
||||
resolution: {integrity: sha512-v147gLOR+E+9H4dNaP9rBeS/S/CTQJMRItlX9jLOXjdBGfSRauLwiz7LBCViaQmn6URXIlOdN6iMzSzOaeoUUw==}
|
||||
engines: {node: '>=18.18'}
|
||||
peerDependencies:
|
||||
'@eslint/css': '>=1.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@eslint/css':
|
||||
optional: true
|
||||
|
||||
tailwind-merge@3.5.0:
|
||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||
|
||||
@@ -12147,14 +12099,6 @@ packages:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
||||
valibot@1.3.1:
|
||||
resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
validator@13.15.35:
|
||||
resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -15146,11 +15090,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
||||
'@eslint/css-tree@4.0.2':
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@eslint/js@10.0.1(eslint@10.2.1(jiti@2.6.1))':
|
||||
optionalDependencies:
|
||||
eslint: 10.2.1(jiti@2.6.1)
|
||||
@@ -17948,10 +17887,6 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@valibot/to-json-schema@1.6.0(valibot@1.3.1(typescript@6.0.3))':
|
||||
dependencies:
|
||||
valibot: 1.3.1(typescript@6.0.3)
|
||||
|
||||
'@vercel/oidc@3.0.5': {}
|
||||
|
||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
|
||||
@@ -17985,7 +17920,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
'@vitest/expect@3.2.4':
|
||||
dependencies:
|
||||
@@ -18774,10 +18709,6 @@ snapshots:
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
ce-la-react@0.3.2(react@19.2.5):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
@@ -20046,23 +19977,6 @@ snapshots:
|
||||
dependencies:
|
||||
eslint: 10.2.1(jiti@2.6.1)
|
||||
|
||||
eslint-plugin-better-tailwindcss@4.5.0(eslint@10.2.1(jiti@2.6.1))(tailwindcss@4.2.4)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@eslint/css-tree': 4.0.2
|
||||
'@valibot/to-json-schema': 1.6.0(valibot@1.3.1(typescript@6.0.3))
|
||||
enhanced-resolve: 5.21.0
|
||||
jiti: 2.6.1
|
||||
synckit: 0.11.12
|
||||
tailwind-csstree: 0.3.1
|
||||
tailwindcss: 4.2.4
|
||||
tsconfig-paths-webpack-plugin: 4.2.0
|
||||
valibot: 1.3.1(typescript@6.0.3)
|
||||
optionalDependencies:
|
||||
eslint: 10.2.1(jiti@2.6.1)
|
||||
transitivePeerDependencies:
|
||||
- '@eslint/css'
|
||||
- typescript
|
||||
|
||||
eslint-plugin-compat@7.0.1(eslint@10.2.1(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@mdn/browser-compat-data': 6.1.5
|
||||
@@ -22180,14 +22094,6 @@ snapshots:
|
||||
|
||||
mdn-data@2.0.30: {}
|
||||
|
||||
mdn-data@2.27.1: {}
|
||||
|
||||
media-chrome@4.19.0(react@19.2.5):
|
||||
dependencies:
|
||||
ce-la-react: 0.3.2(react@19.2.5)
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
@@ -25244,8 +25150,6 @@ snapshots:
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tailwind-csstree@0.3.1: {}
|
||||
|
||||
tailwind-merge@3.5.0: {}
|
||||
|
||||
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.4):
|
||||
@@ -25845,10 +25749,6 @@ snapshots:
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
valibot@1.3.1(typescript@6.0.3):
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
validator@13.15.35: {}
|
||||
|
||||
value-equal@1.0.1: {}
|
||||
|
||||
@@ -48,7 +48,6 @@ export default typescriptEslint.config([
|
||||
'unicorn/import-style': 'off',
|
||||
'unicorn/prefer-structured-clone': 'off',
|
||||
'unicorn/no-for-loop': 'off',
|
||||
'unicorn/no-array-sort': 'off',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'require-await': 'off',
|
||||
|
||||
@@ -14,6 +14,7 @@ export const ErrorMessages = {
|
||||
|
||||
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
|
||||
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <2';
|
||||
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
|
||||
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
|
||||
|
||||
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
||||
@@ -23,10 +24,15 @@ export const EXTENSION_NAMES: Record<DatabaseExtension, string> = {
|
||||
cube: 'cube',
|
||||
earthdistance: 'earthdistance',
|
||||
vector: 'pgvector',
|
||||
vectors: 'pgvecto.rs',
|
||||
vchord: 'VectorChord',
|
||||
} as const;
|
||||
|
||||
export const VECTOR_EXTENSIONS = [DatabaseExtension.VectorChord, DatabaseExtension.Vector] as const;
|
||||
export const VECTOR_EXTENSIONS = [
|
||||
DatabaseExtension.VectorChord,
|
||||
DatabaseExtension.Vectors,
|
||||
DatabaseExtension.Vector,
|
||||
] as const;
|
||||
|
||||
export const VECTOR_INDEX_TABLES = {
|
||||
[VectorIndex.Clip]: 'smart_search',
|
||||
@@ -36,8 +42,6 @@ export const VECTOR_INDEX_TABLES = {
|
||||
export const VECTORCHORD_LIST_SLACK_FACTOR = 1.2;
|
||||
|
||||
export const SALT_ROUNDS = 10;
|
||||
// Syntactically valid bcrypt hash used in login() preventing timing-based user enumeration.
|
||||
export const LOGIN_DUMMY_HASH = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZabcde';
|
||||
|
||||
export const IWorker = 'IWorker';
|
||||
|
||||
|
||||
@@ -28,16 +28,14 @@ describe(ActivityController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/activities');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['albumId'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
factory.responses.badRequest(['[albumId] Invalid input: expected string, received undefined']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid albumId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/activities').query({ albumId: '123' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
|
||||
});
|
||||
|
||||
it('should reject an invalid assetId', async () => {
|
||||
@@ -45,7 +43,7 @@ describe(ActivityController.name, () => {
|
||||
.get('/activities')
|
||||
.query({ albumId: factory.uuid(), assetId: '123' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +58,7 @@ describe(ActivityController.name, () => {
|
||||
.post('/activities')
|
||||
.send({ albumId: '123', type: 'like' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
|
||||
});
|
||||
|
||||
it('should require a comment when type is comment', async () => {
|
||||
@@ -68,11 +66,7 @@ describe(ActivityController.name, () => {
|
||||
.post('/activities')
|
||||
.send({ albumId: factory.uuid(), type: 'comment', comment: null });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['comment'], message: 'Invalid input: expected string, received null' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[comment] Invalid input: expected string, received null']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +79,7 @@ describe(ActivityController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,17 +27,13 @@ describe(AlbumController.name, () => {
|
||||
it('should reject an invalid shared param', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['shared'], message: 'Invalid option: expected one of "true"|"false"' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[shared] Invalid option: expected one of "true"|"false"']));
|
||||
});
|
||||
|
||||
it('should reject an invalid assetId param', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/albums?assetId=invalid');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe(ApiKeyController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ describe(ApiKeyController.name, () => {
|
||||
.put(`/api-keys/123`)
|
||||
.send({ name: 'new name', permissions: [Permission.All] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
|
||||
it('should allow updating just the name', async () => {
|
||||
@@ -84,7 +84,7 @@ describe(ApiKeyController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,9 +80,7 @@ describe(AssetMediaController.name, () => {
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['metadata'], message: 'Invalid input: expected JSON string, received string' },
|
||||
]),
|
||||
factory.responses.badRequest(['[metadata] Invalid input: expected JSON string, received string']),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -93,8 +91,8 @@ describe(AssetMediaController.name, () => {
|
||||
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['fileCreatedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' },
|
||||
factory.responses.badRequest([
|
||||
'[fileCreatedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -106,8 +104,8 @@ describe(AssetMediaController.name, () => {
|
||||
.field(makeUploadDto({ omit: 'fileModifiedAt' }));
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['fileModifiedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' },
|
||||
factory.responses.badRequest([
|
||||
'[fileModifiedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -119,9 +117,7 @@ describe(AssetMediaController.name, () => {
|
||||
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['isFavorite'], message: 'Invalid option: expected one of "true"|"false"' },
|
||||
]),
|
||||
factory.responses.badRequest(['[isFavorite] Invalid option: expected one of "true"|"false"']),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -132,9 +128,7 @@ describe(AssetMediaController.name, () => {
|
||||
.field({ ...makeUploadDto(), visibility: 'not-an-option' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
factory.responses.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe(AssetController.name, () => {
|
||||
.send({ ids: ['123'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
|
||||
});
|
||||
|
||||
it('should require duplicateId to be a string', async () => {
|
||||
@@ -42,9 +42,7 @@ describe(AssetController.name, () => {
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['duplicateId'], message: 'Invalid input: expected string, received boolean' },
|
||||
]),
|
||||
factory.responses.badRequest(['[duplicateId] Invalid input: expected string, received boolean']),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,7 +70,7 @@ describe(AssetController.name, () => {
|
||||
.send({ ids: ['123'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +83,7 @@ describe(AssetController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,10 +97,12 @@ describe(AssetController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['sourceId'], message: 'Invalid input: expected string, received undefined' },
|
||||
{ path: ['targetId'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining([
|
||||
'[sourceId] Invalid input: expected string, received undefined',
|
||||
'[targetId] Invalid input: expected string, received undefined',
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -125,9 +125,7 @@ describe(AssetController.name, () => {
|
||||
.put('/assets/metadata')
|
||||
.send({ items: [{ assetId: '123', key: 'test', value: {} }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
|
||||
);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
@@ -136,9 +134,9 @@ describe(AssetController.name, () => {
|
||||
.send({ items: [{ assetId: factory.uuid(), value: {} }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -161,9 +159,7 @@ describe(AssetController.name, () => {
|
||||
.delete('/assets/metadata')
|
||||
.send({ items: [{ assetId: '123', key: 'test' }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
|
||||
);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
@@ -172,9 +168,9 @@ describe(AssetController.name, () => {
|
||||
.send({ items: [{ assetId: factory.uuid() }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -195,56 +191,33 @@ describe(AssetController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: [], message: 'Invalid input: expected object, received undefined' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(factory.responses.badRequest(['Invalid input: expected object, received undefined']));
|
||||
});
|
||||
|
||||
it('should reject invalid gps coordinates', async () => {
|
||||
for (const [test, errors] of [
|
||||
[{ latitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
|
||||
[{ longitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
|
||||
[
|
||||
{ latitude: 12, longitude: 'abc' },
|
||||
[{ path: ['longitude'], message: 'Invalid input: expected number, received string' }],
|
||||
],
|
||||
[
|
||||
{ latitude: 'abc', longitude: 12 },
|
||||
[{ path: ['latitude'], message: 'Invalid input: expected number, received string' }],
|
||||
],
|
||||
[
|
||||
{ latitude: null, longitude: 12 },
|
||||
[{ path: ['latitude'], message: 'Invalid input: expected number, received null' }],
|
||||
],
|
||||
[
|
||||
{ latitude: 12, longitude: null },
|
||||
[{ path: ['longitude'], message: 'Invalid input: expected number, received null' }],
|
||||
],
|
||||
[{ latitude: 91, longitude: 12 }, [{ path: ['latitude'], message: 'Too big: expected number to be <=90' }]],
|
||||
[{ latitude: -91, longitude: 12 }, [{ path: ['latitude'], message: 'Too small: expected number to be >=-90' }]],
|
||||
[
|
||||
{ latitude: 12, longitude: -181 },
|
||||
[{ path: ['longitude'], message: 'Too small: expected number to be >=-180' }],
|
||||
],
|
||||
[{ latitude: 12, longitude: 181 }, [{ path: ['longitude'], message: 'Too big: expected number to be <=180' }]],
|
||||
] as const) {
|
||||
for (const test of [
|
||||
{ latitude: 12 },
|
||||
{ longitude: 12 },
|
||||
{ latitude: 12, longitude: 'abc' },
|
||||
{ latitude: 'abc', longitude: 12 },
|
||||
{ latitude: null, longitude: 12 },
|
||||
{ latitude: 12, longitude: null },
|
||||
{ latitude: 91, longitude: 12 },
|
||||
{ latitude: -91, longitude: 12 },
|
||||
{ latitude: 12, longitude: -181 },
|
||||
{ latitude: 12, longitude: 181 },
|
||||
]) {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError(errors));
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid rating', async () => {
|
||||
for (const [test, errors] of [
|
||||
[{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]],
|
||||
[{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]],
|
||||
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]],
|
||||
] as const) {
|
||||
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError(errors));
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -257,7 +230,7 @@ describe(AssetController.name, () => {
|
||||
|
||||
it('should leave correct ratings as-is', async () => {
|
||||
const assetId = factory.uuid();
|
||||
for (const test of [{ rating: -1 }, { rating: 1 }, { rating: 5 }]) {
|
||||
for (const test of [{ rating: 1 }, { rating: 5 }]) {
|
||||
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send(test);
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, assetId, test);
|
||||
expect(status).toBe(200);
|
||||
@@ -288,17 +261,13 @@ describe(AssetController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
|
||||
});
|
||||
|
||||
it('should require items to be an array', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['items'], message: 'Invalid input: expected array, received undefined' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[items] Invalid input: expected array, received undefined']));
|
||||
});
|
||||
|
||||
it('should require each item to have a valid key', async () => {
|
||||
@@ -307,9 +276,7 @@ describe(AssetController.name, () => {
|
||||
.send({ items: [{ value: { some: 'value' } }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
factory.responses.badRequest(['[items.0.key] Invalid input: expected string, received undefined']),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -319,9 +286,9 @@ describe(AssetController.name, () => {
|
||||
.send({ items: [{ key: 'mobile-app', value: null }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['items', 0, 'value'], message: 'Invalid input: expected record, received null' },
|
||||
]),
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['[items.0.value] Invalid input: expected record, received null']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -359,7 +326,7 @@ describe(AssetController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,7 +376,7 @@ describe(AssetController.name, () => {
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
|
||||
});
|
||||
|
||||
it('should check the action and parameters discriminator', async () => {
|
||||
@@ -431,12 +398,13 @@ describe(AssetController.name, () => {
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{
|
||||
path: ['edits', 0, 'parameters'],
|
||||
message: expect.stringContaining("Invalid parameters for action 'rotate', expecting keys: angle"),
|
||||
},
|
||||
]),
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"[edits.0.parameters] Invalid parameters for action 'rotate', expecting keys: angle",
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -445,11 +413,7 @@ describe(AssetController.name, () => {
|
||||
.put(`/assets/${factory.uuid()}/edits`)
|
||||
.send({ edits: [] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['edits'], message: 'Too small: expected array to have >=1 items' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(factory.responses.badRequest(['[edits] Too small: expected array to have >=1 items']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -462,7 +426,7 @@ describe(AssetController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,27 +28,19 @@ describe(AuthController.name, () => {
|
||||
it('should require an email address', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, password });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received undefined' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should require a password', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, email });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['password'], message: 'Invalid input: expected string, received undefined' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ email, password });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should require a valid email', async () => {
|
||||
@@ -56,9 +48,7 @@ describe(AuthController.name, () => {
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ name, email: 'immich', password });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received string' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should transform email to lower case', async () => {
|
||||
@@ -83,9 +73,9 @@ describe(AuthController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/login').send({ name: 'admin' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['email'], message: 'Invalid input: expected email, received undefined' },
|
||||
{ path: ['password'], message: 'Invalid input: expected string, received undefined' },
|
||||
errorDto.badRequest([
|
||||
'[email] Invalid input: expected email, received undefined',
|
||||
'[password] Invalid input: expected string, received undefined',
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -95,9 +85,7 @@ describe(AuthController.name, () => {
|
||||
.post('/auth/login')
|
||||
.send({ name: 'admin', email: null, password: 'password' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
|
||||
});
|
||||
|
||||
it(`should not allow null password`, async () => {
|
||||
@@ -105,9 +93,7 @@ describe(AuthController.name, () => {
|
||||
.post('/auth/login')
|
||||
.send({ name: 'admin', email: 'admin@immich.cloud', password: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['password'], message: 'Invalid input: expected string, received null' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[password] Invalid input: expected string, received null']));
|
||||
});
|
||||
|
||||
it('should reject an invalid email', async () => {
|
||||
@@ -118,9 +104,7 @@ describe(AuthController.name, () => {
|
||||
.send({ name: 'admin', email: [], password: 'password' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
|
||||
});
|
||||
|
||||
it('should transform the email to all lowercase', async () => {
|
||||
@@ -211,31 +195,19 @@ describe(AuthController.name, () => {
|
||||
it('should reject 5 digits', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
|
||||
});
|
||||
|
||||
it('should reject 7 digits', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
|
||||
});
|
||||
|
||||
it('should reject non-numbers', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ describe(DuplicateController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,8 +41,8 @@ export class DuplicateController {
|
||||
@Authenticated({ permission: Permission.DuplicateDelete })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Dismiss a duplicate group',
|
||||
description: 'Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.',
|
||||
summary: 'Delete a duplicate',
|
||||
description: 'Delete a single duplicate asset specified by its ID.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
|
||||
@@ -31,9 +31,7 @@ describe(MaintenanceController.name, () => {
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['restoreBackupFilename'], message: 'Backup filename is required when action is restore_database' },
|
||||
]),
|
||||
errorDto.badRequest(['[restoreBackupFilename] Backup filename is required when action is restore_database']),
|
||||
);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -47,11 +47,7 @@ describe(MemoryController.name, () => {
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['data', 'year'], message: 'Invalid input: expected number, received undefined' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[data.year] Invalid input: expected number, received undefined']));
|
||||
});
|
||||
|
||||
it('should accept showAt and hideAt', async () => {
|
||||
@@ -85,7 +81,7 @@ describe(MemoryController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,15 +94,13 @@ describe(MemoryController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
|
||||
});
|
||||
|
||||
it('should require at least one field', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['At least one field must be provided']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +120,7 @@ describe(MemoryController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
|
||||
it('should require a valid asset id', async () => {
|
||||
@@ -134,7 +128,7 @@ describe(MemoryController.name, () => {
|
||||
.put(`/memories/${factory.uuid()}/assets`)
|
||||
.send({ ids: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,7 +141,7 @@ describe(MemoryController.name, () => {
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
|
||||
it('should require a valid asset id', async () => {
|
||||
@@ -155,7 +149,7 @@ describe(MemoryController.name, () => {
|
||||
.delete(`/memories/${factory.uuid()}/assets`)
|
||||
.send({ ids: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,11 +31,7 @@ describe(NotificationController.name, () => {
|
||||
.query({ level: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['level'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[level] Invalid option: expected one of')]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,9 +45,7 @@ describe(NotificationController.name, () => {
|
||||
it('should require a list', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['ids'], message: 'Invalid input: expected array, received boolean' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[ids] Invalid input: expected array, received boolean']));
|
||||
});
|
||||
|
||||
it('should require uuids', async () => {
|
||||
@@ -59,9 +53,7 @@ describe(NotificationController.name, () => {
|
||||
.put(`/notifications`)
|
||||
.send({ ids: [true] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['ids', 0], message: 'Invalid input: expected string, received boolean' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid input: expected string, received boolean']));
|
||||
});
|
||||
|
||||
it('should accept valid uuids', async () => {
|
||||
@@ -83,7 +75,7 @@ describe(NotificationController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,9 +33,7 @@ describe(PartnerController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,9 +44,7 @@ describe(PartnerController.name, () => {
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -65,7 +61,7 @@ describe(PartnerController.name, () => {
|
||||
.send({ sharedWithId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['sharedWithId'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[sharedWithId] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,7 +77,7 @@ describe(PartnerController.name, () => {
|
||||
.send({ inTimeline: true })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +92,7 @@ describe(PartnerController.name, () => {
|
||||
.delete(`/partners/invalid`)
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ describe(PersonController.name, () => {
|
||||
.query({ closestPersonId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['closestPersonId'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[closestPersonId] Invalid UUID']));
|
||||
});
|
||||
|
||||
it(`should require closestAssetId to be a uuid`, async () => {
|
||||
@@ -44,7 +44,7 @@ describe(PersonController.name, () => {
|
||||
.query({ closestAssetId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['closestAssetId'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[closestAssetId] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe(PersonController.name, () => {
|
||||
.delete('/people')
|
||||
.send({ ids: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
|
||||
});
|
||||
|
||||
it('should respond with 204', async () => {
|
||||
@@ -104,9 +104,7 @@ describe(PersonController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
|
||||
});
|
||||
|
||||
it(`should not allow a null name`, async () => {
|
||||
@@ -115,9 +113,7 @@ describe(PersonController.name, () => {
|
||||
.send({ name: null })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received null' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received null']));
|
||||
});
|
||||
|
||||
it(`should require featureFaceAssetId to be a uuid`, async () => {
|
||||
@@ -126,7 +122,7 @@ describe(PersonController.name, () => {
|
||||
.send({ featureFaceAssetId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['featureFaceAssetId'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[featureFaceAssetId] Invalid UUID']));
|
||||
});
|
||||
|
||||
it(`should require isFavorite to be a boolean`, async () => {
|
||||
@@ -135,11 +131,7 @@ describe(PersonController.name, () => {
|
||||
.send({ isFavorite: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string']));
|
||||
});
|
||||
|
||||
it(`should require isHidden to be a boolean`, async () => {
|
||||
@@ -148,9 +140,7 @@ describe(PersonController.name, () => {
|
||||
.send({ isHidden: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['isHidden'], message: 'Invalid input: expected boolean, received string' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isHidden] Invalid input: expected boolean, received string']));
|
||||
});
|
||||
|
||||
it('should map an empty birthDate to null', async () => {
|
||||
@@ -164,11 +154,7 @@ describe(PersonController.name, () => {
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ birthDate: false });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['birthDate'], message: 'Invalid input: expected string, received boolean' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received boolean']));
|
||||
});
|
||||
|
||||
it('should not accept an invalid birth date (number)', async () => {
|
||||
@@ -176,9 +162,7 @@ describe(PersonController.name, () => {
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ birthDate: 123_456 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['birthDate'], message: 'Invalid input: expected string, received number' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received number']));
|
||||
});
|
||||
|
||||
it('should not accept a birth date in the future)', async () => {
|
||||
@@ -186,9 +170,7 @@ describe(PersonController.name, () => {
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ birthDate: '9999-01-01' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['birthDate'], message: 'Birth date cannot be in the future' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[birthDate] Birth date cannot be in the future']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,7 +183,7 @@ describe(PersonController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
|
||||
it('should respond with 204', async () => {
|
||||
|
||||
@@ -27,41 +27,31 @@ describe(SearchController.name, () => {
|
||||
it('should reject page as a string', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['page'], message: 'Invalid input: expected number, received string' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[page] Invalid input: expected number, received string']));
|
||||
});
|
||||
|
||||
it('should reject page as a negative number', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: -10 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1']));
|
||||
});
|
||||
|
||||
it('should reject page as 0', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 0 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1']));
|
||||
});
|
||||
|
||||
it('should reject size as a string', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: 'abc' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['size'], message: 'Invalid input: expected number, received string' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[size] Invalid input: expected number, received string']));
|
||||
});
|
||||
|
||||
it('should reject an invalid size', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['size'], message: 'Too small: expected number to be >=1' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
|
||||
});
|
||||
|
||||
it('should reject an visibility as not an enum', async () => {
|
||||
@@ -70,9 +60,7 @@ describe(SearchController.name, () => {
|
||||
.send({ visibility: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
errorDto.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -81,11 +69,7 @@ describe(SearchController.name, () => {
|
||||
.post('/search/metadata')
|
||||
.send({ isFavorite: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string']));
|
||||
});
|
||||
|
||||
it('should reject an isEncoded as not a boolean', async () => {
|
||||
@@ -93,11 +77,7 @@ describe(SearchController.name, () => {
|
||||
.post('/search/metadata')
|
||||
.send({ isEncoded: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['isEncoded'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isEncoded] Invalid input: expected boolean, received string']));
|
||||
});
|
||||
|
||||
it('should reject an isOffline as not a boolean', async () => {
|
||||
@@ -105,19 +85,13 @@ describe(SearchController.name, () => {
|
||||
.post('/search/metadata')
|
||||
.send({ isOffline: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['isOffline'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isOffline] Invalid input: expected boolean, received string']));
|
||||
});
|
||||
|
||||
it('should reject an isMotion as not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ isMotion: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['isMotion'], message: 'Invalid input: expected boolean, received string' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[isMotion] Invalid input: expected boolean, received string']));
|
||||
});
|
||||
|
||||
describe('POST /search/random', () => {
|
||||
@@ -131,11 +105,7 @@ describe(SearchController.name, () => {
|
||||
.post('/search/random')
|
||||
.send({ withStacked: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['withStacked'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[withStacked] Invalid input: expected boolean, received string']));
|
||||
});
|
||||
|
||||
it('should reject if withPeople is not a boolean', async () => {
|
||||
@@ -143,11 +113,7 @@ describe(SearchController.name, () => {
|
||||
.post('/search/random')
|
||||
.send({ withPeople: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['withPeople'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[withPeople] Invalid input: expected boolean, received string']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,9 +140,7 @@ describe(SearchController.name, () => {
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,9 +153,7 @@ describe(SearchController.name, () => {
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,11 +173,7 @@ describe(SearchController.name, () => {
|
||||
it('should require a type', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['type'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[type] Invalid option: expected one of')]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,11 +35,7 @@ describe(SyncController.name, () => {
|
||||
.post('/sync/stream')
|
||||
.send({ types: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')]));
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -61,9 +57,7 @@ describe(SyncController.name, () => {
|
||||
const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`);
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['acks'], message: 'Too big: expected array to have <=1000 items' }]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest(['[acks] Too big: expected array to have <=1000 items']));
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -79,11 +73,7 @@ describe(SyncController.name, () => {
|
||||
.delete('/sync/ack')
|
||||
.send({ types: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')]));
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,11 +67,8 @@ describe(SystemConfigController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{
|
||||
path: ['nightlyTasks', 'startTime'],
|
||||
message: 'Invalid input: expected string in HH:mm format, received string',
|
||||
},
|
||||
errorDto.badRequest([
|
||||
'[nightlyTasks.startTime] Invalid input: expected string in HH:mm format, received string',
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -89,9 +86,7 @@ describe(SystemConfigController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['nightlyTasks', 'databaseCleanup'], message: 'Invalid input: expected boolean, received string' },
|
||||
]),
|
||||
errorDto.badRequest(['[nightlyTasks.databaseCleanup] Invalid input: expected boolean, received string']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -121,12 +116,7 @@ describe(SystemConfigController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{
|
||||
path: ['image', 'thumbnail', 'progressive'],
|
||||
message: 'Invalid input: expected boolean, received string',
|
||||
},
|
||||
]),
|
||||
errorDto.badRequest(['[image.thumbnail.progressive] Invalid input: expected boolean, received string']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ describe(TagController.name, () => {
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,9 +42,7 @@ describe(TimelineController.name, () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/timeline/buckets').query({ bbox: '1,2,3' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['bbox'], message: 'bbox must have 4 comma-separated numbers: west,south,east,north' },
|
||||
]),
|
||||
errorDto.badRequest(['[bbox] bbox must have 4 comma-separated numbers: west,south,east,north'] as any),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -53,7 +51,7 @@ describe(TimelineController.name, () => {
|
||||
.get('/timeline/buckets')
|
||||
.query({ bbox: '1,2,3,invalid' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['bbox'], message: 'bbox parts must be valid numbers' }]));
|
||||
expect(body).toEqual(errorDto.badRequest(['[bbox] bbox parts must be valid numbers'] as any));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -78,9 +78,9 @@ describe(UserAdminController.name, () => {
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
|
||||
]),
|
||||
errorDto.badRequest(
|
||||
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -98,9 +98,9 @@ describe(UserAdminController.name, () => {
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
|
||||
]),
|
||||
errorDto.badRequest(
|
||||
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -125,9 +125,9 @@ describe(UserAdminController.name, () => {
|
||||
.send({ quotaSizeInBytes: 1.2 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
|
||||
]),
|
||||
errorDto.badRequest(
|
||||
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -43,17 +43,15 @@ describe(UserController.name, () => {
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const [key, message] of [
|
||||
['email', 'Invalid input: expected email, received object'],
|
||||
['name', 'Invalid input: expected string, received null'],
|
||||
] as const) {
|
||||
for (const key of ['email', 'name']) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const dto = { [key]: null };
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/users/me`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send({ [key]: null });
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export enum UploadFieldName {
|
||||
const AssetMediaBaseSchema = z.object({
|
||||
fileCreatedAt: isoDatetimeToDate.describe('File creation date'),
|
||||
fileModifiedAt: isoDatetimeToDate.describe('File modification date'),
|
||||
duration: z.int32().min(0).optional().describe('Duration in milliseconds (for videos)'),
|
||||
duration: z.string().optional().describe('Duration (for videos)'),
|
||||
filename: z.string().optional().describe('Filename'),
|
||||
/** The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled in the controller. */
|
||||
[UploadFieldName.ASSET_DATA]: z.any().describe('Asset file data').meta({ type: 'string', format: 'binary' }),
|
||||
|
||||
@@ -47,7 +47,7 @@ const SanitizedAssetResponseSchema = z
|
||||
.describe(
|
||||
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
|
||||
),
|
||||
duration: z.int32().min(0).nullable().describe('Video/gif duration in milliseconds (null for static images)'),
|
||||
duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'),
|
||||
livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
|
||||
hasMetadata: z.boolean().describe('Whether asset has metadata'),
|
||||
width: z.int().min(0).nullable().describe('Asset width'),
|
||||
@@ -136,7 +136,7 @@ export type MapAsset = {
|
||||
checksum: Buffer<ArrayBufferLike>;
|
||||
checksumAlgorithm: ChecksumAlgorithm;
|
||||
duplicateId: string | null;
|
||||
duration: number | null;
|
||||
duration: string | null;
|
||||
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
|
||||
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
|
||||
faces?: ShallowDehydrateObject<AssetFace>[];
|
||||
|
||||
@@ -14,9 +14,8 @@ const UpdateAssetBaseSchema = z
|
||||
latitude: latitudeSchema.optional().describe('Latitude coordinate'),
|
||||
longitude: longitudeSchema.optional().describe('Longitude coordinate'),
|
||||
rating: z
|
||||
.number()
|
||||
.int()
|
||||
.min(-1)
|
||||
.min(0)
|
||||
.max(5)
|
||||
.transform((value) => (value === 0 ? null : value))
|
||||
.nullish()
|
||||
@@ -26,6 +25,7 @@ const UpdateAssetBaseSchema = z
|
||||
.added('v1')
|
||||
.stable('v2')
|
||||
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.')
|
||||
.updated('v3', 'Using -1 as a rating is no longer valid.')
|
||||
.getExtensions(),
|
||||
}),
|
||||
description: z.string().optional().describe('Asset description'),
|
||||
|
||||
@@ -77,7 +77,7 @@ export const EnvSchema = z
|
||||
DB_SSL_MODE: DatabaseSslModeSchema.optional(),
|
||||
DB_URL: z.string().optional(),
|
||||
DB_USERNAME: z.string().optional(),
|
||||
DB_VECTOR_EXTENSION: z.enum(['pgvector', 'vectorchord']).optional(),
|
||||
DB_VECTOR_EXTENSION: z.enum(['pgvector', 'pgvecto.rs', 'vectorchord']).optional(),
|
||||
NO_COLOR: z.string().optional(),
|
||||
REDIS_HOSTNAME: z.string().optional(),
|
||||
REDIS_PORT: z.coerce.number().int().optional(),
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ExifResponseSchema = z
|
||||
country: z.string().nullish().default(null).describe('Country name'),
|
||||
description: z.string().nullish().default(null).describe('Image description'),
|
||||
projectionType: z.string().nullish().default(null).describe('Projection type'),
|
||||
rating: z.int().nullish().default(null).describe('Rating'),
|
||||
rating: z.int().min(1).max(5).nullish().default(null).describe('Rating'),
|
||||
})
|
||||
.describe('EXIF response')
|
||||
.meta({ id: 'ExifResponseDto' });
|
||||
|
||||
@@ -35,8 +35,9 @@ const BaseSearchSchema = z.object({
|
||||
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
|
||||
rating: z
|
||||
.int()
|
||||
.min(-1)
|
||||
.min(0)
|
||||
.max(5)
|
||||
.transform((value) => (value === 0 ? null : value))
|
||||
.nullish()
|
||||
.describe('Filter by rating [1-5], or null for unrated')
|
||||
.meta({
|
||||
@@ -44,6 +45,7 @@ const BaseSearchSchema = z.object({
|
||||
.added('v1')
|
||||
.stable('v2')
|
||||
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.')
|
||||
.updated('v3', 'Using -1 as a rating is no longer valid.')
|
||||
.getExtensions(),
|
||||
}),
|
||||
ocr: z.string().optional().describe('Filter by OCR text content'),
|
||||
|
||||
+12
-32
@@ -90,30 +90,6 @@ const SyncAssetV1Schema = z
|
||||
})
|
||||
.meta({ id: 'SyncAssetV1' });
|
||||
|
||||
const SyncAssetV2Schema = z
|
||||
.object({
|
||||
id: z.string().describe('Asset ID'),
|
||||
ownerId: z.string().describe('Owner ID'),
|
||||
originalFileName: z.string().describe('Original file name'),
|
||||
thumbhash: z.string().nullable().describe('Thumbhash'),
|
||||
checksum: z.string().describe('Checksum'),
|
||||
fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'),
|
||||
fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'),
|
||||
localDateTime: isoDatetimeToDate.nullable().describe('Local date time'),
|
||||
duration: z.int32().min(0).nullable().describe('Duration'),
|
||||
type: AssetTypeSchema,
|
||||
deletedAt: isoDatetimeToDate.nullable().describe('Deleted at'),
|
||||
isFavorite: z.boolean().describe('Is favorite'),
|
||||
visibility: AssetVisibilitySchema,
|
||||
livePhotoVideoId: z.string().nullable().describe('Live photo video ID'),
|
||||
stackId: z.string().nullable().describe('Stack ID'),
|
||||
libraryId: z.string().nullable().describe('Library ID'),
|
||||
width: z.int().nullable().describe('Asset width'),
|
||||
height: z.int().nullable().describe('Asset height'),
|
||||
isEdited: z.boolean().describe('Is edited'),
|
||||
})
|
||||
.meta({ id: 'SyncAssetV2' });
|
||||
|
||||
@ExtraModel()
|
||||
class SyncUserV1 extends createZodDto(SyncUserV1Schema) {}
|
||||
@ExtraModel()
|
||||
@@ -126,8 +102,6 @@ class SyncPartnerV1 extends createZodDto(SyncPartnerV1Schema) {}
|
||||
class SyncPartnerDeleteV1 extends createZodDto(SyncPartnerDeleteV1Schema) {}
|
||||
@ExtraModel()
|
||||
export class SyncAssetV1 extends createZodDto(SyncAssetV1Schema) {}
|
||||
@ExtraModel()
|
||||
export class SyncAssetV2 extends createZodDto(SyncAssetV2Schema) {}
|
||||
|
||||
const SyncAssetDeleteV1Schema = z
|
||||
.object({ assetId: z.string().describe('Asset ID') })
|
||||
@@ -420,6 +394,12 @@ class SyncPersonDeleteV1 extends createZodDto(SyncPersonDeleteV1Schema) {}
|
||||
class SyncAssetFaceV1 extends createZodDto(SyncAssetFaceV1Schema) {}
|
||||
@ExtraModel()
|
||||
class SyncAssetFaceV2 extends createZodDto(SyncAssetFaceV2Schema) {}
|
||||
|
||||
export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 {
|
||||
const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2;
|
||||
|
||||
return faceV1;
|
||||
}
|
||||
@ExtraModel()
|
||||
class SyncAssetFaceDeleteV1 extends createZodDto(SyncAssetFaceDeleteV1Schema) {}
|
||||
@ExtraModel()
|
||||
@@ -439,15 +419,15 @@ export type SyncItem = {
|
||||
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
|
||||
[SyncEntityType.PartnerV1]: SyncPartnerV1;
|
||||
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
|
||||
[SyncEntityType.AssetV2]: SyncAssetV2;
|
||||
[SyncEntityType.AssetV1]: SyncAssetV1;
|
||||
[SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
|
||||
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
|
||||
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
|
||||
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
||||
[SyncEntityType.AssetEditV1]: SyncAssetEditV1;
|
||||
[SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1;
|
||||
[SyncEntityType.PartnerAssetV2]: SyncAssetV2;
|
||||
[SyncEntityType.PartnerAssetBackfillV2]: SyncAssetV2;
|
||||
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
||||
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
|
||||
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
|
||||
[SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1;
|
||||
[SyncEntityType.PartnerAssetExifBackfillV1]: SyncAssetExifV1;
|
||||
@@ -457,9 +437,9 @@ export type SyncItem = {
|
||||
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
|
||||
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
|
||||
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
|
||||
[SyncEntityType.AlbumAssetCreateV2]: SyncAssetV2;
|
||||
[SyncEntityType.AlbumAssetUpdateV2]: SyncAssetV2;
|
||||
[SyncEntityType.AlbumAssetBackfillV2]: SyncAssetV2;
|
||||
[SyncEntityType.AlbumAssetCreateV1]: SyncAssetV1;
|
||||
[SyncEntityType.AlbumAssetUpdateV1]: SyncAssetV1;
|
||||
[SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1;
|
||||
[SyncEntityType.AlbumAssetExifCreateV1]: SyncAssetExifV1;
|
||||
[SyncEntityType.AlbumAssetExifUpdateV1]: SyncAssetExifV1;
|
||||
[SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1;
|
||||
|
||||
@@ -89,8 +89,8 @@ const TimeBucketAssetResponseSchema = z
|
||||
"Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.",
|
||||
),
|
||||
duration: z
|
||||
.array(z.int32().min(0).nullable())
|
||||
.describe('Array of video/gif durations in milliseconds (null for static images)'),
|
||||
.array(z.string().nullable())
|
||||
.describe('Array of video/gif durations in hh:mm:ss.SSS format (null for static images)'),
|
||||
stack: z
|
||||
.array(stackTupleSchema)
|
||||
.optional()
|
||||
|
||||
+1
-152
@@ -445,12 +445,6 @@ export enum VideoCodec {
|
||||
|
||||
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
|
||||
|
||||
export enum VideoSegmentCodec {
|
||||
Av1 = 'av1',
|
||||
Hevc = 'hevc',
|
||||
H264 = 'h264',
|
||||
}
|
||||
|
||||
export enum AudioCodec {
|
||||
Mp3 = 'mp3',
|
||||
Aac = 'aac',
|
||||
@@ -603,137 +597,11 @@ export enum ExifOrientation {
|
||||
Rotate270CW = 8,
|
||||
}
|
||||
|
||||
/** ITU-T H.273 colour primaries codes. */
|
||||
export enum ColorPrimaries {
|
||||
Reserved = 0,
|
||||
Bt709 = 1,
|
||||
Unknown = 2,
|
||||
Bt470M = 4,
|
||||
Bt470Bg = 5,
|
||||
Smpte170M = 6,
|
||||
Smpte240M = 7,
|
||||
Film = 8,
|
||||
Bt2020 = 9,
|
||||
Smpte428 = 10,
|
||||
Smpte431 = 11,
|
||||
Smpte432 = 12,
|
||||
Ebu3213 = 22,
|
||||
}
|
||||
|
||||
/** ITU-T H.273 transfer characteristics codes. */
|
||||
export enum ColorTransfer {
|
||||
Reserved = 0,
|
||||
Bt709 = 1,
|
||||
Unknown = 2,
|
||||
Bt470M = 4,
|
||||
Bt470Bg = 5,
|
||||
Smpte170M = 6,
|
||||
Smpte240M = 7,
|
||||
Linear = 8,
|
||||
Log100 = 9,
|
||||
Log316 = 10,
|
||||
Iec6196624 = 11,
|
||||
Bt1361E = 12,
|
||||
Iec6196621 = 13,
|
||||
Bt202010 = 14,
|
||||
Bt202012 = 15,
|
||||
Smpte2084 = 16,
|
||||
Smpte428 = 17,
|
||||
AribStdB67 = 18,
|
||||
}
|
||||
|
||||
/** ITU-T H.273 matrix coefficients codes. */
|
||||
export enum ColorMatrix {
|
||||
Gbr = 0,
|
||||
Bt709 = 1,
|
||||
Unknown = 2,
|
||||
Reserved = 3,
|
||||
Fcc = 4,
|
||||
Bt470Bg = 5,
|
||||
Smpte170M = 6,
|
||||
Smpte240M = 7,
|
||||
Ycgco = 8,
|
||||
Bt2020Nc = 9,
|
||||
Bt2020C = 10,
|
||||
Smpte2085 = 11,
|
||||
ChromaDerivedNc = 12,
|
||||
ChromaDerivedC = 13,
|
||||
Ictcp = 14,
|
||||
}
|
||||
|
||||
/** H.264 `profile_idc` values. */
|
||||
// H.264 has a few profiles that have the same value but different names, included so lookup by name works
|
||||
export enum H264Profile {
|
||||
ConstrainedBaseline = 66,
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
|
||||
Baseline = 66,
|
||||
Main = 77,
|
||||
Extended = 88,
|
||||
ConstrainedHigh = 100,
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
|
||||
ProgressiveHigh = 100,
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
|
||||
High = 100,
|
||||
High10 = 110,
|
||||
High422 = 122,
|
||||
High444Predictive = 244,
|
||||
}
|
||||
|
||||
/** HEVC `profile_idc` values. */
|
||||
export enum HevcProfile {
|
||||
Main = 1,
|
||||
Main10 = 2,
|
||||
MainStillPicture = 3,
|
||||
Rext = 4,
|
||||
}
|
||||
|
||||
/** AV1 `seq_profile` values. */
|
||||
export enum Av1Profile {
|
||||
Main = 0,
|
||||
High = 1,
|
||||
Professional = 2,
|
||||
}
|
||||
|
||||
/** MPEG-4 Audio Object Type values for AAC. */
|
||||
export enum AacProfile {
|
||||
Main = 1,
|
||||
Lc = 2,
|
||||
Ssr = 3,
|
||||
Ltp = 4,
|
||||
HeAac = 5,
|
||||
Ld = 23,
|
||||
HeAacv2 = 29,
|
||||
Eld = 39,
|
||||
XheAac = 42,
|
||||
}
|
||||
|
||||
/** Dolby Vision bitstream profile numbers from the DOVI configuration record. */
|
||||
export enum DvProfile {
|
||||
Dvhe03 = 3,
|
||||
Dvhe04 = 4,
|
||||
Dvhe05 = 5,
|
||||
Dvhe07 = 7,
|
||||
Dvhe08 = 8,
|
||||
Dvav09 = 9,
|
||||
Dav110 = 10,
|
||||
}
|
||||
|
||||
/**
|
||||
* Dolby Vision base-layer signal-compatibility ID from the DOVI configuration record.
|
||||
* Identifies what the base HEVC/AVC layer renders as on a non-DV decoder.
|
||||
*/
|
||||
export enum DvSignalCompatibility {
|
||||
None = 0,
|
||||
Hdr10 = 1,
|
||||
Sdr709 = 2,
|
||||
Hlg = 4,
|
||||
Sdr2020 = 6,
|
||||
}
|
||||
|
||||
export enum DatabaseExtension {
|
||||
Cube = 'cube',
|
||||
EarthDistance = 'earthdistance',
|
||||
Vector = 'vector',
|
||||
Vectors = 'vectors',
|
||||
VectorChord = 'vchord',
|
||||
}
|
||||
|
||||
@@ -933,13 +801,9 @@ export enum SyncRequestType {
|
||||
AlbumsV2 = 'AlbumsV2',
|
||||
AlbumUsersV1 = 'AlbumUsersV1',
|
||||
AlbumToAssetsV1 = 'AlbumToAssetsV1',
|
||||
/** @deprecated */
|
||||
AlbumAssetsV1 = 'AlbumAssetsV1',
|
||||
AlbumAssetsV2 = 'AlbumAssetsV2',
|
||||
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
|
||||
/** @deprecated */
|
||||
AssetsV1 = 'AssetsV1',
|
||||
AssetsV2 = 'AssetsV2',
|
||||
AssetExifsV1 = 'AssetExifsV1',
|
||||
AssetEditsV1 = 'AssetEditsV1',
|
||||
AssetMetadataV1 = 'AssetMetadataV1',
|
||||
@@ -947,15 +811,12 @@ export enum SyncRequestType {
|
||||
MemoriesV1 = 'MemoriesV1',
|
||||
MemoryToAssetsV1 = 'MemoryToAssetsV1',
|
||||
PartnersV1 = 'PartnersV1',
|
||||
/** @deprecated */
|
||||
PartnerAssetsV1 = 'PartnerAssetsV1',
|
||||
PartnerAssetsV2 = 'PartnerAssetsV2',
|
||||
PartnerAssetExifsV1 = 'PartnerAssetExifsV1',
|
||||
PartnerStacksV1 = 'PartnerStacksV1',
|
||||
StacksV1 = 'StacksV1',
|
||||
UsersV1 = 'UsersV1',
|
||||
PeopleV1 = 'PeopleV1',
|
||||
/** @deprecated */
|
||||
AssetFacesV1 = 'AssetFacesV1',
|
||||
AssetFacesV2 = 'AssetFacesV2',
|
||||
UserMetadataV1 = 'UserMetadataV1',
|
||||
@@ -972,9 +833,7 @@ export enum SyncEntityType {
|
||||
UserV1 = 'UserV1',
|
||||
UserDeleteV1 = 'UserDeleteV1',
|
||||
|
||||
/** @deprecated */
|
||||
AssetV1 = 'AssetV1',
|
||||
AssetV2 = 'AssetV2',
|
||||
AssetDeleteV1 = 'AssetDeleteV1',
|
||||
AssetExifV1 = 'AssetExifV1',
|
||||
AssetEditV1 = 'AssetEditV1',
|
||||
@@ -985,12 +844,8 @@ export enum SyncEntityType {
|
||||
PartnerV1 = 'PartnerV1',
|
||||
PartnerDeleteV1 = 'PartnerDeleteV1',
|
||||
|
||||
/** @deprecated */
|
||||
PartnerAssetV1 = 'PartnerAssetV1',
|
||||
PartnerAssetV2 = 'PartnerAssetV2',
|
||||
/** @deprecated */
|
||||
PartnerAssetBackfillV1 = 'PartnerAssetBackfillV1',
|
||||
PartnerAssetBackfillV2 = 'PartnerAssetBackfillV2',
|
||||
PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
|
||||
PartnerAssetExifV1 = 'PartnerAssetExifV1',
|
||||
PartnerAssetExifBackfillV1 = 'PartnerAssetExifBackfillV1',
|
||||
@@ -1006,15 +861,9 @@ export enum SyncEntityType {
|
||||
AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
|
||||
AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
|
||||
|
||||
/** @deprecated */
|
||||
AlbumAssetCreateV1 = 'AlbumAssetCreateV1',
|
||||
AlbumAssetCreateV2 = 'AlbumAssetCreateV2',
|
||||
/** @deprecated */
|
||||
AlbumAssetUpdateV1 = 'AlbumAssetUpdateV1',
|
||||
AlbumAssetUpdateV2 = 'AlbumAssetUpdateV2',
|
||||
/** @deprecated */
|
||||
AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1',
|
||||
AlbumAssetBackfillV2 = 'AlbumAssetBackfillV2',
|
||||
AlbumAssetExifCreateV1 = 'AlbumAssetExifCreateV1',
|
||||
AlbumAssetExifUpdateV1 = 'AlbumAssetExifUpdateV1',
|
||||
AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1',
|
||||
|
||||
@@ -40,16 +40,16 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
|
||||
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
|
||||
const zodError = error.getZodError();
|
||||
if (zodError instanceof ZodError && zodError.issues.length > 0) {
|
||||
return {
|
||||
status,
|
||||
body: { message: 'Validation failed', errors: zodError.issues },
|
||||
};
|
||||
body['message'] = zodError.issues.map((issue) =>
|
||||
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// remove fields injected by NestJS that duplicate the HTTP response line
|
||||
// remove fields that duplicate the HTTP response line or will be reformatted in a later step
|
||||
delete body['error'];
|
||||
delete body['statusCode'];
|
||||
delete body['errors'];
|
||||
return { status, body };
|
||||
}
|
||||
|
||||
|
||||
@@ -239,68 +239,10 @@ select
|
||||
"asset_edit"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "edits",
|
||||
to_json("asset_exif") as "exifInfo",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_video"."index",
|
||||
"asset_video"."codecName",
|
||||
"asset_video"."profile",
|
||||
"asset_video"."level",
|
||||
"asset_video"."bitrate",
|
||||
"asset_exif"."exifImageWidth" as "width",
|
||||
"asset_exif"."exifImageHeight" as "height",
|
||||
"asset_video"."pixelFormat",
|
||||
"asset_video"."frameCount",
|
||||
"asset_exif"."fps" as "frameRate",
|
||||
"asset_video"."timeBase",
|
||||
case
|
||||
when "asset_exif"."orientation" = '6' then -90
|
||||
when "asset_exif"."orientation" = '8' then 90
|
||||
when "asset_exif"."orientation" = '3' then 180
|
||||
else 0
|
||||
end as "rotation",
|
||||
"asset_video"."colorPrimaries",
|
||||
"asset_video"."colorMatrix",
|
||||
"asset_video"."colorTransfer",
|
||||
"asset_video"."dvProfile",
|
||||
"asset_video"."dvLevel",
|
||||
"asset_video"."dvBlSignalCompatibilityId"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_video"."assetId" is not null
|
||||
) as obj
|
||||
) as "videoStream",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_video"."formatName",
|
||||
"asset_video"."formatLongName",
|
||||
"asset"."duration",
|
||||
"asset_video"."bitrate"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_video"."assetId" is not null
|
||||
) as obj
|
||||
) as "format"
|
||||
to_json("asset_exif") as "exifInfo"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
left join "asset_video" on "asset_video"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."id" = $4
|
||||
|
||||
@@ -612,88 +554,9 @@ select
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "files",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_audio"."index",
|
||||
"asset_audio"."codecName",
|
||||
"asset_audio"."profile",
|
||||
"asset_audio"."bitrate"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_audio"."assetId" is not null
|
||||
) as obj
|
||||
) as "audioStream",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_video"."index",
|
||||
"asset_video"."codecName",
|
||||
"asset_video"."profile",
|
||||
"asset_video"."level",
|
||||
"asset_video"."bitrate",
|
||||
"asset_exif"."exifImageWidth" as "width",
|
||||
"asset_exif"."exifImageHeight" as "height",
|
||||
"asset_video"."pixelFormat",
|
||||
"asset_video"."frameCount",
|
||||
"asset_exif"."fps" as "frameRate",
|
||||
"asset_video"."timeBase",
|
||||
case
|
||||
when "asset_exif"."orientation" = '6' then -90
|
||||
when "asset_exif"."orientation" = '8' then 90
|
||||
when "asset_exif"."orientation" = '3' then 180
|
||||
else 0
|
||||
end as "rotation",
|
||||
"asset_video"."colorPrimaries",
|
||||
"asset_video"."colorMatrix",
|
||||
"asset_video"."colorTransfer",
|
||||
"asset_video"."dvProfile",
|
||||
"asset_video"."dvLevel",
|
||||
"asset_video"."dvBlSignalCompatibilityId"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_video"."assetId" is not null
|
||||
) as obj
|
||||
) as "videoStream",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_video"."formatName",
|
||||
"asset_video"."formatLongName",
|
||||
"asset"."duration",
|
||||
"asset_video"."bitrate"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_video"."assetId" is not null
|
||||
) as obj
|
||||
) as "format"
|
||||
) as "files"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
inner join "asset_video" on "asset_video"."assetId" = "asset"."id"
|
||||
left join "asset_audio" on "asset_audio"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = 'VIDEO'
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- VideoStreamRepository.getSession
|
||||
select
|
||||
*
|
||||
from
|
||||
"video_stream_session"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- VideoStreamRepository.getVariant
|
||||
select
|
||||
*
|
||||
from
|
||||
"video_stream_variant"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- VideoStreamRepository.getSegment
|
||||
select
|
||||
*
|
||||
from
|
||||
"video_stream_segment"
|
||||
where
|
||||
"variantId" = $1
|
||||
and "index" = $2
|
||||
|
||||
-- VideoStreamRepository.getExpiredSessions
|
||||
select
|
||||
"id"
|
||||
from
|
||||
"video_stream_session"
|
||||
where
|
||||
"expiresAt" <= $1
|
||||
|
||||
-- VideoStreamRepository.extendSession
|
||||
update "video_stream_session"
|
||||
set
|
||||
"expiresAt" = $1
|
||||
where
|
||||
"id" = $2
|
||||
|
||||
-- VideoStreamRepository.deleteSession
|
||||
delete from "video_stream_session"
|
||||
where
|
||||
"id" = $1
|
||||
@@ -9,7 +9,6 @@ import { DB } from 'src/schema';
|
||||
import {
|
||||
anyUuid,
|
||||
asUuid,
|
||||
withAudioStream,
|
||||
withDefaultVisibility,
|
||||
withEdits,
|
||||
withExif,
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
withFaces,
|
||||
withFilePath,
|
||||
withFiles,
|
||||
withVideoFormat,
|
||||
withVideoStream,
|
||||
} from 'src/utils/database';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
@@ -137,9 +134,6 @@ export class AssetJobRepository {
|
||||
)
|
||||
.select(withEdits)
|
||||
.$call(withExifInner)
|
||||
.leftJoin('asset_video', 'asset_video.assetId', 'asset.id')
|
||||
.select((eb) => withVideoStream(eb).as('videoStream'))
|
||||
.select((eb) => withVideoFormat(eb).as('format'))
|
||||
.where('asset.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
@@ -339,14 +333,8 @@ export class AssetJobRepository {
|
||||
getForVideoConversion(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.innerJoin('asset_video', 'asset_video.assetId', 'asset.id')
|
||||
.leftJoin('asset_audio', 'asset_audio.assetId', 'asset.id')
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
|
||||
.select(withFiles)
|
||||
.select((eb) => withAudioStream(eb).as('audioStream'))
|
||||
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
|
||||
.select((eb) => withVideoFormat(eb).$notNull().as('format'))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -19,7 +19,6 @@ import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
||||
@@ -125,14 +124,6 @@ interface GetByIdsRelations {
|
||||
edits?: boolean;
|
||||
}
|
||||
|
||||
type UpsertExifOptions = {
|
||||
exif: Insertable<AssetExifTable>;
|
||||
audio?: Insertable<AssetAudioTable>;
|
||||
video?: Insertable<AssetVideoTable>;
|
||||
keyframes?: Insertable<AssetKeyframeTable>;
|
||||
lockedPropertiesBehavior: 'override' | 'append' | 'skip';
|
||||
};
|
||||
|
||||
const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuilder<DB, 'asset_exif'>, columns: T) =>
|
||||
sql<T>`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`;
|
||||
|
||||
@@ -170,76 +161,15 @@ export class AssetRepository {
|
||||
|
||||
@GenerateSql({
|
||||
params: [
|
||||
{
|
||||
exif: { dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
},
|
||||
{ dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
],
|
||||
})
|
||||
async upsertExif({ exif, audio, video, keyframes, lockedPropertiesBehavior }: UpsertExifOptions): Promise<void> {
|
||||
let query = this.db;
|
||||
if (audio) {
|
||||
(query as any) = this.db.with('audio', (qb) =>
|
||||
qb
|
||||
.insertInto('asset_audio')
|
||||
.values(audio)
|
||||
.onConflict((oc) =>
|
||||
oc.column('assetId').doUpdateSet(({ ref }) => ({
|
||||
bitrate: ref('asset_audio.bitrate'),
|
||||
index: ref('asset_audio.index'),
|
||||
profile: ref('asset_audio.profile'),
|
||||
codecName: ref('asset_audio.codecName'),
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (video) {
|
||||
(query as any) = query.with('video', (qb) =>
|
||||
qb
|
||||
.insertInto('asset_video')
|
||||
.values(video)
|
||||
.onConflict((oc) =>
|
||||
oc.column('assetId').doUpdateSet(({ ref }) => ({
|
||||
bitrate: ref('asset_video.bitrate'),
|
||||
timeBase: ref('asset_video.timeBase'),
|
||||
index: ref('asset_video.index'),
|
||||
profile: ref('asset_video.profile'),
|
||||
level: ref('asset_video.level'),
|
||||
colorPrimaries: ref('asset_video.colorPrimaries'),
|
||||
colorTransfer: ref('asset_video.colorTransfer'),
|
||||
colorMatrix: ref('asset_video.colorMatrix'),
|
||||
dvProfile: ref('asset_video.dvProfile'),
|
||||
dvLevel: ref('asset_video.dvLevel'),
|
||||
dvBlSignalCompatibilityId: ref('asset_video.dvBlSignalCompatibilityId'),
|
||||
codecName: ref('asset_video.codecName'),
|
||||
formatName: ref('asset_video.formatName'),
|
||||
formatLongName: ref('asset_video.formatLongName'),
|
||||
pixelFormat: ref('asset_video.pixelFormat'),
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (keyframes) {
|
||||
(query as any) = query.with('keyframe', (qb) =>
|
||||
qb
|
||||
.insertInto('asset_keyframe')
|
||||
.values(keyframes)
|
||||
.onConflict((oc) =>
|
||||
oc.column('assetId').doUpdateSet(({ ref }) => ({
|
||||
pts: ref('asset_keyframe.pts'),
|
||||
accDuration: ref('asset_keyframe.accDuration'),
|
||||
ownDuration: ref('asset_keyframe.ownDuration'),
|
||||
totalDuration: ref('asset_keyframe.totalDuration'),
|
||||
packetCount: ref('asset_keyframe.packetCount'),
|
||||
outputFrames: ref('asset_keyframe.outputFrames'),
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await query
|
||||
async upsertExif(
|
||||
exif: Insertable<AssetExifTable>,
|
||||
{ lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'override' | 'append' | 'skip' },
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.insertInto('asset_exif')
|
||||
.values(exif)
|
||||
.onConflict((oc) =>
|
||||
|
||||
@@ -248,6 +248,10 @@ const getEnv = (): EnvData => {
|
||||
vectorExtension = DatabaseExtension.Vector;
|
||||
break;
|
||||
}
|
||||
case 'pgvecto.rs': {
|
||||
vectorExtension = DatabaseExtension.Vectors;
|
||||
break;
|
||||
}
|
||||
case 'vectorchord': {
|
||||
vectorExtension = DatabaseExtension.VectorChord;
|
||||
break;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import AsyncLock from 'async-lock';
|
||||
import { FileMigrationProvider, Kysely, Migrator, sql } from 'kysely';
|
||||
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
VECTOR_VERSION_RANGE,
|
||||
VECTORCHORD_LIST_SLACK_FACTOR,
|
||||
VECTORCHORD_VERSION_RANGE,
|
||||
VECTORS_VERSION_RANGE,
|
||||
} from 'src/constants';
|
||||
import { GenerateSql } from 'src/decorators';
|
||||
import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum';
|
||||
@@ -22,7 +23,7 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode
|
||||
import { DB } from 'src/schema';
|
||||
import { immich_uuid_v7 } from 'src/schema/functions';
|
||||
import { ExtensionVersion, VectorExtension } from 'src/types';
|
||||
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
|
||||
import { vectorIndexQuery } from 'src/utils/database';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
|
||||
@@ -72,7 +73,7 @@ export class DatabaseRepository {
|
||||
return getVectorExtension(this.db);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DatabaseExtension.Vector]] })
|
||||
@GenerateSql({ params: [[DatabaseExtension.Vectors]] })
|
||||
async getExtensionVersions(extensions: readonly DatabaseExtension[]): Promise<ExtensionVersion[]> {
|
||||
const { rows } = await sql<ExtensionVersion>`
|
||||
SELECT name, default_version as "availableVersion", installed_version as "installedVersion"
|
||||
@@ -87,6 +88,9 @@ export class DatabaseRepository {
|
||||
case DatabaseExtension.VectorChord: {
|
||||
return VECTORCHORD_VERSION_RANGE;
|
||||
}
|
||||
case DatabaseExtension.Vectors: {
|
||||
return VECTORS_VERSION_RANGE;
|
||||
}
|
||||
case DatabaseExtension.Vector: {
|
||||
return VECTOR_VERSION_RANGE;
|
||||
}
|
||||
@@ -121,7 +125,7 @@ export class DatabaseRepository {
|
||||
await sql`DROP EXTENSION IF EXISTS ${sql.raw(extension)}`.execute(this.db);
|
||||
}
|
||||
|
||||
async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise<void> {
|
||||
async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise<VectorUpdateResult> {
|
||||
const [{ availableVersion, installedVersion }] = await this.getExtensionVersions([extension]);
|
||||
if (!installedVersion) {
|
||||
throw new Error(`${EXTENSION_NAMES[extension]} extension is not installed`);
|
||||
@@ -132,8 +136,10 @@ export class DatabaseRepository {
|
||||
}
|
||||
targetVersion ??= availableVersion;
|
||||
|
||||
if (!semver.diff(installedVersion, targetVersion)) {
|
||||
return;
|
||||
let restartRequired = false;
|
||||
const diff = semver.diff(installedVersion, targetVersion);
|
||||
if (!diff) {
|
||||
return { restartRequired: false };
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
@@ -141,8 +147,22 @@ export class DatabaseRepository {
|
||||
this.db.schema.dropIndex(VectorIndex.Face).ifExists().execute(),
|
||||
]);
|
||||
|
||||
await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(this.db);
|
||||
await Promise.all([this.reindexVectors(VectorIndex.Clip), this.reindexVectors(VectorIndex.Face)]);
|
||||
await this.db.transaction().execute(async (tx) => {
|
||||
await this.setSearchPath(tx);
|
||||
|
||||
await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(tx);
|
||||
|
||||
if (extension === DatabaseExtension.Vectors && (diff === 'major' || diff === 'minor')) {
|
||||
await sql`SELECT pgvectors_upgrade()`.execute(tx);
|
||||
restartRequired = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!restartRequired) {
|
||||
await Promise.all([this.reindexVectors(VectorIndex.Clip), this.reindexVectors(VectorIndex.Face)]);
|
||||
}
|
||||
|
||||
return { restartRequired };
|
||||
}
|
||||
|
||||
async prewarm(index: VectorIndex): Promise<void> {
|
||||
@@ -178,6 +198,12 @@ export class DatabaseRepository {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DatabaseExtension.Vectors: {
|
||||
if (!row.indexdef.toLowerCase().includes('using vectors')) {
|
||||
promises.push(this.reindexVectors(indexName));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DatabaseExtension.VectorChord: {
|
||||
const matches = row.indexdef.match(/(?<=lists = \[)\d+/g);
|
||||
const lists = matches && matches.length > 0 ? Number(matches[0]) : 1;
|
||||
@@ -234,10 +260,11 @@ export class DatabaseRepository {
|
||||
await sql`ALTER TABLE ${sql.raw(table)} ADD COLUMN embedding real[] NOT NULL`.execute(tx);
|
||||
}
|
||||
await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx);
|
||||
const schema = vectorExtension === DatabaseExtension.Vectors ? 'vectors.' : '';
|
||||
await sql`
|
||||
ALTER TABLE ${sql.raw(table)}
|
||||
ALTER COLUMN embedding
|
||||
SET DATA TYPE vector(${sql.raw(String(dimSize))})`.execute(tx);
|
||||
SET DATA TYPE ${sql.raw(schema)}vector(${sql.raw(String(dimSize))})`.execute(tx);
|
||||
await sql.raw(vectorIndexQuery({ vectorExtension, table, indexName, lists })).execute(tx);
|
||||
});
|
||||
try {
|
||||
@@ -248,6 +275,10 @@ export class DatabaseRepository {
|
||||
this.logger.log(`Reindexed ${indexName}`);
|
||||
}
|
||||
|
||||
private async setSearchPath(tx: Transaction<DB>): Promise<void> {
|
||||
await sql`SET search_path TO "$user", public, vectors`.execute(tx);
|
||||
}
|
||||
|
||||
private async getDatabaseName(): Promise<string> {
|
||||
const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(this.db);
|
||||
return rows[0].db;
|
||||
|
||||
@@ -46,7 +46,6 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
@@ -101,7 +100,6 @@ export const repositories = [
|
||||
UserRepository,
|
||||
ViewRepository,
|
||||
VersionHistoryRepository,
|
||||
VideoStreamRepository,
|
||||
WebsocketRepository,
|
||||
WorkflowRepository,
|
||||
];
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ExifDateTime, exiftool, WriteTags } from 'exiftool-vendored';
|
||||
import ffmpeg, { FfprobeData, FfprobeStream } from 'fluent-ffmpeg';
|
||||
import _ from 'lodash';
|
||||
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
||||
import { Duration } from 'luxon';
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { Writable } from 'node:stream';
|
||||
import { promisify } from 'node:util';
|
||||
import sharp from 'sharp';
|
||||
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
|
||||
import { Exif } from 'src/database';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import {
|
||||
AacProfile,
|
||||
Av1Profile,
|
||||
ColorMatrix,
|
||||
ColorPrimaries,
|
||||
Colorspace,
|
||||
ColorTransfer,
|
||||
DvProfile,
|
||||
DvSignalCompatibility,
|
||||
H264Profile,
|
||||
HevcProfile,
|
||||
LogLevel,
|
||||
RawExtractedFormat,
|
||||
} from 'src/enum';
|
||||
import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import {
|
||||
DecodeToBufferOptions,
|
||||
@@ -34,7 +18,6 @@ import {
|
||||
ProbeOptions,
|
||||
TranscodeCommand,
|
||||
VideoInfo,
|
||||
VideoPacketInfo,
|
||||
} from 'src/types';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
import { createAffineMatrix } from 'src/utils/transform';
|
||||
@@ -43,14 +26,9 @@ const probe = (input: string, options: string[]): Promise<FfprobeData> =>
|
||||
new Promise((resolve, reject) =>
|
||||
ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))),
|
||||
);
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
sharp.concurrency(0);
|
||||
sharp.cache({ files: 0 });
|
||||
|
||||
const pascalCase = (str: string) => _.upperFirst(_.camelCase(str.toLowerCase()));
|
||||
|
||||
type ProgressEvent = {
|
||||
frames: number;
|
||||
currentFps: number;
|
||||
@@ -266,7 +244,6 @@ export class MediaRepository {
|
||||
},
|
||||
videoStreams: results.streams
|
||||
.filter((stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic)
|
||||
.sort((a, b) => this.compareStreams(a, b))
|
||||
.map((stream) => {
|
||||
const height = this.parseInt(stream.height);
|
||||
const dar = this.getDar(stream.display_aspect_ratio);
|
||||
@@ -275,98 +252,28 @@ export class MediaRepository {
|
||||
height,
|
||||
width: dar ? Math.round(height * dar) : this.parseInt(stream.width),
|
||||
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
|
||||
profile: this.parseVideoProfile(stream.codec_name, stream.profile as string | undefined),
|
||||
level: this.parseOptionalInt(stream.level),
|
||||
codecType: stream.codec_type,
|
||||
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
|
||||
frameRate: this.parseFrameRate(stream.avg_frame_rate ?? stream.r_frame_rate),
|
||||
timeBase: this.parseRational(stream.time_base)?.den,
|
||||
rotation: this.parseInt(stream.rotation),
|
||||
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
|
||||
bitrate: this.parseInt(stream.bit_rate),
|
||||
pixelFormat: stream.pix_fmt || 'yuv420p',
|
||||
colorPrimaries: this.parseEnum(ColorPrimaries, stream.color_primaries) ?? ColorPrimaries.Unknown,
|
||||
colorMatrix: this.parseEnum(ColorMatrix, stream.color_space) ?? ColorMatrix.Unknown,
|
||||
colorTransfer: this.parseEnum(ColorTransfer, stream.color_transfer) ?? ColorTransfer.Unknown,
|
||||
dvProfile: this.parseOptionalInt(stream.dv_profile) as DvProfile | undefined,
|
||||
dvLevel: this.parseOptionalInt(stream.dv_level),
|
||||
dvBlSignalCompatibilityId: this.parseOptionalInt(stream.dv_bl_signal_compatibility_id) as
|
||||
| DvSignalCompatibility
|
||||
| undefined,
|
||||
colorPrimaries: stream.color_primaries,
|
||||
colorSpace: stream.color_space,
|
||||
colorTransfer: stream.color_transfer,
|
||||
};
|
||||
}),
|
||||
audioStreams: results.streams
|
||||
.filter((stream) => stream.codec_type === 'audio')
|
||||
.sort((a, b) => this.compareStreams(a, b))
|
||||
.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecType: stream.codec_type,
|
||||
codecName: stream.codec_name,
|
||||
profile:
|
||||
stream.codec_name === 'aac' ? this.parseEnum(AacProfile, stream.profile as string | undefined) : undefined,
|
||||
bitrate: this.parseInt(stream.bit_rate),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved.
|
||||
* Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video.
|
||||
*/
|
||||
async probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
|
||||
const { stdout } = await execFile('ffprobe', [
|
||||
'-v',
|
||||
'error',
|
||||
'-select_streams',
|
||||
String(streamIndex),
|
||||
'-show_entries',
|
||||
'packet=pts,duration,flags',
|
||||
'-of',
|
||||
'csv=p=0',
|
||||
input,
|
||||
]);
|
||||
|
||||
let totalDuration = 0;
|
||||
const keyframePts: number[] = [];
|
||||
const keyframeAccDuration: number[] = [];
|
||||
const keyframeOwnDuration: number[] = [];
|
||||
const postDiscard: { pts: number; duration: number }[] = [];
|
||||
for (const line of stdout.split('\n')) {
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
const [ptsStr, durationStr, flags] = line.split(',');
|
||||
const pts = Number.parseInt(ptsStr);
|
||||
const duration = Number.parseInt(durationStr);
|
||||
if (Number.isNaN(pts) || Number.isNaN(duration)) {
|
||||
continue;
|
||||
}
|
||||
// Discarded packets don't contribute to packet count, but still contribute to video duration
|
||||
totalDuration += duration;
|
||||
if (flags[1] !== 'D') {
|
||||
postDiscard.push({ pts, duration });
|
||||
}
|
||||
if (flags[0] === 'K') {
|
||||
keyframePts.push(pts);
|
||||
keyframeAccDuration.push(totalDuration);
|
||||
// VFR content can have variable duration keyframes,
|
||||
// so we need to track their duration separately for accurate segment boundaries.
|
||||
// Non-keyframes are accounted for in totalDuration.
|
||||
keyframeOwnDuration.push(duration);
|
||||
}
|
||||
}
|
||||
|
||||
if (postDiscard.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
totalDuration,
|
||||
packetCount: postDiscard.length,
|
||||
outputFrames: this.cfrOutputFrames(postDiscard, postDiscard.length / totalDuration),
|
||||
keyframePts,
|
||||
keyframeAccDuration,
|
||||
keyframeOwnDuration,
|
||||
};
|
||||
}
|
||||
|
||||
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
|
||||
if (!options.twoPass) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -449,31 +356,6 @@ export class MediaRepository {
|
||||
return Number.parseFloat(value as string) || 0;
|
||||
}
|
||||
|
||||
private parseOptionalInt(value: string | number | undefined): number | undefined {
|
||||
const parsed = Number.parseInt(value as string);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
private parseEnum<E extends Record<string, number | string>>(enumObj: E, value?: string) {
|
||||
return value ? (enumObj[pascalCase(value)] as Extract<E[keyof E], number> | undefined) : undefined;
|
||||
}
|
||||
|
||||
/** Parse a rational like "60000/1001" or "1/600" into `{ num, den }`. */
|
||||
private parseRational(value: string | undefined): { num: number; den: number } | undefined {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const [num, den = 1] = value.split('/').map(Number);
|
||||
if (num && den) {
|
||||
return { num, den };
|
||||
}
|
||||
}
|
||||
|
||||
private parseFrameRate(value: string | undefined): number | undefined {
|
||||
const r = this.parseRational(value);
|
||||
return r ? r.num / r.den : undefined;
|
||||
}
|
||||
|
||||
private getDar(dar: string | undefined): number {
|
||||
if (dar) {
|
||||
const [darW, darH] = dar.split(':').map(Number);
|
||||
@@ -484,42 +366,4 @@ export class MediaRepository {
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private parseVideoProfile(codec?: string, profile?: string) {
|
||||
switch (codec) {
|
||||
case 'h264': {
|
||||
return this.parseEnum(H264Profile, profile);
|
||||
}
|
||||
case 'h265':
|
||||
case 'hevc': {
|
||||
return this.parseEnum(HevcProfile, profile);
|
||||
}
|
||||
case 'av1': {
|
||||
return this.parseEnum(Av1Profile, profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private compareStreams(a: FfprobeStream, b: FfprobeStream): number {
|
||||
const d = (b.disposition?.default ?? 0) - (a.disposition?.default ?? 0);
|
||||
if (d !== 0) {
|
||||
return d;
|
||||
}
|
||||
return this.parseInt(b.bit_rate) - this.parseInt(a.bit_rate);
|
||||
}
|
||||
|
||||
private cfrOutputFrames(packets: { pts: number; duration: number }[], slotsPerTick: number) {
|
||||
// Packets may be out of PTS order due to B-frames
|
||||
packets.sort((a, b) => a.pts - b.pts);
|
||||
const firstPts = packets[0].pts;
|
||||
let outputFrames = 0;
|
||||
let nextPts = 0;
|
||||
for (const pkt of packets) {
|
||||
const delta = (pkt.pts - firstPts) * slotsPerTick - nextPts + pkt.duration * slotsPerTick;
|
||||
const nb = delta < -1.1 ? 0 : delta > 1.1 ? Math.round(delta) : 1;
|
||||
outputFrames += nb;
|
||||
nextPts += nb;
|
||||
}
|
||||
return outputFrames;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { DB } from 'src/schema';
|
||||
import {
|
||||
VideoStreamSegmentTable,
|
||||
VideoStreamSessionTable,
|
||||
VideoStreamVariantTable,
|
||||
} from 'src/schema/tables/video-stream.table';
|
||||
|
||||
@Injectable()
|
||||
export class VideoStreamRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
createSession(session: Insertable<VideoStreamSessionTable>) {
|
||||
return this.db.insertInto('video_stream_session').values(session).returning(['id']).executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
createVariant(variant: Insertable<VideoStreamVariantTable>) {
|
||||
return this.db.insertInto('video_stream_variant').values(variant).returning(['id']).executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async createSegment(segment: Insertable<VideoStreamSegmentTable>) {
|
||||
await this.db.insertInto('video_stream_segment').values(segment).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getSession(id: string) {
|
||||
return this.db.selectFrom('video_stream_session').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getVariant(id: string) {
|
||||
return this.db.selectFrom('video_stream_variant').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
|
||||
getSegment(variantId: string, index: number) {
|
||||
return this.db
|
||||
.selectFrom('video_stream_segment')
|
||||
.selectAll()
|
||||
.where('variantId', '=', variantId)
|
||||
.where('index', '=', index)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
getExpiredSessions() {
|
||||
return this.db.selectFrom('video_stream_session').select(['id']).where('expiresAt', '<=', new Date()).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
|
||||
async extendSession(id: string, expiresAt: Date) {
|
||||
await this.db.updateTable('video_stream_session').set({ expiresAt }).where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async deleteSession(id: string) {
|
||||
await this.db.deleteFrom('video_stream_session').where('id', '=', id).execute();
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { NotificationDto } from 'src/dtos/notification.dto';
|
||||
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV2 } from 'src/dtos/sync.dto';
|
||||
import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto';
|
||||
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
@@ -35,9 +35,9 @@ export interface ClientEventMap {
|
||||
on_notification: [NotificationDto];
|
||||
on_session_delete: [string];
|
||||
|
||||
AssetUploadReadyV2: [{ asset: SyncAssetV2; exif: SyncAssetExifV1 }];
|
||||
AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }];
|
||||
AppRestartV1: [AppRestartEvent];
|
||||
AssetEditReadyV2: [{ asset: SyncAssetV2; edit: SyncAssetEditV1[] }];
|
||||
AssetEditReadyV1: [{ asset: SyncAssetV1; edit: SyncAssetEditV1[] }];
|
||||
}
|
||||
|
||||
export type AuthFn = (client: Socket) => Promise<AuthDto>;
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { registerEnum } from '@immich/sql-tools';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetStatus,
|
||||
AssetVisibility,
|
||||
ChecksumAlgorithm,
|
||||
SourceType,
|
||||
VideoSegmentCodec,
|
||||
} from 'src/enum';
|
||||
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
|
||||
|
||||
export const album_user_role_enum = registerEnum({
|
||||
name: 'album_user_role_enum',
|
||||
@@ -32,8 +25,3 @@ export const asset_checksum_algorithm_enum = registerEnum({
|
||||
name: 'asset_checksum_algorithm_enum',
|
||||
values: Object.values(ChecksumAlgorithm),
|
||||
});
|
||||
|
||||
export const video_stream_variant_codec_enum = registerEnum({
|
||||
name: 'video_stream_variant_codec_enum',
|
||||
values: Object.values(VideoSegmentCodec),
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user