mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
Merge remote-tracking branch 'origin/main' into misc_tweaks
This commit is contained in:
commit
a9f31a2f8d
6
cli/package-lock.json
generated
6
cli/package-lock.json
generated
@ -4144,9 +4144,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.2.5",
|
"version": "6.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
|
||||||
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
|
"integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -63,6 +63,13 @@ If you only want to do web development connected to an existing, remote backend,
|
|||||||
IMMICH_SERVER_URL=https://demo.immich.app/ npm run dev
|
IMMICH_SERVER_URL=https://demo.immich.app/ npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you're using PowerShell on Windows you may need to set the env var separately like so:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:IMMICH_SERVER_URL = "https://demo.immich.app/"
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
#### `@immich/ui`
|
#### `@immich/ui`
|
||||||
|
|
||||||
To see local changes to `@immich/ui` in Immich, do the following:
|
To see local changes to `@immich/ui` in Immich, do the following:
|
||||||
|
@ -1141,7 +1141,7 @@ describe('/asset', () => {
|
|||||||
fNumber: 8,
|
fNumber: 8,
|
||||||
focalLength: 97,
|
focalLength: 97,
|
||||||
iso: 100,
|
iso: 100,
|
||||||
lensModel: 'E PZ 18-105mm F4 G OSS',
|
lensModel: 'Sony E PZ 18-105mm F4 G OSS',
|
||||||
fileSizeInByte: 25_001_984,
|
fileSizeInByte: 25_001_984,
|
||||||
dateTimeOriginal: '2016-09-27T10:51:44+00:00',
|
dateTimeOriginal: '2016-09-27T10:51:44+00:00',
|
||||||
orientation: '1',
|
orientation: '1',
|
||||||
@ -1163,7 +1163,7 @@ describe('/asset', () => {
|
|||||||
fNumber: 22,
|
fNumber: 22,
|
||||||
focalLength: 25,
|
focalLength: 25,
|
||||||
iso: 100,
|
iso: 100,
|
||||||
lensModel: 'E 25mm F2',
|
lensModel: 'Zeiss Batis 25mm F2',
|
||||||
fileSizeInByte: 49_512_448,
|
fileSizeInByte: 49_512_448,
|
||||||
dateTimeOriginal: '2016-01-08T14:08:01+00:00',
|
dateTimeOriginal: '2016-01-08T14:08:01+00:00',
|
||||||
orientation: '1',
|
orientation: '1',
|
||||||
@ -1234,7 +1234,7 @@ describe('/asset', () => {
|
|||||||
focalLength: 18.3,
|
focalLength: 18.3,
|
||||||
iso: 100,
|
iso: 100,
|
||||||
latitude: 36.613_24,
|
latitude: 36.613_24,
|
||||||
lensModel: 'GR LENS 18.3mm F2.8',
|
lensModel: '18.3mm F2.8',
|
||||||
longitude: -121.897_85,
|
longitude: -121.897_85,
|
||||||
make: 'RICOH IMAGING COMPANY, LTD.',
|
make: 'RICOH IMAGING COMPANY, LTD.',
|
||||||
model: 'RICOH GR III',
|
model: 'RICOH GR III',
|
||||||
|
@ -48,7 +48,7 @@ test.describe('Shared Links', () => {
|
|||||||
await page.waitForSelector('[data-group] svg');
|
await page.waitForSelector('[data-group] svg');
|
||||||
await page.getByRole('checkbox').click();
|
await page.getByRole('checkbox').click();
|
||||||
await page.getByRole('button', { name: 'Download' }).click();
|
await page.getByRole('button', { name: 'Download' }).click();
|
||||||
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
|
await page.waitForEvent('download');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('download all from shared link', async ({ page }) => {
|
test('download all from shared link', async ({ page }) => {
|
||||||
@ -56,6 +56,7 @@ test.describe('Shared Links', () => {
|
|||||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||||
await page.getByRole('button', { name: 'Download' }).click();
|
await page.getByRole('button', { name: 'Download' }).click();
|
||||||
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
|
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
|
||||||
|
await page.waitForEvent('download');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('enter password for a shared link', async ({ page }) => {
|
test('enter password for a shared link', async ({ page }) => {
|
||||||
|
@ -1371,6 +1371,7 @@
|
|||||||
"view_next_asset": "View next asset",
|
"view_next_asset": "View next asset",
|
||||||
"view_previous_asset": "View previous asset",
|
"view_previous_asset": "View previous asset",
|
||||||
"view_stack": "View Stack",
|
"view_stack": "View Stack",
|
||||||
|
"view_qr_code": "View QR code",
|
||||||
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
|
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
|
||||||
"waiting": "Waiting",
|
"waiting": "Waiting",
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
|
@ -278,8 +278,8 @@ class TestOrtSession:
|
|||||||
|
|
||||||
assert session.provider_options == []
|
assert session.provider_options == []
|
||||||
|
|
||||||
def test_sets_default_sess_options(self) -> None:
|
def test_sets_default_sess_options_if_cpu(self) -> None:
|
||||||
session = OrtSession("ViT-B-32__openai")
|
session = OrtSession("ViT-B-32__openai", providers=["CPUExecutionProvider"])
|
||||||
|
|
||||||
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
|
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
|
||||||
assert session.sess_options.inter_op_num_threads == 1
|
assert session.sess_options.inter_op_num_threads == 1
|
||||||
|
@ -7,6 +7,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
|
|||||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
|
|
||||||
class TopControlAppBar extends HookConsumerWidget {
|
class TopControlAppBar extends HookConsumerWidget {
|
||||||
const TopControlAppBar({
|
const TopControlAppBar({
|
||||||
@ -166,6 +167,9 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isInHomePage = ref.read(tabProvider.notifier).state == TabEnum.home;
|
||||||
|
bool? isInTrash = ref.read(currentAssetProvider)?.isTrashed;
|
||||||
|
|
||||||
return AppBar(
|
return AppBar(
|
||||||
foregroundColor: Colors.grey[100],
|
foregroundColor: Colors.grey[100],
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
@ -174,7 +178,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
shape: const Border(),
|
shape: const Border(),
|
||||||
actions: [
|
actions: [
|
||||||
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
||||||
if (isOwner && ref.read(tabProvider.notifier).state != TabEnum.home)
|
if (isOwner && !isInHomePage && !(isInTrash ?? false))
|
||||||
buildLocateButton(),
|
buildLocateButton(),
|
||||||
if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
|
if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
|
||||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||||
|
@ -4,6 +4,7 @@ FROM ghcr.io/immich-app/base-server-dev:202503251114@sha256:10e8973e8603c5729436
|
|||||||
RUN apt-get install --no-install-recommends -yqq tini
|
RUN apt-get install --no-install-recommends -yqq tini
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY server/package.json server/package-lock.json ./
|
COPY server/package.json server/package-lock.json ./
|
||||||
|
COPY server/patches ./patches
|
||||||
RUN npm ci && \
|
RUN npm ci && \
|
||||||
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
|
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
|
||||||
# they're marked as optional dependencies, so we need to copy them manually after pruning
|
# they're marked as optional dependencies, so we need to copy them manually after pruning
|
||||||
@ -56,7 +57,7 @@ COPY server/resources resources
|
|||||||
COPY server/package.json server/package-lock.json ./
|
COPY server/package.json server/package-lock.json ./
|
||||||
COPY server/start*.sh ./
|
COPY server/start*.sh ./
|
||||||
COPY "docker/scripts/get-cpus.sh" ./
|
COPY "docker/scripts/get-cpus.sh" ./
|
||||||
RUN npm link && npm install -g @immich/cli && npm cache clean --force
|
RUN npm install -g @immich/cli && npm cache clean --force
|
||||||
COPY LICENSE /licenses/LICENSE.txt
|
COPY LICENSE /licenses/LICENSE.txt
|
||||||
COPY LICENSE /LICENSE
|
COPY LICENSE /LICENSE
|
||||||
ENV PATH="${PATH}:/usr/src/app/bin"
|
ENV PATH="${PATH}:/usr/src/app/bin"
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
"sync:open-api": "node ./dist/bin/sync-open-api.js",
|
"sync:open-api": "node ./dist/bin/sync-open-api.js",
|
||||||
"sync:sql": "node ./dist/bin/sync-sql.js",
|
"sync:sql": "node ./dist/bin/sync-sql.js",
|
||||||
"email:dev": "email dev -p 3050 --dir src/emails",
|
"email:dev": "email dev -p 3050 --dir src/emails",
|
||||||
"postinstall": "[ \"$npm_config_global\" != \"true\" ] && patch-package || true"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/bullmq": "^11.0.1",
|
"@nestjs/bullmq": "^11.0.1",
|
||||||
|
@ -1,39 +1,48 @@
|
|||||||
diff --git a/node_modules/postgres/cf/src/connection.js b/node_modules/postgres/cf/src/connection.js
|
diff --git a/node_modules/postgres/cf/src/connection.js b/node_modules/postgres/cf/src/connection.js
|
||||||
index ee8b1e6..d03b9dd 100644
|
index ee8b1e6..acf4566 100644
|
||||||
--- a/node_modules/postgres/cf/src/connection.js
|
--- a/node_modules/postgres/cf/src/connection.js
|
||||||
+++ b/node_modules/postgres/cf/src/connection.js
|
+++ b/node_modules/postgres/cf/src/connection.js
|
||||||
@@ -387,6 +387,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
|
@@ -387,8 +387,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
|
||||||
}
|
}
|
||||||
|
|
||||||
function queryError(query, err) {
|
function queryError(query, err) {
|
||||||
+ if (!query || typeof query !== 'object') throw err
|
+ if (!query || typeof query !== 'object' || !query.reject) throw err
|
||||||
+
|
+
|
||||||
'query' in err || 'parameters' in err || Object.defineProperties(err, {
|
'query' in err || 'parameters' in err || Object.defineProperties(err, {
|
||||||
stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
|
- stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
|
||||||
|
+ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug },
|
||||||
query: { value: query.string, enumerable: options.debug },
|
query: { value: query.string, enumerable: options.debug },
|
||||||
|
parameters: { value: query.parameters, enumerable: options.debug },
|
||||||
|
args: { value: query.args, enumerable: options.debug },
|
||||||
diff --git a/node_modules/postgres/cjs/src/connection.js b/node_modules/postgres/cjs/src/connection.js
|
diff --git a/node_modules/postgres/cjs/src/connection.js b/node_modules/postgres/cjs/src/connection.js
|
||||||
index f7f58d1..8a37571 100644
|
index f7f58d1..b7f2d65 100644
|
||||||
--- a/node_modules/postgres/cjs/src/connection.js
|
--- a/node_modules/postgres/cjs/src/connection.js
|
||||||
+++ b/node_modules/postgres/cjs/src/connection.js
|
+++ b/node_modules/postgres/cjs/src/connection.js
|
||||||
@@ -385,6 +385,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
|
@@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
|
||||||
}
|
}
|
||||||
|
|
||||||
function queryError(query, err) {
|
function queryError(query, err) {
|
||||||
+ if (!query || typeof query !== 'object') throw err
|
+ if (!query || typeof query !== 'object' || !query.reject) throw err
|
||||||
+
|
+
|
||||||
'query' in err || 'parameters' in err || Object.defineProperties(err, {
|
'query' in err || 'parameters' in err || Object.defineProperties(err, {
|
||||||
stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
|
- stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
|
||||||
|
+ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug },
|
||||||
query: { value: query.string, enumerable: options.debug },
|
query: { value: query.string, enumerable: options.debug },
|
||||||
|
parameters: { value: query.parameters, enumerable: options.debug },
|
||||||
|
args: { value: query.args, enumerable: options.debug },
|
||||||
diff --git a/node_modules/postgres/src/connection.js b/node_modules/postgres/src/connection.js
|
diff --git a/node_modules/postgres/src/connection.js b/node_modules/postgres/src/connection.js
|
||||||
index 97cc97e..58f5298 100644
|
index 97cc97e..26f508e 100644
|
||||||
--- a/node_modules/postgres/src/connection.js
|
--- a/node_modules/postgres/src/connection.js
|
||||||
+++ b/node_modules/postgres/src/connection.js
|
+++ b/node_modules/postgres/src/connection.js
|
||||||
@@ -385,6 +385,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
|
@@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
|
||||||
}
|
}
|
||||||
|
|
||||||
function queryError(query, err) {
|
function queryError(query, err) {
|
||||||
+ if (!query || typeof query !== 'object') throw err
|
+ if (!query || typeof query !== 'object' || !query.reject) throw err
|
||||||
+
|
+
|
||||||
'query' in err || 'parameters' in err || Object.defineProperties(err, {
|
'query' in err || 'parameters' in err || Object.defineProperties(err, {
|
||||||
stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
|
- stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
|
||||||
|
+ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug },
|
||||||
query: { value: query.string, enumerable: options.debug },
|
query: { value: query.string, enumerable: options.debug },
|
||||||
|
parameters: { value: query.parameters, enumerable: options.debug },
|
||||||
|
args: { value: query.args, enumerable: options.debug },
|
||||||
|
@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { APP_MEDIA_LOCATION } from 'src/constants';
|
import { APP_MEDIA_LOCATION } from 'src/constants';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
|
||||||
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
|
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
|
||||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
@ -85,7 +84,7 @@ export class StorageCore {
|
|||||||
return join(APP_MEDIA_LOCATION, folder);
|
return join(APP_MEDIA_LOCATION, folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPersonThumbnailPath(person: PersonEntity) {
|
static getPersonThumbnailPath(person: { id: string; ownerId: string }) {
|
||||||
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
|
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,7 +134,7 @@ export class StorageCore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async movePersonFile(person: PersonEntity, pathType: PersonPathType) {
|
async movePersonFile(person: { id: string; ownerId: string; thumbnailPath: string }, pathType: PersonPathType) {
|
||||||
const { id: entityId, thumbnailPath } = person;
|
const { id: entityId, thumbnailPath } = person;
|
||||||
switch (pathType) {
|
switch (pathType) {
|
||||||
case PersonPathType.FACE: {
|
case PersonPathType.FACE: {
|
||||||
|
@ -1,4 +1,15 @@
|
|||||||
import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum';
|
import { Selectable } from 'kysely';
|
||||||
|
import { Exif as DatabaseExif } from 'src/db';
|
||||||
|
import {
|
||||||
|
AlbumUserRole,
|
||||||
|
AssetFileType,
|
||||||
|
AssetStatus,
|
||||||
|
AssetType,
|
||||||
|
MemoryType,
|
||||||
|
Permission,
|
||||||
|
SourceType,
|
||||||
|
UserStatus,
|
||||||
|
} from 'src/enum';
|
||||||
import { OnThisDayData, UserMetadataItem } from 'src/types';
|
import { OnThisDayData, UserMetadataItem } from 'src/types';
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
@ -10,6 +21,17 @@ export type AuthUser = {
|
|||||||
quotaSizeInBytes: number | null;
|
quotaSizeInBytes: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AlbumUser = {
|
||||||
|
user: User;
|
||||||
|
role: AlbumUserRole;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetFile = {
|
||||||
|
id: string;
|
||||||
|
type: AssetFileType;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Library = {
|
export type Library = {
|
||||||
id: string;
|
id: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
@ -184,6 +206,38 @@ export type Session = {
|
|||||||
deviceType: string;
|
deviceType: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Exif = Omit<Selectable<DatabaseExif>, 'updatedAt' | 'updateId'>;
|
||||||
|
|
||||||
|
export type Person = {
|
||||||
|
createdAt: Date;
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
updateId: string;
|
||||||
|
isFavorite: boolean;
|
||||||
|
name: string;
|
||||||
|
birthDate: Date | null;
|
||||||
|
color: string | null;
|
||||||
|
faceAssetId: string | null;
|
||||||
|
isHidden: boolean;
|
||||||
|
thumbnailPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetFace = {
|
||||||
|
id: string;
|
||||||
|
deletedAt: Date | null;
|
||||||
|
assetId: string;
|
||||||
|
boundingBoxX1: number;
|
||||||
|
boundingBoxX2: number;
|
||||||
|
boundingBoxY1: number;
|
||||||
|
boundingBoxY2: number;
|
||||||
|
imageHeight: number;
|
||||||
|
imageWidth: number;
|
||||||
|
personId: string | null;
|
||||||
|
sourceType: SourceType;
|
||||||
|
person?: Person | null;
|
||||||
|
};
|
||||||
|
|
||||||
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
|
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
|
||||||
|
|
||||||
export const columns = {
|
export const columns = {
|
||||||
|
6
server/src/db.d.ts
vendored
6
server/src/db.d.ts
vendored
@ -17,7 +17,7 @@ import {
|
|||||||
SyncEntityType,
|
SyncEntityType,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
import { OnThisDayData } from 'src/types';
|
import { OnThisDayData, UserMetadataItem } from 'src/types';
|
||||||
|
|
||||||
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
|
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
|
||||||
|
|
||||||
@ -412,10 +412,8 @@ export interface TypeormMetadata {
|
|||||||
value: string | null;
|
value: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserMetadata {
|
export interface UserMetadata extends UserMetadataItem {
|
||||||
key: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
value: Json;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsersAudit {
|
export interface UsersAudit {
|
||||||
|
@ -143,13 +143,11 @@ export class AlbumResponseDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
|
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
|
||||||
const sharedUsers: UserResponseDto[] = [];
|
|
||||||
const albumUsers: AlbumUserResponseDto[] = [];
|
const albumUsers: AlbumUserResponseDto[] = [];
|
||||||
|
|
||||||
if (entity.albumUsers) {
|
if (entity.albumUsers) {
|
||||||
for (const albumUser of entity.albumUsers) {
|
for (const albumUser of entity.albumUsers) {
|
||||||
const user = mapUser(albumUser.user);
|
const user = mapUser(albumUser.user);
|
||||||
sharedUsers.push(user);
|
|
||||||
albumUsers.push({
|
albumUsers.push({
|
||||||
user,
|
user,
|
||||||
role: albumUser.role,
|
role: albumUser.role,
|
||||||
@ -162,7 +160,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
|
|||||||
const assets = entity.assets || [];
|
const assets = entity.assets || [];
|
||||||
|
|
||||||
const hasSharedLink = entity.sharedLinks?.length > 0;
|
const hasSharedLink = entity.sharedLinks?.length > 0;
|
||||||
const hasSharedUser = sharedUsers.length > 0;
|
const hasSharedUser = albumUsers.length > 0;
|
||||||
|
|
||||||
let startDate = assets.at(0)?.localDateTime;
|
let startDate = assets.at(0)?.localDateTime;
|
||||||
let endDate = assets.at(-1)?.localDateTime;
|
let endDate = assets.at(-1)?.localDateTime;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { AssetFace } from 'src/database';
|
||||||
import { PropertyLifecycle } from 'src/decorators';
|
import { PropertyLifecycle } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
|
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
|
||||||
@ -10,7 +11,6 @@ import {
|
|||||||
} from 'src/dtos/person.dto';
|
} from 'src/dtos/person.dto';
|
||||||
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
||||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { AssetType } from 'src/enum';
|
import { AssetType } from 'src/enum';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
@ -71,7 +71,8 @@ export type AssetMapOptions = {
|
|||||||
auth?: AuthDto;
|
auth?: AuthDto;
|
||||||
};
|
};
|
||||||
|
|
||||||
const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => {
|
// TODO: this is inefficient
|
||||||
|
const peopleWithFaces = (faces: AssetFace[]): PersonWithFacesResponseDto[] => {
|
||||||
const result: PersonWithFacesResponseDto[] = [];
|
const result: PersonWithFacesResponseDto[] = [];
|
||||||
if (faces) {
|
if (faces) {
|
||||||
for (const face of faces) {
|
for (const face of faces) {
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser } from 'src/database';
|
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
|
||||||
import { ImmichCookie } from 'src/enum';
|
import { ImmichCookie } from 'src/enum';
|
||||||
import { toEmail } from 'src/validation';
|
import { toEmail } from 'src/validation';
|
||||||
|
|
||||||
@ -42,7 +41,7 @@ export class LoginResponseDto {
|
|||||||
shouldChangePassword!: boolean;
|
shouldChangePassword!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto {
|
export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto {
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
userId: entity.id,
|
userId: entity.id,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
import { Exif } from 'src/database';
|
||||||
|
|
||||||
export class ExifResponseDto {
|
export class ExifResponseDto {
|
||||||
make?: string | null = null;
|
make?: string | null = null;
|
||||||
@ -28,7 +28,7 @@ export class ExifResponseDto {
|
|||||||
rating?: number | null = null;
|
rating?: number | null = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapExif(entity: ExifEntity): ExifResponseDto {
|
export function mapExif(entity: Exif): ExifResponseDto {
|
||||||
return {
|
return {
|
||||||
make: entity.make,
|
make: entity.make,
|
||||||
model: entity.model,
|
model: entity.model,
|
||||||
@ -55,7 +55,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto {
|
export function mapSanitizedExif(entity: Exif): ExifResponseDto {
|
||||||
return {
|
return {
|
||||||
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
||||||
orientation: entity.orientation,
|
orientation: entity.orientation,
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator';
|
import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator';
|
||||||
|
import { Selectable } from 'kysely';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { AssetFace, Person } from 'src/database';
|
||||||
|
import { AssetFaces } from 'src/db';
|
||||||
import { PropertyLifecycle } from 'src/decorators';
|
import { PropertyLifecycle } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
|
||||||
import { SourceType } from 'src/enum';
|
import { SourceType } from 'src/enum';
|
||||||
import { asDateString } from 'src/utils/date';
|
import { asDateString } from 'src/utils/date';
|
||||||
import {
|
import {
|
||||||
@ -219,7 +220,7 @@ export class PeopleResponseDto {
|
|||||||
hasNextPage?: boolean;
|
hasNextPage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapPerson(person: PersonEntity): PersonResponseDto {
|
export function mapPerson(person: Person): PersonResponseDto {
|
||||||
return {
|
return {
|
||||||
id: person.id,
|
id: person.id,
|
||||||
name: person.name,
|
name: person.name,
|
||||||
@ -232,7 +233,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapFacesWithoutPerson(face: AssetFaceEntity): AssetFaceWithoutPersonResponseDto {
|
export function mapFacesWithoutPerson(face: Selectable<AssetFaces>): AssetFaceWithoutPersonResponseDto {
|
||||||
return {
|
return {
|
||||||
id: face.id,
|
id: face.id,
|
||||||
imageHeight: face.imageHeight,
|
imageHeight: face.imageHeight,
|
||||||
@ -245,9 +246,16 @@ export function mapFacesWithoutPerson(face: AssetFaceEntity): AssetFaceWithoutPe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapFaces(face: AssetFaceEntity, auth: AuthDto): AssetFaceResponseDto {
|
export function mapFaces(face: AssetFace, auth: AuthDto): AssetFaceResponseDto {
|
||||||
return {
|
return {
|
||||||
...mapFacesWithoutPerson(face),
|
id: face.id,
|
||||||
|
imageHeight: face.imageHeight,
|
||||||
|
imageWidth: face.imageWidth,
|
||||||
|
boundingBoxX1: face.boundingBoxX1,
|
||||||
|
boundingBoxX2: face.boundingBoxX2,
|
||||||
|
boundingBoxY1: face.boundingBoxY1,
|
||||||
|
boundingBoxY2: face.boundingBoxY2,
|
||||||
|
sourceType: face.sourceType,
|
||||||
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
|
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,6 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||||
import { User, UserAdmin } from 'src/database';
|
import { User, UserAdmin } from 'src/database';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
|
||||||
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||||
import { UserMetadataItem } from 'src/types';
|
import { UserMetadataItem } from 'src/types';
|
||||||
import { getPreferences } from 'src/utils/preferences';
|
import { getPreferences } from 'src/utils/preferences';
|
||||||
@ -42,13 +41,13 @@ export class UserLicense {
|
|||||||
activatedAt!: Date;
|
activatedAt!: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapUser = (entity: UserEntity | User): UserResponseDto => {
|
export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
email: entity.email,
|
email: entity.email,
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
profileImagePath: entity.profileImagePath,
|
profileImagePath: entity.profileImagePath,
|
||||||
avatarColor: getPreferences(entity.email, (entity as UserEntity).metadata || []).avatar.color,
|
avatarColor: getPreferences(entity.email, (entity as UserAdmin).metadata || []).avatar.color,
|
||||||
profileChangedAt: entity.profileChangedAt,
|
profileChangedAt: entity.profileChangedAt,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -142,7 +141,7 @@ export class UserAdminResponseDto extends UserResponseDto {
|
|||||||
license!: UserLicense | null;
|
license!: UserLicense | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapUserAdmin(entity: UserEntity | UserAdmin): UserAdminResponseDto {
|
export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
||||||
const metadata = entity.metadata || [];
|
const metadata = entity.metadata || [];
|
||||||
const license = metadata.find(
|
const license = metadata.find(
|
||||||
(item): item is UserMetadataItem<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE,
|
(item): item is UserMetadataItem<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE,
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
import { AlbumEntity } from 'src/entities/album.entity';
|
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
|
||||||
import { AlbumUserRole } from 'src/enum';
|
|
||||||
|
|
||||||
export class AlbumUserEntity {
|
|
||||||
albumId!: string;
|
|
||||||
userId!: string;
|
|
||||||
album!: AlbumEntity;
|
|
||||||
user!: UserEntity;
|
|
||||||
role!: AlbumUserRole;
|
|
||||||
}
|
|
@ -1,12 +1,11 @@
|
|||||||
import { AlbumUserEntity } from 'src/entities/album-user.entity';
|
import { AlbumUser, User } from 'src/database';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
|
||||||
import { AssetOrder } from 'src/enum';
|
import { AssetOrder } from 'src/enum';
|
||||||
|
|
||||||
export class AlbumEntity {
|
export class AlbumEntity {
|
||||||
id!: string;
|
id!: string;
|
||||||
owner!: UserEntity;
|
owner!: User;
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
albumName!: string;
|
albumName!: string;
|
||||||
description!: string;
|
description!: string;
|
||||||
@ -16,7 +15,7 @@ export class AlbumEntity {
|
|||||||
deletedAt!: Date | null;
|
deletedAt!: Date | null;
|
||||||
albumThumbnailAsset!: AssetEntity | null;
|
albumThumbnailAsset!: AssetEntity | null;
|
||||||
albumThumbnailAssetId!: string | null;
|
albumThumbnailAssetId!: string | null;
|
||||||
albumUsers!: AlbumUserEntity[];
|
albumUsers!: AlbumUser[];
|
||||||
assets!: AssetEntity[];
|
assets!: AssetEntity[];
|
||||||
sharedLinks!: SharedLinkEntity[];
|
sharedLinks!: SharedLinkEntity[];
|
||||||
isActivityEnabled!: boolean;
|
isActivityEnabled!: boolean;
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
|
||||||
import { FaceSearchEntity } from 'src/entities/face-search.entity';
|
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
|
||||||
import { SourceType } from 'src/enum';
|
|
||||||
|
|
||||||
export class AssetFaceEntity {
|
|
||||||
id!: string;
|
|
||||||
assetId!: string;
|
|
||||||
personId!: string | null;
|
|
||||||
faceSearch?: FaceSearchEntity;
|
|
||||||
imageWidth!: number;
|
|
||||||
imageHeight!: number;
|
|
||||||
boundingBoxX1!: number;
|
|
||||||
boundingBoxY1!: number;
|
|
||||||
boundingBoxX2!: number;
|
|
||||||
boundingBoxY2!: number;
|
|
||||||
sourceType!: SourceType;
|
|
||||||
asset!: AssetEntity;
|
|
||||||
person!: PersonEntity | null;
|
|
||||||
deletedAt!: Date | null;
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
|
||||||
import { AssetFileType } from 'src/enum';
|
|
||||||
|
|
||||||
export class AssetFileEntity {
|
|
||||||
id!: string;
|
|
||||||
assetId!: string;
|
|
||||||
asset?: AssetEntity;
|
|
||||||
createdAt!: Date;
|
|
||||||
updatedAt!: Date;
|
|
||||||
updateId?: string;
|
|
||||||
type!: AssetFileType;
|
|
||||||
path!: string;
|
|
||||||
}
|
|
@ -1,15 +1,11 @@
|
|||||||
import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely';
|
import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely';
|
||||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import { Tag } from 'src/database';
|
import { AssetFace, AssetFile, Exif, Tag, User } from 'src/database';
|
||||||
import { DB } from 'src/db';
|
import { DB } from 'src/db';
|
||||||
import { AlbumEntity } from 'src/entities/album.entity';
|
import { AlbumEntity } from 'src/entities/album.entity';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
|
||||||
import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
|
||||||
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
|
||||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||||
import { StackEntity } from 'src/entities/stack.entity';
|
import { StackEntity } from 'src/entities/stack.entity';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
|
||||||
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
|
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
|
||||||
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
||||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||||
@ -20,14 +16,14 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
|
|||||||
export class AssetEntity {
|
export class AssetEntity {
|
||||||
id!: string;
|
id!: string;
|
||||||
deviceAssetId!: string;
|
deviceAssetId!: string;
|
||||||
owner!: UserEntity;
|
owner!: User;
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
libraryId?: string | null;
|
libraryId?: string | null;
|
||||||
deviceId!: string;
|
deviceId!: string;
|
||||||
type!: AssetType;
|
type!: AssetType;
|
||||||
status!: AssetStatus;
|
status!: AssetStatus;
|
||||||
originalPath!: string;
|
originalPath!: string;
|
||||||
files!: AssetFileEntity[];
|
files!: AssetFile[];
|
||||||
thumbhash!: Buffer | null;
|
thumbhash!: Buffer | null;
|
||||||
encodedVideoPath!: string | null;
|
encodedVideoPath!: string | null;
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
@ -48,11 +44,11 @@ export class AssetEntity {
|
|||||||
livePhotoVideoId!: string | null;
|
livePhotoVideoId!: string | null;
|
||||||
originalFileName!: string;
|
originalFileName!: string;
|
||||||
sidecarPath!: string | null;
|
sidecarPath!: string | null;
|
||||||
exifInfo?: ExifEntity;
|
exifInfo?: Exif;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
sharedLinks!: SharedLinkEntity[];
|
sharedLinks!: SharedLinkEntity[];
|
||||||
albums?: AlbumEntity[];
|
albums?: AlbumEntity[];
|
||||||
faces!: AssetFaceEntity[];
|
faces!: AssetFace[];
|
||||||
stackId?: string | null;
|
stackId?: string | null;
|
||||||
stack?: StackEntity | null;
|
stack?: StackEntity | null;
|
||||||
jobStatus?: AssetJobStatusEntity;
|
jobStatus?: AssetJobStatusEntity;
|
||||||
@ -66,7 +62,9 @@ export type AssetEntityPlaceholder = AssetEntity & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
|
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
|
||||||
return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo'));
|
return qb
|
||||||
|
.leftJoin('exif', 'assets.id', 'exif.assetId')
|
||||||
|
.select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif>().as('exifInfo'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
|
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
|
||||||
|
|
||||||
export class ExifEntity {
|
|
||||||
asset?: AssetEntity;
|
|
||||||
assetId!: string;
|
|
||||||
updatedAt?: Date;
|
|
||||||
updateId?: string;
|
|
||||||
description!: string; // or caption
|
|
||||||
exifImageWidth!: number | null;
|
|
||||||
exifImageHeight!: number | null;
|
|
||||||
fileSizeInByte!: number | null;
|
|
||||||
orientation!: string | null;
|
|
||||||
dateTimeOriginal!: Date | null;
|
|
||||||
modifyDate!: Date | null;
|
|
||||||
timeZone!: string | null;
|
|
||||||
latitude!: number | null;
|
|
||||||
longitude!: number | null;
|
|
||||||
projectionType!: string | null;
|
|
||||||
city!: string | null;
|
|
||||||
livePhotoCID!: string | null;
|
|
||||||
autoStackId!: string | null;
|
|
||||||
state!: string | null;
|
|
||||||
country!: string | null;
|
|
||||||
make!: string | null;
|
|
||||||
model!: string | null;
|
|
||||||
lensModel!: string | null;
|
|
||||||
fNumber!: number | null;
|
|
||||||
focalLength!: number | null;
|
|
||||||
iso!: number | null;
|
|
||||||
exposureTime!: string | null;
|
|
||||||
profileDescription!: string | null;
|
|
||||||
colorspace!: string | null;
|
|
||||||
bitsPerSample!: number | null;
|
|
||||||
rating!: number | null;
|
|
||||||
fps?: number | null;
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
|
||||||
|
|
||||||
export class FaceSearchEntity {
|
|
||||||
face?: AssetFaceEntity;
|
|
||||||
faceId!: string;
|
|
||||||
embedding!: string;
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
|
||||||
|
|
||||||
export class PersonEntity {
|
|
||||||
id!: string;
|
|
||||||
createdAt!: Date;
|
|
||||||
updatedAt!: Date;
|
|
||||||
updateId?: string;
|
|
||||||
ownerId!: string;
|
|
||||||
owner!: UserEntity;
|
|
||||||
name!: string;
|
|
||||||
birthDate!: Date | string | null;
|
|
||||||
thumbnailPath!: string;
|
|
||||||
faceAssetId!: string | null;
|
|
||||||
faceAsset!: AssetFaceEntity | null;
|
|
||||||
faces!: AssetFaceEntity[];
|
|
||||||
isHidden!: boolean;
|
|
||||||
isFavorite!: boolean;
|
|
||||||
color?: string | null;
|
|
||||||
}
|
|
@ -1,6 +1,5 @@
|
|||||||
import { AlbumEntity } from 'src/entities/album.entity';
|
import { AlbumEntity } from 'src/entities/album.entity';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
|
||||||
import { SharedLinkType } from 'src/enum';
|
import { SharedLinkType } from 'src/enum';
|
||||||
|
|
||||||
export class SharedLinkEntity {
|
export class SharedLinkEntity {
|
||||||
@ -8,7 +7,6 @@ export class SharedLinkEntity {
|
|||||||
description!: string | null;
|
description!: string | null;
|
||||||
password!: string | null;
|
password!: string | null;
|
||||||
userId!: string;
|
userId!: string;
|
||||||
user!: UserEntity;
|
|
||||||
key!: Buffer; // use to access the inidividual asset
|
key!: Buffer; // use to access the inidividual asset
|
||||||
type!: SharedLinkType;
|
type!: SharedLinkType;
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
|
||||||
|
|
||||||
export class StackEntity {
|
export class StackEntity {
|
||||||
id!: string;
|
id!: string;
|
||||||
owner!: UserEntity;
|
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
assets!: AssetEntity[];
|
assets!: AssetEntity[];
|
||||||
primaryAsset!: AssetEntity;
|
primaryAsset!: AssetEntity;
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
import { ExpressionBuilder } from 'kysely';
|
|
||||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
|
||||||
import { DB } from 'src/db';
|
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
|
||||||
import { UserStatus } from 'src/enum';
|
|
||||||
import { UserMetadataItem } from 'src/types';
|
|
||||||
|
|
||||||
export class UserEntity {
|
|
||||||
id!: string;
|
|
||||||
name!: string;
|
|
||||||
isAdmin!: boolean;
|
|
||||||
email!: string;
|
|
||||||
storageLabel!: string | null;
|
|
||||||
password?: string;
|
|
||||||
oauthId!: string;
|
|
||||||
profileImagePath!: string;
|
|
||||||
shouldChangePassword!: boolean;
|
|
||||||
createdAt!: Date;
|
|
||||||
deletedAt!: Date | null;
|
|
||||||
status!: UserStatus;
|
|
||||||
updatedAt!: Date;
|
|
||||||
updateId?: string;
|
|
||||||
assets!: AssetEntity[];
|
|
||||||
quotaSizeInBytes!: number | null;
|
|
||||||
quotaUsageInBytes!: number;
|
|
||||||
metadata!: UserMetadataItem[];
|
|
||||||
profileChangedAt!: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const withMetadata = (eb: ExpressionBuilder<DB, 'users'>) => {
|
|
||||||
return jsonArrayFrom(
|
|
||||||
eb.selectFrom('user_metadata').selectAll('user_metadata').whereRef('users.id', '=', 'user_metadata.userId'),
|
|
||||||
).as('metadata');
|
|
||||||
};
|
|
@ -23,7 +23,7 @@ REINDEX TABLE person
|
|||||||
-- PersonRepository.delete
|
-- PersonRepository.delete
|
||||||
delete from "person"
|
delete from "person"
|
||||||
where
|
where
|
||||||
"person"."id" in ($1)
|
"person"."id" in $1
|
||||||
|
|
||||||
-- PersonRepository.deleteFaces
|
-- PersonRepository.deleteFaces
|
||||||
delete from "asset_faces"
|
delete from "asset_faces"
|
||||||
@ -95,41 +95,72 @@ where
|
|||||||
"asset_faces"."id" = $1
|
"asset_faces"."id" = $1
|
||||||
and "asset_faces"."deletedAt" is null
|
and "asset_faces"."deletedAt" is null
|
||||||
|
|
||||||
-- PersonRepository.getFaceByIdWithAssets
|
-- PersonRepository.getFaceForFacialRecognitionJob
|
||||||
select
|
select
|
||||||
"asset_faces".*,
|
"asset_faces"."id",
|
||||||
|
"asset_faces"."personId",
|
||||||
|
"asset_faces"."sourceType",
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
to_json(obj)
|
to_json(obj)
|
||||||
from
|
from
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
"person".*
|
"assets"."ownerId",
|
||||||
from
|
"assets"."isArchived",
|
||||||
"person"
|
"assets"."fileCreatedAt"
|
||||||
where
|
|
||||||
"person"."id" = "asset_faces"."personId"
|
|
||||||
) as obj
|
|
||||||
) as "person",
|
|
||||||
(
|
|
||||||
select
|
|
||||||
to_json(obj)
|
|
||||||
from
|
|
||||||
(
|
|
||||||
select
|
|
||||||
"assets".*
|
|
||||||
from
|
from
|
||||||
"assets"
|
"assets"
|
||||||
where
|
where
|
||||||
"assets"."id" = "asset_faces"."assetId"
|
"assets"."id" = "asset_faces"."assetId"
|
||||||
) as obj
|
) as obj
|
||||||
) as "asset"
|
) as "asset",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
to_json(obj)
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"face_search".*
|
||||||
|
from
|
||||||
|
"face_search"
|
||||||
|
where
|
||||||
|
"face_search"."faceId" = "asset_faces"."id"
|
||||||
|
) as obj
|
||||||
|
) as "faceSearch"
|
||||||
from
|
from
|
||||||
"asset_faces"
|
"asset_faces"
|
||||||
where
|
where
|
||||||
"asset_faces"."id" = $1
|
"asset_faces"."id" = $1
|
||||||
and "asset_faces"."deletedAt" is null
|
and "asset_faces"."deletedAt" is null
|
||||||
|
|
||||||
|
-- PersonRepository.getDataForThumbnailGenerationJob
|
||||||
|
select
|
||||||
|
"person"."ownerId",
|
||||||
|
"asset_faces"."boundingBoxX1" as "x1",
|
||||||
|
"asset_faces"."boundingBoxY1" as "y1",
|
||||||
|
"asset_faces"."boundingBoxX2" as "x2",
|
||||||
|
"asset_faces"."boundingBoxY2" as "y2",
|
||||||
|
"asset_faces"."imageWidth" as "oldWidth",
|
||||||
|
"asset_faces"."imageHeight" as "oldHeight",
|
||||||
|
"exif"."exifImageWidth",
|
||||||
|
"exif"."exifImageHeight",
|
||||||
|
"assets"."type",
|
||||||
|
"assets"."originalPath",
|
||||||
|
"asset_files"."path" as "previewPath"
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
inner join "asset_faces" on "asset_faces"."id" = "person"."faceAssetId"
|
||||||
|
inner join "assets" on "asset_faces"."assetId" = "assets"."id"
|
||||||
|
inner join "exif" on "exif"."assetId" = "assets"."id"
|
||||||
|
inner join "asset_files" on "asset_files"."assetId" = "assets"."id"
|
||||||
|
where
|
||||||
|
"person"."id" = $1
|
||||||
|
and "asset_faces"."deletedAt" is null
|
||||||
|
and "asset_files"."type" = $2
|
||||||
|
and "exif"."exifImageWidth" > $3
|
||||||
|
and "exif"."exifImageHeight" > $4
|
||||||
|
|
||||||
-- PersonRepository.reassignFace
|
-- PersonRepository.reassignFace
|
||||||
update "asset_faces"
|
update "asset_faces"
|
||||||
set
|
set
|
||||||
|
@ -24,7 +24,8 @@ select
|
|||||||
from
|
from
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
"user_metadata".*
|
"user_metadata"."key",
|
||||||
|
"user_metadata"."value"
|
||||||
from
|
from
|
||||||
"user_metadata"
|
"user_metadata"
|
||||||
where
|
where
|
||||||
@ -54,7 +55,21 @@ select
|
|||||||
"shouldChangePassword",
|
"shouldChangePassword",
|
||||||
"storageLabel",
|
"storageLabel",
|
||||||
"quotaSizeInBytes",
|
"quotaSizeInBytes",
|
||||||
"quotaUsageInBytes"
|
"quotaUsageInBytes",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"user_metadata"."key",
|
||||||
|
"user_metadata"."value"
|
||||||
|
from
|
||||||
|
"user_metadata"
|
||||||
|
where
|
||||||
|
"users"."id" = "user_metadata"."userId"
|
||||||
|
) as agg
|
||||||
|
) as "metadata"
|
||||||
from
|
from
|
||||||
"users"
|
"users"
|
||||||
where
|
where
|
||||||
@ -87,7 +102,21 @@ select
|
|||||||
"shouldChangePassword",
|
"shouldChangePassword",
|
||||||
"storageLabel",
|
"storageLabel",
|
||||||
"quotaSizeInBytes",
|
"quotaSizeInBytes",
|
||||||
"quotaUsageInBytes"
|
"quotaUsageInBytes",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"user_metadata"."key",
|
||||||
|
"user_metadata"."value"
|
||||||
|
from
|
||||||
|
"user_metadata"
|
||||||
|
where
|
||||||
|
"users"."id" = "user_metadata"."userId"
|
||||||
|
) as agg
|
||||||
|
) as "metadata"
|
||||||
from
|
from
|
||||||
"users"
|
"users"
|
||||||
where
|
where
|
||||||
@ -135,7 +164,21 @@ select
|
|||||||
"shouldChangePassword",
|
"shouldChangePassword",
|
||||||
"storageLabel",
|
"storageLabel",
|
||||||
"quotaSizeInBytes",
|
"quotaSizeInBytes",
|
||||||
"quotaUsageInBytes"
|
"quotaUsageInBytes",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"user_metadata"."key",
|
||||||
|
"user_metadata"."value"
|
||||||
|
from
|
||||||
|
"user_metadata"
|
||||||
|
where
|
||||||
|
"users"."id" = "user_metadata"."userId"
|
||||||
|
) as agg
|
||||||
|
) as "metadata"
|
||||||
from
|
from
|
||||||
"users"
|
"users"
|
||||||
where
|
where
|
||||||
@ -174,7 +217,8 @@ select
|
|||||||
from
|
from
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
"user_metadata".*
|
"user_metadata"."key",
|
||||||
|
"user_metadata"."value"
|
||||||
from
|
from
|
||||||
"user_metadata"
|
"user_metadata"
|
||||||
where
|
where
|
||||||
@ -210,7 +254,8 @@ select
|
|||||||
from
|
from
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
"user_metadata".*
|
"user_metadata"."key",
|
||||||
|
"user_metadata"."value"
|
||||||
from
|
from
|
||||||
"user_metadata"
|
"user_metadata"
|
||||||
where
|
where
|
||||||
@ -232,15 +277,15 @@ select
|
|||||||
count(*) filter (
|
count(*) filter (
|
||||||
where
|
where
|
||||||
(
|
(
|
||||||
"assets"."type" = $1
|
"assets"."type" = 'IMAGE'
|
||||||
and "assets"."isVisible" = $2
|
and "assets"."isVisible" = true
|
||||||
)
|
)
|
||||||
) as "photos",
|
) as "photos",
|
||||||
count(*) filter (
|
count(*) filter (
|
||||||
where
|
where
|
||||||
(
|
(
|
||||||
"assets"."type" = $3
|
"assets"."type" = 'VIDEO'
|
||||||
and "assets"."isVisible" = $4
|
and "assets"."isVisible" = true
|
||||||
)
|
)
|
||||||
) as "videos",
|
) as "videos",
|
||||||
coalesce(
|
coalesce(
|
||||||
@ -255,7 +300,7 @@ select
|
|||||||
where
|
where
|
||||||
(
|
(
|
||||||
"assets"."libraryId" is null
|
"assets"."libraryId" is null
|
||||||
and "assets"."type" = $5
|
and "assets"."type" = 'IMAGE'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
0
|
0
|
||||||
@ -265,7 +310,7 @@ select
|
|||||||
where
|
where
|
||||||
(
|
(
|
||||||
"assets"."libraryId" is null
|
"assets"."libraryId" is null
|
||||||
and "assets"."type" = $6
|
and "assets"."type" = 'VIDEO'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
0
|
0
|
||||||
|
@ -69,7 +69,7 @@ export class ActivityRepository {
|
|||||||
async getStatistics({ albumId, assetId }: { albumId: string; assetId?: string }): Promise<number> {
|
async getStatistics({ albumId, assetId }: { albumId: string; assetId?: string }): Promise<number> {
|
||||||
const { count } = await this.db
|
const { count } = await this.db
|
||||||
.selectFrom('activity')
|
.selectFrom('activity')
|
||||||
.select((eb) => eb.fn.countAll().as('count'))
|
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||||
.innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null))
|
.innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null))
|
||||||
.leftJoin('assets', 'assets.id', 'activity.assetId')
|
.leftJoin('assets', 'assets.id', 'activity.assetId')
|
||||||
.$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!))
|
.$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!))
|
||||||
@ -81,6 +81,6 @@ export class ActivityRepository {
|
|||||||
.where('assets.localDateTime', 'is not', null)
|
.where('assets.localDateTime', 'is not', null)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return count as number;
|
return count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -470,10 +470,10 @@ export class AssetRepository {
|
|||||||
async getLivePhotoCount(motionId: string): Promise<number> {
|
async getLivePhotoCount(motionId: string): Promise<number> {
|
||||||
const [{ count }] = await this.db
|
const [{ count }] = await this.db
|
||||||
.selectFrom('assets')
|
.selectFrom('assets')
|
||||||
.select((eb) => eb.fn.countAll().as('count'))
|
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||||
.where('livePhotoVideoId', '=', asUuid(motionId))
|
.where('livePhotoVideoId', '=', asUuid(motionId))
|
||||||
.execute();
|
.execute();
|
||||||
return count as number;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
@ -773,10 +773,10 @@ export class AssetRepository {
|
|||||||
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
|
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('assets')
|
.selectFrom('assets')
|
||||||
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO))
|
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO))
|
||||||
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE))
|
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE))
|
||||||
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO))
|
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO))
|
||||||
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER))
|
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER))
|
||||||
.where('ownerId', '=', asUuid(ownerId))
|
.where('ownerId', '=', asUuid(ownerId))
|
||||||
.where('assets.fileCreatedAt', 'is not', null)
|
.where('assets.fileCreatedAt', 'is not', null)
|
||||||
.where('assets.fileModifiedAt', 'is not', null)
|
.where('assets.fileModifiedAt', 'is not', null)
|
||||||
@ -786,7 +786,7 @@ export class AssetRepository {
|
|||||||
.$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!))
|
.$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!))
|
||||||
.$if(!!isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
.$if(!!isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
||||||
.where('deletedAt', isTrashed ? 'is not' : 'is', null)
|
.where('deletedAt', isTrashed ? 'is not' : 'is', null)
|
||||||
.executeTakeFirst() as Promise<AssetStats>;
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
getRandom(userIds: string[], take: number): Promise<AssetEntity[]> {
|
getRandom(userIds: string[], take: number): Promise<AssetEntity[]> {
|
||||||
@ -847,7 +847,7 @@ export class AssetRepository {
|
|||||||
The line below outputs in YYYY-MM-DD format, but needs a change in the web app to work.
|
The line below outputs in YYYY-MM-DD format, but needs a change in the web app to work.
|
||||||
.select(sql<string>`"timeBucket"::date::text`.as('timeBucket'))
|
.select(sql<string>`"timeBucket"::date::text`.as('timeBucket'))
|
||||||
*/
|
*/
|
||||||
.select((eb) => eb.fn.countAll().as('count'))
|
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||||
.groupBy('timeBucket')
|
.groupBy('timeBucket')
|
||||||
.orderBy('timeBucket', options.order ?? 'desc')
|
.orderBy('timeBucket', options.order ?? 'desc')
|
||||||
.execute() as any as Promise<TimeBucketItem[]>
|
.execute() as any as Promise<TimeBucketItem[]>
|
||||||
@ -1145,10 +1145,10 @@ export class AssetRepository {
|
|||||||
async getLibraryAssetCount(libraryId: string): Promise<number> {
|
async getLibraryAssetCount(libraryId: string): Promise<number> {
|
||||||
const { count } = await this.db
|
const { count } = await this.db
|
||||||
.selectFrom('assets')
|
.selectFrom('assets')
|
||||||
.select((eb) => eb.fn.countAll().as('count'))
|
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||||
.where('libraryId', '=', asUuid(libraryId))
|
.where('libraryId', '=', asUuid(libraryId))
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return Number(count);
|
return count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -250,7 +250,7 @@ const getEnv = (): EnvData => {
|
|||||||
},
|
},
|
||||||
bigint: {
|
bigint: {
|
||||||
to: 20,
|
to: 20,
|
||||||
from: [20],
|
from: [20, 1700],
|
||||||
parse: (value: string) => Number.parseInt(value),
|
parse: (value: string) => Number.parseInt(value),
|
||||||
serialize: (value: number) => value.toString(),
|
serialize: (value: number) => value.toString(),
|
||||||
},
|
},
|
||||||
|
@ -76,13 +76,13 @@ export class LibraryRepository {
|
|||||||
.leftJoin('exif', 'exif.assetId', 'assets.id')
|
.leftJoin('exif', 'exif.assetId', 'assets.id')
|
||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
eb.fn
|
eb.fn
|
||||||
.countAll()
|
.countAll<number>()
|
||||||
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)]))
|
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)]))
|
||||||
.as('photos'),
|
.as('photos'),
|
||||||
)
|
)
|
||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
eb.fn
|
eb.fn
|
||||||
.countAll()
|
.countAll<number>()
|
||||||
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)]))
|
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)]))
|
||||||
.as('videos'),
|
.as('videos'),
|
||||||
)
|
)
|
||||||
@ -105,10 +105,10 @@ export class LibraryRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
photos: Number(stats.photos),
|
photos: stats.photos,
|
||||||
videos: Number(stats.videos),
|
videos: stats.videos,
|
||||||
usage: Number(stats.usage),
|
usage: stats.usage,
|
||||||
total: Number(stats.photos) + Number(stats.videos),
|
total: stats.photos + stats.videos,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { getName } from 'i18n-iso-countries';
|
import { getName } from 'i18n-iso-countries';
|
||||||
import { Expression, Insertable, Kysely, sql, SqlBool } from 'kysely';
|
import { Expression, Insertable, Kysely, NotNull, sql, SqlBool } from 'kysely';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { createReadStream, existsSync } from 'node:fs';
|
import { createReadStream, existsSync } from 'node:fs';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
@ -87,6 +87,7 @@ export class MapRepository {
|
|||||||
.on('exif.longitude', 'is not', null),
|
.on('exif.longitude', 'is not', null),
|
||||||
)
|
)
|
||||||
.select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country'])
|
.select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country'])
|
||||||
|
.$narrowType<{ lat: NotNull; lon: NotNull }>()
|
||||||
.where('isVisible', '=', true)
|
.where('isVisible', '=', true)
|
||||||
.$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!))
|
.$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!))
|
||||||
.$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!))
|
.$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!))
|
||||||
@ -114,7 +115,7 @@ export class MapRepository {
|
|||||||
return eb.or(expression);
|
return eb.or(expression);
|
||||||
})
|
})
|
||||||
.orderBy('fileCreatedAt', 'desc')
|
.orderBy('fileCreatedAt', 'desc')
|
||||||
.execute() as Promise<MapMarker[]>;
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
|
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
|
||||||
|
@ -6,7 +6,7 @@ import fs from 'node:fs/promises';
|
|||||||
import { Writable } from 'node:stream';
|
import { Writable } from 'node:stream';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
|
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
import { Exif } from 'src/database';
|
||||||
import { Colorspace, LogLevel } from 'src/enum';
|
import { Colorspace, LogLevel } from 'src/enum';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import {
|
import {
|
||||||
@ -66,7 +66,7 @@ export class MediaRepository {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeExif(tags: Partial<ExifEntity>, output: string): Promise<boolean> {
|
async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const tagsToWrite: WriteTags = {
|
const tagsToWrite: WriteTags = {
|
||||||
ExifImageWidth: tags.exifImageWidth,
|
ExifImageWidth: tags.exifImageWidth,
|
||||||
|
@ -63,6 +63,18 @@ export class OAuthRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProfilePicture(url: string) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch picture: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: await response.arrayBuffer(),
|
||||||
|
contentType: response.headers.get('content-type'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async getClient({
|
private async getClient({
|
||||||
issuerUrl,
|
issuerUrl,
|
||||||
clientId,
|
clientId,
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely';
|
import { ExpressionBuilder, Insertable, Kysely, NotNull, Updateable } from 'kysely';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { columns, Partner } from 'src/database';
|
import { columns } from 'src/database';
|
||||||
import { DB, Partners } from 'src/db';
|
import { DB, Partners } from 'src/db';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
|
|
||||||
@ -44,7 +44,7 @@ export class PartnerRepository {
|
|||||||
return this.builder()
|
return this.builder()
|
||||||
.where('sharedWithId', '=', sharedWithId)
|
.where('sharedWithId', '=', sharedWithId)
|
||||||
.where('sharedById', '=', sharedById)
|
.where('sharedById', '=', sharedById)
|
||||||
.executeTakeFirst() as Promise<Partner | undefined>;
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] })
|
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] })
|
||||||
@ -55,7 +55,8 @@ export class PartnerRepository {
|
|||||||
.returningAll()
|
.returningAll()
|
||||||
.returning(withSharedBy)
|
.returning(withSharedBy)
|
||||||
.returning(withSharedWith)
|
.returning(withSharedWith)
|
||||||
.executeTakeFirstOrThrow() as Promise<Partner>;
|
.$narrowType<{ sharedWith: NotNull; sharedBy: NotNull }>()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }, { inTimeline: true }] })
|
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }, { inTimeline: true }] })
|
||||||
@ -68,7 +69,8 @@ export class PartnerRepository {
|
|||||||
.returningAll()
|
.returningAll()
|
||||||
.returning(withSharedBy)
|
.returning(withSharedBy)
|
||||||
.returning(withSharedWith)
|
.returning(withSharedWith)
|
||||||
.executeTakeFirstOrThrow() as Promise<Partner>;
|
.$narrowType<{ sharedWith: NotNull; sharedBy: NotNull }>()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] })
|
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] })
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ExpressionBuilder, Insertable, Kysely, Selectable, sql } from 'kysely';
|
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
|
import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
|
||||||
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFileType, SourceType } from 'src/enum';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
|
||||||
import { SourceType } from 'src/enum';
|
|
||||||
import { removeUndefinedKeys } from 'src/utils/database';
|
import { removeUndefinedKeys } from 'src/utils/database';
|
||||||
import { Paginated, PaginationOptions } from 'src/utils/pagination';
|
import { PaginationOptions } from 'src/utils/pagination';
|
||||||
|
|
||||||
export interface PersonSearchOptions {
|
export interface PersonSearchOptions {
|
||||||
minimumFaceCount: number;
|
minimumFaceCount: number;
|
||||||
@ -49,6 +47,19 @@ export interface DeleteFacesOptions {
|
|||||||
sourceType: SourceType;
|
sourceType: SourceType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GetAllPeopleOptions {
|
||||||
|
ownerId?: string;
|
||||||
|
thumbnailPath?: string;
|
||||||
|
faceAssetId?: string | null;
|
||||||
|
isHidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetAllFacesOptions {
|
||||||
|
personId?: string | null;
|
||||||
|
assetId?: string;
|
||||||
|
sourceType?: SourceType;
|
||||||
|
}
|
||||||
|
|
||||||
export type UnassignFacesOptions = DeleteFacesOptions;
|
export type UnassignFacesOptions = DeleteFacesOptions;
|
||||||
|
|
||||||
export type SelectFaceOptions = (keyof Selectable<AssetFaces>)[];
|
export type SelectFaceOptions = (keyof Selectable<AssetFaces>)[];
|
||||||
@ -98,20 +109,13 @@ export class PersonRepository {
|
|||||||
await this.vacuum({ reindexVectors: false });
|
await this.vacuum({ reindexVectors: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[{ id: DummyValue.UUID }]] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
async delete(entities: PersonEntity[]): Promise<void> {
|
async delete(ids: string[]): Promise<void> {
|
||||||
if (entities.length === 0) {
|
if (ids.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db
|
await this.db.deleteFrom('person').where('person.id', 'in', ids).execute();
|
||||||
.deleteFrom('person')
|
|
||||||
.where(
|
|
||||||
'person.id',
|
|
||||||
'in',
|
|
||||||
entities.map(({ id }) => id),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [{ sourceType: SourceType.EXIF }] })
|
@GenerateSql({ params: [{ sourceType: SourceType.EXIF }] })
|
||||||
@ -121,7 +125,7 @@ export class PersonRepository {
|
|||||||
await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING });
|
await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING });
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllFaces(options: Partial<AssetFaceEntity> = {}): AsyncIterableIterator<AssetFaceEntity> {
|
getAllFaces(options: GetAllFacesOptions = {}) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_faces')
|
.selectFrom('asset_faces')
|
||||||
.selectAll('asset_faces')
|
.selectAll('asset_faces')
|
||||||
@ -130,10 +134,10 @@ export class PersonRepository {
|
|||||||
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
|
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
|
||||||
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
|
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
|
||||||
.where('asset_faces.deletedAt', 'is', null)
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.stream() as AsyncIterableIterator<AssetFaceEntity>;
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
getAll(options: Partial<PersonEntity> = {}): AsyncIterableIterator<PersonEntity> {
|
getAll(options: GetAllPeopleOptions = {}) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.selectAll('person')
|
.selectAll('person')
|
||||||
@ -142,15 +146,11 @@ export class PersonRepository {
|
|||||||
.$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null))
|
.$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null))
|
||||||
.$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!))
|
.$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!))
|
||||||
.$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!))
|
.$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!))
|
||||||
.stream() as AsyncIterableIterator<PersonEntity>;
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllForUser(
|
async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) {
|
||||||
pagination: PaginationOptions,
|
const items = await this.db
|
||||||
userId: string,
|
|
||||||
options?: PersonSearchOptions,
|
|
||||||
): Paginated<PersonEntity> {
|
|
||||||
const items = (await this.db
|
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.selectAll('person')
|
.selectAll('person')
|
||||||
.innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
|
.innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
|
||||||
@ -198,7 +198,7 @@ export class PersonRepository {
|
|||||||
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
||||||
.offset(pagination.skip ?? 0)
|
.offset(pagination.skip ?? 0)
|
||||||
.limit(pagination.take + 1)
|
.limit(pagination.take + 1)
|
||||||
.execute()) as PersonEntity[];
|
.execute();
|
||||||
|
|
||||||
if (items.length > pagination.take) {
|
if (items.length > pagination.take) {
|
||||||
return { items: items.slice(0, -1), hasNextPage: true };
|
return { items: items.slice(0, -1), hasNextPage: true };
|
||||||
@ -208,7 +208,7 @@ export class PersonRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql()
|
@GenerateSql()
|
||||||
getAllWithoutFaces(): Promise<PersonEntity[]> {
|
getAllWithoutFaces() {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.selectAll('person')
|
.selectAll('person')
|
||||||
@ -216,11 +216,11 @@ export class PersonRepository {
|
|||||||
.where('asset_faces.deletedAt', 'is', null)
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0)
|
.having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0)
|
||||||
.groupBy('person.id')
|
.groupBy('person.id')
|
||||||
.execute() as Promise<PersonEntity[]>;
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getFaces(assetId: string): Promise<AssetFaceEntity[]> {
|
getFaces(assetId: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_faces')
|
.selectFrom('asset_faces')
|
||||||
.selectAll('asset_faces')
|
.selectAll('asset_faces')
|
||||||
@ -228,11 +228,11 @@ export class PersonRepository {
|
|||||||
.where('asset_faces.assetId', '=', assetId)
|
.where('asset_faces.assetId', '=', assetId)
|
||||||
.where('asset_faces.deletedAt', 'is', null)
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.orderBy('asset_faces.boundingBoxX1', 'asc')
|
.orderBy('asset_faces.boundingBoxX1', 'asc')
|
||||||
.execute() as Promise<AssetFaceEntity[]>;
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getFaceById(id: string): Promise<AssetFaceEntity> {
|
getFaceById(id: string) {
|
||||||
// TODO return null instead of find or fail
|
// TODO return null instead of find or fail
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_faces')
|
.selectFrom('asset_faces')
|
||||||
@ -240,25 +240,57 @@ export class PersonRepository {
|
|||||||
.select(withPerson)
|
.select(withPerson)
|
||||||
.where('asset_faces.id', '=', id)
|
.where('asset_faces.id', '=', id)
|
||||||
.where('asset_faces.deletedAt', 'is', null)
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.executeTakeFirstOrThrow() as Promise<AssetFaceEntity>;
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getFaceByIdWithAssets(
|
getFaceForFacialRecognitionJob(id: string) {
|
||||||
id: string,
|
|
||||||
relations?: { faceSearch?: boolean },
|
|
||||||
select?: SelectFaceOptions,
|
|
||||||
): Promise<AssetFaceEntity | undefined> {
|
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_faces')
|
.selectFrom('asset_faces')
|
||||||
.$if(!!select, (qb) => qb.select(select!))
|
.select(['asset_faces.id', 'asset_faces.personId', 'asset_faces.sourceType'])
|
||||||
.$if(!select, (qb) => qb.selectAll('asset_faces'))
|
.select((eb) =>
|
||||||
.select(withPerson)
|
jsonObjectFrom(
|
||||||
.select(withAsset)
|
eb
|
||||||
.$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch))
|
.selectFrom('assets')
|
||||||
|
.select(['assets.ownerId', 'assets.isArchived', 'assets.fileCreatedAt'])
|
||||||
|
.whereRef('assets.id', '=', 'asset_faces.assetId'),
|
||||||
|
).as('asset'),
|
||||||
|
)
|
||||||
|
.select(withFaceSearch)
|
||||||
.where('asset_faces.id', '=', id)
|
.where('asset_faces.id', '=', id)
|
||||||
.where('asset_faces.deletedAt', 'is', null)
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
getDataForThumbnailGenerationJob(id: string) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('person')
|
||||||
|
.innerJoin('asset_faces', 'asset_faces.id', 'person.faceAssetId')
|
||||||
|
.innerJoin('assets', 'asset_faces.assetId', 'assets.id')
|
||||||
|
.innerJoin('exif', 'exif.assetId', 'assets.id')
|
||||||
|
.innerJoin('asset_files', 'asset_files.assetId', 'assets.id')
|
||||||
|
.select([
|
||||||
|
'person.ownerId',
|
||||||
|
'asset_faces.boundingBoxX1 as x1',
|
||||||
|
'asset_faces.boundingBoxY1 as y1',
|
||||||
|
'asset_faces.boundingBoxX2 as x2',
|
||||||
|
'asset_faces.boundingBoxY2 as y2',
|
||||||
|
'asset_faces.imageWidth as oldWidth',
|
||||||
|
'asset_faces.imageHeight as oldHeight',
|
||||||
|
'exif.exifImageWidth',
|
||||||
|
'exif.exifImageHeight',
|
||||||
|
'assets.type',
|
||||||
|
'assets.originalPath',
|
||||||
|
'asset_files.path as previewPath',
|
||||||
|
])
|
||||||
|
.where('person.id', '=', id)
|
||||||
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
|
.where('asset_files.type', '=', AssetFileType.PREVIEW)
|
||||||
|
.where('exif.exifImageWidth', '>', 0)
|
||||||
|
.where('exif.exifImageHeight', '>', 0)
|
||||||
|
.$narrowType<{ exifImageWidth: NotNull; exifImageHeight: NotNull }>()
|
||||||
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||||
@ -272,16 +304,16 @@ export class PersonRepository {
|
|||||||
return Number(result.numChangedRows ?? 0);
|
return Number(result.numChangedRows ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
getById(personId: string): Promise<PersonEntity | null> {
|
getById(personId: string) {
|
||||||
return (this.db //
|
return this.db //
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.selectAll('person')
|
.selectAll('person')
|
||||||
.where('person.id', '=', personId)
|
.where('person.id', '=', personId)
|
||||||
.executeTakeFirst() ?? null) as Promise<PersonEntity | null>;
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
|
||||||
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
|
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.selectAll('person')
|
.selectAll('person')
|
||||||
@ -296,7 +328,7 @@ export class PersonRepository {
|
|||||||
)
|
)
|
||||||
.limit(1000)
|
.limit(1000)
|
||||||
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
||||||
.execute() as Promise<PersonEntity[]>;
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] })
|
@GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] })
|
||||||
@ -362,8 +394,8 @@ export class PersonRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
create(person: Insertable<Person>): Promise<PersonEntity> {
|
create(person: Insertable<Person>) {
|
||||||
return this.db.insertInto('person').values(person).returningAll().executeTakeFirst() as Promise<PersonEntity>;
|
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAll(people: Insertable<Person>[]): Promise<string[]> {
|
async createAll(people: Insertable<Person>[]): Promise<string[]> {
|
||||||
@ -399,13 +431,13 @@ export class PersonRepository {
|
|||||||
await query.selectFrom(sql`(select 1)`.as('dummy')).execute();
|
await query.selectFrom(sql`(select 1)`.as('dummy')).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(person: Partial<PersonEntity> & { id: string }): Promise<PersonEntity> {
|
async update(person: Updateable<Person> & { id: string }) {
|
||||||
return this.db
|
return this.db
|
||||||
.updateTable('person')
|
.updateTable('person')
|
||||||
.set(person)
|
.set(person)
|
||||||
.where('person.id', '=', person.id)
|
.where('person.id', '=', person.id)
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.executeTakeFirstOrThrow() as Promise<PersonEntity>;
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAll(people: Insertable<Person>[]): Promise<void> {
|
async updateAll(people: Insertable<Person>[]): Promise<void> {
|
||||||
@ -437,7 +469,7 @@ export class PersonRepository {
|
|||||||
|
|
||||||
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
|
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
|
||||||
@ChunkedArray()
|
@ChunkedArray()
|
||||||
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
|
getFacesByIds(ids: AssetFaceId[]) {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
@ -457,17 +489,17 @@ export class PersonRepository {
|
|||||||
.where('asset_faces.assetId', 'in', assetIds)
|
.where('asset_faces.assetId', 'in', assetIds)
|
||||||
.where('asset_faces.personId', 'in', personIds)
|
.where('asset_faces.personId', 'in', personIds)
|
||||||
.where('asset_faces.deletedAt', 'is', null)
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.execute() as Promise<AssetFaceEntity[]>;
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getRandomFace(personId: string): Promise<AssetFaceEntity | undefined> {
|
getRandomFace(personId: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_faces')
|
.selectFrom('asset_faces')
|
||||||
.selectAll('asset_faces')
|
.selectAll('asset_faces')
|
||||||
.where('asset_faces.personId', '=', personId)
|
.where('asset_faces.personId', '=', personId)
|
||||||
.where('asset_faces.deletedAt', 'is', null)
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql()
|
@GenerateSql()
|
||||||
|
@ -162,7 +162,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
|||||||
hasPerson?: boolean;
|
hasPerson?: boolean;
|
||||||
numResults: number;
|
numResults: number;
|
||||||
maxDistance: number;
|
maxDistance: number;
|
||||||
minBirthDate?: Date;
|
minBirthDate?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetDuplicateSearch {
|
export interface AssetDuplicateSearch {
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Insertable, Kysely, sql, Updateable } from 'kysely';
|
import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely';
|
||||||
|
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { columns, UserAdmin } from 'src/database';
|
import { columns } from 'src/database';
|
||||||
import { DB, UserMetadata as DbUserMetadata } from 'src/db';
|
import { DB, UserMetadata as DbUserMetadata } from 'src/db';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { UserEntity, withMetadata } from 'src/entities/user.entity';
|
|
||||||
import { AssetType, UserStatus } from 'src/enum';
|
import { AssetType, UserStatus } from 'src/enum';
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
import { UserMetadata, UserMetadataItem } from 'src/types';
|
import { UserMetadata, UserMetadataItem } from 'src/types';
|
||||||
@ -32,12 +32,21 @@ export interface UserFindOptions {
|
|||||||
withDeleted?: boolean;
|
withDeleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const withMetadata = (eb: ExpressionBuilder<DB, 'users'>) => {
|
||||||
|
return jsonArrayFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom('user_metadata')
|
||||||
|
.select(['user_metadata.key', 'user_metadata.value'])
|
||||||
|
.whereRef('users.id', '=', 'user_metadata.userId'),
|
||||||
|
).as('metadata');
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserRepository {
|
export class UserRepository {
|
||||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] })
|
||||||
get(userId: string, options: UserFindOptions): Promise<UserEntity | undefined> {
|
get(userId: string, options: UserFindOptions) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
|
|
||||||
return this.db
|
return this.db
|
||||||
@ -46,7 +55,7 @@ export class UserRepository {
|
|||||||
.select(withMetadata)
|
.select(withMetadata)
|
||||||
.where('users.id', '=', userId)
|
.where('users.id', '=', userId)
|
||||||
.$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
|
.$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
|
||||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
getMetadata(userId: string) {
|
getMetadata(userId: string) {
|
||||||
@ -58,13 +67,14 @@ export class UserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql()
|
@GenerateSql()
|
||||||
getAdmin(): Promise<UserEntity | undefined> {
|
getAdmin() {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
|
.select(withMetadata)
|
||||||
.where('users.isAdmin', '=', true)
|
.where('users.isAdmin', '=', true)
|
||||||
.where('users.deletedAt', 'is', null)
|
.where('users.deletedAt', 'is', null)
|
||||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql()
|
@GenerateSql()
|
||||||
@ -80,34 +90,36 @@ export class UserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.EMAIL] })
|
@GenerateSql({ params: [DummyValue.EMAIL] })
|
||||||
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | undefined> {
|
getByEmail(email: string, withPassword?: boolean) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
|
.select(withMetadata)
|
||||||
.$if(!!withPassword, (eb) => eb.select('password'))
|
.$if(!!withPassword, (eb) => eb.select('password'))
|
||||||
.where('email', '=', email)
|
.where('email', '=', email)
|
||||||
.where('users.deletedAt', 'is', null)
|
.where('users.deletedAt', 'is', null)
|
||||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.STRING] })
|
||||||
getByStorageLabel(storageLabel: string): Promise<UserEntity | undefined> {
|
getByStorageLabel(storageLabel: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
.where('users.storageLabel', '=', storageLabel)
|
.where('users.storageLabel', '=', storageLabel)
|
||||||
.where('users.deletedAt', 'is', null)
|
.where('users.deletedAt', 'is', null)
|
||||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.STRING] })
|
||||||
getByOAuthId(oauthId: string): Promise<UserEntity | undefined> {
|
getByOAuthId(oauthId: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
|
.select(withMetadata)
|
||||||
.where('users.oauthId', '=', oauthId)
|
.where('users.oauthId', '=', oauthId)
|
||||||
.where('users.deletedAt', 'is', null)
|
.where('users.deletedAt', 'is', null)
|
||||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DateTime.now().minus({ years: 1 })] })
|
@GenerateSql({ params: [DateTime.now().minus({ years: 1 })] })
|
||||||
@ -126,18 +138,19 @@ export class UserRepository {
|
|||||||
.select(withMetadata)
|
.select(withMetadata)
|
||||||
.$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
|
.$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
|
||||||
.orderBy('createdAt', 'desc')
|
.orderBy('createdAt', 'desc')
|
||||||
.execute() as Promise<UserAdmin[]>;
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: Insertable<UserTable>): Promise<UserEntity> {
|
async create(dto: Insertable<UserTable>) {
|
||||||
return this.db
|
return this.db
|
||||||
.insertInto('users')
|
.insertInto('users')
|
||||||
.values(dto)
|
.values(dto)
|
||||||
.returning(columns.userAdmin)
|
.returning(columns.userAdmin)
|
||||||
.executeTakeFirst() as unknown as Promise<UserEntity>;
|
.returning(withMetadata)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: string, dto: Updateable<UserTable>): Promise<UserEntity> {
|
update(id: string, dto: Updateable<UserTable>) {
|
||||||
return this.db
|
return this.db
|
||||||
.updateTable('users')
|
.updateTable('users')
|
||||||
.set(dto)
|
.set(dto)
|
||||||
@ -145,17 +158,17 @@ export class UserRepository {
|
|||||||
.where('users.deletedAt', 'is', null)
|
.where('users.deletedAt', 'is', null)
|
||||||
.returning(columns.userAdmin)
|
.returning(columns.userAdmin)
|
||||||
.returning(withMetadata)
|
.returning(withMetadata)
|
||||||
.executeTakeFirst() as unknown as Promise<UserEntity>;
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
restore(id: string): Promise<UserEntity> {
|
restore(id: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.updateTable('users')
|
.updateTable('users')
|
||||||
.set({ status: UserStatus.ACTIVE, deletedAt: null })
|
.set({ status: UserStatus.ACTIVE, deletedAt: null })
|
||||||
.where('users.id', '=', asUuid(id))
|
.where('users.id', '=', asUuid(id))
|
||||||
.returning(columns.userAdmin)
|
.returning(columns.userAdmin)
|
||||||
.returning(withMetadata)
|
.returning(withMetadata)
|
||||||
.executeTakeFirst() as unknown as Promise<UserEntity>;
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
|
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
|
||||||
@ -175,41 +188,41 @@ export class UserRepository {
|
|||||||
await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute();
|
await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(user: { id: string }, hard?: boolean): Promise<UserEntity> {
|
delete(user: { id: string }, hard?: boolean) {
|
||||||
return hard
|
return hard
|
||||||
? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise<UserEntity>)
|
? this.db.deleteFrom('users').where('id', '=', user.id).execute()
|
||||||
: (this.db
|
: this.db.updateTable('users').set({ deletedAt: new Date() }).where('id', '=', user.id).execute();
|
||||||
.updateTable('users')
|
|
||||||
.set({ deletedAt: new Date() })
|
|
||||||
.where('id', '=', user.id)
|
|
||||||
.execute() as unknown as Promise<UserEntity>);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql()
|
@GenerateSql()
|
||||||
async getUserStats(): Promise<UserStatsQueryResponse[]> {
|
getUserStats() {
|
||||||
const stats = (await this.db
|
return this.db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.leftJoin('assets', 'assets.ownerId', 'users.id')
|
.leftJoin('assets', 'assets.ownerId', 'users.id')
|
||||||
.leftJoin('exif', 'exif.assetId', 'assets.id')
|
.leftJoin('exif', 'exif.assetId', 'assets.id')
|
||||||
.select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes'])
|
.select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes'])
|
||||||
.select((eb) => [
|
.select((eb) => [
|
||||||
eb.fn
|
eb.fn
|
||||||
.countAll()
|
.countAll<number>()
|
||||||
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)]))
|
.filterWhere((eb) =>
|
||||||
|
eb.and([eb('assets.type', '=', sql.lit(AssetType.IMAGE)), eb('assets.isVisible', '=', sql.lit(true))]),
|
||||||
|
)
|
||||||
.as('photos'),
|
.as('photos'),
|
||||||
eb.fn
|
eb.fn
|
||||||
.countAll()
|
.countAll<number>()
|
||||||
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)]))
|
.filterWhere((eb) =>
|
||||||
|
eb.and([eb('assets.type', '=', sql.lit(AssetType.VIDEO)), eb('assets.isVisible', '=', sql.lit(true))]),
|
||||||
|
)
|
||||||
.as('videos'),
|
.as('videos'),
|
||||||
eb.fn
|
eb.fn
|
||||||
.coalesce(eb.fn.sum('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0))
|
.coalesce(eb.fn.sum<number>('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0))
|
||||||
.as('usage'),
|
.as('usage'),
|
||||||
eb.fn
|
eb.fn
|
||||||
.coalesce(
|
.coalesce(
|
||||||
eb.fn
|
eb.fn
|
||||||
.sum('exif.fileSizeInByte')
|
.sum<number>('exif.fileSizeInByte')
|
||||||
.filterWhere((eb) =>
|
.filterWhere((eb) =>
|
||||||
eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.IMAGE)]),
|
eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', sql.lit(AssetType.IMAGE))]),
|
||||||
),
|
),
|
||||||
eb.lit(0),
|
eb.lit(0),
|
||||||
)
|
)
|
||||||
@ -217,9 +230,9 @@ export class UserRepository {
|
|||||||
eb.fn
|
eb.fn
|
||||||
.coalesce(
|
.coalesce(
|
||||||
eb.fn
|
eb.fn
|
||||||
.sum('exif.fileSizeInByte')
|
.sum<number>('exif.fileSizeInByte')
|
||||||
.filterWhere((eb) =>
|
.filterWhere((eb) =>
|
||||||
eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.VIDEO)]),
|
eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', sql.lit(AssetType.VIDEO))]),
|
||||||
),
|
),
|
||||||
eb.lit(0),
|
eb.lit(0),
|
||||||
)
|
)
|
||||||
@ -228,17 +241,7 @@ export class UserRepository {
|
|||||||
.where('assets.deletedAt', 'is', null)
|
.where('assets.deletedAt', 'is', null)
|
||||||
.groupBy('users.id')
|
.groupBy('users.id')
|
||||||
.orderBy('users.createdAt', 'asc')
|
.orderBy('users.createdAt', 'asc')
|
||||||
.execute()) as UserStatsQueryResponse[];
|
.execute();
|
||||||
|
|
||||||
for (const stat of stats) {
|
|
||||||
stat.photos = Number(stat.photos);
|
|
||||||
stat.videos = Number(stat.videos);
|
|
||||||
stat.usage = Number(stat.usage);
|
|
||||||
stat.usagePhotos = Number(stat.usagePhotos);
|
|
||||||
stat.usageVideos = Number(stat.usageVideos);
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
|
||||||
|
@ -23,7 +23,7 @@ describe(ActivityService.name, () => {
|
|||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.search.mockResolvedValue([]);
|
mocks.activity.search.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(sut.getAll(factory.auth({ id: userId }), { assetId, albumId })).resolves.toEqual([]);
|
await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]);
|
||||||
|
|
||||||
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined });
|
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined });
|
||||||
});
|
});
|
||||||
@ -35,7 +35,7 @@ describe(ActivityService.name, () => {
|
|||||||
mocks.activity.search.mockResolvedValue([]);
|
mocks.activity.search.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.getAll(factory.auth({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }),
|
sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }),
|
||||||
).resolves.toEqual([]);
|
).resolves.toEqual([]);
|
||||||
|
|
||||||
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true });
|
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true });
|
||||||
@ -80,7 +80,7 @@ describe(ActivityService.name, () => {
|
|||||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.create.mockResolvedValue(activity);
|
mocks.activity.create.mockResolvedValue(activity);
|
||||||
|
|
||||||
await sut.create(factory.auth({ id: userId }), {
|
await sut.create(factory.auth({ user: { id: userId } }), {
|
||||||
albumId,
|
albumId,
|
||||||
assetId,
|
assetId,
|
||||||
type: ReactionType.COMMENT,
|
type: ReactionType.COMMENT,
|
||||||
@ -116,7 +116,7 @@ describe(ActivityService.name, () => {
|
|||||||
mocks.activity.create.mockResolvedValue(activity);
|
mocks.activity.create.mockResolvedValue(activity);
|
||||||
mocks.activity.search.mockResolvedValue([]);
|
mocks.activity.search.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.create(factory.auth({ id: userId }), { albumId, assetId, type: ReactionType.LIKE });
|
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
|
||||||
|
|
||||||
expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true });
|
expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true });
|
||||||
});
|
});
|
||||||
|
@ -7,13 +7,13 @@ import {
|
|||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
GetAlbumsDto,
|
GetAlbumsDto,
|
||||||
UpdateAlbumDto,
|
UpdateAlbumDto,
|
||||||
|
UpdateAlbumUserDto,
|
||||||
mapAlbum,
|
mapAlbum,
|
||||||
mapAlbumWithAssets,
|
mapAlbumWithAssets,
|
||||||
mapAlbumWithoutAssets,
|
mapAlbumWithoutAssets,
|
||||||
} from 'src/dtos/album.dto';
|
} from 'src/dtos/album.dto';
|
||||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AlbumUserEntity } from 'src/entities/album-user.entity';
|
|
||||||
import { AlbumEntity } from 'src/entities/album.entity';
|
import { AlbumEntity } from 'src/entities/album.entity';
|
||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
||||||
@ -247,7 +247,7 @@ export class AlbumService extends BaseService {
|
|||||||
await this.albumUserRepository.delete({ albumsId: id, usersId: userId });
|
await this.albumUserRepository.delete({ albumsId: id, usersId: userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial<AlbumUserEntity>): Promise<void> {
|
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
|
||||||
await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] });
|
||||||
await this.albumUserRepository.update({ albumsId: id, usersId: userId }, { role: dto.role });
|
await this.albumUserRepository.update({ albumsId: id, usersId: userId }, { role: dto.role });
|
||||||
}
|
}
|
||||||
|
@ -54,7 +54,7 @@ describe(ApiKeyService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if the api key does not have sufficient permissions', async () => {
|
it('should throw an error if the api key does not have sufficient permissions', async () => {
|
||||||
const auth = factory.auth({ apiKey: factory.authApiKey({ permissions: [Permission.ASSET_READ] }) });
|
const auth = factory.auth({ apiKey: { permissions: [Permission.ASSET_READ] } });
|
||||||
|
|
||||||
await expect(sut.create(auth, { permissions: [Permission.ASSET_UPDATE] })).rejects.toBeInstanceOf(
|
await expect(sut.create(auth, { permissions: [Permission.ASSET_UPDATE] })).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
@ -5,9 +5,9 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
|
import { AssetFile } from 'src/database';
|
||||||
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
||||||
import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
|
import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||||
import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
|
||||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
|
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum';
|
import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum';
|
||||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||||
@ -166,7 +166,7 @@ const assetEntity = Object.freeze({
|
|||||||
isArchived: false,
|
isArchived: false,
|
||||||
encodedVideoPath: '',
|
encodedVideoPath: '',
|
||||||
duration: '0:00:00.000000',
|
duration: '0:00:00.000000',
|
||||||
files: [] as AssetFileEntity[],
|
files: [] as AssetFile[],
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
latitude: 49.533_547,
|
latitude: 49.533_547,
|
||||||
longitude: 10.703_075,
|
longitude: 10.703_075,
|
||||||
@ -535,12 +535,9 @@ describe(AssetMediaService.name, () => {
|
|||||||
...assetStub.image,
|
...assetStub.image,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
assetId: assetStub.image.id,
|
|
||||||
createdAt: assetStub.image.fileCreatedAt,
|
|
||||||
id: '42',
|
id: '42',
|
||||||
path: '/path/to/preview',
|
path: '/path/to/preview',
|
||||||
type: AssetFileType.THUMBNAIL,
|
type: AssetFileType.THUMBNAIL,
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -555,12 +552,9 @@ describe(AssetMediaService.name, () => {
|
|||||||
...assetStub.image,
|
...assetStub.image,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
assetId: assetStub.image.id,
|
|
||||||
createdAt: assetStub.image.fileCreatedAt,
|
|
||||||
id: '42',
|
id: '42',
|
||||||
path: '/path/to/preview.jpg',
|
path: '/path/to/preview.jpg',
|
||||||
type: AssetFileType.PREVIEW,
|
type: AssetFileType.PREVIEW,
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
@ -88,7 +88,7 @@ describe(AssetService.name, () => {
|
|||||||
|
|
||||||
it('should get memories with partners with inTimeline enabled', async () => {
|
it('should get memories with partners with inTimeline enabled', async () => {
|
||||||
const partner = factory.partner();
|
const partner = factory.partner();
|
||||||
const auth = factory.auth({ id: partner.sharedWithId });
|
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||||
|
|
||||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
mocks.partner.getAll.mockResolvedValue([partner]);
|
||||||
mocks.asset.getByDayOfYear.mockResolvedValue([]);
|
mocks.asset.getByDayOfYear.mockResolvedValue([]);
|
||||||
@ -139,7 +139,7 @@ describe(AssetService.name, () => {
|
|||||||
|
|
||||||
it('should not include partner assets if not in timeline', async () => {
|
it('should not include partner assets if not in timeline', async () => {
|
||||||
const partner = factory.partner({ inTimeline: false });
|
const partner = factory.partner({ inTimeline: false });
|
||||||
const auth = factory.auth({ id: partner.sharedWithId });
|
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||||
|
|
||||||
mocks.asset.getRandom.mockResolvedValue([assetStub.image]);
|
mocks.asset.getRandom.mockResolvedValue([assetStub.image]);
|
||||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
mocks.partner.getAll.mockResolvedValue([partner]);
|
||||||
@ -151,7 +151,7 @@ describe(AssetService.name, () => {
|
|||||||
|
|
||||||
it('should include partner assets if in timeline', async () => {
|
it('should include partner assets if in timeline', async () => {
|
||||||
const partner = factory.partner({ inTimeline: true });
|
const partner = factory.partner({ inTimeline: true });
|
||||||
const auth = factory.auth({ id: partner.sharedWithId });
|
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||||
|
|
||||||
mocks.asset.getRandom.mockResolvedValue([assetStub.image]);
|
mocks.asset.getRandom.mockResolvedValue([assetStub.image]);
|
||||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
mocks.partner.getAll.mockResolvedValue([partner]);
|
||||||
|
@ -43,7 +43,7 @@ export class AssetService extends BaseService {
|
|||||||
yearsAgo,
|
yearsAgo,
|
||||||
// TODO move this to clients
|
// TODO move this to clients
|
||||||
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
|
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
|
||||||
assets: assets.map((asset) => mapAsset(asset as AssetEntity, { auth })),
|
assets: assets.map((asset) => mapAsset(asset as unknown as AssetEntity, { auth })),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,34 @@
|
|||||||
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { UserAdmin } from 'src/database';
|
||||||
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
|
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
|
||||||
import { AuthType, Permission } from 'src/enum';
|
import { AuthType, Permission } from 'src/enum';
|
||||||
import { AuthService } from 'src/services/auth.service';
|
import { AuthService } from 'src/services/auth.service';
|
||||||
import { UserMetadataItem } from 'src/types';
|
import { UserMetadataItem } from 'src/types';
|
||||||
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { factory, newUuid } from 'test/small.factory';
|
||||||
import { factory } from 'test/small.factory';
|
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
const oauthResponse = {
|
const oauthResponse = ({
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
profileImagePath,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
profileImagePath?: string;
|
||||||
|
}) => ({
|
||||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||||
userId: 'user-id',
|
userId: id,
|
||||||
userEmail: 'immich@test.com',
|
userEmail: email,
|
||||||
name: 'immich_name',
|
name,
|
||||||
profileImagePath: '',
|
profileImagePath,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
shouldChangePassword: false,
|
shouldChangePassword: false,
|
||||||
};
|
});
|
||||||
|
|
||||||
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||||
|
|
||||||
@ -39,15 +48,7 @@ const fixtures = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const oauthUserWithDefaultQuota = {
|
describe(AuthService.name, () => {
|
||||||
email,
|
|
||||||
name: ' ',
|
|
||||||
oauthId: sub,
|
|
||||||
quotaSizeInBytes: '1073741824',
|
|
||||||
storageLabel: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('AuthService', () => {
|
|
||||||
let sut: AuthService;
|
let sut: AuthService;
|
||||||
let mocks: ServiceMocks;
|
let mocks: ServiceMocks;
|
||||||
|
|
||||||
@ -89,7 +90,7 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should check the user has a password', async () => {
|
it('should check the user has a password', async () => {
|
||||||
mocks.user.getByEmail.mockResolvedValue({} as UserEntity);
|
mocks.user.getByEmail.mockResolvedValue({} as UserAdmin);
|
||||||
|
|
||||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
|
||||||
@ -97,7 +98,7 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should successfully log the user in', async () => {
|
it('should successfully log the user in', async () => {
|
||||||
const user = { ...factory.user(), password: 'immich_password' } as UserEntity;
|
const user = { ...(factory.user() as UserAdmin), password: 'immich_password' };
|
||||||
const session = factory.session();
|
const session = factory.session();
|
||||||
mocks.user.getByEmail.mockResolvedValue(user);
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(session);
|
mocks.session.create.mockResolvedValue(session);
|
||||||
@ -118,14 +119,12 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
describe('changePassword', () => {
|
describe('changePassword', () => {
|
||||||
it('should change the password', async () => {
|
it('should change the password', async () => {
|
||||||
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
|
const user = factory.userAdmin();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||||
|
|
||||||
mocks.user.getByEmail.mockResolvedValue({
|
mocks.user.getByEmail.mockResolvedValue({ ...user, password: 'hash-password' });
|
||||||
email: 'test@immich.com',
|
mocks.user.update.mockResolvedValue(user);
|
||||||
password: 'hash-password',
|
|
||||||
} as UserEntity);
|
|
||||||
mocks.user.update.mockResolvedValue(userStub.user1);
|
|
||||||
|
|
||||||
await sut.changePassword(auth, dto);
|
await sut.changePassword(auth, dto);
|
||||||
|
|
||||||
@ -143,7 +142,7 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when password does not match existing password', async () => {
|
it('should throw when password does not match existing password', async () => {
|
||||||
const auth = { user: { email: 'test@imimch.com' } as UserEntity };
|
const auth = { user: { email: 'test@imimch.com' } as UserAdmin };
|
||||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||||
|
|
||||||
mocks.crypto.compareBcrypt.mockReturnValue(false);
|
mocks.crypto.compareBcrypt.mockReturnValue(false);
|
||||||
@ -151,7 +150,7 @@ describe('AuthService', () => {
|
|||||||
mocks.user.getByEmail.mockResolvedValue({
|
mocks.user.getByEmail.mockResolvedValue({
|
||||||
email: 'test@immich.com',
|
email: 'test@immich.com',
|
||||||
password: 'hash-password',
|
password: 'hash-password',
|
||||||
} as UserEntity);
|
} as UserAdmin & { password: string });
|
||||||
|
|
||||||
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
@ -163,7 +162,7 @@ describe('AuthService', () => {
|
|||||||
mocks.user.getByEmail.mockResolvedValue({
|
mocks.user.getByEmail.mockResolvedValue({
|
||||||
email: 'test@immich.com',
|
email: 'test@immich.com',
|
||||||
password: '',
|
password: '',
|
||||||
} as UserEntity);
|
} as UserAdmin & { password: string });
|
||||||
|
|
||||||
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
@ -217,7 +216,7 @@ describe('AuthService', () => {
|
|||||||
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' };
|
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' };
|
||||||
|
|
||||||
it('should only allow one admin', async () => {
|
it('should only allow one admin', async () => {
|
||||||
mocks.user.getAdmin.mockResolvedValue({} as UserEntity);
|
mocks.user.getAdmin.mockResolvedValue({} as UserAdmin);
|
||||||
|
|
||||||
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
@ -231,7 +230,7 @@ describe('AuthService', () => {
|
|||||||
id: 'admin',
|
id: 'admin',
|
||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
metadata: [] as UserMetadataItem[],
|
metadata: [] as UserMetadataItem[],
|
||||||
} as UserEntity);
|
} as unknown as UserAdmin);
|
||||||
|
|
||||||
await expect(sut.adminSignUp(dto)).resolves.toMatchObject({
|
await expect(sut.adminSignUp(dto)).resolves.toMatchObject({
|
||||||
avatarColor: expect.any(String),
|
avatarColor: expect.any(String),
|
||||||
@ -294,7 +293,7 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not accept an expired key', async () => {
|
it('should not accept an expired key', async () => {
|
||||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.authenticate({
|
sut.authenticate({
|
||||||
@ -306,7 +305,7 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not accept a key on a non-shared route', async () => {
|
it('should not accept a key on a non-shared route', async () => {
|
||||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.authenticate({
|
sut.authenticate({
|
||||||
@ -318,7 +317,7 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not accept a key without a user', async () => {
|
it('should not accept a key without a user', async () => {
|
||||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired as any);
|
||||||
mocks.user.get.mockResolvedValue(void 0);
|
mocks.user.get.mockResolvedValue(void 0);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@ -331,37 +330,39 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should accept a base64url key', async () => {
|
it('should accept a base64url key', async () => {
|
||||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
const user = factory.userAdmin();
|
||||||
mocks.user.get.mockResolvedValue(userStub.admin);
|
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
||||||
|
|
||||||
|
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||||
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.authenticate({
|
sut.authenticate({
|
||||||
headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') },
|
headers: { 'x-immich-share-key': sharedLink.key.toString('base64url') },
|
||||||
queryParams: {},
|
queryParams: {},
|
||||||
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
|
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({ user, sharedLink });
|
||||||
user: userStub.admin,
|
|
||||||
sharedLink: sharedLinkStub.valid,
|
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key);
|
||||||
});
|
|
||||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept a hex key', async () => {
|
it('should accept a hex key', async () => {
|
||||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
const user = factory.userAdmin();
|
||||||
mocks.user.get.mockResolvedValue(userStub.admin);
|
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
||||||
|
|
||||||
|
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||||
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.authenticate({
|
sut.authenticate({
|
||||||
headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') },
|
headers: { 'x-immich-share-key': sharedLink.key.toString('hex') },
|
||||||
queryParams: {},
|
queryParams: {},
|
||||||
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
|
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({ user, sharedLink });
|
||||||
user: userStub.admin,
|
|
||||||
sharedLink: sharedLinkStub.valid,
|
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key);
|
||||||
});
|
|
||||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -533,24 +534,28 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should link an existing user', async () => {
|
it('should link an existing user', async () => {
|
||||||
|
const user = factory.userAdmin();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.user.getByEmail.mockResolvedValue(userStub.user1);
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue(userStub.user1);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||||
oauthResponse,
|
oauthResponse(user),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub });
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not link to a user with a different oauth sub', async () => {
|
it('should not link to a user with a different oauth sub', async () => {
|
||||||
|
const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
mocks.user.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' });
|
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
||||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
mocks.user.getAdmin.mockResolvedValue(user);
|
||||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow(
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@ -561,14 +566,16 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should allow auto registering by default', async () => {
|
it('should allow auto registering by default', async () => {
|
||||||
|
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||||
oauthResponse,
|
oauthResponse(user),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
||||||
@ -576,10 +583,12 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
|
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
|
||||||
|
const user = factory.userAdmin({ isAdmin: true });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
mocks.user.getAdmin.mockResolvedValue(user);
|
||||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
||||||
|
|
||||||
@ -600,8 +609,10 @@ describe('AuthService', () => {
|
|||||||
'app.immich:///oauth-callback?code=abc123',
|
'app.immich:///oauth-callback?code=abc123',
|
||||||
]) {
|
]) {
|
||||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||||
|
const user = factory.userAdmin();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(userStub.user1);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await sut.callback({ url }, loginDetails);
|
await sut.callback({ url }, loginDetails);
|
||||||
@ -611,100 +622,162 @@ describe('AuthService', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('should use the default quota', async () => {
|
it('should use the default quota', async () => {
|
||||||
|
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||||
oauthResponse,
|
oauthResponse(user),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
|
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore an invalid storage quota', async () => {
|
it('should ignore an invalid storage quota', async () => {
|
||||||
|
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
|
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
|
||||||
|
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' });
|
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||||
oauthResponse,
|
oauthResponse(user),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
|
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore a negative quota', async () => {
|
it('should ignore a negative quota', async () => {
|
||||||
|
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
|
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
|
||||||
|
mocks.user.getAdmin.mockResolvedValue(user);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 });
|
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||||
oauthResponse,
|
oauthResponse(user),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
|
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not set quota for 0 quota', async () => {
|
it('should not set quota for 0 quota', async () => {
|
||||||
|
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
|
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
|
||||||
|
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 });
|
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||||
oauthResponse,
|
oauthResponse(user),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||||
email,
|
email: user.email,
|
||||||
name: ' ',
|
name: ' ',
|
||||||
oauthId: sub,
|
oauthId: user.oauthId,
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
storageLabel: null,
|
storageLabel: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use a valid storage quota', async () => {
|
it('should use a valid storage quota', async () => {
|
||||||
|
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
|
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 });
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||||
oauthResponse,
|
oauthResponse(user),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||||
email,
|
email: user.email,
|
||||||
name: ' ',
|
name: ' ',
|
||||||
oauthId: sub,
|
oauthId: user.oauthId,
|
||||||
quotaSizeInBytes: 5_368_709_120,
|
quotaSizeInBytes: 5_368_709_120,
|
||||||
storageLabel: null,
|
storageLabel: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should sync the profile picture', async () => {
|
||||||
|
const fileId = newUuid();
|
||||||
|
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||||
|
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
|
||||||
|
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
|
mocks.oauth.getProfile.mockResolvedValue({
|
||||||
|
sub: user.oauthId,
|
||||||
|
email: user.email,
|
||||||
|
picture: pictureUrl,
|
||||||
|
});
|
||||||
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
|
mocks.crypto.randomUUID.mockReturnValue(fileId);
|
||||||
|
mocks.oauth.getProfilePicture.mockResolvedValue({
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
data: new Uint8Array([1, 2, 3, 4, 5]),
|
||||||
|
});
|
||||||
|
mocks.user.update.mockResolvedValue(user);
|
||||||
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||||
|
oauthResponse(user),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
|
||||||
|
profileImagePath: `upload/profile/${user.id}/${fileId}.jpg`,
|
||||||
|
profileChangedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sync the profile picture if the user already has one', async () => {
|
||||||
|
const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
|
||||||
|
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
|
mocks.oauth.getProfile.mockResolvedValue({
|
||||||
|
sub: user.oauthId,
|
||||||
|
email: user.email,
|
||||||
|
picture: 'https://auth.immich.cloud/profiles/1.jpg',
|
||||||
|
});
|
||||||
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
|
mocks.user.update.mockResolvedValue(user);
|
||||||
|
mocks.session.create.mockResolvedValue(factory.session());
|
||||||
|
|
||||||
|
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||||
|
oauthResponse(user),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('link', () => {
|
describe('link', () => {
|
||||||
it('should link an account', async () => {
|
it('should link an account', async () => {
|
||||||
const authUser = factory.authUser();
|
const user = factory.userAdmin();
|
||||||
const authApiKey = factory.authApiKey({ permissions: [] });
|
const auth = factory.auth({ apiKey: { permissions: [] }, user });
|
||||||
const auth = { user: authUser, apiKey: authApiKey };
|
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.user.update.mockResolvedValue(userStub.user1);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
|
|
||||||
await sut.link(auth, { url: 'http://immich/user-settings?code=abc123' });
|
await sut.link(auth, { url: 'http://immich/user-settings?code=abc123' });
|
||||||
|
|
||||||
@ -717,7 +790,7 @@ describe('AuthService', () => {
|
|||||||
const auth = { user: authUser, apiKey: authApiKey };
|
const auth = { user: authUser, apiKey: authApiKey };
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
|
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
|
||||||
|
|
||||||
await expect(sut.link(auth, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
|
await expect(sut.link(auth, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@ -729,12 +802,11 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
describe('unlink', () => {
|
describe('unlink', () => {
|
||||||
it('should unlink an account', async () => {
|
it('should unlink an account', async () => {
|
||||||
const authUser = factory.authUser();
|
const user = factory.userAdmin();
|
||||||
const authApiKey = factory.authApiKey({ permissions: [] });
|
const auth = factory.auth({ user, apiKey: { permissions: [] } });
|
||||||
const auth = { user: authUser, apiKey: authApiKey };
|
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.user.update.mockResolvedValue(userStub.user1);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
|
|
||||||
await sut.unlink(auth);
|
await sut.unlink(auth);
|
||||||
|
|
||||||
|
@ -3,7 +3,10 @@ import { isString } from 'class-validator';
|
|||||||
import { parse } from 'cookie';
|
import { parse } from 'cookie';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { IncomingHttpHeaders } from 'node:http';
|
import { IncomingHttpHeaders } from 'node:http';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
||||||
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { UserAdmin } from 'src/database';
|
||||||
import { OnEvent } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import {
|
import {
|
||||||
AuthDto,
|
AuthDto,
|
||||||
@ -17,13 +20,12 @@ import {
|
|||||||
mapLoginResponse,
|
mapLoginResponse,
|
||||||
} from 'src/dtos/auth.dto';
|
} from 'src/dtos/auth.dto';
|
||||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission, StorageFolder } from 'src/enum';
|
||||||
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum';
|
|
||||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { isGranted } from 'src/utils/access';
|
import { isGranted } from 'src/utils/access';
|
||||||
import { HumanReadableSize } from 'src/utils/bytes';
|
import { HumanReadableSize } from 'src/utils/bytes';
|
||||||
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
export interface LoginDetails {
|
export interface LoginDetails {
|
||||||
isSecure: boolean;
|
isSecure: boolean;
|
||||||
clientIp: string;
|
clientIp: string;
|
||||||
@ -190,7 +192,7 @@ export class AuthService extends BaseService {
|
|||||||
const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.resolveRedirectUri(oauth, dto.url));
|
const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.resolveRedirectUri(oauth, dto.url));
|
||||||
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
|
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
|
||||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||||
let user = await this.userRepository.getByOAuthId(profile.sub);
|
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
|
||||||
|
|
||||||
// link by email
|
// link by email
|
||||||
if (!user && profile.email) {
|
if (!user && profile.email) {
|
||||||
@ -239,9 +241,36 @@ export class AuthService extends BaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.profileImagePath && profile.picture) {
|
||||||
|
await this.syncProfilePicture(user, profile.picture);
|
||||||
|
}
|
||||||
|
|
||||||
return this.createLoginResponse(user, loginDetails);
|
return this.createLoginResponse(user, loginDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async syncProfilePicture(user: UserAdmin, url: string) {
|
||||||
|
try {
|
||||||
|
const oldPath = user.profileImagePath;
|
||||||
|
|
||||||
|
const { contentType, data } = await this.oauthRepository.getProfilePicture(url);
|
||||||
|
const extensionWithDot = mimeTypes.toExtension(contentType || 'image/jpeg') ?? 'jpg';
|
||||||
|
const profileImagePath = join(
|
||||||
|
StorageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
|
||||||
|
`${this.cryptoRepository.randomUUID()}${extensionWithDot}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.storageCore.ensureFolders(profileImagePath);
|
||||||
|
await this.storageRepository.createFile(profileImagePath, Buffer.from(data));
|
||||||
|
await this.userRepository.update(user.id, { profileImagePath, profileChangedAt: new Date() });
|
||||||
|
|
||||||
|
if (oldPath) {
|
||||||
|
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldPath] } });
|
||||||
|
}
|
||||||
|
} catch (error: Error | any) {
|
||||||
|
this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
|
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
|
||||||
const { oauth } = await this.getConfig({ withCache: false });
|
const { oauth } = await this.getConfig({ withCache: false });
|
||||||
const { sub: oauthId } = await this.oauthRepository.getProfile(
|
const { sub: oauthId } = await this.oauthRepository.getProfile(
|
||||||
@ -318,7 +347,7 @@ export class AuthService extends BaseService {
|
|||||||
throw new UnauthorizedException('Invalid API key');
|
throw new UnauthorizedException('Invalid API key');
|
||||||
}
|
}
|
||||||
|
|
||||||
private validatePassword(inputPassword: string, user: UserEntity): boolean {
|
private validatePassword(inputPassword: string, user: { password?: string }): boolean {
|
||||||
if (!user || !user.password) {
|
if (!user || !user.password) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -347,7 +376,7 @@ export class AuthService extends BaseService {
|
|||||||
throw new UnauthorizedException('Invalid user token');
|
throw new UnauthorizedException('Invalid user token');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createLoginResponse(user: UserEntity, loginDetails: LoginDetails) {
|
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
|
||||||
const key = this.cryptoRepository.newPassword(32);
|
const key = this.cryptoRepository.newPassword(32);
|
||||||
const token = this.cryptoRepository.hashSha256(key);
|
const token = this.cryptoRepository.hashSha256(key);
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import sanitize from 'sanitize-filename';
|
|||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
import { SALT_ROUNDS } from 'src/constants';
|
import { SALT_ROUNDS } from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserAdmin } from 'src/database';
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||||
@ -138,7 +138,7 @@ export class BaseService {
|
|||||||
return checkAccess(this.accessRepository, request);
|
return checkAccess(this.accessRepository, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserEntity> {
|
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserAdmin> {
|
||||||
const user = await this.userRepository.getByEmail(dto.email);
|
const user = await this.userRepository.getByEmail(dto.email);
|
||||||
if (user) {
|
if (user) {
|
||||||
throw new BadRequestException('User exists');
|
throw new BadRequestException('User exists');
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { CliService } from 'src/services/cli.service';
|
import { CliService } from 'src/services/cli.service';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { factory } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ describe(CliService.name, () => {
|
|||||||
|
|
||||||
describe('listUsers', () => {
|
describe('listUsers', () => {
|
||||||
it('should list users', async () => {
|
it('should list users', async () => {
|
||||||
mocks.user.getList.mockResolvedValue([userStub.admin]);
|
mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]);
|
||||||
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
|
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
|
||||||
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true });
|
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true });
|
||||||
});
|
});
|
||||||
@ -30,8 +30,10 @@ describe(CliService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should default to a random password', async () => {
|
it('should default to a random password', async () => {
|
||||||
mocks.user.getAdmin.mockResolvedValue(userStub.admin);
|
const admin = factory.userAdmin({ isAdmin: true });
|
||||||
mocks.user.update.mockResolvedValue(userStub.admin);
|
|
||||||
|
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||||
|
mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||||
|
|
||||||
const ask = vitest.fn().mockImplementation(() => {});
|
const ask = vitest.fn().mockImplementation(() => {});
|
||||||
|
|
||||||
@ -41,13 +43,15 @@ describe(CliService.name, () => {
|
|||||||
|
|
||||||
expect(response.provided).toBe(false);
|
expect(response.provided).toBe(false);
|
||||||
expect(ask).toHaveBeenCalled();
|
expect(ask).toHaveBeenCalled();
|
||||||
expect(id).toEqual(userStub.admin.id);
|
expect(id).toEqual(admin.id);
|
||||||
expect(update.password).toBeDefined();
|
expect(update.password).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use the supplied password', async () => {
|
it('should use the supplied password', async () => {
|
||||||
mocks.user.getAdmin.mockResolvedValue(userStub.admin);
|
const admin = factory.userAdmin({ isAdmin: true });
|
||||||
mocks.user.update.mockResolvedValue(userStub.admin);
|
|
||||||
|
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||||
|
mocks.user.update.mockResolvedValue(admin);
|
||||||
|
|
||||||
const ask = vitest.fn().mockResolvedValue('new-password');
|
const ask = vitest.fn().mockResolvedValue('new-password');
|
||||||
|
|
||||||
@ -57,7 +61,7 @@ describe(CliService.name, () => {
|
|||||||
|
|
||||||
expect(response.provided).toBe(true);
|
expect(response.provided).toBe(true);
|
||||||
expect(ask).toHaveBeenCalled();
|
expect(ask).toHaveBeenCalled();
|
||||||
expect(id).toEqual(userStub.admin.id);
|
expect(id).toEqual(admin.id);
|
||||||
expect(update.password).toBeDefined();
|
expect(update.password).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -35,7 +35,7 @@ describe(MapService.name, () => {
|
|||||||
|
|
||||||
it('should include partner assets', async () => {
|
it('should include partner assets', async () => {
|
||||||
const partner = factory.partner();
|
const partner = factory.partner();
|
||||||
const auth = factory.auth({ id: partner.sharedWithId });
|
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||||
|
|
||||||
const asset = assetStub.withLocation;
|
const asset = assetStub.withLocation;
|
||||||
const marker = {
|
const marker = {
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { OutputInfo } from 'sharp';
|
import { OutputInfo } from 'sharp';
|
||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
|
import { Exif } from 'src/database';
|
||||||
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
|
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
|
||||||
import {
|
import {
|
||||||
AssetFileType,
|
AssetFileType,
|
||||||
AssetPathType,
|
AssetPathType,
|
||||||
@ -319,7 +319,7 @@ describe(MediaService.name, () => {
|
|||||||
it('should generate P3 thumbnails for a wide gamut image', async () => {
|
it('should generate P3 thumbnails for a wide gamut image', async () => {
|
||||||
mocks.asset.getById.mockResolvedValue({
|
mocks.asset.getById.mockResolvedValue({
|
||||||
...assetStub.image,
|
...assetStub.image,
|
||||||
exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity,
|
exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as Exif,
|
||||||
});
|
});
|
||||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||||
@ -2608,47 +2608,47 @@ describe(MediaService.name, () => {
|
|||||||
|
|
||||||
describe('isSRGB', () => {
|
describe('isSRGB', () => {
|
||||||
it('should return true for srgb colorspace', () => {
|
it('should return true for srgb colorspace', () => {
|
||||||
const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as ExifEntity };
|
const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as Exif };
|
||||||
expect(sut.isSRGB(asset)).toEqual(true);
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for srgb profile description', () => {
|
it('should return true for srgb profile description', () => {
|
||||||
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as ExifEntity };
|
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as Exif };
|
||||||
expect(sut.isSRGB(asset)).toEqual(true);
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for 8-bit image with no colorspace metadata', () => {
|
it('should return true for 8-bit image with no colorspace metadata', () => {
|
||||||
const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as ExifEntity };
|
const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as Exif };
|
||||||
expect(sut.isSRGB(asset)).toEqual(true);
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for image with no colorspace or bit depth metadata', () => {
|
it('should return true for image with no colorspace or bit depth metadata', () => {
|
||||||
const asset = { ...assetStub.image, exifInfo: {} as ExifEntity };
|
const asset = { ...assetStub.image, exifInfo: {} as Exif };
|
||||||
expect(sut.isSRGB(asset)).toEqual(true);
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for non-srgb colorspace', () => {
|
it('should return false for non-srgb colorspace', () => {
|
||||||
const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as ExifEntity };
|
const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as Exif };
|
||||||
expect(sut.isSRGB(asset)).toEqual(false);
|
expect(sut.isSRGB(asset)).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for non-srgb profile description', () => {
|
it('should return false for non-srgb profile description', () => {
|
||||||
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as ExifEntity };
|
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as Exif };
|
||||||
expect(sut.isSRGB(asset)).toEqual(false);
|
expect(sut.isSRGB(asset)).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false for 16-bit image with no colorspace metadata', () => {
|
it('should return false for 16-bit image with no colorspace metadata', () => {
|
||||||
const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as ExifEntity };
|
const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as Exif };
|
||||||
expect(sut.isSRGB(asset)).toEqual(false);
|
expect(sut.isSRGB(asset)).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for 16-bit image with sRGB colorspace', () => {
|
it('should return true for 16-bit image with sRGB colorspace', () => {
|
||||||
const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as ExifEntity };
|
const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as Exif };
|
||||||
expect(sut.isSRGB(asset)).toEqual(true);
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for 16-bit image with sRGB profile', () => {
|
it('should return true for 16-bit image with sRGB profile', () => {
|
||||||
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as ExifEntity };
|
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as Exif };
|
||||||
expect(sut.isSRGB(asset)).toEqual(true);
|
expect(sut.isSRGB(asset)).toEqual(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -24,7 +24,7 @@ describe(MemoryService.name, () => {
|
|||||||
|
|
||||||
mocks.memory.search.mockResolvedValue([memory1, memory2]);
|
mocks.memory.search.mockResolvedValue([memory1, memory2]);
|
||||||
|
|
||||||
await expect(sut.search(factory.auth({ id: userId }), {})).resolves.toEqual(
|
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ id: memory1.id, assets: [expect.objectContaining({ id: asset.id })] }),
|
expect.objectContaining({ id: memory1.id, assets: [expect.objectContaining({ id: asset.id })] }),
|
||||||
expect.objectContaining({ id: memory2.id, assets: [] }),
|
expect.objectContaining({ id: memory2.id, assets: [] }),
|
||||||
@ -60,7 +60,9 @@ describe(MemoryService.name, () => {
|
|||||||
mocks.memory.get.mockResolvedValue(memory);
|
mocks.memory.get.mockResolvedValue(memory);
|
||||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||||
|
|
||||||
await expect(sut.get(factory.auth({ id: userId }), memory.id)).resolves.toMatchObject({ id: memory.id });
|
await expect(sut.get(factory.auth({ user: { id: userId } }), memory.id)).resolves.toMatchObject({
|
||||||
|
id: memory.id,
|
||||||
|
});
|
||||||
|
|
||||||
expect(mocks.memory.get).toHaveBeenCalledWith(memory.id);
|
expect(mocks.memory.get).toHaveBeenCalledWith(memory.id);
|
||||||
expect(mocks.access.memory.checkOwnerAccess).toHaveBeenCalledWith(memory.ownerId, new Set([memory.id]));
|
expect(mocks.access.memory.checkOwnerAccess).toHaveBeenCalledWith(memory.ownerId, new Set([memory.id]));
|
||||||
@ -75,7 +77,7 @@ describe(MemoryService.name, () => {
|
|||||||
mocks.memory.create.mockResolvedValue(memory);
|
mocks.memory.create.mockResolvedValue(memory);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(factory.auth({ id: userId }), {
|
sut.create(factory.auth({ user: { id: userId } }), {
|
||||||
type: memory.type,
|
type: memory.type,
|
||||||
data: memory.data,
|
data: memory.data,
|
||||||
memoryAt: memory.memoryAt,
|
memoryAt: memory.memoryAt,
|
||||||
@ -105,7 +107,7 @@ describe(MemoryService.name, () => {
|
|||||||
mocks.memory.create.mockResolvedValue(memory);
|
mocks.memory.create.mockResolvedValue(memory);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(factory.auth({ id: userId }), {
|
sut.create(factory.auth({ user: { id: userId } }), {
|
||||||
type: memory.type,
|
type: memory.type,
|
||||||
data: memory.data,
|
data: memory.data,
|
||||||
assetIds: memory.assets.map((asset) => asset.id),
|
assetIds: memory.assets.map((asset) => asset.id),
|
||||||
|
@ -3,8 +3,8 @@ import { randomBytes } from 'node:crypto';
|
|||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { constants } from 'node:fs/promises';
|
import { constants } from 'node:fs/promises';
|
||||||
import { defaults } from 'src/config';
|
import { defaults } from 'src/config';
|
||||||
|
import { Exif } from 'src/database';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
|
||||||
import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
|
import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
|
||||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
import { WithoutProperty } from 'src/repositories/asset.repository';
|
||||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||||
@ -12,12 +12,34 @@ import { MetadataService } from 'src/services/metadata.service';
|
|||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { fileStub } from 'test/fixtures/file.stub';
|
import { fileStub } from 'test/fixtures/file.stub';
|
||||||
import { probeStub } from 'test/fixtures/media.stub';
|
import { probeStub } from 'test/fixtures/media.stub';
|
||||||
import { metadataStub } from 'test/fixtures/metadata.stub';
|
|
||||||
import { personStub } from 'test/fixtures/person.stub';
|
import { personStub } from 'test/fixtures/person.stub';
|
||||||
import { tagStub } from 'test/fixtures/tag.stub';
|
import { tagStub } from 'test/fixtures/tag.stub';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
|
const makeFaceTags = (face: Partial<{ Name: string }> = {}) => ({
|
||||||
|
RegionInfo: {
|
||||||
|
AppliedToDimensions: {
|
||||||
|
W: 100,
|
||||||
|
H: 100,
|
||||||
|
Unit: 'normalized',
|
||||||
|
},
|
||||||
|
RegionList: [
|
||||||
|
{
|
||||||
|
Type: 'face',
|
||||||
|
Area: {
|
||||||
|
X: 0.05,
|
||||||
|
Y: 0.05,
|
||||||
|
W: 0.1,
|
||||||
|
H: 0.1,
|
||||||
|
Unit: 'normalized',
|
||||||
|
},
|
||||||
|
...face,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
describe(MetadataService.name, () => {
|
describe(MetadataService.name, () => {
|
||||||
let sut: MetadataService;
|
let sut: MetadataService;
|
||||||
let mocks: ServiceMocks;
|
let mocks: ServiceMocks;
|
||||||
@ -969,7 +991,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should skip importing metadata when the feature is disabled', async () => {
|
it('should skip importing metadata when the feature is disabled', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } });
|
||||||
mockReadTags(metadataStub.withFace);
|
mockReadTags(makeFaceTags({ Name: 'Person 1' }));
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
expect(mocks.person.getDistinctNames).not.toHaveBeenCalled();
|
expect(mocks.person.getDistinctNames).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -977,7 +999,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should skip importing metadata face for assets without tags.RegionInfo', async () => {
|
it('should skip importing metadata face for assets without tags.RegionInfo', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(metadataStub.empty);
|
mockReadTags();
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
expect(mocks.person.getDistinctNames).not.toHaveBeenCalled();
|
expect(mocks.person.getDistinctNames).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -985,7 +1007,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should skip importing faces without name', async () => {
|
it('should skip importing faces without name', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(metadataStub.withFaceNoName);
|
mockReadTags(makeFaceTags());
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||||
mocks.person.createAll.mockResolvedValue([]);
|
mocks.person.createAll.mockResolvedValue([]);
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
@ -997,7 +1019,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should skip importing faces with empty name', async () => {
|
it('should skip importing faces with empty name', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(metadataStub.withFaceEmptyName);
|
mockReadTags(makeFaceTags({ Name: '' }));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||||
mocks.person.createAll.mockResolvedValue([]);
|
mocks.person.createAll.mockResolvedValue([]);
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
@ -1009,7 +1031,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should apply metadata face tags creating new persons', async () => {
|
it('should apply metadata face tags creating new persons', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(metadataStub.withFace);
|
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||||
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||||
@ -1050,7 +1072,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should assign metadata face tags to existing persons', async () => {
|
it('should assign metadata face tags to existing persons', async () => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(metadataStub.withFace);
|
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
|
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
|
||||||
mocks.person.createAll.mockResolvedValue([]);
|
mocks.person.createAll.mockResolvedValue([]);
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||||
@ -1190,7 +1212,7 @@ describe(MetadataService.name, () => {
|
|||||||
mocks.asset.getByIds.mockResolvedValue([
|
mocks.asset.getByIds.mockResolvedValue([
|
||||||
{
|
{
|
||||||
...assetStub.livePhotoStillAsset,
|
...assetStub.livePhotoStillAsset,
|
||||||
exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity,
|
exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as Exif,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||||
@ -1229,18 +1251,51 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ Make: '1', Model: '2', Device: { Manufacturer: '3', ModelName: '4' }, AndroidMake: '4', AndroidModel: '5' },
|
{
|
||||||
{ Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' },
|
exif: {
|
||||||
{ AndroidMake: '1', AndroidModel: '2' },
|
Make: '1',
|
||||||
])('should read camera make and model correct place %s', async (metaData) => {
|
Model: '2',
|
||||||
|
Device: { Manufacturer: '3', ModelName: '4' },
|
||||||
|
AndroidMake: '4',
|
||||||
|
AndroidModel: '5',
|
||||||
|
},
|
||||||
|
expected: { make: '1', model: '2' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exif: { Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' },
|
||||||
|
expected: { make: '1', model: '2' },
|
||||||
|
},
|
||||||
|
{ exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } },
|
||||||
|
])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => {
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
mockReadTags(metaData);
|
mockReadTags(exif);
|
||||||
|
|
||||||
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ exif: {}, expected: null },
|
||||||
|
{ exif: { LensID: '1', LensSpec: '2', LensType: '3', LensModel: '4' }, expected: '1' },
|
||||||
|
{ exif: { LensSpec: '2', LensType: '3', LensModel: '4' }, expected: '3' },
|
||||||
|
{ exif: { LensSpec: '2', LensModel: '4' }, expected: '2' },
|
||||||
|
{ exif: { LensModel: '4' }, expected: '4' },
|
||||||
|
{ exif: { LensID: '----' }, expected: null },
|
||||||
|
{ exif: { LensID: 'Unknown (0 ff ff)' }, expected: null },
|
||||||
|
{
|
||||||
|
exif: { LensID: 'Unknown (E1 40 19 36 2C 35 DF 0E) Tamron 10-24mm f/3.5-4.5 Di II VC HLD (B023) ?' },
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{ exif: { LensID: ' Unknown 6-30mm' }, expected: null },
|
||||||
|
{ exif: { LensID: '' }, expected: null },
|
||||||
|
])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => {
|
||||||
|
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
|
mockReadTags(exif);
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
make: '1',
|
lensModel: expected,
|
||||||
model: '2',
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -9,11 +9,9 @@ import { constants } from 'node:fs/promises';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { Exif } from 'src/db';
|
import { AssetFaces, Exif, Person } from 'src/db';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
|
||||||
import {
|
import {
|
||||||
AssetType,
|
AssetType,
|
||||||
DatabaseLock,
|
DatabaseLock,
|
||||||
@ -76,6 +74,19 @@ const validateRange = (value: number | undefined, min: number, max: number): Non
|
|||||||
return val;
|
return val;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getLensModel = (exifTags: ImmichTags): string | null => {
|
||||||
|
const lensModel = String(
|
||||||
|
exifTags.LensID ?? exifTags.LensType ?? exifTags.LensSpec ?? exifTags.LensModel ?? '',
|
||||||
|
).trim();
|
||||||
|
if (lensModel === '----') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (lensModel.startsWith('Unknown')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return lensModel || null;
|
||||||
|
};
|
||||||
|
|
||||||
type ImmichTagsWithFaces = ImmichTags & { RegionInfo: NonNullable<ImmichTags['RegionInfo']> };
|
type ImmichTagsWithFaces = ImmichTags & { RegionInfo: NonNullable<ImmichTags['RegionInfo']> };
|
||||||
|
|
||||||
type Dates = {
|
type Dates = {
|
||||||
@ -228,7 +239,7 @@ export class MetadataService extends BaseService {
|
|||||||
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
||||||
iso: validate(exifTags.ISO) as number,
|
iso: validate(exifTags.ISO) as number,
|
||||||
exposureTime: exifTags.ExposureTime ?? null,
|
exposureTime: exifTags.ExposureTime ?? null,
|
||||||
lensModel: exifTags.LensModel ?? null,
|
lensModel: getLensModel(exifTags),
|
||||||
fNumber: validate(exifTags.FNumber),
|
fNumber: validate(exifTags.FNumber),
|
||||||
focalLength: validate(exifTags.FocalLength),
|
focalLength: validate(exifTags.FocalLength),
|
||||||
|
|
||||||
@ -574,10 +585,10 @@ export class MetadataService extends BaseService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const facesToAdd: (Partial<AssetFaceEntity> & { assetId: string })[] = [];
|
const facesToAdd: (Insertable<AssetFaces> & { assetId: string })[] = [];
|
||||||
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
|
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
|
||||||
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
|
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
|
||||||
const missing: (Partial<PersonEntity> & { ownerId: string })[] = [];
|
const missing: (Insertable<Person> & { ownerId: string })[] = [];
|
||||||
const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = [];
|
const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = [];
|
||||||
for (const region of tags.RegionInfo.RegionList) {
|
for (const region of tags.RegionInfo.RegionList) {
|
||||||
if (!region.Name) {
|
if (!region.Name) {
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { defaults, SystemConfig } from 'src/config';
|
import { defaults, SystemConfig } from 'src/config';
|
||||||
|
import { AlbumUser } from 'src/database';
|
||||||
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
||||||
import { AlbumUserEntity } from 'src/entities/album-user.entity';
|
|
||||||
import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
|
||||||
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
|
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
|
||||||
import { EmailTemplate } from 'src/repositories/notification.repository';
|
import { EmailTemplate } from 'src/repositories/notification.repository';
|
||||||
import { NotificationService } from 'src/services/notification.service';
|
import { NotificationService } from 'src/services/notification.service';
|
||||||
@ -442,7 +441,7 @@ describe(NotificationService.name, () => {
|
|||||||
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||||
mocks.asset.getById.mockResolvedValue({
|
mocks.asset.getById.mockResolvedValue({
|
||||||
...assetStub.image,
|
...assetStub.image,
|
||||||
files: [{ assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' } as AssetFileEntity],
|
files: [{ id: '1', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
|
||||||
@ -503,7 +502,7 @@ describe(NotificationService.name, () => {
|
|||||||
it('should skip recipient that could not be looked up', async () => {
|
it('should skip recipient that could not be looked up', async () => {
|
||||||
mocks.album.getById.mockResolvedValue({
|
mocks.album.getById.mockResolvedValue({
|
||||||
...albumStub.emptyWithValidThumbnail,
|
...albumStub.emptyWithValidThumbnail,
|
||||||
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity],
|
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
|
||||||
});
|
});
|
||||||
mocks.user.get.mockResolvedValueOnce(userStub.user1);
|
mocks.user.get.mockResolvedValueOnce(userStub.user1);
|
||||||
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||||
@ -516,7 +515,7 @@ describe(NotificationService.name, () => {
|
|||||||
it('should skip recipient with disabled email notifications', async () => {
|
it('should skip recipient with disabled email notifications', async () => {
|
||||||
mocks.album.getById.mockResolvedValue({
|
mocks.album.getById.mockResolvedValue({
|
||||||
...albumStub.emptyWithValidThumbnail,
|
...albumStub.emptyWithValidThumbnail,
|
||||||
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity],
|
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
|
||||||
});
|
});
|
||||||
mocks.user.get.mockResolvedValue({
|
mocks.user.get.mockResolvedValue({
|
||||||
...userStub.user1,
|
...userStub.user1,
|
||||||
@ -537,7 +536,7 @@ describe(NotificationService.name, () => {
|
|||||||
it('should skip recipient with disabled email notifications for the album update event', async () => {
|
it('should skip recipient with disabled email notifications for the album update event', async () => {
|
||||||
mocks.album.getById.mockResolvedValue({
|
mocks.album.getById.mockResolvedValue({
|
||||||
...albumStub.emptyWithValidThumbnail,
|
...albumStub.emptyWithValidThumbnail,
|
||||||
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity],
|
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
|
||||||
});
|
});
|
||||||
mocks.user.get.mockResolvedValue({
|
mocks.user.get.mockResolvedValue({
|
||||||
...userStub.user1,
|
...userStub.user1,
|
||||||
@ -558,7 +557,7 @@ describe(NotificationService.name, () => {
|
|||||||
it('should send email', async () => {
|
it('should send email', async () => {
|
||||||
mocks.album.getById.mockResolvedValue({
|
mocks.album.getById.mockResolvedValue({
|
||||||
...albumStub.emptyWithValidThumbnail,
|
...albumStub.emptyWithValidThumbnail,
|
||||||
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity],
|
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
|
||||||
});
|
});
|
||||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||||
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||||
|
@ -22,7 +22,7 @@ describe(PartnerService.name, () => {
|
|||||||
const user2 = factory.user();
|
const user2 = factory.user();
|
||||||
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||||
const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 });
|
const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 });
|
||||||
const auth = factory.auth({ id: user1.id });
|
const auth = factory.auth({ user: { id: user1.id } });
|
||||||
|
|
||||||
mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]);
|
mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]);
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ describe(PartnerService.name, () => {
|
|||||||
const user2 = factory.user();
|
const user2 = factory.user();
|
||||||
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||||
const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 });
|
const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 });
|
||||||
const auth = factory.auth({ id: user1.id });
|
const auth = factory.auth({ user: { id: user1.id } });
|
||||||
|
|
||||||
mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]);
|
mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]);
|
||||||
await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
|
await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
|
||||||
@ -48,7 +48,7 @@ describe(PartnerService.name, () => {
|
|||||||
const user1 = factory.user();
|
const user1 = factory.user();
|
||||||
const user2 = factory.user();
|
const user2 = factory.user();
|
||||||
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||||
const auth = factory.auth({ id: user1.id });
|
const auth = factory.auth({ user: { id: user1.id } });
|
||||||
|
|
||||||
mocks.partner.get.mockResolvedValue(void 0);
|
mocks.partner.get.mockResolvedValue(void 0);
|
||||||
mocks.partner.create.mockResolvedValue(partner);
|
mocks.partner.create.mockResolvedValue(partner);
|
||||||
@ -65,7 +65,7 @@ describe(PartnerService.name, () => {
|
|||||||
const user1 = factory.user();
|
const user1 = factory.user();
|
||||||
const user2 = factory.user();
|
const user2 = factory.user();
|
||||||
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||||
const auth = factory.auth({ id: user1.id });
|
const auth = factory.auth({ user: { id: user1.id } });
|
||||||
|
|
||||||
mocks.partner.get.mockResolvedValue(partner);
|
mocks.partner.get.mockResolvedValue(partner);
|
||||||
|
|
||||||
@ -80,7 +80,7 @@ describe(PartnerService.name, () => {
|
|||||||
const user1 = factory.user();
|
const user1 = factory.user();
|
||||||
const user2 = factory.user();
|
const user2 = factory.user();
|
||||||
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||||
const auth = factory.auth({ id: user1.id });
|
const auth = factory.auth({ user: { id: user1.id } });
|
||||||
|
|
||||||
mocks.partner.get.mockResolvedValue(partner);
|
mocks.partner.get.mockResolvedValue(partner);
|
||||||
|
|
||||||
@ -113,7 +113,7 @@ describe(PartnerService.name, () => {
|
|||||||
const user1 = factory.user();
|
const user1 = factory.user();
|
||||||
const user2 = factory.user();
|
const user2 = factory.user();
|
||||||
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||||
const auth = factory.auth({ id: user1.id });
|
const auth = factory.auth({ user: { id: user1.id } });
|
||||||
|
|
||||||
mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id]));
|
mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id]));
|
||||||
mocks.partner.update.mockResolvedValue(partner);
|
mocks.partner.update.mockResolvedValue(partner);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AssetFace } from 'src/database';
|
||||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
|
||||||
import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
|
import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
|
||||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
import { WithoutProperty } from 'src/repositories/asset.repository';
|
||||||
import { DetectedFaces } from 'src/repositories/machine-learning.repository';
|
import { DetectedFaces } from 'src/repositories/machine-learning.repository';
|
||||||
@ -11,8 +11,9 @@ import { ImmichFileResponse } from 'src/utils/file';
|
|||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { faceStub } from 'test/fixtures/face.stub';
|
import { faceStub } from 'test/fixtures/face.stub';
|
||||||
import { personStub } from 'test/fixtures/person.stub';
|
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
|
||||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
const responseDto: PersonResponseDto = {
|
const responseDto: PersonResponseDto = {
|
||||||
@ -23,6 +24,7 @@ const responseDto: PersonResponseDto = {
|
|||||||
isHidden: false,
|
isHidden: false,
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
|
color: expect.any(String),
|
||||||
};
|
};
|
||||||
|
|
||||||
const statistics = { assets: 3 };
|
const statistics = { assets: 3 };
|
||||||
@ -89,6 +91,7 @@ describe(PersonService.name, () => {
|
|||||||
isHidden: true,
|
isHidden: true,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
|
color: expect.any(String),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -117,6 +120,7 @@ describe(PersonService.name, () => {
|
|||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
|
color: personStub.isFavorite.color,
|
||||||
},
|
},
|
||||||
responseDto,
|
responseDto,
|
||||||
],
|
],
|
||||||
@ -136,7 +140,6 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw a bad request when person is not found', async () => {
|
it('should throw a bad request when person is not found', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(null);
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||||
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||||
@ -160,7 +163,6 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when personId is invalid', async () => {
|
it('should throw an error when personId is invalid', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(null);
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||||
@ -230,6 +232,7 @@ describe(PersonService.name, () => {
|
|||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
|
color: expect.any(String),
|
||||||
});
|
});
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
|
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
|
||||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||||
@ -345,7 +348,6 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
describe('handlePersonMigration', () => {
|
describe('handlePersonMigration', () => {
|
||||||
it('should not move person files', async () => {
|
it('should not move person files', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(null);
|
|
||||||
await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED);
|
await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -399,6 +401,7 @@ describe(PersonService.name, () => {
|
|||||||
name: personStub.noName.name,
|
name: personStub.noName.name,
|
||||||
thumbnailPath: personStub.noName.thumbnailPath,
|
thumbnailPath: personStub.noName.thumbnailPath,
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
|
color: personStub.noName.color,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||||
@ -437,7 +440,7 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
await sut.handlePersonCleanup();
|
await sut.handlePersonCleanup();
|
||||||
|
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName.id]);
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -479,7 +482,7 @@ describe(PersonService.name, () => {
|
|||||||
await sut.handleQueueDetectFaces({ force: true });
|
await sut.handleQueueDetectFaces({ force: true });
|
||||||
|
|
||||||
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING });
|
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING });
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]);
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
|
||||||
expect(mocks.asset.getAll).toHaveBeenCalled();
|
expect(mocks.asset.getAll).toHaveBeenCalled();
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
@ -530,7 +533,7 @@ describe(PersonService.name, () => {
|
|||||||
data: { id: assetStub.image.id },
|
data: { id: assetStub.image.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -697,7 +700,7 @@ describe(PersonService.name, () => {
|
|||||||
data: { id: faceStub.face1.id, deferred: false },
|
data: { id: faceStub.face1.id, deferred: false },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -730,7 +733,7 @@ describe(PersonService.name, () => {
|
|||||||
id: 'asset-face-1',
|
id: 'asset-face-1',
|
||||||
assetId: assetStub.noResizePath.id,
|
assetId: assetStub.noResizePath.id,
|
||||||
personId: faceStub.face1.personId,
|
personId: faceStub.face1.personId,
|
||||||
} as AssetFaceEntity,
|
} as AssetFace,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@ -847,8 +850,8 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should fail if face does not have asset', async () => {
|
it('should fail if face does not have asset', async () => {
|
||||||
const face = { ...faceStub.face1, asset: null } as AssetFaceEntity & { asset: null };
|
const face = { ...faceStub.face1, asset: null };
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(face);
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(face);
|
||||||
|
|
||||||
expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED);
|
expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED);
|
||||||
|
|
||||||
@ -857,7 +860,7 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should skip if face already has an assigned person', async () => {
|
it('should skip if face already has an assigned person', async () => {
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.face1);
|
||||||
|
|
||||||
expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.SKIPPED);
|
expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.SKIPPED);
|
||||||
|
|
||||||
@ -879,7 +882,7 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||||
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
|
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
@ -909,7 +912,7 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||||
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
|
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
@ -939,7 +942,7 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||||
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
|
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
@ -964,7 +967,7 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||||
mocks.person.create.mockResolvedValue(personStub.withName);
|
mocks.person.create.mockResolvedValue(personStub.withName);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
@ -983,7 +986,7 @@ describe(PersonService.name, () => {
|
|||||||
const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
|
const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
|
||||||
|
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||||
mocks.person.create.mockResolvedValue(personStub.withName);
|
mocks.person.create.mockResolvedValue(personStub.withName);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
@ -1002,7 +1005,7 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||||
mocks.person.create.mockResolvedValue(personStub.withName);
|
mocks.person.create.mockResolvedValue(personStub.withName);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
@ -1024,7 +1027,7 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||||
mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||||
mocks.person.create.mockResolvedValue(personStub.withName);
|
mocks.person.create.mockResolvedValue(personStub.withName);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true });
|
||||||
@ -1046,7 +1049,6 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should skip a person not found', async () => {
|
it('should skip a person not found', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(null);
|
|
||||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -1057,30 +1059,18 @@ describe(PersonService.name, () => {
|
|||||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip a person with a face asset id not found', async () => {
|
it('should skip a person with face not found', async () => {
|
||||||
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id });
|
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
|
|
||||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
|
||||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip a person with a face asset id without a thumbnail', async () => {
|
|
||||||
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
|
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
|
|
||||||
mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]);
|
|
||||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail', async () => {
|
it('should generate a thumbnail', async () => {
|
||||||
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle);
|
|
||||||
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
|
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
|
|
||||||
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
|
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
|
||||||
|
|
||||||
expect(mocks.asset.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true });
|
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
|
||||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
|
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
|
||||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||||
assetStub.primaryImage.originalPath,
|
assetStub.primaryImage.originalPath,
|
||||||
@ -1106,9 +1096,7 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail without going negative', async () => {
|
it('should generate a thumbnail without going negative', async () => {
|
||||||
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId });
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart);
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.start);
|
|
||||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
|
|
||||||
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
|
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
|
||||||
@ -1133,10 +1121,8 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail without overflowing', async () => {
|
it('should generate a thumbnail without overflowing', async () => {
|
||||||
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd);
|
||||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.end);
|
|
||||||
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
|
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
|
||||||
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
|
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
|
|
||||||
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
|
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
|
||||||
@ -1219,7 +1205,6 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when the primary person is not found', async () => {
|
it('should throw an error when the primary person is not found', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(null);
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
|
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
|
||||||
@ -1232,7 +1217,6 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
it('should handle invalid merge ids', async () => {
|
it('should handle invalid merge ids', async () => {
|
||||||
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||||
mocks.person.getById.mockResolvedValueOnce(null);
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
||||||
|
|
||||||
@ -1279,7 +1263,8 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
describe('mapFace', () => {
|
describe('mapFace', () => {
|
||||||
it('should map a face', () => {
|
it('should map a face', () => {
|
||||||
expect(mapFaces(faceStub.face1, { user: personStub.withName.owner })).toEqual({
|
const authDto = factory.auth({ user: { id: faceStub.face1.person.ownerId } });
|
||||||
|
expect(mapFaces(faceStub.face1, authDto)).toEqual({
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
boundingBoxX2: 1,
|
boundingBoxX2: 1,
|
||||||
boundingBoxY1: 0,
|
boundingBoxY1: 0,
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Insertable, Updateable } from 'kysely';
|
||||||
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { AssetFaces, FaceSearch, Person } from 'src/db';
|
||||||
import { Chunked, OnJob } from 'src/decorators';
|
import { Chunked, OnJob } from 'src/decorators';
|
||||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
@ -21,10 +23,6 @@ import {
|
|||||||
PersonStatisticsResponseDto,
|
PersonStatisticsResponseDto,
|
||||||
PersonUpdateDto,
|
PersonUpdateDto,
|
||||||
} from 'src/dtos/person.dto';
|
} from 'src/dtos/person.dto';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
|
||||||
import { FaceSearchEntity } from 'src/entities/face-search.entity';
|
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
|
||||||
import {
|
import {
|
||||||
AssetFileType,
|
AssetFileType,
|
||||||
AssetType,
|
AssetType,
|
||||||
@ -243,9 +241,9 @@ export class PersonService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Chunked()
|
@Chunked()
|
||||||
private async delete(people: PersonEntity[]) {
|
private async delete(people: { id: string; thumbnailPath: string }[]) {
|
||||||
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
|
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
|
||||||
await this.personRepository.delete(people);
|
await this.personRepository.delete(people.map((person) => person.id));
|
||||||
this.logger.debug(`Deleted ${people.length} people`);
|
this.logger.debug(`Deleted ${people.length} people`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -317,8 +315,8 @@ export class PersonService extends BaseService {
|
|||||||
);
|
);
|
||||||
this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`);
|
this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`);
|
||||||
|
|
||||||
const facesToAdd: (Partial<AssetFaceEntity> & { id: string; assetId: string })[] = [];
|
const facesToAdd: (Insertable<AssetFaces> & { id: string })[] = [];
|
||||||
const embeddings: FaceSearchEntity[] = [];
|
const embeddings: FaceSearch[] = [];
|
||||||
const mlFaceIds = new Set<string>();
|
const mlFaceIds = new Set<string>();
|
||||||
for (const face of asset.faces) {
|
for (const face of asset.faces) {
|
||||||
if (face.sourceType === SourceType.MACHINE_LEARNING) {
|
if (face.sourceType === SourceType.MACHINE_LEARNING) {
|
||||||
@ -377,7 +375,10 @@ export class PersonService extends BaseService {
|
|||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private iou(face: AssetFaceEntity, newBox: BoundingBox): number {
|
private iou(
|
||||||
|
face: { boundingBoxX1: number; boundingBoxY1: number; boundingBoxX2: number; boundingBoxY2: number },
|
||||||
|
newBox: BoundingBox,
|
||||||
|
): number {
|
||||||
const x1 = Math.max(face.boundingBoxX1, newBox.x1);
|
const x1 = Math.max(face.boundingBoxX1, newBox.x1);
|
||||||
const y1 = Math.max(face.boundingBoxY1, newBox.y1);
|
const y1 = Math.max(face.boundingBoxY1, newBox.y1);
|
||||||
const x2 = Math.min(face.boundingBoxX2, newBox.x2);
|
const x2 = Math.min(face.boundingBoxX2, newBox.x2);
|
||||||
@ -453,11 +454,7 @@ export class PersonService extends BaseService {
|
|||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const face = await this.personRepository.getFaceByIdWithAssets(id, { faceSearch: true }, [
|
const face = await this.personRepository.getFaceForFacialRecognitionJob(id);
|
||||||
'id',
|
|
||||||
'personId',
|
|
||||||
'sourceType',
|
|
||||||
]);
|
|
||||||
if (!face || !face.asset) {
|
if (!face || !face.asset) {
|
||||||
this.logger.warn(`Face ${id} not found`);
|
this.logger.warn(`Face ${id} not found`);
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
@ -545,46 +542,23 @@ export class PersonService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.GENERATE_PERSON_THUMBNAIL, queue: QueueName.THUMBNAIL_GENERATION })
|
@OnJob({ name: JobName.GENERATE_PERSON_THUMBNAIL, queue: QueueName.THUMBNAIL_GENERATION })
|
||||||
async handleGeneratePersonThumbnail(data: JobOf<JobName.GENERATE_PERSON_THUMBNAIL>): Promise<JobStatus> {
|
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.GENERATE_PERSON_THUMBNAIL>): Promise<JobStatus> {
|
||||||
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
|
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
|
||||||
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
|
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
|
||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const person = await this.personRepository.getById(data.id);
|
const data = await this.personRepository.getDataForThumbnailGenerationJob(id);
|
||||||
if (!person?.faceAssetId) {
|
if (!data) {
|
||||||
this.logger.error(`Could not generate person thumbnail: person ${person?.id} has no face asset`);
|
this.logger.error(`Could not generate person thumbnail for ${id}: missing data`);
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const face = await this.personRepository.getFaceByIdWithAssets(person.faceAssetId);
|
const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight } = data;
|
||||||
if (!face) {
|
|
||||||
this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`);
|
|
||||||
return JobStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const { width, height, inputPath } = await this.getInputDimensions(data);
|
||||||
assetId,
|
|
||||||
boundingBoxX1: x1,
|
|
||||||
boundingBoxX2: x2,
|
|
||||||
boundingBoxY1: y1,
|
|
||||||
boundingBoxY2: y2,
|
|
||||||
imageWidth: oldWidth,
|
|
||||||
imageHeight: oldHeight,
|
|
||||||
} = face;
|
|
||||||
|
|
||||||
const asset = await this.assetRepository.getById(assetId, {
|
const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId });
|
||||||
exifInfo: true,
|
|
||||||
files: true,
|
|
||||||
});
|
|
||||||
if (!asset) {
|
|
||||||
this.logger.error(`Could not generate person thumbnail: asset ${assetId} does not exist`);
|
|
||||||
return JobStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { width, height, inputPath } = await this.getInputDimensions(asset, { width: oldWidth, height: oldHeight });
|
|
||||||
|
|
||||||
const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
|
|
||||||
this.storageCore.ensureFolders(thumbnailPath);
|
this.storageCore.ensureFolders(thumbnailPath);
|
||||||
|
|
||||||
const thumbnailOptions = {
|
const thumbnailOptions = {
|
||||||
@ -597,7 +571,7 @@ export class PersonService extends BaseService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath);
|
await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath);
|
||||||
await this.personRepository.update({ id: person.id, thumbnailPath });
|
await this.personRepository.update({ id, thumbnailPath });
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
@ -634,7 +608,7 @@ export class PersonService extends BaseService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const update: Partial<PersonEntity> = {};
|
const update: Updateable<Person> & { id: string } = { id: primaryPerson.id };
|
||||||
if (!primaryPerson.name && mergePerson.name) {
|
if (!primaryPerson.name && mergePerson.name) {
|
||||||
update.name = mergePerson.name;
|
update.name = mergePerson.name;
|
||||||
}
|
}
|
||||||
@ -644,7 +618,7 @@ export class PersonService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(update).length > 0) {
|
if (Object.keys(update).length > 0) {
|
||||||
primaryPerson = await this.personRepository.update({ id: primaryPerson.id, ...update });
|
primaryPerson = await this.personRepository.update(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeName = mergePerson.name || mergePerson.id;
|
const mergeName = mergePerson.name || mergePerson.id;
|
||||||
@ -672,27 +646,26 @@ export class PersonService extends BaseService {
|
|||||||
return person;
|
return person;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getInputDimensions(asset: AssetEntity, oldDims: ImageDimensions): Promise<InputDimensions> {
|
private async getInputDimensions(asset: {
|
||||||
if (!asset.exifInfo?.exifImageHeight || !asset.exifInfo.exifImageWidth) {
|
type: AssetType;
|
||||||
throw new Error(`Asset ${asset.id} dimensions are unknown`);
|
exifImageWidth: number;
|
||||||
}
|
exifImageHeight: number;
|
||||||
|
previewPath: string;
|
||||||
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
|
originalPath: string;
|
||||||
if (!previewFile) {
|
oldWidth: number;
|
||||||
throw new Error(`Asset ${asset.id} has no preview path`);
|
oldHeight: number;
|
||||||
}
|
}): Promise<InputDimensions> {
|
||||||
|
|
||||||
if (asset.type === AssetType.IMAGE) {
|
if (asset.type === AssetType.IMAGE) {
|
||||||
let { exifImageWidth: width, exifImageHeight: height } = asset.exifInfo;
|
let { exifImageWidth: width, exifImageHeight: height } = asset;
|
||||||
if (oldDims.height > oldDims.width !== height > width) {
|
if (asset.oldHeight > asset.oldWidth !== height > width) {
|
||||||
[width, height] = [height, width];
|
[width, height] = [height, width];
|
||||||
}
|
}
|
||||||
|
|
||||||
return { width, height, inputPath: asset.originalPath };
|
return { width, height, inputPath: asset.originalPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { width, height } = await this.mediaRepository.getImageDimensions(previewFile.path);
|
const { width, height } = await this.mediaRepository.getImageDimensions(asset.previewPath);
|
||||||
return { width, height, inputPath: previewFile.path };
|
return { width, height, inputPath: asset.previewPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions {
|
private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions {
|
||||||
|
@ -7,6 +7,7 @@ import { albumStub } from 'test/fixtures/album.stub';
|
|||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
describe(SharedLinkService.name, () => {
|
describe(SharedLinkService.name, () => {
|
||||||
@ -46,7 +47,13 @@ describe(SharedLinkService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not return metadata', async () => {
|
it('should not return metadata', async () => {
|
||||||
const authDto = authStub.adminSharedLinkNoExif;
|
const authDto = factory.auth({
|
||||||
|
sharedLink: {
|
||||||
|
showExif: false,
|
||||||
|
allowDownload: true,
|
||||||
|
allowUpload: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
|
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
|
||||||
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
|
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
|
||||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
||||||
@ -208,7 +215,9 @@ describe(SharedLinkService.name, () => {
|
|||||||
it('should update a shared link', async () => {
|
it('should update a shared link', async () => {
|
||||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||||
mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid);
|
mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid);
|
||||||
|
|
||||||
await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false });
|
await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false });
|
||||||
|
|
||||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
|
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
|
||||||
expect(mocks.sharedLink.update).toHaveBeenCalledWith({
|
expect(mocks.sharedLink.update).toHaveBeenCalledWith({
|
||||||
id: sharedLinkStub.valid.id,
|
id: sharedLinkStub.valid.id,
|
||||||
@ -242,6 +251,7 @@ describe(SharedLinkService.name, () => {
|
|||||||
describe('addAssets', () => {
|
describe('addAssets', () => {
|
||||||
it('should not work on album shared links', async () => {
|
it('should not work on album shared links', async () => {
|
||||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||||
|
|
||||||
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
@ -273,6 +283,7 @@ describe(SharedLinkService.name, () => {
|
|||||||
describe('removeAssets', () => {
|
describe('removeAssets', () => {
|
||||||
it('should not work on album shared links', async () => {
|
it('should not work on album shared links', async () => {
|
||||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||||
|
|
||||||
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
@ -297,31 +308,39 @@ describe(SharedLinkService.name, () => {
|
|||||||
describe('getMetadataTags', () => {
|
describe('getMetadataTags', () => {
|
||||||
it('should return null when auth is not a shared link', async () => {
|
it('should return null when auth is not a shared link', async () => {
|
||||||
await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null);
|
await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null);
|
||||||
|
|
||||||
expect(mocks.sharedLink.get).not.toHaveBeenCalled();
|
expect(mocks.sharedLink.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null when shared link has a password', async () => {
|
it('should return null when shared link has a password', async () => {
|
||||||
await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null);
|
const auth = factory.auth({ user: {}, sharedLink: { password: 'password' } });
|
||||||
|
|
||||||
|
await expect(sut.getMetadataTags(auth)).resolves.toBe(null);
|
||||||
|
|
||||||
expect(mocks.sharedLink.get).not.toHaveBeenCalled();
|
expect(mocks.sharedLink.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return metadata tags', async () => {
|
it('should return metadata tags', async () => {
|
||||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual);
|
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual);
|
||||||
|
|
||||||
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
|
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
|
||||||
description: '1 shared photos & videos',
|
description: '1 shared photos & videos',
|
||||||
imageUrl: `https://my.immich.app/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`,
|
imageUrl: `https://my.immich.app/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`,
|
||||||
title: 'Public Share',
|
title: 'Public Share',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.sharedLink.get).toHaveBeenCalled();
|
expect(mocks.sharedLink.get).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return metadata tags with a default image path if the asset id is not set', async () => {
|
it('should return metadata tags with a default image path if the asset id is not set', async () => {
|
||||||
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] });
|
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] });
|
||||||
|
|
||||||
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
|
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
|
||||||
description: '0 shared photos & videos',
|
description: '0 shared photos & videos',
|
||||||
imageUrl: `https://my.immich.app/feature-panel.png`,
|
imageUrl: `https://my.immich.app/feature-panel.png`,
|
||||||
title: 'Public Share',
|
title: 'Public Share',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.sharedLink.get).toHaveBeenCalled();
|
expect(mocks.sharedLink.get).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -5,6 +5,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
|
|||||||
import { albumStub } from 'test/fixtures/album.stub';
|
import { albumStub } from 'test/fixtures/album.stub';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
const motionAsset = assetStub.storageAsset({});
|
const motionAsset = assetStub.storageAsset({});
|
||||||
@ -426,15 +427,16 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use the user storage label', async () => {
|
it('should use the user storage label', async () => {
|
||||||
const asset = assetStub.storageAsset();
|
const user = factory.userAdmin({ storageLabel: 'label-1' });
|
||||||
|
const asset = assetStub.storageAsset({ ownerId: user.id });
|
||||||
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
||||||
mocks.user.getList.mockResolvedValue([userStub.storageLabel]);
|
mocks.user.getList.mockResolvedValue([user]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
entityId: asset.id,
|
entityId: asset.id,
|
||||||
pathType: AssetPathType.ORIGINAL,
|
pathType: AssetPathType.ORIGINAL,
|
||||||
oldPath: asset.originalPath,
|
oldPath: asset.originalPath,
|
||||||
newPath: `upload/library/user-id/2023/2023-02-23/${asset.originalFileName}`,
|
newPath: `upload/library/${user.storageLabel}/2023/2023-02-23/${asset.originalFileName}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
@ -442,11 +444,11 @@ describe(StorageTemplateService.name, () => {
|
|||||||
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
||||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||||
'/original/path.jpg',
|
'/original/path.jpg',
|
||||||
`upload/library/label-1/2022/2022-06-19/${asset.originalFileName}`,
|
`upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`,
|
||||||
);
|
);
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
originalPath: `upload/library/label-1/2022/2022-06-19/${asset.originalFileName}`,
|
originalPath: `upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -551,98 +553,106 @@ describe(StorageTemplateService.name, () => {
|
|||||||
|
|
||||||
describe('file rename correctness', () => {
|
describe('file rename correctness', () => {
|
||||||
it('should not create double extensions when filename has lower extension', async () => {
|
it('should not create double extensions when filename has lower extension', async () => {
|
||||||
|
const user = factory.userAdmin({ storageLabel: 'label-1' });
|
||||||
const asset = assetStub.storageAsset({
|
const asset = assetStub.storageAsset({
|
||||||
originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.heic',
|
ownerId: user.id,
|
||||||
|
originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
|
||||||
originalFileName: 'IMG_7065.HEIC',
|
originalFileName: 'IMG_7065.HEIC',
|
||||||
});
|
});
|
||||||
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
||||||
mocks.user.getList.mockResolvedValue([userStub.storageLabel]);
|
mocks.user.getList.mockResolvedValue([user]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
entityId: asset.id,
|
entityId: asset.id,
|
||||||
pathType: AssetPathType.ORIGINAL,
|
pathType: AssetPathType.ORIGINAL,
|
||||||
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.heic',
|
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
|
||||||
newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.heic',
|
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
||||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||||
'upload/library/user-id/2022/2022-06-19/IMG_7065.heic',
|
`upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
|
||||||
'upload/library/label-1/2022/2022-06-19/IMG_7065.heic',
|
`upload/library/${user.storageLabel}/2022/2022-06-19/IMG_7065.heic`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not create double extensions when filename has uppercase extension', async () => {
|
it('should not create double extensions when filename has uppercase extension', async () => {
|
||||||
|
const user = factory.userAdmin();
|
||||||
const asset = assetStub.storageAsset({
|
const asset = assetStub.storageAsset({
|
||||||
originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.HEIC',
|
ownerId: user.id,
|
||||||
|
originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
|
||||||
originalFileName: 'IMG_7065.HEIC',
|
originalFileName: 'IMG_7065.HEIC',
|
||||||
});
|
});
|
||||||
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
||||||
mocks.user.getList.mockResolvedValue([userStub.storageLabel]);
|
mocks.user.getList.mockResolvedValue([user]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
entityId: asset.id,
|
entityId: asset.id,
|
||||||
pathType: AssetPathType.ORIGINAL,
|
pathType: AssetPathType.ORIGINAL,
|
||||||
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.HEIC',
|
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
|
||||||
newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.heic',
|
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
||||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||||
'upload/library/user-id/2022/2022-06-19/IMG_7065.HEIC',
|
`upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
|
||||||
'upload/library/label-1/2022/2022-06-19/IMG_7065.heic',
|
`upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should normalize the filename to lowercase (JPEG > jpg)', async () => {
|
it('should normalize the filename to lowercase (JPEG > jpg)', async () => {
|
||||||
|
const user = factory.userAdmin();
|
||||||
const asset = assetStub.storageAsset({
|
const asset = assetStub.storageAsset({
|
||||||
originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPEG',
|
ownerId: user.id,
|
||||||
|
originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
|
||||||
originalFileName: 'IMG_7065.JPEG',
|
originalFileName: 'IMG_7065.JPEG',
|
||||||
});
|
});
|
||||||
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
||||||
mocks.user.getList.mockResolvedValue([userStub.storageLabel]);
|
mocks.user.getList.mockResolvedValue([user]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
entityId: asset.id,
|
entityId: asset.id,
|
||||||
pathType: AssetPathType.ORIGINAL,
|
pathType: AssetPathType.ORIGINAL,
|
||||||
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPEG',
|
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
|
||||||
newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.jpg',
|
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
||||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||||
'upload/library/user-id/2022/2022-06-19/IMG_7065.JPEG',
|
`upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
|
||||||
'upload/library/label-1/2022/2022-06-19/IMG_7065.jpg',
|
`upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should normalize the filename to lowercase (JPG > jpg)', async () => {
|
it('should normalize the filename to lowercase (JPG > jpg)', async () => {
|
||||||
|
const user = factory.userAdmin();
|
||||||
const asset = assetStub.storageAsset({
|
const asset = assetStub.storageAsset({
|
||||||
|
ownerId: user.id,
|
||||||
originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG',
|
originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG',
|
||||||
originalFileName: 'IMG_7065.JPG',
|
originalFileName: 'IMG_7065.JPG',
|
||||||
});
|
});
|
||||||
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
||||||
mocks.user.getList.mockResolvedValue([userStub.storageLabel]);
|
mocks.user.getList.mockResolvedValue([user]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
entityId: asset.id,
|
entityId: asset.id,
|
||||||
pathType: AssetPathType.ORIGINAL,
|
pathType: AssetPathType.ORIGINAL,
|
||||||
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG',
|
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`,
|
||||||
newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.jpg',
|
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
||||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||||
'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG',
|
`upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`,
|
||||||
'upload/library/label-1/2022/2022-06-19/IMG_7065.jpg',
|
`upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -39,7 +39,7 @@ describe(SyncService.name, () => {
|
|||||||
describe('getChangesForDeltaSync', () => {
|
describe('getChangesForDeltaSync', () => {
|
||||||
it('should return a response requiring a full sync when partners are out of sync', async () => {
|
it('should return a response requiring a full sync when partners are out of sync', async () => {
|
||||||
const partner = factory.partner();
|
const partner = factory.partner();
|
||||||
const auth = factory.auth({ id: partner.sharedWithId });
|
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||||
|
|
||||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
mocks.partner.getAll.mockResolvedValue([partner]);
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { TimeBucketSize } from 'src/repositories/asset.repository';
|
|||||||
import { TimelineService } from 'src/services/timeline.service';
|
import { TimelineService } from 'src/services/timeline.service';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
describe(TimelineService.name, () => {
|
describe(TimelineService.name, () => {
|
||||||
@ -114,15 +115,15 @@ describe(TimelineService.name, () => {
|
|||||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id']));
|
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id']));
|
||||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||||
|
|
||||||
const buckets = await sut.getTimeBucket(
|
const auth = factory.auth({ sharedLink: { showExif: false } });
|
||||||
{ ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
|
|
||||||
{
|
const buckets = await sut.getTimeBucket(auth, {
|
||||||
size: TimeBucketSize.DAY,
|
size: TimeBucketSize.DAY,
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
isArchived: true,
|
isArchived: true,
|
||||||
albumId: 'album-id',
|
albumId: 'album-id',
|
||||||
},
|
});
|
||||||
);
|
|
||||||
expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]);
|
expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]);
|
||||||
expect(buckets[0]).not.toHaveProperty('exif');
|
expect(buckets[0]).not.toHaveProperty('exif');
|
||||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserAdmin } from 'src/database';
|
||||||
import { CacheControl, JobName, UserMetadataKey } from 'src/enum';
|
import { CacheControl, JobName, UserMetadataKey } from 'src/enum';
|
||||||
import { UserService } from 'src/services/user.service';
|
import { UserService } from 'src/services/user.service';
|
||||||
import { ImmichFileResponse } from 'src/utils/file';
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
@ -29,7 +29,7 @@ describe(UserService.name, () => {
|
|||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
it('admin should get all users', async () => {
|
it('admin should get all users', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = factory.userAdmin();
|
||||||
const auth = factory.auth(user);
|
const auth = factory.auth({ user });
|
||||||
|
|
||||||
mocks.user.getList.mockResolvedValue([user]);
|
mocks.user.getList.mockResolvedValue([user]);
|
||||||
|
|
||||||
@ -39,14 +39,12 @@ describe(UserService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('non-admin should get all users when publicUsers enabled', async () => {
|
it('non-admin should get all users when publicUsers enabled', async () => {
|
||||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
const user = factory.userAdmin();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
|
||||||
await expect(sut.search(authStub.user1)).resolves.toEqual([
|
mocks.user.getList.mockResolvedValue([user]);
|
||||||
expect.objectContaining({
|
|
||||||
id: authStub.user1.user.id,
|
await expect(sut.search(auth)).resolves.toEqual([expect.objectContaining({ id: user.id, email: user.email })]);
|
||||||
email: authStub.user1.user.email,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false });
|
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false });
|
||||||
});
|
});
|
||||||
@ -107,17 +105,19 @@ describe(UserService.name, () => {
|
|||||||
|
|
||||||
it('should throw an error if the user profile could not be updated with the new image', async () => {
|
it('should throw an error if the user profile could not be updated with the new image', async () => {
|
||||||
const file = { path: '/profile/path' } as Express.Multer.File;
|
const file = { path: '/profile/path' } as Express.Multer.File;
|
||||||
mocks.user.get.mockResolvedValue(userStub.profilePath);
|
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||||
|
mocks.user.get.mockResolvedValue(user);
|
||||||
mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error'));
|
mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error'));
|
||||||
|
|
||||||
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException);
|
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete the previous profile image', async () => {
|
it('should delete the previous profile image', async () => {
|
||||||
|
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||||
const file = { path: '/profile/path' } as Express.Multer.File;
|
const file = { path: '/profile/path' } as Express.Multer.File;
|
||||||
const files = [userStub.profilePath.profileImagePath];
|
const files = [user.profileImagePath];
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(userStub.profilePath);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
|
mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
|
||||||
|
|
||||||
await sut.createProfileImage(authStub.admin, file);
|
await sut.createProfileImage(authStub.admin, file);
|
||||||
@ -149,8 +149,10 @@ describe(UserService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should delete the profile image if user has one', async () => {
|
it('should delete the profile image if user has one', async () => {
|
||||||
mocks.user.get.mockResolvedValue(userStub.profilePath);
|
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||||
const files = [userStub.profilePath.profileImagePath];
|
const files = [user.profileImagePath];
|
||||||
|
|
||||||
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
|
||||||
await sut.deleteProfileImage(authStub.admin);
|
await sut.deleteProfileImage(authStub.admin);
|
||||||
|
|
||||||
@ -176,9 +178,10 @@ describe(UserService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return the profile picture', async () => {
|
it('should return the profile picture', async () => {
|
||||||
mocks.user.get.mockResolvedValue(userStub.profilePath);
|
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||||
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
|
||||||
await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual(
|
await expect(sut.getProfileImage(user.id)).resolves.toEqual(
|
||||||
new ImmichFileResponse({
|
new ImmichFileResponse({
|
||||||
path: '/path/to/profile.jpg',
|
path: '/path/to/profile.jpg',
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
@ -186,7 +189,7 @@ describe(UserService.name, () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.get).toHaveBeenCalledWith(userStub.profilePath.id, {});
|
expect(mocks.user.get).toHaveBeenCalledWith(user.id, {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -214,7 +217,7 @@ describe(UserService.name, () => {
|
|||||||
|
|
||||||
describe('handleUserDelete', () => {
|
describe('handleUserDelete', () => {
|
||||||
it('should skip users not ready for deletion', async () => {
|
it('should skip users not ready for deletion', async () => {
|
||||||
const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserEntity;
|
const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserAdmin;
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
|
||||||
@ -225,7 +228,7 @@ describe(UserService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should delete the user and associated assets', async () => {
|
it('should delete the user and associated assets', async () => {
|
||||||
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;
|
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserAdmin;
|
||||||
const options = { force: true, recursive: true };
|
const options = { force: true, recursive: true };
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
@ -242,7 +245,7 @@ describe(UserService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should delete the library path for a storage label', async () => {
|
it('should delete the library path for a storage label', async () => {
|
||||||
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity;
|
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserAdmin;
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Updateable } from 'kysely';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { SALT_ROUNDS } from 'src/constants';
|
import { SALT_ROUNDS } from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
@ -8,9 +9,9 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
|||||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||||
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
|
||||||
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
||||||
import { UserFindOptions } from 'src/repositories/user.repository';
|
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||||
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { JobOf, UserMetadataItem } from 'src/types';
|
import { JobOf, UserMetadataItem } from 'src/types';
|
||||||
import { ImmichFileResponse } from 'src/utils/file';
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
@ -49,7 +50,7 @@ export class UserService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const update: Partial<UserEntity> = {
|
const update: Updateable<UserTable> = {
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
};
|
};
|
||||||
@ -229,7 +230,7 @@ export class UserService extends BaseService {
|
|||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean {
|
private isReadyForDeletion(user: { id: string; deletedAt?: Date | null }, deleteDelay: number): boolean {
|
||||||
if (!user.deletedAt) {
|
if (!user.deletedAt) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
|
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { AssetFile } from 'src/database';
|
||||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
|
||||||
import { AssetFileType, AssetType, Permission } from 'src/enum';
|
import { AssetFileType, AssetType, Permission } from 'src/enum';
|
||||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
@ -20,7 +20,7 @@ export const getAssetFile = <T extends { type: AssetFileType }>(
|
|||||||
return (files || []).find((file) => file.type === type);
|
return (files || []).find((file) => file.type === type);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAssetFiles = (files: AssetFileEntity[]) => ({
|
export const getAssetFiles = (files: AssetFile[]) => ({
|
||||||
fullsizeFile: getAssetFile(files, AssetFileType.FULLSIZE),
|
fullsizeFile: getAssetFile(files, AssetFileType.FULLSIZE),
|
||||||
previewFile: getAssetFile(files, AssetFileType.PREVIEW),
|
previewFile: getAssetFile(files, AssetFileType.PREVIEW),
|
||||||
thumbnailFile: getAssetFile(files, AssetFileType.THUMBNAIL),
|
thumbnailFile: getAssetFile(files, AssetFileType.THUMBNAIL),
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Expression, sql } from 'kysely';
|
import { Expression, ExpressionBuilder, ExpressionWrapper, Nullable, Selectable, Simplify, sql } from 'kysely';
|
||||||
|
|
||||||
export const asUuid = (id: string | Expression<string>) => sql<string>`${id}::uuid`;
|
export const asUuid = (id: string | Expression<string>) => sql<string>`${id}::uuid`;
|
||||||
|
|
||||||
@ -17,3 +17,25 @@ export const removeUndefinedKeys = <T extends object>(update: T, template: unkno
|
|||||||
|
|
||||||
return update;
|
return update;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Modifies toJson return type to not set all properties as nullable */
|
||||||
|
export function toJson<DB, TB extends keyof DB & string, T extends TB | Expression<unknown>>(
|
||||||
|
eb: ExpressionBuilder<DB, TB>,
|
||||||
|
table: T,
|
||||||
|
) {
|
||||||
|
return eb.fn.toJson<T>(table) as ExpressionWrapper<
|
||||||
|
DB,
|
||||||
|
TB,
|
||||||
|
Simplify<
|
||||||
|
T extends TB
|
||||||
|
? Selectable<DB[T]> extends Nullable<infer N>
|
||||||
|
? N | null
|
||||||
|
: Selectable<DB[T]>
|
||||||
|
: T extends Expression<infer O>
|
||||||
|
? O extends Nullable<infer N>
|
||||||
|
? N | null
|
||||||
|
: O
|
||||||
|
: never
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
@ -101,6 +101,20 @@ describe('mimeTypes', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('toExtension', () => {
|
||||||
|
it('should get an extension for a png file', () => {
|
||||||
|
expect(mimeTypes.toExtension('image/png')).toEqual('.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get an extension for a jpeg file', () => {
|
||||||
|
expect(mimeTypes.toExtension('image/jpeg')).toEqual('.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get an extension from a webp file', () => {
|
||||||
|
expect(mimeTypes.toExtension('image/webp')).toEqual('.webp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('profile', () => {
|
describe('profile', () => {
|
||||||
it('should contain only lowercase mime types', () => {
|
it('should contain only lowercase mime types', () => {
|
||||||
const keys = Object.keys(mimeTypes.profile);
|
const keys = Object.keys(mimeTypes.profile);
|
||||||
|
@ -55,6 +55,10 @@ const image: Record<string, string[]> = {
|
|||||||
'.webp': ['image/webp'],
|
'.webp': ['image/webp'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extensionOverrides: Record<string, string> = {
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg
|
* list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg
|
||||||
* @TODO share with the client
|
* @TODO share with the client
|
||||||
@ -104,6 +108,11 @@ const types = { ...image, ...video, ...sidecar };
|
|||||||
const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r;
|
const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r;
|
||||||
|
|
||||||
const lookup = (filename: string) => types[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream';
|
const lookup = (filename: string) => types[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream';
|
||||||
|
const toExtension = (mimeType: string) => {
|
||||||
|
return (
|
||||||
|
extensionOverrides[mimeType] || Object.entries(types).find(([, mimeTypes]) => mimeTypes.includes(mimeType))?.[0]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const mimeTypes = {
|
export const mimeTypes = {
|
||||||
image,
|
image,
|
||||||
@ -120,6 +129,8 @@ export const mimeTypes = {
|
|||||||
isVideo: (filename: string) => isType(filename, video),
|
isVideo: (filename: string) => isType(filename, video),
|
||||||
isRaw: (filename: string) => isType(filename, raw),
|
isRaw: (filename: string) => isType(filename, raw),
|
||||||
lookup,
|
lookup,
|
||||||
|
/** return an extension (including a leading `.`) for a mime-type */
|
||||||
|
toExtension,
|
||||||
assetType: (filename: string) => {
|
assetType: (filename: string) => {
|
||||||
const contentType = lookup(filename);
|
const contentType = lookup(filename);
|
||||||
if (contentType.startsWith('image/')) {
|
if (contentType.startsWith('image/')) {
|
||||||
|
12
server/test/fixtures/album.stub.ts
vendored
12
server/test/fixtures/album.stub.ts
vendored
@ -38,10 +38,7 @@ export const albumStub = {
|
|||||||
albumUsers: [
|
albumUsers: [
|
||||||
{
|
{
|
||||||
user: userStub.user1,
|
user: userStub.user1,
|
||||||
album: undefined as unknown as AlbumEntity,
|
|
||||||
role: AlbumUserRole.EDITOR,
|
role: AlbumUserRole.EDITOR,
|
||||||
userId: userStub.user1.id,
|
|
||||||
albumId: 'album-2',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isActivityEnabled: true,
|
isActivityEnabled: true,
|
||||||
@ -63,17 +60,11 @@ export const albumStub = {
|
|||||||
albumUsers: [
|
albumUsers: [
|
||||||
{
|
{
|
||||||
user: userStub.user1,
|
user: userStub.user1,
|
||||||
album: undefined as unknown as AlbumEntity,
|
|
||||||
role: AlbumUserRole.EDITOR,
|
role: AlbumUserRole.EDITOR,
|
||||||
userId: userStub.user1.id,
|
|
||||||
albumId: 'album-3',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
user: userStub.user2,
|
user: userStub.user2,
|
||||||
album: undefined as unknown as AlbumEntity,
|
|
||||||
role: AlbumUserRole.EDITOR,
|
role: AlbumUserRole.EDITOR,
|
||||||
userId: userStub.user2.id,
|
|
||||||
albumId: 'album-3',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isActivityEnabled: true,
|
isActivityEnabled: true,
|
||||||
@ -95,10 +86,7 @@ export const albumStub = {
|
|||||||
albumUsers: [
|
albumUsers: [
|
||||||
{
|
{
|
||||||
user: userStub.admin,
|
user: userStub.admin,
|
||||||
album: undefined as unknown as AlbumEntity,
|
|
||||||
role: AlbumUserRole.EDITOR,
|
role: AlbumUserRole.EDITOR,
|
||||||
userId: userStub.admin.id,
|
|
||||||
albumId: 'album-3',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isActivityEnabled: true,
|
isActivityEnabled: true,
|
||||||
|
51
server/test/fixtures/asset.stub.ts
vendored
51
server/test/fixtures/asset.stub.ts
vendored
@ -1,6 +1,5 @@
|
|||||||
import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
import { AssetFile, Exif } from 'src/database';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
|
||||||
import { StackEntity } from 'src/entities/stack.entity';
|
import { StackEntity } from 'src/entities/stack.entity';
|
||||||
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
|
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
|
||||||
import { StorageAsset } from 'src/types';
|
import { StorageAsset } from 'src/types';
|
||||||
@ -8,40 +7,30 @@ import { authStub } from 'test/fixtures/auth.stub';
|
|||||||
import { fileStub } from 'test/fixtures/file.stub';
|
import { fileStub } from 'test/fixtures/file.stub';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
|
|
||||||
const previewFile: AssetFileEntity = {
|
export const previewFile: AssetFile = {
|
||||||
id: 'file-1',
|
id: 'file-1',
|
||||||
assetId: 'asset-id',
|
|
||||||
type: AssetFileType.PREVIEW,
|
type: AssetFileType.PREVIEW,
|
||||||
path: '/uploads/user-id/thumbs/path.jpg',
|
path: '/uploads/user-id/thumbs/path.jpg',
|
||||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const thumbnailFile: AssetFileEntity = {
|
const thumbnailFile: AssetFile = {
|
||||||
id: 'file-2',
|
id: 'file-2',
|
||||||
assetId: 'asset-id',
|
|
||||||
type: AssetFileType.THUMBNAIL,
|
type: AssetFileType.THUMBNAIL,
|
||||||
path: '/uploads/user-id/webp/path.ext',
|
path: '/uploads/user-id/webp/path.ext',
|
||||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fullsizeFile: AssetFileEntity = {
|
const fullsizeFile: AssetFile = {
|
||||||
id: 'file-3',
|
id: 'file-3',
|
||||||
assetId: 'asset-id',
|
|
||||||
type: AssetFileType.FULLSIZE,
|
type: AssetFileType.FULLSIZE,
|
||||||
path: '/uploads/user-id/fullsize/path.webp',
|
path: '/uploads/user-id/fullsize/path.webp',
|
||||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const files: AssetFileEntity[] = [fullsizeFile, previewFile, thumbnailFile];
|
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
|
||||||
|
|
||||||
export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => {
|
export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => {
|
||||||
return {
|
return {
|
||||||
id: stackId,
|
id: stackId,
|
||||||
assets,
|
assets,
|
||||||
owner: assets[0].owner,
|
|
||||||
ownerId: assets[0].ownerId,
|
ownerId: assets[0].ownerId,
|
||||||
primaryAsset: assets[0],
|
primaryAsset: assets[0],
|
||||||
primaryAssetId: assets[0].id,
|
primaryAssetId: assets[0].id,
|
||||||
@ -129,7 +118,7 @@ export const assetStub = {
|
|||||||
isExternal: false,
|
isExternal: false,
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 123_000,
|
fileSizeInByte: 123_000,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
@ -203,7 +192,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
exifImageHeight: 1000,
|
exifImageHeight: 1000,
|
||||||
exifImageWidth: 1000,
|
exifImageWidth: 1000,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
stackId: 'stack-1',
|
stackId: 'stack-1',
|
||||||
stack: stackStub('stack-1', [
|
stack: stackStub('stack-1', [
|
||||||
{ id: 'primary-asset-id' } as AssetEntity,
|
{ id: 'primary-asset-id' } as AssetEntity,
|
||||||
@ -248,7 +237,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
exifImageHeight: 3840,
|
exifImageHeight: 3840,
|
||||||
exifImageWidth: 2160,
|
exifImageWidth: 2160,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
@ -286,7 +275,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
exifImageHeight: 3840,
|
exifImageHeight: 3840,
|
||||||
exifImageWidth: 2160,
|
exifImageWidth: 2160,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
status: AssetStatus.TRASHED,
|
status: AssetStatus.TRASHED,
|
||||||
@ -327,7 +316,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
exifImageHeight: 3840,
|
exifImageHeight: 3840,
|
||||||
exifImageWidth: 2160,
|
exifImageWidth: 2160,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: true,
|
isOffline: true,
|
||||||
}),
|
}),
|
||||||
@ -365,7 +354,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
exifImageHeight: 3840,
|
exifImageHeight: 3840,
|
||||||
exifImageWidth: 2160,
|
exifImageWidth: 2160,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
@ -403,7 +392,7 @@ export const assetStub = {
|
|||||||
sidecarPath: null,
|
sidecarPath: null,
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
@ -440,7 +429,7 @@ export const assetStub = {
|
|||||||
sidecarPath: null,
|
sidecarPath: null,
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
@ -476,7 +465,7 @@ export const assetStub = {
|
|||||||
sidecarPath: null,
|
sidecarPath: null,
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
@ -515,7 +504,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 100_000,
|
fileSizeInByte: 100_000,
|
||||||
exifImageHeight: 2160,
|
exifImageHeight: 2160,
|
||||||
exifImageWidth: 3840,
|
exifImageWidth: 3840,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
@ -606,7 +595,7 @@ export const assetStub = {
|
|||||||
city: 'test-city',
|
city: 'test-city',
|
||||||
state: 'test-state',
|
state: 'test-state',
|
||||||
country: 'test-country',
|
country: 'test-country',
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
@ -711,7 +700,7 @@ export const assetStub = {
|
|||||||
sidecarPath: null,
|
sidecarPath: null,
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 100_000,
|
fileSizeInByte: 100_000,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
@ -750,7 +739,7 @@ export const assetStub = {
|
|||||||
sidecarPath: null,
|
sidecarPath: null,
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
@ -789,7 +778,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
profileDescription: 'Adobe RGB',
|
profileDescription: 'Adobe RGB',
|
||||||
bitsPerSample: 14,
|
bitsPerSample: 14,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
@ -828,7 +817,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5000,
|
fileSizeInByte: 5000,
|
||||||
profileDescription: 'Adobe RGB',
|
profileDescription: 'Adobe RGB',
|
||||||
bitsPerSample: 14,
|
bitsPerSample: 14,
|
||||||
} as ExifEntity,
|
} as Exif,
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
}),
|
}),
|
||||||
|
20
server/test/fixtures/auth.stub.ts
vendored
20
server/test/fixtures/auth.stub.ts
vendored
@ -52,24 +52,4 @@ export const authStub = {
|
|||||||
key: Buffer.from('shared-link-key'),
|
key: Buffer.from('shared-link-key'),
|
||||||
} as SharedLinkEntity,
|
} as SharedLinkEntity,
|
||||||
}),
|
}),
|
||||||
adminSharedLinkNoExif: Object.freeze<AuthDto>({
|
|
||||||
user: authUser.admin,
|
|
||||||
sharedLink: {
|
|
||||||
id: '123',
|
|
||||||
showExif: false,
|
|
||||||
allowDownload: true,
|
|
||||||
allowUpload: true,
|
|
||||||
key: Buffer.from('shared-link-key'),
|
|
||||||
} as SharedLinkEntity,
|
|
||||||
}),
|
|
||||||
passwordSharedLink: Object.freeze<AuthDto>({
|
|
||||||
user: authUser.admin,
|
|
||||||
sharedLink: {
|
|
||||||
id: '123',
|
|
||||||
allowUpload: false,
|
|
||||||
allowDownload: false,
|
|
||||||
password: 'password-123',
|
|
||||||
showExif: true,
|
|
||||||
} as SharedLinkEntity,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
74
server/test/fixtures/face.stub.ts
vendored
74
server/test/fixtures/face.stub.ts
vendored
@ -1,15 +1,17 @@
|
|||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
|
||||||
import { SourceType } from 'src/enum';
|
import { SourceType } from 'src/enum';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { personStub } from 'test/fixtures/person.stub';
|
import { personStub } from 'test/fixtures/person.stub';
|
||||||
|
|
||||||
type NonNullableProperty<T> = { [P in keyof T]: NonNullable<T[P]> };
|
|
||||||
|
|
||||||
export const faceStub = {
|
export const faceStub = {
|
||||||
face1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
face1: Object.freeze({
|
||||||
id: 'assetFaceId1',
|
id: 'assetFaceId1',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: {
|
||||||
|
...assetStub.image,
|
||||||
|
libraryId: null,
|
||||||
|
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
|
||||||
|
stackId: null,
|
||||||
|
},
|
||||||
personId: personStub.withName.id,
|
personId: personStub.withName.id,
|
||||||
person: personStub.withName,
|
person: personStub.withName,
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
@ -22,7 +24,7 @@ export const faceStub = {
|
|||||||
faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' },
|
||||||
deletedAt: new Date(),
|
deletedAt: new Date(),
|
||||||
}),
|
}),
|
||||||
primaryFace1: Object.freeze<AssetFaceEntity>({
|
primaryFace1: Object.freeze({
|
||||||
id: 'assetFaceId2',
|
id: 'assetFaceId2',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
@ -38,7 +40,7 @@ export const faceStub = {
|
|||||||
faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' },
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
mergeFace1: Object.freeze<AssetFaceEntity>({
|
mergeFace1: Object.freeze({
|
||||||
id: 'assetFaceId3',
|
id: 'assetFaceId3',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
@ -54,55 +56,7 @@ export const faceStub = {
|
|||||||
faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' },
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
start: Object.freeze<AssetFaceEntity>({
|
noPerson1: Object.freeze({
|
||||||
id: 'assetFaceId5',
|
|
||||||
assetId: assetStub.image.id,
|
|
||||||
asset: assetStub.image,
|
|
||||||
personId: personStub.newThumbnail.id,
|
|
||||||
person: personStub.newThumbnail,
|
|
||||||
boundingBoxX1: 5,
|
|
||||||
boundingBoxY1: 5,
|
|
||||||
boundingBoxX2: 505,
|
|
||||||
boundingBoxY2: 505,
|
|
||||||
imageHeight: 2880,
|
|
||||||
imageWidth: 2160,
|
|
||||||
sourceType: SourceType.MACHINE_LEARNING,
|
|
||||||
faceSearch: { faceId: 'assetFaceId5', embedding: '[1, 2, 3, 4]' },
|
|
||||||
deletedAt: null,
|
|
||||||
}),
|
|
||||||
middle: Object.freeze<AssetFaceEntity>({
|
|
||||||
id: 'assetFaceId6',
|
|
||||||
assetId: assetStub.image.id,
|
|
||||||
asset: assetStub.image,
|
|
||||||
personId: personStub.newThumbnail.id,
|
|
||||||
person: personStub.newThumbnail,
|
|
||||||
boundingBoxX1: 100,
|
|
||||||
boundingBoxY1: 100,
|
|
||||||
boundingBoxX2: 200,
|
|
||||||
boundingBoxY2: 200,
|
|
||||||
imageHeight: 500,
|
|
||||||
imageWidth: 400,
|
|
||||||
sourceType: SourceType.MACHINE_LEARNING,
|
|
||||||
faceSearch: { faceId: 'assetFaceId6', embedding: '[1, 2, 3, 4]' },
|
|
||||||
deletedAt: null,
|
|
||||||
}),
|
|
||||||
end: Object.freeze<AssetFaceEntity>({
|
|
||||||
id: 'assetFaceId7',
|
|
||||||
assetId: assetStub.image.id,
|
|
||||||
asset: assetStub.image,
|
|
||||||
personId: personStub.newThumbnail.id,
|
|
||||||
person: personStub.newThumbnail,
|
|
||||||
boundingBoxX1: 300,
|
|
||||||
boundingBoxY1: 300,
|
|
||||||
boundingBoxX2: 495,
|
|
||||||
boundingBoxY2: 495,
|
|
||||||
imageHeight: 500,
|
|
||||||
imageWidth: 500,
|
|
||||||
sourceType: SourceType.MACHINE_LEARNING,
|
|
||||||
faceSearch: { faceId: 'assetFaceId7', embedding: '[1, 2, 3, 4]' },
|
|
||||||
deletedAt: null,
|
|
||||||
}),
|
|
||||||
noPerson1: Object.freeze<AssetFaceEntity>({
|
|
||||||
id: 'assetFaceId8',
|
id: 'assetFaceId8',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
@ -118,7 +72,7 @@ export const faceStub = {
|
|||||||
faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' },
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
noPerson2: Object.freeze<AssetFaceEntity>({
|
noPerson2: Object.freeze({
|
||||||
id: 'assetFaceId9',
|
id: 'assetFaceId9',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
@ -134,7 +88,7 @@ export const faceStub = {
|
|||||||
faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' },
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
fromExif1: Object.freeze<AssetFaceEntity>({
|
fromExif1: Object.freeze({
|
||||||
id: 'assetFaceId9',
|
id: 'assetFaceId9',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
@ -149,7 +103,7 @@ export const faceStub = {
|
|||||||
sourceType: SourceType.EXIF,
|
sourceType: SourceType.EXIF,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
fromExif2: Object.freeze<AssetFaceEntity>({
|
fromExif2: Object.freeze({
|
||||||
id: 'assetFaceId9',
|
id: 'assetFaceId9',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
@ -164,7 +118,7 @@ export const faceStub = {
|
|||||||
sourceType: SourceType.EXIF,
|
sourceType: SourceType.EXIF,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
withBirthDate: Object.freeze<AssetFaceEntity>({
|
withBirthDate: Object.freeze({
|
||||||
id: 'assetFaceId10',
|
id: 'assetFaceId10',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
|
71
server/test/fixtures/metadata.stub.ts
vendored
71
server/test/fixtures/metadata.stub.ts
vendored
@ -1,71 +0,0 @@
|
|||||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
|
||||||
import { personStub } from 'test/fixtures/person.stub';
|
|
||||||
|
|
||||||
export const metadataStub = {
|
|
||||||
empty: Object.freeze<ImmichTags>({}),
|
|
||||||
withFace: Object.freeze<ImmichTags>({
|
|
||||||
RegionInfo: {
|
|
||||||
AppliedToDimensions: {
|
|
||||||
W: 100,
|
|
||||||
H: 100,
|
|
||||||
Unit: 'normalized',
|
|
||||||
},
|
|
||||||
RegionList: [
|
|
||||||
{
|
|
||||||
Type: 'face',
|
|
||||||
Name: personStub.withName.name,
|
|
||||||
Area: {
|
|
||||||
X: 0.05,
|
|
||||||
Y: 0.05,
|
|
||||||
W: 0.1,
|
|
||||||
H: 0.1,
|
|
||||||
Unit: 'normalized',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
withFaceEmptyName: Object.freeze<ImmichTags>({
|
|
||||||
RegionInfo: {
|
|
||||||
AppliedToDimensions: {
|
|
||||||
W: 100,
|
|
||||||
H: 100,
|
|
||||||
Unit: 'normalized',
|
|
||||||
},
|
|
||||||
RegionList: [
|
|
||||||
{
|
|
||||||
Type: 'face',
|
|
||||||
Name: '',
|
|
||||||
Area: {
|
|
||||||
X: 0.05,
|
|
||||||
Y: 0.05,
|
|
||||||
W: 0.1,
|
|
||||||
H: 0.1,
|
|
||||||
Unit: 'normalized',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
withFaceNoName: Object.freeze<ImmichTags>({
|
|
||||||
RegionInfo: {
|
|
||||||
AppliedToDimensions: {
|
|
||||||
W: 100,
|
|
||||||
H: 100,
|
|
||||||
Unit: 'normalized',
|
|
||||||
},
|
|
||||||
RegionList: [
|
|
||||||
{
|
|
||||||
Type: 'face',
|
|
||||||
Area: {
|
|
||||||
X: 0.05,
|
|
||||||
Y: 0.05,
|
|
||||||
W: 0.1,
|
|
||||||
H: 0.1,
|
|
||||||
Unit: 'normalized',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
102
server/test/fixtures/person.stub.ts
vendored
102
server/test/fixtures/person.stub.ts
vendored
@ -1,13 +1,16 @@
|
|||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { AssetType } from 'src/enum';
|
||||||
|
import { previewFile } from 'test/fixtures/asset.stub';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
|
|
||||||
|
const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125';
|
||||||
|
|
||||||
export const personStub = {
|
export const personStub = {
|
||||||
noName: Object.freeze<PersonEntity>({
|
noName: Object.freeze({
|
||||||
id: 'person-1',
|
id: 'person-1',
|
||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
updateId,
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
owner: userStub.admin,
|
|
||||||
name: '',
|
name: '',
|
||||||
birthDate: null,
|
birthDate: null,
|
||||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||||
@ -16,13 +19,14 @@ export const personStub = {
|
|||||||
faceAsset: null,
|
faceAsset: null,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
|
color: 'red',
|
||||||
}),
|
}),
|
||||||
hidden: Object.freeze<PersonEntity>({
|
hidden: Object.freeze({
|
||||||
id: 'person-1',
|
id: 'person-1',
|
||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
updateId,
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
owner: userStub.admin,
|
|
||||||
name: '',
|
name: '',
|
||||||
birthDate: null,
|
birthDate: null,
|
||||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||||
@ -31,13 +35,14 @@ export const personStub = {
|
|||||||
faceAsset: null,
|
faceAsset: null,
|
||||||
isHidden: true,
|
isHidden: true,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
|
color: 'red',
|
||||||
}),
|
}),
|
||||||
withName: Object.freeze<PersonEntity>({
|
withName: Object.freeze({
|
||||||
id: 'person-1',
|
id: 'person-1',
|
||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
updateId,
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
owner: userStub.admin,
|
|
||||||
name: 'Person 1',
|
name: 'Person 1',
|
||||||
birthDate: null,
|
birthDate: null,
|
||||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||||
@ -46,28 +51,30 @@ export const personStub = {
|
|||||||
faceAsset: null,
|
faceAsset: null,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
|
color: 'red',
|
||||||
}),
|
}),
|
||||||
withBirthDate: Object.freeze<PersonEntity>({
|
withBirthDate: Object.freeze({
|
||||||
id: 'person-1',
|
id: 'person-1',
|
||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
updateId,
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
owner: userStub.admin,
|
|
||||||
name: 'Person 1',
|
name: 'Person 1',
|
||||||
birthDate: '1976-06-30',
|
birthDate: new Date('1976-06-30'),
|
||||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||||
faces: [],
|
faces: [],
|
||||||
faceAssetId: null,
|
faceAssetId: null,
|
||||||
faceAsset: null,
|
faceAsset: null,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
|
color: 'red',
|
||||||
}),
|
}),
|
||||||
noThumbnail: Object.freeze<PersonEntity>({
|
noThumbnail: Object.freeze({
|
||||||
id: 'person-1',
|
id: 'person-1',
|
||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
updateId,
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
owner: userStub.admin,
|
|
||||||
name: '',
|
name: '',
|
||||||
birthDate: null,
|
birthDate: null,
|
||||||
thumbnailPath: '',
|
thumbnailPath: '',
|
||||||
@ -76,13 +83,14 @@ export const personStub = {
|
|||||||
faceAsset: null,
|
faceAsset: null,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
|
color: 'red',
|
||||||
}),
|
}),
|
||||||
newThumbnail: Object.freeze<PersonEntity>({
|
newThumbnail: Object.freeze({
|
||||||
id: 'person-1',
|
id: 'person-1',
|
||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
updateId,
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
owner: userStub.admin,
|
|
||||||
name: '',
|
name: '',
|
||||||
birthDate: null,
|
birthDate: null,
|
||||||
thumbnailPath: '/new/path/to/thumbnail.jpg',
|
thumbnailPath: '/new/path/to/thumbnail.jpg',
|
||||||
@ -91,13 +99,14 @@ export const personStub = {
|
|||||||
faceAsset: null,
|
faceAsset: null,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
|
color: 'red',
|
||||||
}),
|
}),
|
||||||
primaryPerson: Object.freeze<PersonEntity>({
|
primaryPerson: Object.freeze({
|
||||||
id: 'person-1',
|
id: 'person-1',
|
||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
updateId,
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
owner: userStub.admin,
|
|
||||||
name: 'Person 1',
|
name: 'Person 1',
|
||||||
birthDate: null,
|
birthDate: null,
|
||||||
thumbnailPath: '/path/to/thumbnail',
|
thumbnailPath: '/path/to/thumbnail',
|
||||||
@ -106,13 +115,14 @@ export const personStub = {
|
|||||||
faceAsset: null,
|
faceAsset: null,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
|
color: 'red',
|
||||||
}),
|
}),
|
||||||
mergePerson: Object.freeze<PersonEntity>({
|
mergePerson: Object.freeze({
|
||||||
id: 'person-2',
|
id: 'person-2',
|
||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
updateId,
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
owner: userStub.admin,
|
|
||||||
name: 'Person 2',
|
name: 'Person 2',
|
||||||
birthDate: null,
|
birthDate: null,
|
||||||
thumbnailPath: '/path/to/thumbnail',
|
thumbnailPath: '/path/to/thumbnail',
|
||||||
@ -121,13 +131,14 @@ export const personStub = {
|
|||||||
faceAsset: null,
|
faceAsset: null,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
|
color: 'red',
|
||||||
}),
|
}),
|
||||||
randomPerson: Object.freeze<PersonEntity>({
|
randomPerson: Object.freeze({
|
||||||
id: 'person-3',
|
id: 'person-3',
|
||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
updateId,
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
owner: userStub.admin,
|
|
||||||
name: '',
|
name: '',
|
||||||
birthDate: null,
|
birthDate: null,
|
||||||
thumbnailPath: '/path/to/thumbnail',
|
thumbnailPath: '/path/to/thumbnail',
|
||||||
@ -136,13 +147,14 @@ export const personStub = {
|
|||||||
faceAsset: null,
|
faceAsset: null,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
|
color: 'red',
|
||||||
}),
|
}),
|
||||||
isFavorite: Object.freeze<PersonEntity>({
|
isFavorite: Object.freeze({
|
||||||
id: 'person-4',
|
id: 'person-4',
|
||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
updateId,
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
owner: userStub.admin,
|
|
||||||
name: 'Person 1',
|
name: 'Person 1',
|
||||||
birthDate: null,
|
birthDate: null,
|
||||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||||
@ -151,5 +163,51 @@ export const personStub = {
|
|||||||
faceAsset: null,
|
faceAsset: null,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
|
color: 'red',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const personThumbnailStub = {
|
||||||
|
newThumbnailStart: Object.freeze({
|
||||||
|
ownerId: userStub.admin.id,
|
||||||
|
x1: 5,
|
||||||
|
y1: 5,
|
||||||
|
x2: 505,
|
||||||
|
y2: 505,
|
||||||
|
oldHeight: 2880,
|
||||||
|
oldWidth: 2160,
|
||||||
|
type: AssetType.IMAGE,
|
||||||
|
originalPath: '/original/path.jpg',
|
||||||
|
exifImageHeight: 3840,
|
||||||
|
exifImageWidth: 2160,
|
||||||
|
previewPath: previewFile.path,
|
||||||
|
}),
|
||||||
|
newThumbnailMiddle: Object.freeze({
|
||||||
|
ownerId: userStub.admin.id,
|
||||||
|
x1: 100,
|
||||||
|
y1: 100,
|
||||||
|
x2: 200,
|
||||||
|
y2: 200,
|
||||||
|
oldHeight: 500,
|
||||||
|
oldWidth: 400,
|
||||||
|
type: AssetType.IMAGE,
|
||||||
|
originalPath: '/original/path.jpg',
|
||||||
|
exifImageHeight: 1000,
|
||||||
|
exifImageWidth: 1000,
|
||||||
|
previewPath: previewFile.path,
|
||||||
|
}),
|
||||||
|
newThumbnailEnd: Object.freeze({
|
||||||
|
ownerId: userStub.admin.id,
|
||||||
|
x1: 300,
|
||||||
|
y1: 300,
|
||||||
|
x2: 495,
|
||||||
|
y2: 495,
|
||||||
|
oldHeight: 500,
|
||||||
|
oldWidth: 500,
|
||||||
|
type: AssetType.IMAGE,
|
||||||
|
originalPath: '/original/path.jpg',
|
||||||
|
exifImageHeight: 1000,
|
||||||
|
exifImageWidth: 1000,
|
||||||
|
previewPath: previewFile.path,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
8
server/test/fixtures/shared-link.stub.ts
vendored
8
server/test/fixtures/shared-link.stub.ts
vendored
@ -1,10 +1,10 @@
|
|||||||
|
import { UserAdmin } from 'src/database';
|
||||||
import { AlbumResponseDto } from 'src/dtos/album.dto';
|
import { AlbumResponseDto } from 'src/dtos/album.dto';
|
||||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||||
import { ExifResponseDto } from 'src/dtos/exif.dto';
|
import { ExifResponseDto } from 'src/dtos/exif.dto';
|
||||||
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
|
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
|
||||||
import { mapUser } from 'src/dtos/user.dto';
|
import { mapUser } from 'src/dtos/user.dto';
|
||||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
|
||||||
import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum';
|
import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
@ -106,7 +106,6 @@ export const sharedLinkStub = {
|
|||||||
individual: Object.freeze({
|
individual: Object.freeze({
|
||||||
id: '123',
|
id: '123',
|
||||||
userId: authStub.admin.user.id,
|
userId: authStub.admin.user.id,
|
||||||
user: userStub.admin,
|
|
||||||
key: sharedLinkBytes,
|
key: sharedLinkBytes,
|
||||||
type: SharedLinkType.INDIVIDUAL,
|
type: SharedLinkType.INDIVIDUAL,
|
||||||
createdAt: today,
|
createdAt: today,
|
||||||
@ -154,7 +153,6 @@ export const sharedLinkStub = {
|
|||||||
readonlyNoExif: Object.freeze<SharedLinkEntity>({
|
readonlyNoExif: Object.freeze<SharedLinkEntity>({
|
||||||
id: '123',
|
id: '123',
|
||||||
userId: authStub.admin.user.id,
|
userId: authStub.admin.user.id,
|
||||||
user: userStub.admin,
|
|
||||||
key: sharedLinkBytes,
|
key: sharedLinkBytes,
|
||||||
type: SharedLinkType.ALBUM,
|
type: SharedLinkType.ALBUM,
|
||||||
createdAt: today,
|
createdAt: today,
|
||||||
@ -185,7 +183,7 @@ export const sharedLinkStub = {
|
|||||||
{
|
{
|
||||||
id: 'id_1',
|
id: 'id_1',
|
||||||
status: AssetStatus.ACTIVE,
|
status: AssetStatus.ACTIVE,
|
||||||
owner: undefined as unknown as UserEntity,
|
owner: undefined as unknown as UserAdmin,
|
||||||
ownerId: 'user_id_1',
|
ownerId: 'user_id_1',
|
||||||
deviceAssetId: 'device_asset_id_1',
|
deviceAssetId: 'device_asset_id_1',
|
||||||
deviceId: 'device_id_1',
|
deviceId: 'device_id_1',
|
||||||
@ -234,7 +232,6 @@ export const sharedLinkStub = {
|
|||||||
iso: 100,
|
iso: 100,
|
||||||
exposureTime: '1/16',
|
exposureTime: '1/16',
|
||||||
fps: 100,
|
fps: 100,
|
||||||
asset: null as any,
|
|
||||||
profileDescription: 'sRGB',
|
profileDescription: 'sRGB',
|
||||||
bitsPerSample: 8,
|
bitsPerSample: 8,
|
||||||
colorspace: 'sRGB',
|
colorspace: 'sRGB',
|
||||||
@ -253,7 +250,6 @@ export const sharedLinkStub = {
|
|||||||
passwordRequired: Object.freeze<SharedLinkEntity>({
|
passwordRequired: Object.freeze<SharedLinkEntity>({
|
||||||
id: '123',
|
id: '123',
|
||||||
userId: authStub.admin.user.id,
|
userId: authStub.admin.user.id,
|
||||||
user: userStub.admin,
|
|
||||||
key: sharedLinkBytes,
|
key: sharedLinkBytes,
|
||||||
type: SharedLinkType.ALBUM,
|
type: SharedLinkType.ALBUM,
|
||||||
createdAt: today,
|
createdAt: today,
|
||||||
|
56
server/test/fixtures/user.stub.ts
vendored
56
server/test/fixtures/user.stub.ts
vendored
@ -1,13 +1,12 @@
|
|||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserAdmin } from 'src/database';
|
||||||
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
|
|
||||||
export const userStub = {
|
export const userStub = {
|
||||||
admin: Object.freeze<UserEntity>({
|
admin: <UserAdmin>{
|
||||||
...authStub.admin.user,
|
...authStub.admin.user,
|
||||||
status: UserStatus.ACTIVE,
|
status: UserStatus.ACTIVE,
|
||||||
profileChangedAt: new Date('2021-01-01'),
|
profileChangedAt: new Date('2021-01-01'),
|
||||||
password: 'admin_password',
|
|
||||||
name: 'admin_name',
|
name: 'admin_name',
|
||||||
id: 'admin_id',
|
id: 'admin_id',
|
||||||
storageLabel: 'admin',
|
storageLabel: 'admin',
|
||||||
@ -17,16 +16,14 @@ export const userStub = {
|
|||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
assets: [],
|
|
||||||
metadata: [],
|
metadata: [],
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
quotaUsageInBytes: 0,
|
quotaUsageInBytes: 0,
|
||||||
}),
|
},
|
||||||
user1: Object.freeze<UserEntity>({
|
user1: <UserAdmin>{
|
||||||
...authStub.user1.user,
|
...authStub.user1.user,
|
||||||
status: UserStatus.ACTIVE,
|
status: UserStatus.ACTIVE,
|
||||||
profileChangedAt: new Date('2021-01-01'),
|
profileChangedAt: new Date('2021-01-01'),
|
||||||
password: 'immich_password',
|
|
||||||
name: 'immich_name',
|
name: 'immich_name',
|
||||||
storageLabel: null,
|
storageLabel: null,
|
||||||
oauthId: '',
|
oauthId: '',
|
||||||
@ -35,7 +32,6 @@ export const userStub = {
|
|||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
assets: [],
|
|
||||||
metadata: [
|
metadata: [
|
||||||
{
|
{
|
||||||
key: UserMetadataKey.PREFERENCES,
|
key: UserMetadataKey.PREFERENCES,
|
||||||
@ -44,13 +40,12 @@ export const userStub = {
|
|||||||
],
|
],
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
quotaUsageInBytes: 0,
|
quotaUsageInBytes: 0,
|
||||||
}),
|
},
|
||||||
user2: Object.freeze<UserEntity>({
|
user2: <UserAdmin>{
|
||||||
...authStub.user2.user,
|
...authStub.user2.user,
|
||||||
status: UserStatus.ACTIVE,
|
status: UserStatus.ACTIVE,
|
||||||
profileChangedAt: new Date('2021-01-01'),
|
profileChangedAt: new Date('2021-01-01'),
|
||||||
metadata: [],
|
metadata: [],
|
||||||
password: 'immich_password',
|
|
||||||
name: 'immich_name',
|
name: 'immich_name',
|
||||||
storageLabel: null,
|
storageLabel: null,
|
||||||
oauthId: '',
|
oauthId: '',
|
||||||
@ -59,44 +54,7 @@ export const userStub = {
|
|||||||
createdAt: new Date('2021-01-01'),
|
createdAt: new Date('2021-01-01'),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
updatedAt: new Date('2021-01-01'),
|
updatedAt: new Date('2021-01-01'),
|
||||||
assets: [],
|
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
quotaUsageInBytes: 0,
|
quotaUsageInBytes: 0,
|
||||||
}),
|
},
|
||||||
storageLabel: Object.freeze<UserEntity>({
|
|
||||||
...authStub.user1.user,
|
|
||||||
status: UserStatus.ACTIVE,
|
|
||||||
profileChangedAt: new Date('2021-01-01'),
|
|
||||||
metadata: [],
|
|
||||||
password: 'immich_password',
|
|
||||||
name: 'immich_name',
|
|
||||||
storageLabel: 'label-1',
|
|
||||||
oauthId: '',
|
|
||||||
shouldChangePassword: false,
|
|
||||||
profileImagePath: '',
|
|
||||||
createdAt: new Date('2021-01-01'),
|
|
||||||
deletedAt: null,
|
|
||||||
updatedAt: new Date('2021-01-01'),
|
|
||||||
assets: [],
|
|
||||||
quotaSizeInBytes: null,
|
|
||||||
quotaUsageInBytes: 0,
|
|
||||||
}),
|
|
||||||
profilePath: Object.freeze<UserEntity>({
|
|
||||||
...authStub.user1.user,
|
|
||||||
status: UserStatus.ACTIVE,
|
|
||||||
profileChangedAt: new Date('2021-01-01'),
|
|
||||||
metadata: [],
|
|
||||||
password: 'immich_password',
|
|
||||||
name: 'immich_name',
|
|
||||||
storageLabel: 'label-1',
|
|
||||||
oauthId: '',
|
|
||||||
shouldChangePassword: false,
|
|
||||||
profileImagePath: '/path/to/profile.jpg',
|
|
||||||
createdAt: new Date('2021-01-01'),
|
|
||||||
deletedAt: null,
|
|
||||||
updatedAt: new Date('2021-01-01'),
|
|
||||||
assets: [],
|
|
||||||
quotaSizeInBytes: null,
|
|
||||||
quotaUsageInBytes: 0,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
@ -14,7 +14,8 @@ export const newPersonRepositoryMock = (): Mocked<RepositoryInterface<PersonRepo
|
|||||||
getAllWithoutFaces: vitest.fn(),
|
getAllWithoutFaces: vitest.fn(),
|
||||||
getFaces: vitest.fn(),
|
getFaces: vitest.fn(),
|
||||||
getFaceById: vitest.fn(),
|
getFaceById: vitest.fn(),
|
||||||
getFaceByIdWithAssets: vitest.fn(),
|
getFaceForFacialRecognitionJob: vitest.fn(),
|
||||||
|
getDataForThumbnailGenerationJob: vitest.fn(),
|
||||||
reassignFace: vitest.fn(),
|
reassignFace: vitest.fn(),
|
||||||
getById: vitest.fn(),
|
getById: vitest.fn(),
|
||||||
getByName: vitest.fn(),
|
getByName: vitest.fn(),
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
ApiKey,
|
ApiKey,
|
||||||
Asset,
|
Asset,
|
||||||
AuthApiKey,
|
AuthApiKey,
|
||||||
|
AuthSharedLink,
|
||||||
AuthUser,
|
AuthUser,
|
||||||
Library,
|
Library,
|
||||||
Memory,
|
Memory,
|
||||||
@ -35,12 +36,20 @@ export const newEmbedding = () => {
|
|||||||
const authFactory = ({
|
const authFactory = ({
|
||||||
apiKey,
|
apiKey,
|
||||||
session,
|
session,
|
||||||
...user
|
sharedLink,
|
||||||
}: Partial<AuthUser> & { apiKey?: Partial<AuthApiKey>; session?: { id: string } } = {}) => {
|
user,
|
||||||
|
}: {
|
||||||
|
apiKey?: Partial<AuthApiKey>;
|
||||||
|
session?: { id: string };
|
||||||
|
user?: Partial<UserAdmin>;
|
||||||
|
sharedLink?: Partial<AuthSharedLink>;
|
||||||
|
} = {}) => {
|
||||||
const auth: AuthDto = {
|
const auth: AuthDto = {
|
||||||
user: authUserFactory(user),
|
user: authUserFactory(userAdminFactory(user ?? {})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const userId = auth.user.id;
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
auth.apiKey = authApiKeyFactory(apiKey);
|
auth.apiKey = authApiKeyFactory(apiKey);
|
||||||
}
|
}
|
||||||
@ -49,24 +58,45 @@ const authFactory = ({
|
|||||||
auth.session = { id: session.id };
|
auth.session = { id: session.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sharedLink) {
|
||||||
|
auth.sharedLink = authSharedLinkFactory({ ...sharedLink, userId });
|
||||||
|
}
|
||||||
|
|
||||||
return auth;
|
return auth;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const authSharedLinkFactory = (sharedLink: Partial<AuthSharedLink> = {}) => {
|
||||||
|
const {
|
||||||
|
id = newUuid(),
|
||||||
|
expiresAt = null,
|
||||||
|
userId = newUuid(),
|
||||||
|
showExif = true,
|
||||||
|
allowUpload = false,
|
||||||
|
allowDownload = true,
|
||||||
|
password = null,
|
||||||
|
} = sharedLink;
|
||||||
|
|
||||||
|
return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password };
|
||||||
|
};
|
||||||
|
|
||||||
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({
|
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({
|
||||||
id: newUuid(),
|
id: newUuid(),
|
||||||
permissions: [Permission.ALL],
|
permissions: [Permission.ALL],
|
||||||
...apiKey,
|
...apiKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const authUserFactory = (authUser: Partial<AuthUser> = {}) => ({
|
const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
|
||||||
id: newUuid(),
|
const {
|
||||||
isAdmin: false,
|
id = newUuid(),
|
||||||
name: 'Test User',
|
isAdmin = false,
|
||||||
email: 'test@immich.cloud',
|
name = 'Test User',
|
||||||
quotaUsageInBytes: 0,
|
email = 'test@immich.cloud',
|
||||||
quotaSizeInBytes: null,
|
quotaUsageInBytes = 0,
|
||||||
...authUser,
|
quotaSizeInBytes = null,
|
||||||
});
|
} = authUser;
|
||||||
|
|
||||||
|
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes };
|
||||||
|
};
|
||||||
|
|
||||||
const partnerFactory = (partner: Partial<Partner> = {}) => {
|
const partnerFactory = (partner: Partial<Partner> = {}) => {
|
||||||
const sharedBy = userFactory(partner.sharedBy || {});
|
const sharedBy = userFactory(partner.sharedBy || {});
|
||||||
@ -112,25 +142,44 @@ const userFactory = (user: Partial<User> = {}) => ({
|
|||||||
...user,
|
...user,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userAdminFactory = (user: Partial<UserAdmin> = {}) => ({
|
const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
||||||
id: newUuid(),
|
const {
|
||||||
name: 'Test User',
|
id = newUuid(),
|
||||||
email: 'test@immich.cloud',
|
name = 'Test User',
|
||||||
profileImagePath: '',
|
email = 'test@immich.cloud',
|
||||||
profileChangedAt: newDate(),
|
profileImagePath = '',
|
||||||
storageLabel: null,
|
profileChangedAt = newDate(),
|
||||||
shouldChangePassword: false,
|
storageLabel = null,
|
||||||
isAdmin: false,
|
shouldChangePassword = false,
|
||||||
createdAt: newDate(),
|
isAdmin = false,
|
||||||
updatedAt: newDate(),
|
createdAt = newDate(),
|
||||||
deletedAt: null,
|
updatedAt = newDate(),
|
||||||
oauthId: '',
|
deletedAt = null,
|
||||||
quotaSizeInBytes: null,
|
oauthId = '',
|
||||||
quotaUsageInBytes: 0,
|
quotaSizeInBytes = null,
|
||||||
status: UserStatus.ACTIVE,
|
quotaUsageInBytes = 0,
|
||||||
metadata: [],
|
status = UserStatus.ACTIVE,
|
||||||
...user,
|
metadata = [],
|
||||||
});
|
} = user;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
profileImagePath,
|
||||||
|
profileChangedAt,
|
||||||
|
storageLabel,
|
||||||
|
shouldChangePassword,
|
||||||
|
isAdmin,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
deletedAt,
|
||||||
|
oauthId,
|
||||||
|
quotaSizeInBytes,
|
||||||
|
quotaUsageInBytes,
|
||||||
|
status,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const assetFactory = (asset: Partial<Asset> = {}) => ({
|
const assetFactory = (asset: Partial<Asset> = {}) => ({
|
||||||
id: newUuid(),
|
id: newUuid(),
|
||||||
|
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@ -9492,9 +9492,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.2.5",
|
"version": "6.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
|
||||||
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
|
"integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import SharedLinkCopy from '$lib/components/sharedlinks-page/actions/shared-link-copy.svelte';
|
import SharedLinkCopy from '$lib/components/sharedlinks-page/actions/shared-link-copy.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import type { AlbumResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
import type { AlbumResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { Text } from '@immich/ui';
|
import { Text } from '@immich/ui';
|
||||||
|
import { mdiQrcode } from '@mdi/js';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
album: AlbumResponseDto;
|
album: AlbumResponseDto;
|
||||||
sharedLink: SharedLinkResponseDto;
|
sharedLink: SharedLinkResponseDto;
|
||||||
|
onViewQrCode: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { album, sharedLink }: Props = $props();
|
const { album, sharedLink, onViewQrCode }: Props = $props();
|
||||||
|
|
||||||
const getShareProperties = () =>
|
const getShareProperties = () =>
|
||||||
[
|
[
|
||||||
@ -37,5 +40,8 @@
|
|||||||
<Text size="small">{sharedLink.description || album.albumName}</Text>
|
<Text size="small">{sharedLink.description || album.albumName}</Text>
|
||||||
<Text size="tiny" color="muted">{getShareProperties()}</Text>
|
<Text size="tiny" color="muted">{getShareProperties()}</Text>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<CircleIconButton title={$t('view_qr_code')} icon={mdiQrcode} onclick={onViewQrCode} />
|
||||||
<SharedLinkCopy link={sharedLink} />
|
<SharedLinkCopy link={sharedLink} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,7 +3,10 @@
|
|||||||
import Dropdown from '$lib/components/elements/dropdown.svelte';
|
import Dropdown from '$lib/components/elements/dropdown.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||||
|
import QrCodeModal from '$lib/components/shared-components/qr-code-modal.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { serverConfig } from '$lib/stores/server-config.store';
|
||||||
|
import { makeSharedLinkUrl } from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
AlbumUserRole,
|
AlbumUserRole,
|
||||||
getAllSharedLinks,
|
getAllSharedLinks,
|
||||||
@ -31,6 +34,11 @@
|
|||||||
let users: UserResponseDto[] = $state([]);
|
let users: UserResponseDto[] = $state([]);
|
||||||
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({});
|
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({});
|
||||||
|
|
||||||
|
let sharedLinkUrl = $state('');
|
||||||
|
const handleViewQrCode = (sharedLink: SharedLinkResponseDto) => {
|
||||||
|
sharedLinkUrl = makeSharedLinkUrl($serverConfig.externalDomain, sharedLink.key);
|
||||||
|
};
|
||||||
|
|
||||||
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
|
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
|
||||||
{ title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil },
|
{ title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil },
|
||||||
{ title: $t('role_viewer'), value: AlbumUserRole.Viewer, icon: mdiEye },
|
{ title: $t('role_viewer'), value: AlbumUserRole.Viewer, icon: mdiEye },
|
||||||
@ -68,7 +76,10 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal title={$t('share')} showLogo {onClose}>
|
{#if sharedLinkUrl}
|
||||||
|
<QrCodeModal title={$t('view_link')} onClose={() => (sharedLinkUrl = '')} value={sharedLinkUrl} />
|
||||||
|
{:else}
|
||||||
|
<FullScreenModal title={$t('share')} showLogo {onClose}>
|
||||||
{#if Object.keys(selectedUsers).length > 0}
|
{#if Object.keys(selectedUsers).length > 0}
|
||||||
<div class="mb-2 py-2 sticky">
|
<div class="mb-2 py-2 sticky">
|
||||||
<p class="text-xs font-medium">{$t('selected')}</p>
|
<p class="text-xs font-medium">{$t('selected')}</p>
|
||||||
@ -119,7 +130,11 @@
|
|||||||
{#each users as user (user.id)}
|
{#each users as user (user.id)}
|
||||||
{#if !Object.keys(selectedUsers).includes(user.id)}
|
{#if !Object.keys(selectedUsers).includes(user.id)}
|
||||||
<div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl">
|
<div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl">
|
||||||
<button type="button" onclick={() => handleToggle(user)} class="flex w-full place-items-center gap-4 p-4">
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleToggle(user)}
|
||||||
|
class="flex w-full place-items-center gap-4 p-4"
|
||||||
|
>
|
||||||
<UserAvatar {user} size="md" />
|
<UserAvatar {user} size="md" />
|
||||||
<div class="text-left flex-grow">
|
<div class="text-left flex-grow">
|
||||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||||
@ -162,11 +177,12 @@
|
|||||||
|
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
{#each sharedLinks as sharedLink (sharedLink.id)}
|
{#each sharedLinks as sharedLink (sharedLink.id)}
|
||||||
<AlbumSharedLink {album} {sharedLink} />
|
<AlbumSharedLink {album} {sharedLink} onViewQrCode={() => handleViewQrCode(sharedLink)} />
|
||||||
{/each}
|
{/each}
|
||||||
</Stack>
|
</Stack>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Button leadingIcon={mdiLink} size="small" shape="round" fullWidth onclick={onShare}>{$t('create_link')}</Button>
|
<Button leadingIcon={mdiLink} size="small" shape="round" fullWidth onclick={onShare}>{$t('create_link')}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
{/if}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type DownloadProgress, downloadAssets, downloadManager, isDownloading } from '$lib/stores/download';
|
import { type DownloadProgress, downloadManager, downloadStore } from '$lib/stores/download-store.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { fly, slide } from 'svelte/transition';
|
import { fly, slide } from 'svelte/transition';
|
||||||
import { getByteUnitString } from '../../utils/byte-units';
|
import { getByteUnitString } from '../../utils/byte-units';
|
||||||
@ -13,15 +13,15 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $isDownloading}
|
{#if downloadStore.isDownloading}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ x: -100, duration: 350 }}
|
transition:fly={{ x: -100, duration: 350 }}
|
||||||
class="fixed bottom-10 left-2 z-[10000] max-h-[270px] w-[315px] rounded-2xl border bg-immich-bg p-4 text-sm shadow-sm"
|
class="fixed bottom-10 left-2 z-[10000] max-h-[270px] w-[315px] rounded-2xl border bg-immich-bg p-4 text-sm shadow-sm"
|
||||||
>
|
>
|
||||||
<p class="mb-2 text-xs text-gray-500">{$t('downloading').toUpperCase()}</p>
|
<p class="mb-2 text-xs text-gray-500">{$t('downloading').toUpperCase()}</p>
|
||||||
<div class="my-2 mb-2 flex max-h-[200px] flex-col overflow-y-auto text-sm">
|
<div class="my-2 mb-2 flex max-h-[200px] flex-col overflow-y-auto text-sm">
|
||||||
{#each Object.keys($downloadAssets) as downloadKey (downloadKey)}
|
{#each Object.keys(downloadStore.assets) as downloadKey (downloadKey)}
|
||||||
{@const download = $downloadAssets[downloadKey]}
|
{@const download = downloadStore.assets[downloadKey]}
|
||||||
<div class="mb-2 flex place-items-center" transition:slide>
|
<div class="mb-2 flex place-items-center" transition:slide>
|
||||||
<div class="w-full pr-10">
|
<div class="w-full pr-10">
|
||||||
<div class="flex place-items-center justify-between gap-2 text-xs font-medium">
|
<div class="flex place-items-center justify-between gap-2 text-xs font-medium">
|
||||||
@ -31,7 +31,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex place-items-center gap-2">
|
<div class="flex place-items-center gap-2">
|
||||||
<div class="h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
<div class="h-[7px] w-full rounded-full bg-gray-200">
|
||||||
<div class="h-[7px] rounded-full bg-immich-primary" style={`width: ${download.percentage}%`}></div>
|
<div class="h-[7px] rounded-full bg-immich-primary" style={`width: ${download.percentage}%`}></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="min-w-[4em] whitespace-nowrap text-right">
|
<p class="min-w-[4em] whitespace-nowrap text-right">
|
||||||
|
@ -51,7 +51,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<main
|
<main
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="relative grid h-dvh grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
|
class="relative grid h-dvh grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]"
|
||||||
>
|
>
|
||||||
{#if sidebar}{@render sidebar()}{:else if admin}
|
{#if sidebar}{@render sidebar()}{:else if admin}
|
||||||
<AdminSideBar />
|
<AdminSideBar />
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { isSearchEnabled } from '$lib/stores/search.store';
|
import { searchStore } from '$lib/stores/search.svelte';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||||
@ -448,7 +448,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if ($isSearchEnabled) {
|
if (searchStore.isSearchEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,7 +459,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onKeyUp = (event: KeyboardEvent) => {
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
if ($isSearchEnabled) {
|
if (searchStore.isSearchEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -648,7 +648,7 @@
|
|||||||
|
|
||||||
let shortcutList = $derived(
|
let shortcutList = $derived(
|
||||||
(() => {
|
(() => {
|
||||||
if ($isSearchEnabled || $showAssetViewer) {
|
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||||
|
import QrCodeModal from '$lib/components/shared-components/qr-code-modal.svelte';
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
import { SettingInputFieldType } from '$lib/constants';
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { serverConfig } from '$lib/stores/server-config.store';
|
import { serverConfig } from '$lib/stores/server-config.store';
|
||||||
import { copyToClipboard, makeSharedLinkUrl } from '$lib/utils';
|
import { makeSharedLinkUrl } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
|
import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { Button, HStack, IconButton, Input } from '@immich/ui';
|
import { Button } from '@immich/ui';
|
||||||
import { mdiContentCopy, mdiLink } from '@mdi/js';
|
import { mdiLink } from '@mdi/js';
|
||||||
import { DateTime, Duration } from 'luxon';
|
import { DateTime, Duration } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { NotificationType, notificationController } from '../notification/notification';
|
import { NotificationType, notificationController } from '../notification/notification';
|
||||||
import SettingInputField from '../settings/setting-input-field.svelte';
|
import SettingInputField from '../settings/setting-input-field.svelte';
|
||||||
import SettingSwitch from '../settings/setting-switch.svelte';
|
import SettingSwitch from '../settings/setting-switch.svelte';
|
||||||
import QRCode from '$lib/components/shared-components/qrcode.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -41,7 +41,6 @@
|
|||||||
let password = $state('');
|
let password = $state('');
|
||||||
let shouldChangeExpirationTime = $state(false);
|
let shouldChangeExpirationTime = $state(false);
|
||||||
let enablePassword = $state(false);
|
let enablePassword = $state(false);
|
||||||
let modalWidth = $state(0);
|
|
||||||
|
|
||||||
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
|
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||||
[30, 'minutes'],
|
[30, 'minutes'],
|
||||||
@ -248,26 +247,5 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
{:else}
|
{:else}
|
||||||
<FullScreenModal title={getTitle()} icon={mdiLink} {onClose}>
|
<QrCodeModal title={$t('view_link')} {onClose} value={sharedLink} />
|
||||||
<div class="w-full">
|
|
||||||
<div class="w-full py-2 px-10">
|
|
||||||
<div bind:clientWidth={modalWidth} class="w-full">
|
|
||||||
<QRCode value={sharedLink} width={modalWidth} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<HStack class="w-full pt-3" gap={1}>
|
|
||||||
<Input bind:value={sharedLink} disabled class="flex flex-row" />
|
|
||||||
<div>
|
|
||||||
<IconButton
|
|
||||||
variant="ghost"
|
|
||||||
shape="round"
|
|
||||||
color="secondary"
|
|
||||||
icon={mdiContentCopy}
|
|
||||||
onclick={() => (sharedLink ? copyToClipboard(sharedLink) : '')}
|
|
||||||
aria-label={$t('copy_link_to_clipboard')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
</FullScreenModal>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
import ThemeButton from '../theme-button.svelte';
|
import ThemeButton from '../theme-button.svelte';
|
||||||
import UserAvatar from '../user-avatar.svelte';
|
import UserAvatar from '../user-avatar.svelte';
|
||||||
import AccountInfoPanel from './account-info-panel.svelte';
|
import AccountInfoPanel from './account-info-panel.svelte';
|
||||||
import { isSidebarOpen } from '$lib/stores/side-bar.svelte';
|
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -62,10 +62,9 @@
|
|||||||
>
|
>
|
||||||
<SkipLink text={$t('skip_to_content')} />
|
<SkipLink text={$t('skip_to_content')} />
|
||||||
<div
|
<div
|
||||||
class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
|
class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]"
|
||||||
>
|
>
|
||||||
<div class="flex flex-row gap-1 mx-4 items-center">
|
<div class="flex flex-row gap-1 mx-4 items-center">
|
||||||
<div>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
id={menuButtonId}
|
id={menuButtonId}
|
||||||
shape="round"
|
shape="round"
|
||||||
@ -75,19 +74,18 @@
|
|||||||
aria-label={$t('main_menu')}
|
aria-label={$t('main_menu')}
|
||||||
icon={mdiMenu}
|
icon={mdiMenu}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
isSidebarOpen.value = !isSidebarOpen.value;
|
sidebarStore.toggle();
|
||||||
}}
|
}}
|
||||||
onmousedown={(event: MouseEvent) => {
|
onmousedown={(event: MouseEvent) => {
|
||||||
if (isSidebarOpen.value) {
|
if (sidebarStore.isOpen) {
|
||||||
// stops event from reaching the default handler when clicking outside of the sidebar
|
// stops event from reaching the default handler when clicking outside of the sidebar
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class="md:hidden"
|
class="sidebar:hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<a data-sveltekit-preload-data="hover" href={AppRoute.PHOTOS}>
|
<a data-sveltekit-preload-data="hover" href={AppRoute.PHOTOS}>
|
||||||
<ImmichLogo class="max-md:h-[48px] h-[50px]" noText={mobileDevice.maxMd} />
|
<ImmichLogo class="max-md:h-[48px] h-[50px]" noText={!mobileDevice.isFullSidebar} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-4 lg:gap-8 pr-6">
|
<div class="flex justify-between gap-4 lg:gap-8 pr-6">
|
||||||
|
@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||||
|
import QRCode from '$lib/components/shared-components/qrcode.svelte';
|
||||||
|
import { copyToClipboard } from '$lib/utils';
|
||||||
|
import { HStack, IconButton, Input } from '@immich/ui';
|
||||||
|
import { mdiContentCopy, mdiLink } from '@mdi/js';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { onClose, title, value }: Props = $props();
|
||||||
|
|
||||||
|
let modalWidth = $state(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FullScreenModal {title} icon={mdiLink} {onClose}>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="w-full py-2 px-10">
|
||||||
|
<div bind:clientWidth={modalWidth} class="w-full">
|
||||||
|
<QRCode {value} width={modalWidth} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HStack class="w-full pt-3" gap={1}>
|
||||||
|
<Input bind:value disabled class="flex flex-row" />
|
||||||
|
<div>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
color="secondary"
|
||||||
|
icon={mdiContentCopy}
|
||||||
|
onclick={() => (value ? copyToClipboard(value) : '')}
|
||||||
|
aria-label={$t('copy_link_to_clipboard')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
</FullScreenModal>
|
@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store';
|
import { searchStore } from '$lib/stores/search.svelte';
|
||||||
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
||||||
import SearchHistoryBox from './search-history-box.svelte';
|
import SearchHistoryBox from './search-history-box.svelte';
|
||||||
import SearchFilterModal from './search-filter-modal.svelte';
|
import SearchFilterModal from './search-filter-modal.svelte';
|
||||||
@ -40,41 +40,43 @@
|
|||||||
|
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
showFilter = false;
|
showFilter = false;
|
||||||
$isSearchEnabled = false;
|
searchStore.isSearchEnabled = false;
|
||||||
await goto(`${AppRoute.SEARCH}?${params}`);
|
await goto(`${AppRoute.SEARCH}?${params}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSearchTerm = (searchTerm: string) => {
|
const clearSearchTerm = (searchTerm: string) => {
|
||||||
input?.focus();
|
input?.focus();
|
||||||
$savedSearchTerms = $savedSearchTerms.filter((item) => item !== searchTerm);
|
searchStore.savedSearchTerms = searchStore.savedSearchTerms.filter((item) => item !== searchTerm);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSearchTerm = (saveValue: string) => {
|
const saveSearchTerm = (saveValue: string) => {
|
||||||
const filteredSearchTerms = $savedSearchTerms.filter((item) => item.toLowerCase() !== saveValue.toLowerCase());
|
const filteredSearchTerms = searchStore.savedSearchTerms.filter(
|
||||||
$savedSearchTerms = [saveValue, ...filteredSearchTerms];
|
(item) => item.toLowerCase() !== saveValue.toLowerCase(),
|
||||||
|
);
|
||||||
|
searchStore.savedSearchTerms = [saveValue, ...filteredSearchTerms];
|
||||||
|
|
||||||
if ($savedSearchTerms.length > 5) {
|
if (searchStore.savedSearchTerms.length > 5) {
|
||||||
$savedSearchTerms = $savedSearchTerms.slice(0, 5);
|
searchStore.savedSearchTerms = searchStore.savedSearchTerms.slice(0, 5);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearAllSearchTerms = () => {
|
const clearAllSearchTerms = () => {
|
||||||
input?.focus();
|
input?.focus();
|
||||||
$savedSearchTerms = [];
|
searchStore.savedSearchTerms = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFocusIn = () => {
|
const onFocusIn = () => {
|
||||||
$isSearchEnabled = true;
|
searchStore.isSearchEnabled = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFocusOut = () => {
|
const onFocusOut = () => {
|
||||||
const focusOutTimer = setTimeout(() => {
|
const focusOutTimer = setTimeout(() => {
|
||||||
if ($isSearchEnabled) {
|
if (searchStore.isSearchEnabled) {
|
||||||
$preventRaceConditionSearchBar = true;
|
searchStore.preventRaceConditionSearchBar = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
$isSearchEnabled = false;
|
searchStore.isSearchEnabled = false;
|
||||||
showFilter = false;
|
showFilter = false;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
@ -225,7 +227,9 @@
|
|||||||
class="w-full transition-all border-2 px-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg
|
class="w-full transition-all border-2 px-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg
|
||||||
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
|
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
|
||||||
{showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
|
{showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
|
||||||
{$isSearchEnabled && !showFilter ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}"
|
{searchStore.isSearchEnabled && !showFilter
|
||||||
|
? 'border-gray-200 dark:border-gray-700 bg-white'
|
||||||
|
: 'border-transparent'}"
|
||||||
placeholder={$t('search_your_photos')}
|
placeholder={$t('search_your_photos')}
|
||||||
required
|
required
|
||||||
pattern="^(?!m:$).*$"
|
pattern="^(?!m:$).*$"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { savedSearchTerms } from '$lib/stores/search.store';
|
import { searchStore } from '$lib/stores/search.svelte';
|
||||||
import { mdiMagnify, mdiClose } from '@mdi/js';
|
import { mdiMagnify, mdiClose } from '@mdi/js';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -29,7 +29,7 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let filteredSearchTerms = $derived(
|
let filteredSearchTerms = $derived(
|
||||||
$savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())),
|
searchStore.savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||||
);
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
@ -110,7 +110,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<Icon
|
<Icon
|
||||||
path={mdiInformationOutline}
|
path={mdiInformationOutline}
|
||||||
class="hidden md:flex text-immich-primary dark:text-immich-dark-primary font-medium"
|
class="hidden sidebar:flex text-immich-primary dark:text-immich-dark-primary font-medium"
|
||||||
size="18"
|
size="18"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -123,7 +123,7 @@
|
|||||||
{#if showMessage}
|
{#if showMessage}
|
||||||
<dialog
|
<dialog
|
||||||
open
|
open
|
||||||
class="hidden md:block w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
|
class="hidden sidebar:block w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
|
||||||
transition:fade={{ duration: 150 }}
|
transition:fade={{ duration: 150 }}
|
||||||
onmouseover={() => (hoverMessage = true)}
|
onmouseover={() => (hoverMessage = true)}
|
||||||
onmouseleave={() => (hoverMessage = false)}
|
onmouseleave={() => (hoverMessage = false)}
|
||||||
|
@ -0,0 +1,80 @@
|
|||||||
|
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
|
||||||
|
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
return {
|
||||||
|
mobileDevice: {
|
||||||
|
isFullSidebar: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('$lib/stores/mobile-device.svelte', () => ({
|
||||||
|
mobileDevice: mocks.mobileDevice,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/stores/sidebar.svelte', () => ({
|
||||||
|
sidebarStore: {
|
||||||
|
isOpen: false,
|
||||||
|
reset: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SideBarSection component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
mocks.mobileDevice.isFullSidebar = false;
|
||||||
|
sidebarStore.isOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each`
|
||||||
|
isFullSidebar | isSidebarOpen | expectedInert
|
||||||
|
${false} | ${false} | ${true}
|
||||||
|
${false} | ${true} | ${false}
|
||||||
|
${true} | ${false} | ${false}
|
||||||
|
${true} | ${true} | ${false}
|
||||||
|
`(
|
||||||
|
'inert is $expectedInert when isFullSidebar=$isFullSidebar and isSidebarOpen=$isSidebarOpen',
|
||||||
|
({ isFullSidebar, isSidebarOpen, expectedInert }) => {
|
||||||
|
// setup
|
||||||
|
mocks.mobileDevice.isFullSidebar = isFullSidebar;
|
||||||
|
sidebarStore.isOpen = isSidebarOpen;
|
||||||
|
|
||||||
|
// when
|
||||||
|
render(SideBarSection);
|
||||||
|
const parent = screen.getByTestId('sidebar-parent');
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(parent.inert).toBe(expectedInert);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should set width when sidebar is expanded', () => {
|
||||||
|
// setup
|
||||||
|
mocks.mobileDevice.isFullSidebar = false;
|
||||||
|
sidebarStore.isOpen = true;
|
||||||
|
|
||||||
|
// when
|
||||||
|
render(SideBarSection);
|
||||||
|
const parent = screen.getByTestId('sidebar-parent');
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(parent.classList).toContain('sidebar:w-[16rem]'); // sets the initial width for page load
|
||||||
|
expect(parent.classList).toContain('w-[min(100vw,16rem)]');
|
||||||
|
expect(parent.classList).toContain('shadow-2xl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close the sidebar if it is open on initial render', () => {
|
||||||
|
// setup
|
||||||
|
mocks.mobileDevice.isFullSidebar = false;
|
||||||
|
sidebarStore.isOpen = true;
|
||||||
|
|
||||||
|
// when
|
||||||
|
render(SideBarSection);
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(sidebarStore.reset).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
@ -2,52 +2,45 @@
|
|||||||
import { clickOutside } from '$lib/actions/click-outside';
|
import { clickOutside } from '$lib/actions/click-outside';
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
import { menuButtonId } from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
import { menuButtonId } from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||||
import { isSidebarOpen } from '$lib/stores/side-bar.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { type Snippet } from 'svelte';
|
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||||
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mdBreakpoint = 768;
|
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
let innerWidth: number = $state(0);
|
const isHidden = $derived(!sidebarStore.isOpen && !mobileDevice.isFullSidebar);
|
||||||
|
const isExpanded = $derived(sidebarStore.isOpen && !mobileDevice.isFullSidebar);
|
||||||
|
|
||||||
const closeSidebar = (width: number) => {
|
onMount(() => {
|
||||||
isSidebarOpen.value = width >= mdBreakpoint;
|
closeSidebar();
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
closeSidebar(innerWidth);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isHidden = $derived(!isSidebarOpen.value && innerWidth < mdBreakpoint);
|
const closeSidebar = () => {
|
||||||
const isExpanded = $derived(isSidebarOpen.value && innerWidth < mdBreakpoint);
|
if (!isExpanded) {
|
||||||
|
|
||||||
const handleClickOutside = () => {
|
|
||||||
if (!isSidebarOpen.value) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeSidebar(innerWidth);
|
sidebarStore.reset();
|
||||||
if (isHidden) {
|
if (isHidden) {
|
||||||
document.querySelector<HTMLButtonElement>(`#${menuButtonId}`)?.focus();
|
document.querySelector<HTMLButtonElement>(`#${menuButtonId}`)?.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window bind:innerWidth />
|
|
||||||
<section
|
<section
|
||||||
id="sidebar"
|
id="sidebar"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="immich-scrollbar relative z-10 w-0 md:w-[16rem] overflow-y-auto overflow-x-hidden bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg"
|
class="immich-scrollbar relative z-10 w-0 sidebar:w-[16rem] overflow-y-auto overflow-x-hidden bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg"
|
||||||
class:shadow-2xl={isExpanded}
|
class:shadow-2xl={isExpanded}
|
||||||
class:dark:border-r-immich-dark-gray={isExpanded}
|
class:dark:border-r-immich-dark-gray={isExpanded}
|
||||||
class:border-r={isExpanded}
|
class:border-r={isExpanded}
|
||||||
class:w-[min(100vw,16rem)]={isSidebarOpen.value}
|
class:w-[min(100vw,16rem)]={sidebarStore.isOpen}
|
||||||
|
data-testid="sidebar-parent"
|
||||||
inert={isHidden}
|
inert={isHidden}
|
||||||
use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }}
|
use:clickOutside={{ onOutclick: closeSidebar, onEscape: closeSidebar }}
|
||||||
use:focusTrap={{ active: isExpanded }}
|
use:focusTrap={{ active: isExpanded }}
|
||||||
>
|
>
|
||||||
<div class="pr-6 flex flex-col gap-1 h-max min-h-full">
|
<div class="pr-6 flex flex-col gap-1 h-max min-h-full">
|
||||||
|
51
web/src/lib/stores/download-store.svelte.ts
Normal file
51
web/src/lib/stores/download-store.svelte.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
export interface DownloadProgress {
|
||||||
|
progress: number;
|
||||||
|
total: number;
|
||||||
|
percentage: number;
|
||||||
|
abort: AbortController | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadStore {
|
||||||
|
assets = $state<Record<string, DownloadProgress>>({});
|
||||||
|
|
||||||
|
isDownloading = $derived(Object.keys(this.assets).length > 0);
|
||||||
|
|
||||||
|
#update(key: string, value: Partial<DownloadProgress> | null) {
|
||||||
|
if (value === null) {
|
||||||
|
delete this.assets[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.assets[key]) {
|
||||||
|
this.assets[key] = { progress: 0, total: 0, percentage: 0, abort: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = this.assets[key];
|
||||||
|
Object.assign(item, value);
|
||||||
|
item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
add(key: string, total: number, abort?: AbortController) {
|
||||||
|
this.#update(key, { total, abort });
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(key: string) {
|
||||||
|
this.#update(key, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(key: string, progress: number, total?: number) {
|
||||||
|
const download: Partial<DownloadProgress> = { progress };
|
||||||
|
if (total !== undefined) {
|
||||||
|
download.total = total;
|
||||||
|
}
|
||||||
|
this.#update(key, download);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloadStore = new DownloadStore();
|
||||||
|
|
||||||
|
export const downloadManager = {
|
||||||
|
add: (key: string, total: number, abort?: AbortController) => downloadStore.add(key, total, abort),
|
||||||
|
clear: (key: string) => downloadStore.clear(key),
|
||||||
|
update: (key: string, progress: number, total?: number) => downloadStore.update(key, progress, total),
|
||||||
|
};
|
@ -1,47 +0,0 @@
|
|||||||
import { derived, writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export interface DownloadProgress {
|
|
||||||
progress: number;
|
|
||||||
total: number;
|
|
||||||
percentage: number;
|
|
||||||
abort: AbortController | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const downloadAssets = writable<Record<string, DownloadProgress>>({});
|
|
||||||
|
|
||||||
export const isDownloading = derived(downloadAssets, ($downloadAssets) => {
|
|
||||||
return Object.keys($downloadAssets).length > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const update = (key: string, value: Partial<DownloadProgress> | null) => {
|
|
||||||
downloadAssets.update((state) => {
|
|
||||||
const newState = { ...state };
|
|
||||||
|
|
||||||
if (value === null) {
|
|
||||||
delete newState[key];
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newState[key]) {
|
|
||||||
newState[key] = { progress: 0, total: 0, percentage: 0, abort: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = newState[key];
|
|
||||||
Object.assign(item, value);
|
|
||||||
item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100);
|
|
||||||
|
|
||||||
return newState;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadManager = {
|
|
||||||
add: (key: string, total: number, abort?: AbortController) => update(key, { total, abort }),
|
|
||||||
clear: (key: string) => update(key, null),
|
|
||||||
update: (key: string, progress: number, total?: number) => {
|
|
||||||
const download: Partial<DownloadProgress> = { progress };
|
|
||||||
if (total !== undefined) {
|
|
||||||
download.total = total;
|
|
||||||
}
|
|
||||||
update(key, download);
|
|
||||||
},
|
|
||||||
};
|
|
@ -2,6 +2,7 @@ import { MediaQuery } from 'svelte/reactivity';
|
|||||||
|
|
||||||
const pointerCoarse = new MediaQuery('pointer:coarse');
|
const pointerCoarse = new MediaQuery('pointer:coarse');
|
||||||
const maxMd = new MediaQuery('max-width: 767px');
|
const maxMd = new MediaQuery('max-width: 767px');
|
||||||
|
const sidebar = new MediaQuery(`min-width: 850px`);
|
||||||
|
|
||||||
export const mobileDevice = {
|
export const mobileDevice = {
|
||||||
get pointerCoarse() {
|
get pointerCoarse() {
|
||||||
@ -10,4 +11,7 @@ export const mobileDevice = {
|
|||||||
get maxMd() {
|
get maxMd() {
|
||||||
return maxMd.current;
|
return maxMd.current;
|
||||||
},
|
},
|
||||||
|
get isFullSidebar() {
|
||||||
|
return sidebar.current;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
import { persisted } from 'svelte-persisted-store';
|
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export const savedSearchTerms = persisted<string[]>('search-terms', [], {});
|
|
||||||
export const isSearchEnabled = writable<boolean>(false);
|
|
||||||
export const preventRaceConditionSearchBar = writable<boolean>(false);
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user