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": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
|
||||
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
|
||||
"version": "6.2.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
|
||||
"integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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
|
||||
```
|
||||
|
||||
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`
|
||||
|
||||
To see local changes to `@immich/ui` in Immich, do the following:
|
||||
|
@ -1141,7 +1141,7 @@ describe('/asset', () => {
|
||||
fNumber: 8,
|
||||
focalLength: 97,
|
||||
iso: 100,
|
||||
lensModel: 'E PZ 18-105mm F4 G OSS',
|
||||
lensModel: 'Sony E PZ 18-105mm F4 G OSS',
|
||||
fileSizeInByte: 25_001_984,
|
||||
dateTimeOriginal: '2016-09-27T10:51:44+00:00',
|
||||
orientation: '1',
|
||||
@ -1163,7 +1163,7 @@ describe('/asset', () => {
|
||||
fNumber: 22,
|
||||
focalLength: 25,
|
||||
iso: 100,
|
||||
lensModel: 'E 25mm F2',
|
||||
lensModel: 'Zeiss Batis 25mm F2',
|
||||
fileSizeInByte: 49_512_448,
|
||||
dateTimeOriginal: '2016-01-08T14:08:01+00:00',
|
||||
orientation: '1',
|
||||
@ -1234,7 +1234,7 @@ describe('/asset', () => {
|
||||
focalLength: 18.3,
|
||||
iso: 100,
|
||||
latitude: 36.613_24,
|
||||
lensModel: 'GR LENS 18.3mm F2.8',
|
||||
lensModel: '18.3mm F2.8',
|
||||
longitude: -121.897_85,
|
||||
make: 'RICOH IMAGING COMPANY, LTD.',
|
||||
model: 'RICOH GR III',
|
||||
|
@ -48,7 +48,7 @@ test.describe('Shared Links', () => {
|
||||
await page.waitForSelector('[data-group] svg');
|
||||
await page.getByRole('checkbox').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 }) => {
|
||||
@ -56,6 +56,7 @@ test.describe('Shared Links', () => {
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
|
||||
await page.waitForEvent('download');
|
||||
});
|
||||
|
||||
test('enter password for a shared link', async ({ page }) => {
|
||||
|
@ -1371,6 +1371,7 @@
|
||||
"view_next_asset": "View next asset",
|
||||
"view_previous_asset": "View previous asset",
|
||||
"view_stack": "View Stack",
|
||||
"view_qr_code": "View QR code",
|
||||
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
|
||||
"waiting": "Waiting",
|
||||
"warning": "Warning",
|
||||
|
@ -278,8 +278,8 @@ class TestOrtSession:
|
||||
|
||||
assert session.provider_options == []
|
||||
|
||||
def test_sets_default_sess_options(self) -> None:
|
||||
session = OrtSession("ViT-B-32__openai")
|
||||
def test_sets_default_sess_options_if_cpu(self) -> None:
|
||||
session = OrtSession("ViT-B-32__openai", providers=["CPUExecutionProvider"])
|
||||
|
||||
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
|
||||
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/tab.provider.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 {
|
||||
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(
|
||||
foregroundColor: Colors.grey[100],
|
||||
backgroundColor: Colors.transparent,
|
||||
@ -174,7 +178,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
shape: const Border(),
|
||||
actions: [
|
||||
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
||||
if (isOwner && ref.read(tabProvider.notifier).state != TabEnum.home)
|
||||
if (isOwner && !isInHomePage && !(isInTrash ?? false))
|
||||
buildLocateButton(),
|
||||
if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
|
||||
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
|
||||
WORKDIR /usr/src/app
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
COPY server/patches ./patches
|
||||
RUN npm ci && \
|
||||
# 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
|
||||
@ -56,7 +57,7 @@ COPY server/resources resources
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
COPY server/start*.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 /LICENSE
|
||||
ENV PATH="${PATH}:/usr/src/app/bin"
|
||||
|
@ -33,7 +33,7 @@
|
||||
"sync:open-api": "node ./dist/bin/sync-open-api.js",
|
||||
"sync:sql": "node ./dist/bin/sync-sql.js",
|
||||
"email:dev": "email dev -p 3050 --dir src/emails",
|
||||
"postinstall": "[ \"$npm_config_global\" != \"true\" ] && patch-package || true"
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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
|
||||
index ee8b1e6..d03b9dd 100644
|
||||
index ee8b1e6..acf4566 100644
|
||||
--- a/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) {
|
||||
+ 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, {
|
||||
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 },
|
||||
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
|
||||
index f7f58d1..8a37571 100644
|
||||
index f7f58d1..b7f2d65 100644
|
||||
--- a/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) {
|
||||
+ 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, {
|
||||
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 },
|
||||
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
|
||||
index 97cc97e..58f5298 100644
|
||||
index 97cc97e..26f508e 100644
|
||||
--- a/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) {
|
||||
+ 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, {
|
||||
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 },
|
||||
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 { APP_MEDIA_LOCATION } from 'src/constants';
|
||||
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 { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
@ -85,7 +84,7 @@ export class StorageCore {
|
||||
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`);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
switch (pathType) {
|
||||
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';
|
||||
|
||||
export type AuthUser = {
|
||||
@ -10,6 +21,17 @@ export type AuthUser = {
|
||||
quotaSizeInBytes: number | null;
|
||||
};
|
||||
|
||||
export type AlbumUser = {
|
||||
user: User;
|
||||
role: AlbumUserRole;
|
||||
};
|
||||
|
||||
export type AssetFile = {
|
||||
id: string;
|
||||
type: AssetFileType;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type Library = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
@ -184,6 +206,38 @@ export type Session = {
|
||||
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;
|
||||
|
||||
export const columns = {
|
||||
|
6
server/src/db.d.ts
vendored
6
server/src/db.d.ts
vendored
@ -17,7 +17,7 @@ import {
|
||||
SyncEntityType,
|
||||
} from 'src/enum';
|
||||
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>;
|
||||
|
||||
@ -412,10 +412,8 @@ export interface TypeormMetadata {
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface UserMetadata {
|
||||
key: string;
|
||||
export interface UserMetadata extends UserMetadataItem {
|
||||
userId: string;
|
||||
value: Json;
|
||||
}
|
||||
|
||||
export interface UsersAudit {
|
||||
|
@ -143,13 +143,11 @@ export class AlbumResponseDto {
|
||||
}
|
||||
|
||||
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
|
||||
const sharedUsers: UserResponseDto[] = [];
|
||||
const albumUsers: AlbumUserResponseDto[] = [];
|
||||
|
||||
if (entity.albumUsers) {
|
||||
for (const albumUser of entity.albumUsers) {
|
||||
const user = mapUser(albumUser.user);
|
||||
sharedUsers.push(user);
|
||||
albumUsers.push({
|
||||
user,
|
||||
role: albumUser.role,
|
||||
@ -162,7 +160,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
|
||||
const assets = entity.assets || [];
|
||||
|
||||
const hasSharedLink = entity.sharedLinks?.length > 0;
|
||||
const hasSharedUser = sharedUsers.length > 0;
|
||||
const hasSharedUser = albumUsers.length > 0;
|
||||
|
||||
let startDate = assets.at(0)?.localDateTime;
|
||||
let endDate = assets.at(-1)?.localDateTime;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { AssetFace } from 'src/database';
|
||||
import { PropertyLifecycle } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
|
||||
@ -10,7 +11,6 @@ import {
|
||||
} from 'src/dtos/person.dto';
|
||||
import { TagResponseDto, mapTag } from 'src/dtos/tag.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 { AssetType } from 'src/enum';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@ -71,7 +71,8 @@ export type AssetMapOptions = {
|
||||
auth?: AuthDto;
|
||||
};
|
||||
|
||||
const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => {
|
||||
// TODO: this is inefficient
|
||||
const peopleWithFaces = (faces: AssetFace[]): PersonWithFacesResponseDto[] => {
|
||||
const result: PersonWithFacesResponseDto[] = [];
|
||||
if (faces) {
|
||||
for (const face of faces) {
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser } from 'src/database';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||
import { ImmichCookie } from 'src/enum';
|
||||
import { toEmail } from 'src/validation';
|
||||
|
||||
@ -42,7 +41,7 @@ export class LoginResponseDto {
|
||||
shouldChangePassword!: boolean;
|
||||
}
|
||||
|
||||
export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto {
|
||||
export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto {
|
||||
return {
|
||||
accessToken,
|
||||
userId: entity.id,
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { Exif } from 'src/database';
|
||||
|
||||
export class ExifResponseDto {
|
||||
make?: string | null = null;
|
||||
@ -28,7 +28,7 @@ export class ExifResponseDto {
|
||||
rating?: number | null = null;
|
||||
}
|
||||
|
||||
export function mapExif(entity: ExifEntity): ExifResponseDto {
|
||||
export function mapExif(entity: Exif): ExifResponseDto {
|
||||
return {
|
||||
make: entity.make,
|
||||
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 {
|
||||
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
||||
orientation: entity.orientation,
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator';
|
||||
import { Selectable } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AssetFace, Person } from 'src/database';
|
||||
import { AssetFaces } from 'src/db';
|
||||
import { PropertyLifecycle } from 'src/decorators';
|
||||
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 { asDateString } from 'src/utils/date';
|
||||
import {
|
||||
@ -219,7 +220,7 @@ export class PeopleResponseDto {
|
||||
hasNextPage?: boolean;
|
||||
}
|
||||
|
||||
export function mapPerson(person: PersonEntity): PersonResponseDto {
|
||||
export function mapPerson(person: Person): PersonResponseDto {
|
||||
return {
|
||||
id: person.id,
|
||||
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 {
|
||||
id: face.id,
|
||||
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 {
|
||||
...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,
|
||||
};
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||
import { User, UserAdmin } from 'src/database';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { getPreferences } from 'src/utils/preferences';
|
||||
@ -42,13 +41,13 @@ export class UserLicense {
|
||||
activatedAt!: Date;
|
||||
}
|
||||
|
||||
export const mapUser = (entity: UserEntity | User): UserResponseDto => {
|
||||
export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
|
||||
return {
|
||||
id: entity.id,
|
||||
email: entity.email,
|
||||
name: entity.name,
|
||||
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,
|
||||
};
|
||||
};
|
||||
@ -142,7 +141,7 @@ export class UserAdminResponseDto extends UserResponseDto {
|
||||
license!: UserLicense | null;
|
||||
}
|
||||
|
||||
export function mapUserAdmin(entity: UserEntity | UserAdmin): UserAdminResponseDto {
|
||||
export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
||||
const metadata = entity.metadata || [];
|
||||
const license = metadata.find(
|
||||
(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 { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { AssetOrder } from 'src/enum';
|
||||
|
||||
export class AlbumEntity {
|
||||
id!: string;
|
||||
owner!: UserEntity;
|
||||
owner!: User;
|
||||
ownerId!: string;
|
||||
albumName!: string;
|
||||
description!: string;
|
||||
@ -16,7 +15,7 @@ export class AlbumEntity {
|
||||
deletedAt!: Date | null;
|
||||
albumThumbnailAsset!: AssetEntity | null;
|
||||
albumThumbnailAssetId!: string | null;
|
||||
albumUsers!: AlbumUserEntity[];
|
||||
albumUsers!: AlbumUser[];
|
||||
assets!: AssetEntity[];
|
||||
sharedLinks!: SharedLinkEntity[];
|
||||
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 { 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 { 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 { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { StackEntity } from 'src/entities/stack.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
|
||||
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
@ -20,14 +16,14 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
|
||||
export class AssetEntity {
|
||||
id!: string;
|
||||
deviceAssetId!: string;
|
||||
owner!: UserEntity;
|
||||
owner!: User;
|
||||
ownerId!: string;
|
||||
libraryId?: string | null;
|
||||
deviceId!: string;
|
||||
type!: AssetType;
|
||||
status!: AssetStatus;
|
||||
originalPath!: string;
|
||||
files!: AssetFileEntity[];
|
||||
files!: AssetFile[];
|
||||
thumbhash!: Buffer | null;
|
||||
encodedVideoPath!: string | null;
|
||||
createdAt!: Date;
|
||||
@ -48,11 +44,11 @@ export class AssetEntity {
|
||||
livePhotoVideoId!: string | null;
|
||||
originalFileName!: string;
|
||||
sidecarPath!: string | null;
|
||||
exifInfo?: ExifEntity;
|
||||
exifInfo?: Exif;
|
||||
tags?: Tag[];
|
||||
sharedLinks!: SharedLinkEntity[];
|
||||
albums?: AlbumEntity[];
|
||||
faces!: AssetFaceEntity[];
|
||||
faces!: AssetFace[];
|
||||
stackId?: string | null;
|
||||
stack?: StackEntity | null;
|
||||
jobStatus?: AssetJobStatusEntity;
|
||||
@ -66,7 +62,9 @@ export type AssetEntityPlaceholder = AssetEntity & {
|
||||
};
|
||||
|
||||
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>) {
|
||||
|
@ -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 { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
|
||||
export class SharedLinkEntity {
|
||||
@ -8,7 +7,6 @@ export class SharedLinkEntity {
|
||||
description!: string | null;
|
||||
password!: string | null;
|
||||
userId!: string;
|
||||
user!: UserEntity;
|
||||
key!: Buffer; // use to access the inidividual asset
|
||||
type!: SharedLinkType;
|
||||
createdAt!: Date;
|
||||
|
@ -1,9 +1,7 @@
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
|
||||
export class StackEntity {
|
||||
id!: string;
|
||||
owner!: UserEntity;
|
||||
ownerId!: string;
|
||||
assets!: 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
|
||||
delete from "person"
|
||||
where
|
||||
"person"."id" in ($1)
|
||||
"person"."id" in $1
|
||||
|
||||
-- PersonRepository.deleteFaces
|
||||
delete from "asset_faces"
|
||||
@ -95,41 +95,72 @@ where
|
||||
"asset_faces"."id" = $1
|
||||
and "asset_faces"."deletedAt" is null
|
||||
|
||||
-- PersonRepository.getFaceByIdWithAssets
|
||||
-- PersonRepository.getFaceForFacialRecognitionJob
|
||||
select
|
||||
"asset_faces".*,
|
||||
"asset_faces"."id",
|
||||
"asset_faces"."personId",
|
||||
"asset_faces"."sourceType",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"person".*
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"person"."id" = "asset_faces"."personId"
|
||||
) as obj
|
||||
) as "person",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"assets".*
|
||||
"assets"."ownerId",
|
||||
"assets"."isArchived",
|
||||
"assets"."fileCreatedAt"
|
||||
from
|
||||
"assets"
|
||||
where
|
||||
"assets"."id" = "asset_faces"."assetId"
|
||||
) 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
|
||||
"asset_faces"
|
||||
where
|
||||
"asset_faces"."id" = $1
|
||||
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
|
||||
update "asset_faces"
|
||||
set
|
||||
|
@ -24,7 +24,8 @@ select
|
||||
from
|
||||
(
|
||||
select
|
||||
"user_metadata".*
|
||||
"user_metadata"."key",
|
||||
"user_metadata"."value"
|
||||
from
|
||||
"user_metadata"
|
||||
where
|
||||
@ -54,7 +55,21 @@ select
|
||||
"shouldChangePassword",
|
||||
"storageLabel",
|
||||
"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
|
||||
"users"
|
||||
where
|
||||
@ -87,7 +102,21 @@ select
|
||||
"shouldChangePassword",
|
||||
"storageLabel",
|
||||
"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
|
||||
"users"
|
||||
where
|
||||
@ -135,7 +164,21 @@ select
|
||||
"shouldChangePassword",
|
||||
"storageLabel",
|
||||
"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
|
||||
"users"
|
||||
where
|
||||
@ -174,7 +217,8 @@ select
|
||||
from
|
||||
(
|
||||
select
|
||||
"user_metadata".*
|
||||
"user_metadata"."key",
|
||||
"user_metadata"."value"
|
||||
from
|
||||
"user_metadata"
|
||||
where
|
||||
@ -210,7 +254,8 @@ select
|
||||
from
|
||||
(
|
||||
select
|
||||
"user_metadata".*
|
||||
"user_metadata"."key",
|
||||
"user_metadata"."value"
|
||||
from
|
||||
"user_metadata"
|
||||
where
|
||||
@ -232,15 +277,15 @@ select
|
||||
count(*) filter (
|
||||
where
|
||||
(
|
||||
"assets"."type" = $1
|
||||
and "assets"."isVisible" = $2
|
||||
"assets"."type" = 'IMAGE'
|
||||
and "assets"."isVisible" = true
|
||||
)
|
||||
) as "photos",
|
||||
count(*) filter (
|
||||
where
|
||||
(
|
||||
"assets"."type" = $3
|
||||
and "assets"."isVisible" = $4
|
||||
"assets"."type" = 'VIDEO'
|
||||
and "assets"."isVisible" = true
|
||||
)
|
||||
) as "videos",
|
||||
coalesce(
|
||||
@ -255,7 +300,7 @@ select
|
||||
where
|
||||
(
|
||||
"assets"."libraryId" is null
|
||||
and "assets"."type" = $5
|
||||
and "assets"."type" = 'IMAGE'
|
||||
)
|
||||
),
|
||||
0
|
||||
@ -265,7 +310,7 @@ select
|
||||
where
|
||||
(
|
||||
"assets"."libraryId" is null
|
||||
and "assets"."type" = $6
|
||||
and "assets"."type" = 'VIDEO'
|
||||
)
|
||||
),
|
||||
0
|
||||
|
@ -69,7 +69,7 @@ export class ActivityRepository {
|
||||
async getStatistics({ albumId, assetId }: { albumId: string; assetId?: string }): Promise<number> {
|
||||
const { count } = await this.db
|
||||
.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))
|
||||
.leftJoin('assets', 'assets.id', 'activity.assetId')
|
||||
.$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!))
|
||||
@ -81,6 +81,6 @@ export class ActivityRepository {
|
||||
.where('assets.localDateTime', 'is not', null)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return count as number;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
@ -470,10 +470,10 @@ export class AssetRepository {
|
||||
async getLivePhotoCount(motionId: string): Promise<number> {
|
||||
const [{ count }] = await this.db
|
||||
.selectFrom('assets')
|
||||
.select((eb) => eb.fn.countAll().as('count'))
|
||||
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||
.where('livePhotoVideoId', '=', asUuid(motionId))
|
||||
.execute();
|
||||
return count as number;
|
||||
return count;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
@ -773,10 +773,10 @@ export class AssetRepository {
|
||||
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO))
|
||||
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE))
|
||||
.select((eb) => eb.fn.countAll().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.AUDIO).as(AssetType.AUDIO))
|
||||
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE))
|
||||
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO))
|
||||
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER))
|
||||
.where('ownerId', '=', asUuid(ownerId))
|
||||
.where('assets.fileCreatedAt', '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(!!isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
||||
.where('deletedAt', isTrashed ? 'is not' : 'is', null)
|
||||
.executeTakeFirst() as Promise<AssetStats>;
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
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.
|
||||
.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')
|
||||
.orderBy('timeBucket', options.order ?? 'desc')
|
||||
.execute() as any as Promise<TimeBucketItem[]>
|
||||
@ -1145,10 +1145,10 @@ export class AssetRepository {
|
||||
async getLibraryAssetCount(libraryId: string): Promise<number> {
|
||||
const { count } = await this.db
|
||||
.selectFrom('assets')
|
||||
.select((eb) => eb.fn.countAll().as('count'))
|
||||
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||
.where('libraryId', '=', asUuid(libraryId))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return Number(count);
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
@ -250,7 +250,7 @@ const getEnv = (): EnvData => {
|
||||
},
|
||||
bigint: {
|
||||
to: 20,
|
||||
from: [20],
|
||||
from: [20, 1700],
|
||||
parse: (value: string) => Number.parseInt(value),
|
||||
serialize: (value: number) => value.toString(),
|
||||
},
|
||||
|
@ -76,13 +76,13 @@ export class LibraryRepository {
|
||||
.leftJoin('exif', 'exif.assetId', 'assets.id')
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.countAll()
|
||||
.countAll<number>()
|
||||
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)]))
|
||||
.as('photos'),
|
||||
)
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.countAll()
|
||||
.countAll<number>()
|
||||
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)]))
|
||||
.as('videos'),
|
||||
)
|
||||
@ -105,10 +105,10 @@ export class LibraryRepository {
|
||||
}
|
||||
|
||||
return {
|
||||
photos: Number(stats.photos),
|
||||
videos: Number(stats.videos),
|
||||
usage: Number(stats.usage),
|
||||
total: Number(stats.photos) + Number(stats.videos),
|
||||
photos: stats.photos,
|
||||
videos: stats.videos,
|
||||
usage: stats.usage,
|
||||
total: stats.photos + stats.videos,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { createReadStream, existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
@ -87,6 +87,7 @@ export class MapRepository {
|
||||
.on('exif.longitude', 'is not', null),
|
||||
)
|
||||
.select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country'])
|
||||
.$narrowType<{ lat: NotNull; lon: NotNull }>()
|
||||
.where('isVisible', '=', true)
|
||||
.$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!))
|
||||
.$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!))
|
||||
@ -114,7 +115,7 @@ export class MapRepository {
|
||||
return eb.or(expression);
|
||||
})
|
||||
.orderBy('fileCreatedAt', 'desc')
|
||||
.execute() as Promise<MapMarker[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
|
||||
|
@ -6,7 +6,7 @@ import fs from 'node:fs/promises';
|
||||
import { Writable } from 'node:stream';
|
||||
import sharp from 'sharp';
|
||||
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 { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import {
|
||||
@ -66,7 +66,7 @@ export class MediaRepository {
|
||||
return true;
|
||||
}
|
||||
|
||||
async writeExif(tags: Partial<ExifEntity>, output: string): Promise<boolean> {
|
||||
async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> {
|
||||
try {
|
||||
const tagsToWrite: WriteTags = {
|
||||
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({
|
||||
issuerUrl,
|
||||
clientId,
|
||||
|
@ -1,8 +1,8 @@
|
||||
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 { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns, Partner } from 'src/database';
|
||||
import { columns } from 'src/database';
|
||||
import { DB, Partners } from 'src/db';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
|
||||
@ -44,7 +44,7 @@ export class PartnerRepository {
|
||||
return this.builder()
|
||||
.where('sharedWithId', '=', sharedWithId)
|
||||
.where('sharedById', '=', sharedById)
|
||||
.executeTakeFirst() as Promise<Partner | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] })
|
||||
@ -55,7 +55,8 @@ export class PartnerRepository {
|
||||
.returningAll()
|
||||
.returning(withSharedBy)
|
||||
.returning(withSharedWith)
|
||||
.executeTakeFirstOrThrow() as Promise<Partner>;
|
||||
.$narrowType<{ sharedWith: NotNull; sharedBy: NotNull }>()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }, { inTimeline: true }] })
|
||||
@ -68,7 +69,8 @@ export class PartnerRepository {
|
||||
.returningAll()
|
||||
.returning(withSharedBy)
|
||||
.returning(withSharedWith)
|
||||
.executeTakeFirstOrThrow() as Promise<Partner>;
|
||||
.$narrowType<{ sharedWith: NotNull; sharedBy: NotNull }>()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] })
|
||||
|
@ -1,14 +1,12 @@
|
||||
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 { InjectKysely } from 'nestjs-kysely';
|
||||
import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
|
||||
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { SourceType } from 'src/enum';
|
||||
import { AssetFileType, SourceType } from 'src/enum';
|
||||
import { removeUndefinedKeys } from 'src/utils/database';
|
||||
import { Paginated, PaginationOptions } from 'src/utils/pagination';
|
||||
import { PaginationOptions } from 'src/utils/pagination';
|
||||
|
||||
export interface PersonSearchOptions {
|
||||
minimumFaceCount: number;
|
||||
@ -49,6 +47,19 @@ export interface DeleteFacesOptions {
|
||||
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 SelectFaceOptions = (keyof Selectable<AssetFaces>)[];
|
||||
@ -98,20 +109,13 @@ export class PersonRepository {
|
||||
await this.vacuum({ reindexVectors: false });
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[{ id: DummyValue.UUID }]] })
|
||||
async delete(entities: PersonEntity[]): Promise<void> {
|
||||
if (entities.length === 0) {
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async delete(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db
|
||||
.deleteFrom('person')
|
||||
.where(
|
||||
'person.id',
|
||||
'in',
|
||||
entities.map(({ id }) => id),
|
||||
)
|
||||
.execute();
|
||||
await this.db.deleteFrom('person').where('person.id', 'in', ids).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ sourceType: SourceType.EXIF }] })
|
||||
@ -121,7 +125,7 @@ export class PersonRepository {
|
||||
await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING });
|
||||
}
|
||||
|
||||
getAllFaces(options: Partial<AssetFaceEntity> = {}): AsyncIterableIterator<AssetFaceEntity> {
|
||||
getAllFaces(options: GetAllFacesOptions = {}) {
|
||||
return this.db
|
||||
.selectFrom('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.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
|
||||
.where('asset_faces.deletedAt', 'is', null)
|
||||
.stream() as AsyncIterableIterator<AssetFaceEntity>;
|
||||
.stream();
|
||||
}
|
||||
|
||||
getAll(options: Partial<PersonEntity> = {}): AsyncIterableIterator<PersonEntity> {
|
||||
getAll(options: GetAllPeopleOptions = {}) {
|
||||
return this.db
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
@ -142,15 +146,11 @@ export class PersonRepository {
|
||||
.$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null))
|
||||
.$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!))
|
||||
.$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!))
|
||||
.stream() as AsyncIterableIterator<PersonEntity>;
|
||||
.stream();
|
||||
}
|
||||
|
||||
async getAllForUser(
|
||||
pagination: PaginationOptions,
|
||||
userId: string,
|
||||
options?: PersonSearchOptions,
|
||||
): Paginated<PersonEntity> {
|
||||
const items = (await this.db
|
||||
async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) {
|
||||
const items = await this.db
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
.innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
|
||||
@ -198,7 +198,7 @@ export class PersonRepository {
|
||||
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
||||
.offset(pagination.skip ?? 0)
|
||||
.limit(pagination.take + 1)
|
||||
.execute()) as PersonEntity[];
|
||||
.execute();
|
||||
|
||||
if (items.length > pagination.take) {
|
||||
return { items: items.slice(0, -1), hasNextPage: true };
|
||||
@ -208,7 +208,7 @@ export class PersonRepository {
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
getAllWithoutFaces(): Promise<PersonEntity[]> {
|
||||
getAllWithoutFaces() {
|
||||
return this.db
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
@ -216,11 +216,11 @@ export class PersonRepository {
|
||||
.where('asset_faces.deletedAt', 'is', null)
|
||||
.having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0)
|
||||
.groupBy('person.id')
|
||||
.execute() as Promise<PersonEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFaces(assetId: string): Promise<AssetFaceEntity[]> {
|
||||
getFaces(assetId: string) {
|
||||
return this.db
|
||||
.selectFrom('asset_faces')
|
||||
.selectAll('asset_faces')
|
||||
@ -228,11 +228,11 @@ export class PersonRepository {
|
||||
.where('asset_faces.assetId', '=', assetId)
|
||||
.where('asset_faces.deletedAt', 'is', null)
|
||||
.orderBy('asset_faces.boundingBoxX1', 'asc')
|
||||
.execute() as Promise<AssetFaceEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFaceById(id: string): Promise<AssetFaceEntity> {
|
||||
getFaceById(id: string) {
|
||||
// TODO return null instead of find or fail
|
||||
return this.db
|
||||
.selectFrom('asset_faces')
|
||||
@ -240,25 +240,57 @@ export class PersonRepository {
|
||||
.select(withPerson)
|
||||
.where('asset_faces.id', '=', id)
|
||||
.where('asset_faces.deletedAt', 'is', null)
|
||||
.executeTakeFirstOrThrow() as Promise<AssetFaceEntity>;
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFaceByIdWithAssets(
|
||||
id: string,
|
||||
relations?: { faceSearch?: boolean },
|
||||
select?: SelectFaceOptions,
|
||||
): Promise<AssetFaceEntity | undefined> {
|
||||
getFaceForFacialRecognitionJob(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset_faces')
|
||||
.$if(!!select, (qb) => qb.select(select!))
|
||||
.$if(!select, (qb) => qb.selectAll('asset_faces'))
|
||||
.select(withPerson)
|
||||
.select(withAsset)
|
||||
.$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch))
|
||||
.select(['asset_faces.id', 'asset_faces.personId', 'asset_faces.sourceType'])
|
||||
.select((eb) =>
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.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.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] })
|
||||
@ -272,16 +304,16 @@ export class PersonRepository {
|
||||
return Number(result.numChangedRows ?? 0);
|
||||
}
|
||||
|
||||
getById(personId: string): Promise<PersonEntity | null> {
|
||||
return (this.db //
|
||||
getById(personId: string) {
|
||||
return this.db //
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
.where('person.id', '=', personId)
|
||||
.executeTakeFirst() ?? null) as Promise<PersonEntity | null>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@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
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
@ -296,7 +328,7 @@ export class PersonRepository {
|
||||
)
|
||||
.limit(1000)
|
||||
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
||||
.execute() as Promise<PersonEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] })
|
||||
@ -362,8 +394,8 @@ export class PersonRepository {
|
||||
};
|
||||
}
|
||||
|
||||
create(person: Insertable<Person>): Promise<PersonEntity> {
|
||||
return this.db.insertInto('person').values(person).returningAll().executeTakeFirst() as Promise<PersonEntity>;
|
||||
create(person: Insertable<Person>) {
|
||||
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async createAll(people: Insertable<Person>[]): Promise<string[]> {
|
||||
@ -399,13 +431,13 @@ export class PersonRepository {
|
||||
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
|
||||
.updateTable('person')
|
||||
.set(person)
|
||||
.where('person.id', '=', person.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow() as Promise<PersonEntity>;
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async updateAll(people: Insertable<Person>[]): Promise<void> {
|
||||
@ -437,7 +469,7 @@ export class PersonRepository {
|
||||
|
||||
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
|
||||
@ChunkedArray()
|
||||
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
|
||||
getFacesByIds(ids: AssetFaceId[]) {
|
||||
if (ids.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
@ -457,17 +489,17 @@ export class PersonRepository {
|
||||
.where('asset_faces.assetId', 'in', assetIds)
|
||||
.where('asset_faces.personId', 'in', personIds)
|
||||
.where('asset_faces.deletedAt', 'is', null)
|
||||
.execute() as Promise<AssetFaceEntity[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getRandomFace(personId: string): Promise<AssetFaceEntity | undefined> {
|
||||
getRandomFace(personId: string) {
|
||||
return this.db
|
||||
.selectFrom('asset_faces')
|
||||
.selectAll('asset_faces')
|
||||
.where('asset_faces.personId', '=', personId)
|
||||
.where('asset_faces.deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
|
@ -162,7 +162,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
||||
hasPerson?: boolean;
|
||||
numResults: number;
|
||||
maxDistance: number;
|
||||
minBirthDate?: Date;
|
||||
minBirthDate?: Date | null;
|
||||
}
|
||||
|
||||
export interface AssetDuplicateSearch {
|
||||
|
@ -1,11 +1,11 @@
|
||||
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 { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns, UserAdmin } from 'src/database';
|
||||
import { columns } from 'src/database';
|
||||
import { DB, UserMetadata as DbUserMetadata } from 'src/db';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { UserEntity, withMetadata } from 'src/entities/user.entity';
|
||||
import { AssetType, UserStatus } from 'src/enum';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { UserMetadata, UserMetadataItem } from 'src/types';
|
||||
@ -32,12 +32,21 @@ export interface UserFindOptions {
|
||||
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()
|
||||
export class UserRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] })
|
||||
get(userId: string, options: UserFindOptions): Promise<UserEntity | undefined> {
|
||||
get(userId: string, options: UserFindOptions) {
|
||||
options = options || {};
|
||||
|
||||
return this.db
|
||||
@ -46,7 +55,7 @@ export class UserRepository {
|
||||
.select(withMetadata)
|
||||
.where('users.id', '=', userId)
|
||||
.$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
|
||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
getMetadata(userId: string) {
|
||||
@ -58,13 +67,14 @@ export class UserRepository {
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
getAdmin(): Promise<UserEntity | undefined> {
|
||||
getAdmin() {
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.select(columns.userAdmin)
|
||||
.select(withMetadata)
|
||||
.where('users.isAdmin', '=', true)
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
@ -80,34 +90,36 @@ export class UserRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.EMAIL] })
|
||||
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | undefined> {
|
||||
getByEmail(email: string, withPassword?: boolean) {
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.select(columns.userAdmin)
|
||||
.select(withMetadata)
|
||||
.$if(!!withPassword, (eb) => eb.select('password'))
|
||||
.where('email', '=', email)
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getByStorageLabel(storageLabel: string): Promise<UserEntity | undefined> {
|
||||
getByStorageLabel(storageLabel: string) {
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.select(columns.userAdmin)
|
||||
.where('users.storageLabel', '=', storageLabel)
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getByOAuthId(oauthId: string): Promise<UserEntity | undefined> {
|
||||
getByOAuthId(oauthId: string) {
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.select(columns.userAdmin)
|
||||
.select(withMetadata)
|
||||
.where('users.oauthId', '=', oauthId)
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<UserEntity | undefined>;
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DateTime.now().minus({ years: 1 })] })
|
||||
@ -126,18 +138,19 @@ export class UserRepository {
|
||||
.select(withMetadata)
|
||||
.$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute() as Promise<UserAdmin[]>;
|
||||
.execute();
|
||||
}
|
||||
|
||||
async create(dto: Insertable<UserTable>): Promise<UserEntity> {
|
||||
async create(dto: Insertable<UserTable>) {
|
||||
return this.db
|
||||
.insertInto('users')
|
||||
.values(dto)
|
||||
.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
|
||||
.updateTable('users')
|
||||
.set(dto)
|
||||
@ -145,17 +158,17 @@ export class UserRepository {
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.returning(columns.userAdmin)
|
||||
.returning(withMetadata)
|
||||
.executeTakeFirst() as unknown as Promise<UserEntity>;
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
restore(id: string): Promise<UserEntity> {
|
||||
restore(id: string) {
|
||||
return this.db
|
||||
.updateTable('users')
|
||||
.set({ status: UserStatus.ACTIVE, deletedAt: null })
|
||||
.where('users.id', '=', asUuid(id))
|
||||
.returning(columns.userAdmin)
|
||||
.returning(withMetadata)
|
||||
.executeTakeFirst() as unknown as Promise<UserEntity>;
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
delete(user: { id: string }, hard?: boolean): Promise<UserEntity> {
|
||||
delete(user: { id: string }, hard?: boolean) {
|
||||
return hard
|
||||
? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise<UserEntity>)
|
||||
: (this.db
|
||||
.updateTable('users')
|
||||
.set({ deletedAt: new Date() })
|
||||
.where('id', '=', user.id)
|
||||
.execute() as unknown as Promise<UserEntity>);
|
||||
? this.db.deleteFrom('users').where('id', '=', user.id).execute()
|
||||
: this.db.updateTable('users').set({ deletedAt: new Date() }).where('id', '=', user.id).execute();
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
async getUserStats(): Promise<UserStatsQueryResponse[]> {
|
||||
const stats = (await this.db
|
||||
getUserStats() {
|
||||
return this.db
|
||||
.selectFrom('users')
|
||||
.leftJoin('assets', 'assets.ownerId', 'users.id')
|
||||
.leftJoin('exif', 'exif.assetId', 'assets.id')
|
||||
.select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes'])
|
||||
.select((eb) => [
|
||||
eb.fn
|
||||
.countAll()
|
||||
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)]))
|
||||
.countAll<number>()
|
||||
.filterWhere((eb) =>
|
||||
eb.and([eb('assets.type', '=', sql.lit(AssetType.IMAGE)), eb('assets.isVisible', '=', sql.lit(true))]),
|
||||
)
|
||||
.as('photos'),
|
||||
eb.fn
|
||||
.countAll()
|
||||
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)]))
|
||||
.countAll<number>()
|
||||
.filterWhere((eb) =>
|
||||
eb.and([eb('assets.type', '=', sql.lit(AssetType.VIDEO)), eb('assets.isVisible', '=', sql.lit(true))]),
|
||||
)
|
||||
.as('videos'),
|
||||
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'),
|
||||
eb.fn
|
||||
.coalesce(
|
||||
eb.fn
|
||||
.sum('exif.fileSizeInByte')
|
||||
.sum<number>('exif.fileSizeInByte')
|
||||
.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),
|
||||
)
|
||||
@ -217,9 +230,9 @@ export class UserRepository {
|
||||
eb.fn
|
||||
.coalesce(
|
||||
eb.fn
|
||||
.sum('exif.fileSizeInByte')
|
||||
.sum<number>('exif.fileSizeInByte')
|
||||
.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),
|
||||
)
|
||||
@ -228,17 +241,7 @@ export class UserRepository {
|
||||
.where('assets.deletedAt', 'is', null)
|
||||
.groupBy('users.id')
|
||||
.orderBy('users.createdAt', 'asc')
|
||||
.execute()) as UserStatsQueryResponse[];
|
||||
|
||||
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;
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
|
||||
|
@ -23,7 +23,7 @@ describe(ActivityService.name, () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||
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 });
|
||||
});
|
||||
@ -35,7 +35,7 @@ describe(ActivityService.name, () => {
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
|
||||
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([]);
|
||||
|
||||
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.activity.create.mockResolvedValue(activity);
|
||||
|
||||
await sut.create(factory.auth({ id: userId }), {
|
||||
await sut.create(factory.auth({ user: { id: userId } }), {
|
||||
albumId,
|
||||
assetId,
|
||||
type: ReactionType.COMMENT,
|
||||
@ -116,7 +116,7 @@ describe(ActivityService.name, () => {
|
||||
mocks.activity.create.mockResolvedValue(activity);
|
||||
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 });
|
||||
});
|
||||
|
@ -7,13 +7,13 @@ import {
|
||||
CreateAlbumDto,
|
||||
GetAlbumsDto,
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
mapAlbum,
|
||||
mapAlbumWithAssets,
|
||||
mapAlbumWithoutAssets,
|
||||
} from 'src/dtos/album.dto';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AlbumUserEntity } from 'src/entities/album-user.entity';
|
||||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { Permission } from 'src/enum';
|
||||
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 });
|
||||
}
|
||||
|
||||
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.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 () => {
|
||||
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(
|
||||
BadRequestException,
|
||||
|
@ -5,9 +5,9 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Stats } from 'node:fs';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.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 { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
@ -166,7 +166,7 @@ const assetEntity = Object.freeze({
|
||||
isArchived: false,
|
||||
encodedVideoPath: '',
|
||||
duration: '0:00:00.000000',
|
||||
files: [] as AssetFileEntity[],
|
||||
files: [] as AssetFile[],
|
||||
exifInfo: {
|
||||
latitude: 49.533_547,
|
||||
longitude: 10.703_075,
|
||||
@ -535,12 +535,9 @@ describe(AssetMediaService.name, () => {
|
||||
...assetStub.image,
|
||||
files: [
|
||||
{
|
||||
assetId: assetStub.image.id,
|
||||
createdAt: assetStub.image.fileCreatedAt,
|
||||
id: '42',
|
||||
path: '/path/to/preview',
|
||||
type: AssetFileType.THUMBNAIL,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -555,12 +552,9 @@ describe(AssetMediaService.name, () => {
|
||||
...assetStub.image,
|
||||
files: [
|
||||
{
|
||||
assetId: assetStub.image.id,
|
||||
createdAt: assetStub.image.fileCreatedAt,
|
||||
id: '42',
|
||||
path: '/path/to/preview.jpg',
|
||||
type: AssetFileType.PREVIEW,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -88,7 +88,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
it('should get memories with partners with inTimeline enabled', async () => {
|
||||
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.asset.getByDayOfYear.mockResolvedValue([]);
|
||||
@ -139,7 +139,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
it('should not include partner assets if not in timeline', async () => {
|
||||
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.partner.getAll.mockResolvedValue([partner]);
|
||||
@ -151,7 +151,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
it('should include partner assets if in timeline', async () => {
|
||||
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.partner.getAll.mockResolvedValue([partner]);
|
||||
|
@ -43,7 +43,7 @@ export class AssetService extends BaseService {
|
||||
yearsAgo,
|
||||
// TODO move this to clients
|
||||
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 { DateTime } from 'luxon';
|
||||
import { UserAdmin } from 'src/database';
|
||||
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { AuthType, Permission } from 'src/enum';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { factory, newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const oauthResponse = {
|
||||
const oauthResponse = ({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
profileImagePath,
|
||||
}: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
profileImagePath?: string;
|
||||
}) => ({
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: 'user-id',
|
||||
userEmail: 'immich@test.com',
|
||||
name: 'immich_name',
|
||||
profileImagePath: '',
|
||||
userId: id,
|
||||
userEmail: email,
|
||||
name,
|
||||
profileImagePath,
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
};
|
||||
});
|
||||
|
||||
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
|
||||
@ -39,15 +48,7 @@ const fixtures = {
|
||||
},
|
||||
};
|
||||
|
||||
const oauthUserWithDefaultQuota = {
|
||||
email,
|
||||
name: ' ',
|
||||
oauthId: sub,
|
||||
quotaSizeInBytes: '1073741824',
|
||||
storageLabel: null,
|
||||
};
|
||||
|
||||
describe('AuthService', () => {
|
||||
describe(AuthService.name, () => {
|
||||
let sut: AuthService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
@ -89,7 +90,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@ -97,7 +98,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(session);
|
||||
@ -118,14 +119,12 @@ describe('AuthService', () => {
|
||||
|
||||
describe('changePassword', () => {
|
||||
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' };
|
||||
|
||||
mocks.user.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: 'hash-password',
|
||||
} as UserEntity);
|
||||
mocks.user.update.mockResolvedValue(userStub.user1);
|
||||
mocks.user.getByEmail.mockResolvedValue({ ...user, password: 'hash-password' });
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
|
||||
await sut.changePassword(auth, dto);
|
||||
|
||||
@ -143,7 +142,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
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' };
|
||||
|
||||
mocks.crypto.compareBcrypt.mockReturnValue(false);
|
||||
@ -151,7 +150,7 @@ describe('AuthService', () => {
|
||||
mocks.user.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: 'hash-password',
|
||||
} as UserEntity);
|
||||
} as UserAdmin & { password: string });
|
||||
|
||||
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@ -163,7 +162,7 @@ describe('AuthService', () => {
|
||||
mocks.user.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: '',
|
||||
} as UserEntity);
|
||||
} as UserAdmin & { password: string });
|
||||
|
||||
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' };
|
||||
|
||||
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);
|
||||
|
||||
@ -231,7 +230,7 @@ describe('AuthService', () => {
|
||||
id: 'admin',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
metadata: [] as UserMetadataItem[],
|
||||
} as UserEntity);
|
||||
} as unknown as UserAdmin);
|
||||
|
||||
await expect(sut.adminSignUp(dto)).resolves.toMatchObject({
|
||||
avatarColor: expect.any(String),
|
||||
@ -294,7 +293,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should not accept an expired key', async () => {
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired as any);
|
||||
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
@ -306,7 +305,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
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(
|
||||
sut.authenticate({
|
||||
@ -318,7 +317,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await expect(
|
||||
@ -331,37 +330,39 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should accept a base64url key', async () => {
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
mocks.user.get.mockResolvedValue(userStub.admin);
|
||||
const user = factory.userAdmin();
|
||||
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
||||
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') },
|
||||
headers: { 'x-immich-share-key': sharedLink.key.toString('base64url') },
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
user: userStub.admin,
|
||||
sharedLink: sharedLinkStub.valid,
|
||||
});
|
||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
|
||||
).resolves.toEqual({ user, sharedLink });
|
||||
|
||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key);
|
||||
});
|
||||
|
||||
it('should accept a hex key', async () => {
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
mocks.user.get.mockResolvedValue(userStub.admin);
|
||||
const user = factory.userAdmin();
|
||||
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
||||
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') },
|
||||
headers: { 'x-immich-share-key': sharedLink.key.toString('hex') },
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
user: userStub.admin,
|
||||
sharedLink: sharedLinkStub.valid,
|
||||
});
|
||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
|
||||
).resolves.toEqual({ user, sharedLink });
|
||||
|
||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key);
|
||||
});
|
||||
});
|
||||
|
||||
@ -533,24 +534,28 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
const user = factory.userAdmin();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(userStub.user1);
|
||||
mocks.user.update.mockResolvedValue(userStub.user1);
|
||||
mocks.user.getByEmail.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,
|
||||
oauthResponse(user),
|
||||
);
|
||||
|
||||
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 () => {
|
||||
const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.user.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' });
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
@ -561,14 +566,16 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
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
|
||||
@ -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 () => {
|
||||
const user = factory.userAdmin({ isAdmin: true });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
||||
|
||||
@ -600,8 +609,10 @@ describe('AuthService', () => {
|
||||
'app.immich:///oauth-callback?code=abc123',
|
||||
]) {
|
||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||
const user = factory.userAdmin();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(userStub.user1);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
await sut.callback({ url }, loginDetails);
|
||||
@ -611,100 +622,162 @@ describe('AuthService', () => {
|
||||
}
|
||||
|
||||
it('should use the default quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
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 () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
|
||||
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.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' });
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
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 () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
|
||||
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.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 });
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
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 () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
|
||||
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.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 });
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse,
|
||||
oauthResponse(user),
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email,
|
||||
email: user.email,
|
||||
name: ' ',
|
||||
oauthId: sub,
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: null,
|
||||
storageLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use a valid storage quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
|
||||
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.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 });
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse,
|
||||
oauthResponse(user),
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email,
|
||||
email: user.email,
|
||||
name: ' ',
|
||||
oauthId: sub,
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: 5_368_709_120,
|
||||
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', () => {
|
||||
it('should link an account', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [] });
|
||||
const auth = { user: authUser, apiKey: authApiKey };
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ apiKey: { permissions: [] }, user });
|
||||
|
||||
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' });
|
||||
|
||||
@ -717,7 +790,7 @@ describe('AuthService', () => {
|
||||
const auth = { user: authUser, apiKey: authApiKey };
|
||||
|
||||
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(
|
||||
BadRequestException,
|
||||
@ -729,12 +802,11 @@ describe('AuthService', () => {
|
||||
|
||||
describe('unlink', () => {
|
||||
it('should unlink an account', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [] });
|
||||
const auth = { user: authUser, apiKey: authApiKey };
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user, apiKey: { permissions: [] } });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.update.mockResolvedValue(userStub.user1);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
|
||||
await sut.unlink(auth);
|
||||
|
||||
|
@ -3,7 +3,10 @@ import { isString } from 'class-validator';
|
||||
import { parse } from 'cookie';
|
||||
import { DateTime } from 'luxon';
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
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 {
|
||||
AuthDto,
|
||||
@ -17,13 +20,12 @@ import {
|
||||
mapLoginResponse,
|
||||
} from 'src/dtos/auth.dto';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum';
|
||||
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission, StorageFolder } from 'src/enum';
|
||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { isGranted } from 'src/utils/access';
|
||||
import { HumanReadableSize } from 'src/utils/bytes';
|
||||
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
export interface LoginDetails {
|
||||
isSecure: boolean;
|
||||
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 { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
|
||||
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
|
||||
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);
|
||||
}
|
||||
|
||||
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> {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
const { sub: oauthId } = await this.oauthRepository.getProfile(
|
||||
@ -318,7 +347,7 @@ export class AuthService extends BaseService {
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
@ -347,7 +376,7 @@ export class AuthService extends BaseService {
|
||||
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 token = this.cryptoRepository.hashSha256(key);
|
||||
|
||||
|
@ -4,7 +4,7 @@ import sanitize from 'sanitize-filename';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
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 { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
@ -138,7 +138,7 @@ export class BaseService {
|
||||
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);
|
||||
if (user) {
|
||||
throw new BadRequestException('User exists');
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { describe, it } from 'vitest';
|
||||
|
||||
@ -13,7 +13,7 @@ describe(CliService.name, () => {
|
||||
|
||||
describe('listUsers', () => {
|
||||
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 })]);
|
||||
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true });
|
||||
});
|
||||
@ -30,8 +30,10 @@ describe(CliService.name, () => {
|
||||
});
|
||||
|
||||
it('should default to a random password', async () => {
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.admin);
|
||||
mocks.user.update.mockResolvedValue(userStub.admin);
|
||||
const admin = factory.userAdmin({ isAdmin: true });
|
||||
|
||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||
mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
|
||||
const ask = vitest.fn().mockImplementation(() => {});
|
||||
|
||||
@ -41,13 +43,15 @@ describe(CliService.name, () => {
|
||||
|
||||
expect(response.provided).toBe(false);
|
||||
expect(ask).toHaveBeenCalled();
|
||||
expect(id).toEqual(userStub.admin.id);
|
||||
expect(id).toEqual(admin.id);
|
||||
expect(update.password).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use the supplied password', async () => {
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.admin);
|
||||
mocks.user.update.mockResolvedValue(userStub.admin);
|
||||
const admin = factory.userAdmin({ isAdmin: true });
|
||||
|
||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||
mocks.user.update.mockResolvedValue(admin);
|
||||
|
||||
const ask = vitest.fn().mockResolvedValue('new-password');
|
||||
|
||||
@ -57,7 +61,7 @@ describe(CliService.name, () => {
|
||||
|
||||
expect(response.provided).toBe(true);
|
||||
expect(ask).toHaveBeenCalled();
|
||||
expect(id).toEqual(userStub.admin.id);
|
||||
expect(id).toEqual(admin.id);
|
||||
expect(update.password).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
@ -35,7 +35,7 @@ describe(MapService.name, () => {
|
||||
|
||||
it('should include partner assets', async () => {
|
||||
const partner = factory.partner();
|
||||
const auth = factory.auth({ id: partner.sharedWithId });
|
||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||
|
||||
const asset = assetStub.withLocation;
|
||||
const marker = {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { OutputInfo } from 'sharp';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { Exif } from 'src/database';
|
||||
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
@ -319,7 +319,7 @@ describe(MediaService.name, () => {
|
||||
it('should generate P3 thumbnails for a wide gamut image', async () => {
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...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');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
@ -2608,47 +2608,47 @@ describe(MediaService.name, () => {
|
||||
|
||||
describe('isSRGB', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -24,7 +24,7 @@ describe(MemoryService.name, () => {
|
||||
|
||||
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.objectContaining({ id: memory1.id, assets: [expect.objectContaining({ id: asset.id })] }),
|
||||
expect.objectContaining({ id: memory2.id, assets: [] }),
|
||||
@ -60,7 +60,9 @@ describe(MemoryService.name, () => {
|
||||
mocks.memory.get.mockResolvedValue(memory);
|
||||
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.access.memory.checkOwnerAccess).toHaveBeenCalledWith(memory.ownerId, new Set([memory.id]));
|
||||
@ -75,7 +77,7 @@ describe(MemoryService.name, () => {
|
||||
mocks.memory.create.mockResolvedValue(memory);
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth({ id: userId }), {
|
||||
sut.create(factory.auth({ user: { id: userId } }), {
|
||||
type: memory.type,
|
||||
data: memory.data,
|
||||
memoryAt: memory.memoryAt,
|
||||
@ -105,7 +107,7 @@ describe(MemoryService.name, () => {
|
||||
mocks.memory.create.mockResolvedValue(memory);
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth({ id: userId }), {
|
||||
sut.create(factory.auth({ user: { id: userId } }), {
|
||||
type: memory.type,
|
||||
data: memory.data,
|
||||
assetIds: memory.assets.map((asset) => asset.id),
|
||||
|
@ -3,8 +3,8 @@ import { randomBytes } from 'node:crypto';
|
||||
import { Stats } from 'node:fs';
|
||||
import { constants } from 'node:fs/promises';
|
||||
import { defaults } from 'src/config';
|
||||
import { Exif } from 'src/database';
|
||||
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 { WithoutProperty } from 'src/repositories/asset.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 { fileStub } from 'test/fixtures/file.stub';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
import { metadataStub } from 'test/fixtures/metadata.stub';
|
||||
import { personStub } from 'test/fixtures/person.stub';
|
||||
import { tagStub } from 'test/fixtures/tag.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
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, () => {
|
||||
let sut: MetadataService;
|
||||
let mocks: ServiceMocks;
|
||||
@ -969,7 +991,7 @@ describe(MetadataService.name, () => {
|
||||
it('should skip importing metadata when the feature is disabled', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } });
|
||||
mockReadTags(metadataStub.withFace);
|
||||
mockReadTags(makeFaceTags({ Name: 'Person 1' }));
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
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 () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(metadataStub.empty);
|
||||
mockReadTags();
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.person.getDistinctNames).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -985,7 +1007,7 @@ describe(MetadataService.name, () => {
|
||||
it('should skip importing faces without name', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(metadataStub.withFaceNoName);
|
||||
mockReadTags(makeFaceTags());
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
mocks.person.createAll.mockResolvedValue([]);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -997,7 +1019,7 @@ describe(MetadataService.name, () => {
|
||||
it('should skip importing faces with empty name', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(metadataStub.withFaceEmptyName);
|
||||
mockReadTags(makeFaceTags({ Name: '' }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
mocks.person.createAll.mockResolvedValue([]);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -1009,7 +1031,7 @@ describe(MetadataService.name, () => {
|
||||
it('should apply metadata face tags creating new persons', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(metadataStub.withFace);
|
||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
@ -1050,7 +1072,7 @@ describe(MetadataService.name, () => {
|
||||
it('should assign metadata face tags to existing persons', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
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.createAll.mockResolvedValue([]);
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
@ -1190,7 +1212,7 @@ describe(MetadataService.name, () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([
|
||||
{
|
||||
...assetStub.livePhotoStillAsset,
|
||||
exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity,
|
||||
exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as Exif,
|
||||
},
|
||||
]);
|
||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
@ -1229,18 +1251,51 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ Make: '1', Model: '2', Device: { Manufacturer: '3', ModelName: '4' }, AndroidMake: '4', AndroidModel: '5' },
|
||||
{ Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' },
|
||||
{ AndroidMake: '1', AndroidModel: '2' },
|
||||
])('should read camera make and model correct place %s', async (metaData) => {
|
||||
{
|
||||
exif: {
|
||||
Make: '1',
|
||||
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]);
|
||||
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 });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
make: '1',
|
||||
model: '2',
|
||||
lensModel: expected,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
@ -9,11 +9,9 @@ import { constants } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
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 { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import {
|
||||
AssetType,
|
||||
DatabaseLock,
|
||||
@ -76,6 +74,19 @@ const validateRange = (value: number | undefined, min: number, max: number): Non
|
||||
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 Dates = {
|
||||
@ -228,7 +239,7 @@ export class MetadataService extends BaseService {
|
||||
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
||||
iso: validate(exifTags.ISO) as number,
|
||||
exposureTime: exifTags.ExposureTime ?? null,
|
||||
lensModel: exifTags.LensModel ?? null,
|
||||
lensModel: getLensModel(exifTags),
|
||||
fNumber: validate(exifTags.FNumber),
|
||||
focalLength: validate(exifTags.FocalLength),
|
||||
|
||||
@ -574,10 +585,10 @@ export class MetadataService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
const facesToAdd: (Partial<AssetFaceEntity> & { assetId: string })[] = [];
|
||||
const facesToAdd: (Insertable<AssetFaces> & { assetId: string })[] = [];
|
||||
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
|
||||
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 }[] = [];
|
||||
for (const region of tags.RegionInfo.RegionList) {
|
||||
if (!region.Name) {
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { defaults, SystemConfig } from 'src/config';
|
||||
import { AlbumUser } from 'src/database';
|
||||
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 { EmailTemplate } from 'src/repositories/notification.repository';
|
||||
import { NotificationService } from 'src/services/notification.service';
|
||||
@ -442,7 +441,7 @@ describe(NotificationService.name, () => {
|
||||
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...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);
|
||||
@ -503,7 +502,7 @@ describe(NotificationService.name, () => {
|
||||
it('should skip recipient that could not be looked up', async () => {
|
||||
mocks.album.getById.mockResolvedValue({
|
||||
...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.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
@ -516,7 +515,7 @@ describe(NotificationService.name, () => {
|
||||
it('should skip recipient with disabled email notifications', async () => {
|
||||
mocks.album.getById.mockResolvedValue({
|
||||
...albumStub.emptyWithValidThumbnail,
|
||||
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity],
|
||||
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
|
||||
});
|
||||
mocks.user.get.mockResolvedValue({
|
||||
...userStub.user1,
|
||||
@ -537,7 +536,7 @@ describe(NotificationService.name, () => {
|
||||
it('should skip recipient with disabled email notifications for the album update event', async () => {
|
||||
mocks.album.getById.mockResolvedValue({
|
||||
...albumStub.emptyWithValidThumbnail,
|
||||
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity],
|
||||
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
|
||||
});
|
||||
mocks.user.get.mockResolvedValue({
|
||||
...userStub.user1,
|
||||
@ -558,7 +557,7 @@ describe(NotificationService.name, () => {
|
||||
it('should send email', async () => {
|
||||
mocks.album.getById.mockResolvedValue({
|
||||
...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.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
|
@ -22,7 +22,7 @@ describe(PartnerService.name, () => {
|
||||
const user2 = factory.user();
|
||||
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||
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]);
|
||||
|
||||
@ -35,7 +35,7 @@ describe(PartnerService.name, () => {
|
||||
const user2 = factory.user();
|
||||
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||
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]);
|
||||
await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
|
||||
@ -48,7 +48,7 @@ describe(PartnerService.name, () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
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.create.mockResolvedValue(partner);
|
||||
@ -65,7 +65,7 @@ describe(PartnerService.name, () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
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);
|
||||
|
||||
@ -80,7 +80,7 @@ describe(PartnerService.name, () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
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);
|
||||
|
||||
@ -113,7 +113,7 @@ describe(PartnerService.name, () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
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.partner.update.mockResolvedValue(partner);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { AssetFace } from 'src/database';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.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 { WithoutProperty } from 'src/repositories/asset.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 { authStub } from 'test/fixtures/auth.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 { factory } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const responseDto: PersonResponseDto = {
|
||||
@ -23,6 +24,7 @@ const responseDto: PersonResponseDto = {
|
||||
isHidden: false,
|
||||
updatedAt: expect.any(Date),
|
||||
isFavorite: false,
|
||||
color: expect.any(String),
|
||||
};
|
||||
|
||||
const statistics = { assets: 3 };
|
||||
@ -89,6 +91,7 @@ describe(PersonService.name, () => {
|
||||
isHidden: true,
|
||||
isFavorite: false,
|
||||
updatedAt: expect.any(Date),
|
||||
color: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -117,6 +120,7 @@ describe(PersonService.name, () => {
|
||||
isHidden: false,
|
||||
isFavorite: true,
|
||||
updatedAt: expect.any(Date),
|
||||
color: personStub.isFavorite.color,
|
||||
},
|
||||
responseDto,
|
||||
],
|
||||
@ -136,7 +140,6 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
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']));
|
||||
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']));
|
||||
@ -160,7 +163,6 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error when personId is invalid', async () => {
|
||||
mocks.person.getById.mockResolvedValue(null);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||
@ -230,6 +232,7 @@ describe(PersonService.name, () => {
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
updatedAt: expect.any(Date),
|
||||
color: expect.any(String),
|
||||
});
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
@ -345,7 +348,6 @@ describe(PersonService.name, () => {
|
||||
|
||||
describe('handlePersonMigration', () => {
|
||||
it('should not move person files', async () => {
|
||||
mocks.person.getById.mockResolvedValue(null);
|
||||
await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED);
|
||||
});
|
||||
});
|
||||
@ -399,6 +401,7 @@ describe(PersonService.name, () => {
|
||||
name: personStub.noName.name,
|
||||
thumbnailPath: personStub.noName.thumbnailPath,
|
||||
updatedAt: expect.any(Date),
|
||||
color: personStub.noName.color,
|
||||
});
|
||||
|
||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||
@ -437,7 +440,7 @@ describe(PersonService.name, () => {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -479,7 +482,7 @@ describe(PersonService.name, () => {
|
||||
await sut.handleQueueDetectFaces({ force: true });
|
||||
|
||||
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.asset.getAll).toHaveBeenCalled();
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
@ -530,7 +533,7 @@ describe(PersonService.name, () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -697,7 +700,7 @@ describe(PersonService.name, () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -730,7 +733,7 @@ describe(PersonService.name, () => {
|
||||
id: 'asset-face-1',
|
||||
assetId: assetStub.noResizePath.id,
|
||||
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 () => {
|
||||
const face = { ...faceStub.face1, asset: null } as AssetFaceEntity & { asset: null };
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(face);
|
||||
const face = { ...faceStub.face1, asset: null };
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(face);
|
||||
|
||||
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 () => {
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.face1);
|
||||
|
||||
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.search.searchFaces.mockResolvedValue(faces);
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||
@ -909,7 +912,7 @@ describe(PersonService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||
@ -939,7 +942,7 @@ describe(PersonService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||
@ -964,7 +967,7 @@ describe(PersonService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||
@ -983,7 +986,7 @@ describe(PersonService.name, () => {
|
||||
const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
|
||||
|
||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||
@ -1002,7 +1005,7 @@ describe(PersonService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||
mocks.search.searchFaces.mockResolvedValue(faces);
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||
@ -1024,7 +1027,7 @@ describe(PersonService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||
mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
|
||||
mocks.person.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true });
|
||||
@ -1046,7 +1049,6 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip a person not found', async () => {
|
||||
mocks.person.getById.mockResolvedValue(null);
|
||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -1057,30 +1059,18 @@ describe(PersonService.name, () => {
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip a person with a face asset id 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]);
|
||||
it('should skip a person with face not found', async () => {
|
||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate a thumbnail', async () => {
|
||||
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
|
||||
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.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
assetStub.primaryImage.originalPath,
|
||||
@ -1106,9 +1096,7 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should generate a thumbnail without going negative', async () => {
|
||||
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId });
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.start);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
|
||||
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
|
||||
@ -1133,10 +1121,8 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should generate a thumbnail without overflowing', async () => {
|
||||
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
|
||||
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.end);
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd);
|
||||
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
|
||||
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 () => {
|
||||
mocks.person.getById.mockResolvedValue(null);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
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 () => {
|
||||
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-2']));
|
||||
|
||||
@ -1279,7 +1263,8 @@ describe(PersonService.name, () => {
|
||||
|
||||
describe('mapFace', () => {
|
||||
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,
|
||||
boundingBoxX2: 1,
|
||||
boundingBoxY1: 0,
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Insertable, Updateable } from 'kysely';
|
||||
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetFaces, FaceSearch, Person } from 'src/db';
|
||||
import { Chunked, OnJob } from 'src/decorators';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@ -21,10 +23,6 @@ import {
|
||||
PersonStatisticsResponseDto,
|
||||
PersonUpdateDto,
|
||||
} 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 {
|
||||
AssetFileType,
|
||||
AssetType,
|
||||
@ -243,9 +241,9 @@ export class PersonService extends BaseService {
|
||||
}
|
||||
|
||||
@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 this.personRepository.delete(people);
|
||||
await this.personRepository.delete(people.map((person) => person.id));
|
||||
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}`);
|
||||
|
||||
const facesToAdd: (Partial<AssetFaceEntity> & { id: string; assetId: string })[] = [];
|
||||
const embeddings: FaceSearchEntity[] = [];
|
||||
const facesToAdd: (Insertable<AssetFaces> & { id: string })[] = [];
|
||||
const embeddings: FaceSearch[] = [];
|
||||
const mlFaceIds = new Set<string>();
|
||||
for (const face of asset.faces) {
|
||||
if (face.sourceType === SourceType.MACHINE_LEARNING) {
|
||||
@ -377,7 +375,10 @@ export class PersonService extends BaseService {
|
||||
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 y1 = Math.max(face.boundingBoxY1, newBox.y1);
|
||||
const x2 = Math.min(face.boundingBoxX2, newBox.x2);
|
||||
@ -453,11 +454,7 @@ export class PersonService extends BaseService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const face = await this.personRepository.getFaceByIdWithAssets(id, { faceSearch: true }, [
|
||||
'id',
|
||||
'personId',
|
||||
'sourceType',
|
||||
]);
|
||||
const face = await this.personRepository.getFaceForFacialRecognitionJob(id);
|
||||
if (!face || !face.asset) {
|
||||
this.logger.warn(`Face ${id} not found`);
|
||||
return JobStatus.FAILED;
|
||||
@ -545,46 +542,23 @@ export class PersonService extends BaseService {
|
||||
}
|
||||
|
||||
@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 });
|
||||
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const person = await this.personRepository.getById(data.id);
|
||||
if (!person?.faceAssetId) {
|
||||
this.logger.error(`Could not generate person thumbnail: person ${person?.id} has no face asset`);
|
||||
const data = await this.personRepository.getDataForThumbnailGenerationJob(id);
|
||||
if (!data) {
|
||||
this.logger.error(`Could not generate person thumbnail for ${id}: missing data`);
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
const face = await this.personRepository.getFaceByIdWithAssets(person.faceAssetId);
|
||||
if (!face) {
|
||||
this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`);
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight } = data;
|
||||
|
||||
const {
|
||||
assetId,
|
||||
boundingBoxX1: x1,
|
||||
boundingBoxX2: x2,
|
||||
boundingBoxY1: y1,
|
||||
boundingBoxY2: y2,
|
||||
imageWidth: oldWidth,
|
||||
imageHeight: oldHeight,
|
||||
} = face;
|
||||
const { width, height, inputPath } = await this.getInputDimensions(data);
|
||||
|
||||
const asset = await this.assetRepository.getById(assetId, {
|
||||
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);
|
||||
const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId });
|
||||
this.storageCore.ensureFolders(thumbnailPath);
|
||||
|
||||
const thumbnailOptions = {
|
||||
@ -597,7 +571,7 @@ export class PersonService extends BaseService {
|
||||
};
|
||||
|
||||
await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath);
|
||||
await this.personRepository.update({ id: person.id, thumbnailPath });
|
||||
await this.personRepository.update({ id, thumbnailPath });
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
@ -634,7 +608,7 @@ export class PersonService extends BaseService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const update: Partial<PersonEntity> = {};
|
||||
const update: Updateable<Person> & { id: string } = { id: primaryPerson.id };
|
||||
if (!primaryPerson.name && mergePerson.name) {
|
||||
update.name = mergePerson.name;
|
||||
}
|
||||
@ -644,7 +618,7 @@ export class PersonService extends BaseService {
|
||||
}
|
||||
|
||||
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;
|
||||
@ -672,27 +646,26 @@ export class PersonService extends BaseService {
|
||||
return person;
|
||||
}
|
||||
|
||||
private async getInputDimensions(asset: AssetEntity, oldDims: ImageDimensions): Promise<InputDimensions> {
|
||||
if (!asset.exifInfo?.exifImageHeight || !asset.exifInfo.exifImageWidth) {
|
||||
throw new Error(`Asset ${asset.id} dimensions are unknown`);
|
||||
}
|
||||
|
||||
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
|
||||
if (!previewFile) {
|
||||
throw new Error(`Asset ${asset.id} has no preview path`);
|
||||
}
|
||||
|
||||
private async getInputDimensions(asset: {
|
||||
type: AssetType;
|
||||
exifImageWidth: number;
|
||||
exifImageHeight: number;
|
||||
previewPath: string;
|
||||
originalPath: string;
|
||||
oldWidth: number;
|
||||
oldHeight: number;
|
||||
}): Promise<InputDimensions> {
|
||||
if (asset.type === AssetType.IMAGE) {
|
||||
let { exifImageWidth: width, exifImageHeight: height } = asset.exifInfo;
|
||||
if (oldDims.height > oldDims.width !== height > width) {
|
||||
let { exifImageWidth: width, exifImageHeight: height } = asset;
|
||||
if (asset.oldHeight > asset.oldWidth !== height > width) {
|
||||
[width, height] = [height, width];
|
||||
}
|
||||
|
||||
return { width, height, inputPath: asset.originalPath };
|
||||
}
|
||||
|
||||
const { width, height } = await this.mediaRepository.getImageDimensions(previewFile.path);
|
||||
return { width, height, inputPath: previewFile.path };
|
||||
const { width, height } = await this.mediaRepository.getImageDimensions(asset.previewPath);
|
||||
return { width, height, inputPath: asset.previewPath };
|
||||
}
|
||||
|
||||
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 { authStub } from 'test/fixtures/auth.stub';
|
||||
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(SharedLinkService.name, () => {
|
||||
@ -46,7 +47,13 @@ describe(SharedLinkService.name, () => {
|
||||
});
|
||||
|
||||
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);
|
||||
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
|
||||
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 () => {
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid);
|
||||
|
||||
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.update).toHaveBeenCalledWith({
|
||||
id: sharedLinkStub.valid.id,
|
||||
@ -242,6 +251,7 @@ describe(SharedLinkService.name, () => {
|
||||
describe('addAssets', () => {
|
||||
it('should not work on album shared links', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
@ -273,6 +283,7 @@ describe(SharedLinkService.name, () => {
|
||||
describe('removeAssets', () => {
|
||||
it('should not work on album shared links', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
@ -297,31 +308,39 @@ describe(SharedLinkService.name, () => {
|
||||
describe('getMetadataTags', () => {
|
||||
it('should return null when auth is not a shared link', async () => {
|
||||
await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null);
|
||||
|
||||
expect(mocks.sharedLink.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('should return metadata tags', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual);
|
||||
|
||||
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
|
||||
description: '1 shared photos & videos',
|
||||
imageUrl: `https://my.immich.app/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`,
|
||||
title: 'Public Share',
|
||||
});
|
||||
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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: [] });
|
||||
|
||||
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
|
||||
description: '0 shared photos & videos',
|
||||
imageUrl: `https://my.immich.app/feature-panel.png`,
|
||||
title: 'Public Share',
|
||||
});
|
||||
|
||||
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 { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const motionAsset = assetStub.storageAsset({});
|
||||
@ -426,15 +427,16 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
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.user.getList.mockResolvedValue([userStub.storageLabel]);
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
entityId: asset.id,
|
||||
pathType: AssetPathType.ORIGINAL,
|
||||
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();
|
||||
@ -442,11 +444,11 @@ describe(StorageTemplateService.name, () => {
|
||||
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||
'/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({
|
||||
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', () => {
|
||||
it('should not create double extensions when filename has lower extension', async () => {
|
||||
const user = factory.userAdmin({ storageLabel: 'label-1' });
|
||||
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',
|
||||
});
|
||||
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
||||
mocks.user.getList.mockResolvedValue([userStub.storageLabel]);
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
entityId: asset.id,
|
||||
pathType: AssetPathType.ORIGINAL,
|
||||
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.heic',
|
||||
newPath: 'upload/library/user-id/2023/2023-02-23/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`,
|
||||
});
|
||||
|
||||
await sut.handleMigration();
|
||||
|
||||
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||
'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`,
|
||||
`upload/library/${user.storageLabel}/2022/2022-06-19/IMG_7065.heic`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not create double extensions when filename has uppercase extension', async () => {
|
||||
const user = factory.userAdmin();
|
||||
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',
|
||||
});
|
||||
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
||||
mocks.user.getList.mockResolvedValue([userStub.storageLabel]);
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
entityId: asset.id,
|
||||
pathType: AssetPathType.ORIGINAL,
|
||||
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.HEIC',
|
||||
newPath: 'upload/library/user-id/2023/2023-02-23/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`,
|
||||
});
|
||||
|
||||
await sut.handleMigration();
|
||||
|
||||
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||
'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`,
|
||||
`upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize the filename to lowercase (JPEG > jpg)', async () => {
|
||||
const user = factory.userAdmin();
|
||||
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',
|
||||
});
|
||||
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
||||
mocks.user.getList.mockResolvedValue([userStub.storageLabel]);
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
entityId: asset.id,
|
||||
pathType: AssetPathType.ORIGINAL,
|
||||
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPEG',
|
||||
newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.jpg',
|
||||
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
|
||||
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`,
|
||||
});
|
||||
|
||||
await sut.handleMigration();
|
||||
|
||||
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||
'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.JPEG`,
|
||||
`upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize the filename to lowercase (JPG > jpg)', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const asset = assetStub.storageAsset({
|
||||
ownerId: user.id,
|
||||
originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG',
|
||||
originalFileName: 'IMG_7065.JPG',
|
||||
});
|
||||
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
|
||||
mocks.user.getList.mockResolvedValue([userStub.storageLabel]);
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
entityId: asset.id,
|
||||
pathType: AssetPathType.ORIGINAL,
|
||||
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG',
|
||||
newPath: 'upload/library/user-id/2023/2023-02-23/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`,
|
||||
});
|
||||
|
||||
await sut.handleMigration();
|
||||
|
||||
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
|
||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||
'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`,
|
||||
`upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -39,7 +39,7 @@ describe(SyncService.name, () => {
|
||||
describe('getChangesForDeltaSync', () => {
|
||||
it('should return a response requiring a full sync when partners are out of sync', async () => {
|
||||
const partner = factory.partner();
|
||||
const auth = factory.auth({ id: partner.sharedWithId });
|
||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||
|
||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
||||
|
||||
|
@ -3,6 +3,7 @@ import { TimeBucketSize } from 'src/repositories/asset.repository';
|
||||
import { TimelineService } from 'src/services/timeline.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(TimelineService.name, () => {
|
||||
@ -114,15 +115,15 @@ describe(TimelineService.name, () => {
|
||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
|
||||
const buckets = await sut.getTimeBucket(
|
||||
{ ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
|
||||
{
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: true,
|
||||
albumId: 'album-id',
|
||||
},
|
||||
);
|
||||
const auth = factory.auth({ sharedLink: { showExif: false } });
|
||||
|
||||
const buckets = await sut.getTimeBucket(auth, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: true,
|
||||
albumId: 'album-id',
|
||||
});
|
||||
|
||||
expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]);
|
||||
expect(buckets[0]).not.toHaveProperty('exif');
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { UserService } from 'src/services/user.service';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
@ -29,7 +29,7 @@ describe(UserService.name, () => {
|
||||
describe('getAll', () => {
|
||||
it('admin should get all users', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth(user);
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
|
||||
@ -39,14 +39,12 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
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([
|
||||
expect.objectContaining({
|
||||
id: authStub.user1.user.id,
|
||||
email: authStub.user1.user.email,
|
||||
}),
|
||||
]);
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
|
||||
await expect(sut.search(auth)).resolves.toEqual([expect.objectContaining({ id: user.id, email: user.email })]);
|
||||
|
||||
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 () => {
|
||||
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'));
|
||||
|
||||
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException);
|
||||
});
|
||||
|
||||
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 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 });
|
||||
|
||||
await sut.createProfileImage(authStub.admin, file);
|
||||
@ -149,8 +149,10 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete the profile image if user has one', async () => {
|
||||
mocks.user.get.mockResolvedValue(userStub.profilePath);
|
||||
const files = [userStub.profilePath.profileImagePath];
|
||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const files = [user.profileImagePath];
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
|
||||
await sut.deleteProfileImage(authStub.admin);
|
||||
|
||||
@ -176,9 +178,10 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
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({
|
||||
path: '/path/to/profile.jpg',
|
||||
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', () => {
|
||||
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);
|
||||
|
||||
@ -225,7 +228,7 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
@ -242,7 +245,7 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Updateable } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
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 { CreateProfileImageResponseDto } from 'src/dtos/user-profile.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 { UserFindOptions } from 'src/repositories/user.repository';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobOf, UserMetadataItem } from 'src/types';
|
||||
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,
|
||||
name: dto.name,
|
||||
};
|
||||
@ -229,7 +230,7 @@ export class UserService extends BaseService {
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean {
|
||||
private isReadyForDeletion(user: { id: string; deletedAt?: Date | null }, deleteDelay: number): boolean {
|
||||
if (!user.deletedAt) {
|
||||
return false;
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
||||
import { AssetFileType, AssetType, Permission } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
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);
|
||||
};
|
||||
|
||||
export const getAssetFiles = (files: AssetFileEntity[]) => ({
|
||||
export const getAssetFiles = (files: AssetFile[]) => ({
|
||||
fullsizeFile: getAssetFile(files, AssetFileType.FULLSIZE),
|
||||
previewFile: getAssetFile(files, AssetFileType.PREVIEW),
|
||||
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`;
|
||||
|
||||
@ -17,3 +17,25 @@ export const removeUndefinedKeys = <T extends object>(update: T, template: unkno
|
||||
|
||||
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', () => {
|
||||
it('should contain only lowercase mime types', () => {
|
||||
const keys = Object.keys(mimeTypes.profile);
|
||||
|
@ -55,6 +55,10 @@ const image: Record<string, string[]> = {
|
||||
'.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
|
||||
* @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 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 = {
|
||||
image,
|
||||
@ -120,6 +129,8 @@ export const mimeTypes = {
|
||||
isVideo: (filename: string) => isType(filename, video),
|
||||
isRaw: (filename: string) => isType(filename, raw),
|
||||
lookup,
|
||||
/** return an extension (including a leading `.`) for a mime-type */
|
||||
toExtension,
|
||||
assetType: (filename: string) => {
|
||||
const contentType = lookup(filename);
|
||||
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: [
|
||||
{
|
||||
user: userStub.user1,
|
||||
album: undefined as unknown as AlbumEntity,
|
||||
role: AlbumUserRole.EDITOR,
|
||||
userId: userStub.user1.id,
|
||||
albumId: 'album-2',
|
||||
},
|
||||
],
|
||||
isActivityEnabled: true,
|
||||
@ -63,17 +60,11 @@ export const albumStub = {
|
||||
albumUsers: [
|
||||
{
|
||||
user: userStub.user1,
|
||||
album: undefined as unknown as AlbumEntity,
|
||||
role: AlbumUserRole.EDITOR,
|
||||
userId: userStub.user1.id,
|
||||
albumId: 'album-3',
|
||||
},
|
||||
{
|
||||
user: userStub.user2,
|
||||
album: undefined as unknown as AlbumEntity,
|
||||
role: AlbumUserRole.EDITOR,
|
||||
userId: userStub.user2.id,
|
||||
albumId: 'album-3',
|
||||
},
|
||||
],
|
||||
isActivityEnabled: true,
|
||||
@ -95,10 +86,7 @@ export const albumStub = {
|
||||
albumUsers: [
|
||||
{
|
||||
user: userStub.admin,
|
||||
album: undefined as unknown as AlbumEntity,
|
||||
role: AlbumUserRole.EDITOR,
|
||||
userId: userStub.admin.id,
|
||||
albumId: 'album-3',
|
||||
},
|
||||
],
|
||||
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 { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { StackEntity } from 'src/entities/stack.entity';
|
||||
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
|
||||
import { StorageAsset } from 'src/types';
|
||||
@ -8,40 +7,30 @@ import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { fileStub } from 'test/fixtures/file.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
|
||||
const previewFile: AssetFileEntity = {
|
||||
export const previewFile: AssetFile = {
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.PREVIEW,
|
||||
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',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.THUMBNAIL,
|
||||
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',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.FULLSIZE,
|
||||
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 => {
|
||||
return {
|
||||
id: stackId,
|
||||
assets,
|
||||
owner: assets[0].owner,
|
||||
ownerId: assets[0].ownerId,
|
||||
primaryAsset: assets[0],
|
||||
primaryAssetId: assets[0].id,
|
||||
@ -129,7 +118,7 @@ export const assetStub = {
|
||||
isExternal: false,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 123_000,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
@ -203,7 +192,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 5000,
|
||||
exifImageHeight: 1000,
|
||||
exifImageWidth: 1000,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
stackId: 'stack-1',
|
||||
stack: stackStub('stack-1', [
|
||||
{ id: 'primary-asset-id' } as AssetEntity,
|
||||
@ -248,7 +237,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 5000,
|
||||
exifImageHeight: 3840,
|
||||
exifImageWidth: 2160,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
}),
|
||||
@ -286,7 +275,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 5000,
|
||||
exifImageHeight: 3840,
|
||||
exifImageWidth: 2160,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
status: AssetStatus.TRASHED,
|
||||
@ -327,7 +316,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 5000,
|
||||
exifImageHeight: 3840,
|
||||
exifImageWidth: 2160,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: true,
|
||||
}),
|
||||
@ -365,7 +354,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 5000,
|
||||
exifImageHeight: 3840,
|
||||
exifImageWidth: 2160,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
}),
|
||||
@ -403,7 +392,7 @@ export const assetStub = {
|
||||
sidecarPath: null,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 5000,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
}),
|
||||
@ -440,7 +429,7 @@ export const assetStub = {
|
||||
sidecarPath: null,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 5000,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
}),
|
||||
@ -476,7 +465,7 @@ export const assetStub = {
|
||||
sidecarPath: null,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 5000,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
@ -515,7 +504,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 100_000,
|
||||
exifImageHeight: 2160,
|
||||
exifImageWidth: 3840,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
@ -606,7 +595,7 @@ export const assetStub = {
|
||||
city: 'test-city',
|
||||
state: 'test-state',
|
||||
country: 'test-country',
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
@ -711,7 +700,7 @@ export const assetStub = {
|
||||
sidecarPath: null,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 100_000,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
@ -750,7 +739,7 @@ export const assetStub = {
|
||||
sidecarPath: null,
|
||||
exifInfo: {
|
||||
fileSizeInByte: 5000,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
}),
|
||||
@ -789,7 +778,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 5000,
|
||||
profileDescription: 'Adobe RGB',
|
||||
bitsPerSample: 14,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
}),
|
||||
@ -828,7 +817,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 5000,
|
||||
profileDescription: 'Adobe RGB',
|
||||
bitsPerSample: 14,
|
||||
} as ExifEntity,
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
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'),
|
||||
} 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 { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { personStub } from 'test/fixtures/person.stub';
|
||||
|
||||
type NonNullableProperty<T> = { [P in keyof T]: NonNullable<T[P]> };
|
||||
|
||||
export const faceStub = {
|
||||
face1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||
face1: Object.freeze({
|
||||
id: 'assetFaceId1',
|
||||
assetId: assetStub.image.id,
|
||||
asset: assetStub.image,
|
||||
asset: {
|
||||
...assetStub.image,
|
||||
libraryId: null,
|
||||
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
|
||||
stackId: null,
|
||||
},
|
||||
personId: personStub.withName.id,
|
||||
person: personStub.withName,
|
||||
boundingBoxX1: 0,
|
||||
@ -22,7 +24,7 @@ export const faceStub = {
|
||||
faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' },
|
||||
deletedAt: new Date(),
|
||||
}),
|
||||
primaryFace1: Object.freeze<AssetFaceEntity>({
|
||||
primaryFace1: Object.freeze({
|
||||
id: 'assetFaceId2',
|
||||
assetId: assetStub.image.id,
|
||||
asset: assetStub.image,
|
||||
@ -38,7 +40,7 @@ export const faceStub = {
|
||||
faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' },
|
||||
deletedAt: null,
|
||||
}),
|
||||
mergeFace1: Object.freeze<AssetFaceEntity>({
|
||||
mergeFace1: Object.freeze({
|
||||
id: 'assetFaceId3',
|
||||
assetId: assetStub.image.id,
|
||||
asset: assetStub.image,
|
||||
@ -54,55 +56,7 @@ export const faceStub = {
|
||||
faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' },
|
||||
deletedAt: null,
|
||||
}),
|
||||
start: Object.freeze<AssetFaceEntity>({
|
||||
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>({
|
||||
noPerson1: Object.freeze({
|
||||
id: 'assetFaceId8',
|
||||
assetId: assetStub.image.id,
|
||||
asset: assetStub.image,
|
||||
@ -118,7 +72,7 @@ export const faceStub = {
|
||||
faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' },
|
||||
deletedAt: null,
|
||||
}),
|
||||
noPerson2: Object.freeze<AssetFaceEntity>({
|
||||
noPerson2: Object.freeze({
|
||||
id: 'assetFaceId9',
|
||||
assetId: assetStub.image.id,
|
||||
asset: assetStub.image,
|
||||
@ -134,7 +88,7 @@ export const faceStub = {
|
||||
faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' },
|
||||
deletedAt: null,
|
||||
}),
|
||||
fromExif1: Object.freeze<AssetFaceEntity>({
|
||||
fromExif1: Object.freeze({
|
||||
id: 'assetFaceId9',
|
||||
assetId: assetStub.image.id,
|
||||
asset: assetStub.image,
|
||||
@ -149,7 +103,7 @@ export const faceStub = {
|
||||
sourceType: SourceType.EXIF,
|
||||
deletedAt: null,
|
||||
}),
|
||||
fromExif2: Object.freeze<AssetFaceEntity>({
|
||||
fromExif2: Object.freeze({
|
||||
id: 'assetFaceId9',
|
||||
assetId: assetStub.image.id,
|
||||
asset: assetStub.image,
|
||||
@ -164,7 +118,7 @@ export const faceStub = {
|
||||
sourceType: SourceType.EXIF,
|
||||
deletedAt: null,
|
||||
}),
|
||||
withBirthDate: Object.freeze<AssetFaceEntity>({
|
||||
withBirthDate: Object.freeze({
|
||||
id: 'assetFaceId10',
|
||||
assetId: assetStub.image.id,
|
||||
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';
|
||||
|
||||
const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125';
|
||||
|
||||
export const personStub = {
|
||||
noName: Object.freeze<PersonEntity>({
|
||||
noName: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: '',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
@ -16,13 +19,14 @@ export const personStub = {
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
hidden: Object.freeze<PersonEntity>({
|
||||
hidden: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: '',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
@ -31,13 +35,14 @@ export const personStub = {
|
||||
faceAsset: null,
|
||||
isHidden: true,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
withName: Object.freeze<PersonEntity>({
|
||||
withName: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: 'Person 1',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
@ -46,28 +51,30 @@ export const personStub = {
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
withBirthDate: Object.freeze<PersonEntity>({
|
||||
withBirthDate: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: 'Person 1',
|
||||
birthDate: '1976-06-30',
|
||||
birthDate: new Date('1976-06-30'),
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
noThumbnail: Object.freeze<PersonEntity>({
|
||||
noThumbnail: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: '',
|
||||
birthDate: null,
|
||||
thumbnailPath: '',
|
||||
@ -76,13 +83,14 @@ export const personStub = {
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
newThumbnail: Object.freeze<PersonEntity>({
|
||||
newThumbnail: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: '',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/new/path/to/thumbnail.jpg',
|
||||
@ -91,13 +99,14 @@ export const personStub = {
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
primaryPerson: Object.freeze<PersonEntity>({
|
||||
primaryPerson: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: 'Person 1',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail',
|
||||
@ -106,13 +115,14 @@ export const personStub = {
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
mergePerson: Object.freeze<PersonEntity>({
|
||||
mergePerson: Object.freeze({
|
||||
id: 'person-2',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: 'Person 2',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail',
|
||||
@ -121,13 +131,14 @@ export const personStub = {
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
randomPerson: Object.freeze<PersonEntity>({
|
||||
randomPerson: Object.freeze({
|
||||
id: 'person-3',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: '',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail',
|
||||
@ -136,13 +147,14 @@ export const personStub = {
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
isFavorite: Object.freeze<PersonEntity>({
|
||||
isFavorite: Object.freeze({
|
||||
id: 'person-4',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: 'Person 1',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
@ -151,5 +163,51 @@ export const personStub = {
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
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 { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { ExifResponseDto } from 'src/dtos/exif.dto';
|
||||
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
|
||||
import { mapUser } from 'src/dtos/user.dto';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
@ -106,7 +106,6 @@ export const sharedLinkStub = {
|
||||
individual: Object.freeze({
|
||||
id: '123',
|
||||
userId: authStub.admin.user.id,
|
||||
user: userStub.admin,
|
||||
key: sharedLinkBytes,
|
||||
type: SharedLinkType.INDIVIDUAL,
|
||||
createdAt: today,
|
||||
@ -154,7 +153,6 @@ export const sharedLinkStub = {
|
||||
readonlyNoExif: Object.freeze<SharedLinkEntity>({
|
||||
id: '123',
|
||||
userId: authStub.admin.user.id,
|
||||
user: userStub.admin,
|
||||
key: sharedLinkBytes,
|
||||
type: SharedLinkType.ALBUM,
|
||||
createdAt: today,
|
||||
@ -185,7 +183,7 @@ export const sharedLinkStub = {
|
||||
{
|
||||
id: 'id_1',
|
||||
status: AssetStatus.ACTIVE,
|
||||
owner: undefined as unknown as UserEntity,
|
||||
owner: undefined as unknown as UserAdmin,
|
||||
ownerId: 'user_id_1',
|
||||
deviceAssetId: 'device_asset_id_1',
|
||||
deviceId: 'device_id_1',
|
||||
@ -234,7 +232,6 @@ export const sharedLinkStub = {
|
||||
iso: 100,
|
||||
exposureTime: '1/16',
|
||||
fps: 100,
|
||||
asset: null as any,
|
||||
profileDescription: 'sRGB',
|
||||
bitsPerSample: 8,
|
||||
colorspace: 'sRGB',
|
||||
@ -253,7 +250,6 @@ export const sharedLinkStub = {
|
||||
passwordRequired: Object.freeze<SharedLinkEntity>({
|
||||
id: '123',
|
||||
userId: authStub.admin.user.id,
|
||||
user: userStub.admin,
|
||||
key: sharedLinkBytes,
|
||||
type: SharedLinkType.ALBUM,
|
||||
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 { authStub } from 'test/fixtures/auth.stub';
|
||||
|
||||
export const userStub = {
|
||||
admin: Object.freeze<UserEntity>({
|
||||
admin: <UserAdmin>{
|
||||
...authStub.admin.user,
|
||||
status: UserStatus.ACTIVE,
|
||||
profileChangedAt: new Date('2021-01-01'),
|
||||
password: 'admin_password',
|
||||
name: 'admin_name',
|
||||
id: 'admin_id',
|
||||
storageLabel: 'admin',
|
||||
@ -17,16 +16,14 @@ export const userStub = {
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
assets: [],
|
||||
metadata: [],
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
}),
|
||||
user1: Object.freeze<UserEntity>({
|
||||
},
|
||||
user1: <UserAdmin>{
|
||||
...authStub.user1.user,
|
||||
status: UserStatus.ACTIVE,
|
||||
profileChangedAt: new Date('2021-01-01'),
|
||||
password: 'immich_password',
|
||||
name: 'immich_name',
|
||||
storageLabel: null,
|
||||
oauthId: '',
|
||||
@ -35,7 +32,6 @@ export const userStub = {
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
assets: [],
|
||||
metadata: [
|
||||
{
|
||||
key: UserMetadataKey.PREFERENCES,
|
||||
@ -44,13 +40,12 @@ export const userStub = {
|
||||
],
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
}),
|
||||
user2: Object.freeze<UserEntity>({
|
||||
},
|
||||
user2: <UserAdmin>{
|
||||
...authStub.user2.user,
|
||||
status: UserStatus.ACTIVE,
|
||||
profileChangedAt: new Date('2021-01-01'),
|
||||
metadata: [],
|
||||
password: 'immich_password',
|
||||
name: 'immich_name',
|
||||
storageLabel: null,
|
||||
oauthId: '',
|
||||
@ -59,44 +54,7 @@ export const userStub = {
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
assets: [],
|
||||
quotaSizeInBytes: null,
|
||||
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(),
|
||||
getFaces: vitest.fn(),
|
||||
getFaceById: vitest.fn(),
|
||||
getFaceByIdWithAssets: vitest.fn(),
|
||||
getFaceForFacialRecognitionJob: vitest.fn(),
|
||||
getDataForThumbnailGenerationJob: vitest.fn(),
|
||||
reassignFace: vitest.fn(),
|
||||
getById: vitest.fn(),
|
||||
getByName: vitest.fn(),
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
ApiKey,
|
||||
Asset,
|
||||
AuthApiKey,
|
||||
AuthSharedLink,
|
||||
AuthUser,
|
||||
Library,
|
||||
Memory,
|
||||
@ -35,12 +36,20 @@ export const newEmbedding = () => {
|
||||
const authFactory = ({
|
||||
apiKey,
|
||||
session,
|
||||
...user
|
||||
}: Partial<AuthUser> & { apiKey?: Partial<AuthApiKey>; session?: { id: string } } = {}) => {
|
||||
sharedLink,
|
||||
user,
|
||||
}: {
|
||||
apiKey?: Partial<AuthApiKey>;
|
||||
session?: { id: string };
|
||||
user?: Partial<UserAdmin>;
|
||||
sharedLink?: Partial<AuthSharedLink>;
|
||||
} = {}) => {
|
||||
const auth: AuthDto = {
|
||||
user: authUserFactory(user),
|
||||
user: authUserFactory(userAdminFactory(user ?? {})),
|
||||
};
|
||||
|
||||
const userId = auth.user.id;
|
||||
|
||||
if (apiKey) {
|
||||
auth.apiKey = authApiKeyFactory(apiKey);
|
||||
}
|
||||
@ -49,24 +58,45 @@ const authFactory = ({
|
||||
auth.session = { id: session.id };
|
||||
}
|
||||
|
||||
if (sharedLink) {
|
||||
auth.sharedLink = authSharedLinkFactory({ ...sharedLink, userId });
|
||||
}
|
||||
|
||||
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> = {}) => ({
|
||||
id: newUuid(),
|
||||
permissions: [Permission.ALL],
|
||||
...apiKey,
|
||||
});
|
||||
|
||||
const authUserFactory = (authUser: Partial<AuthUser> = {}) => ({
|
||||
id: newUuid(),
|
||||
isAdmin: false,
|
||||
name: 'Test User',
|
||||
email: 'test@immich.cloud',
|
||||
quotaUsageInBytes: 0,
|
||||
quotaSizeInBytes: null,
|
||||
...authUser,
|
||||
});
|
||||
const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
|
||||
const {
|
||||
id = newUuid(),
|
||||
isAdmin = false,
|
||||
name = 'Test User',
|
||||
email = 'test@immich.cloud',
|
||||
quotaUsageInBytes = 0,
|
||||
quotaSizeInBytes = null,
|
||||
} = authUser;
|
||||
|
||||
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes };
|
||||
};
|
||||
|
||||
const partnerFactory = (partner: Partial<Partner> = {}) => {
|
||||
const sharedBy = userFactory(partner.sharedBy || {});
|
||||
@ -112,25 +142,44 @@ const userFactory = (user: Partial<User> = {}) => ({
|
||||
...user,
|
||||
});
|
||||
|
||||
const userAdminFactory = (user: Partial<UserAdmin> = {}) => ({
|
||||
id: newUuid(),
|
||||
name: 'Test User',
|
||||
email: 'test@immich.cloud',
|
||||
profileImagePath: '',
|
||||
profileChangedAt: newDate(),
|
||||
storageLabel: null,
|
||||
shouldChangePassword: false,
|
||||
isAdmin: false,
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
deletedAt: null,
|
||||
oauthId: '',
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
status: UserStatus.ACTIVE,
|
||||
metadata: [],
|
||||
...user,
|
||||
});
|
||||
const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
||||
const {
|
||||
id = newUuid(),
|
||||
name = 'Test User',
|
||||
email = 'test@immich.cloud',
|
||||
profileImagePath = '',
|
||||
profileChangedAt = newDate(),
|
||||
storageLabel = null,
|
||||
shouldChangePassword = false,
|
||||
isAdmin = false,
|
||||
createdAt = newDate(),
|
||||
updatedAt = newDate(),
|
||||
deletedAt = null,
|
||||
oauthId = '',
|
||||
quotaSizeInBytes = null,
|
||||
quotaUsageInBytes = 0,
|
||||
status = UserStatus.ACTIVE,
|
||||
metadata = [],
|
||||
} = user;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
profileImagePath,
|
||||
profileChangedAt,
|
||||
storageLabel,
|
||||
shouldChangePassword,
|
||||
isAdmin,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
deletedAt,
|
||||
oauthId,
|
||||
quotaSizeInBytes,
|
||||
quotaUsageInBytes,
|
||||
status,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
|
||||
const assetFactory = (asset: Partial<Asset> = {}) => ({
|
||||
id: newUuid(),
|
||||
|
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@ -9492,9 +9492,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
|
||||
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
|
||||
"version": "6.2.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
|
||||
"integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -1,17 +1,20 @@
|
||||
<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 { locale } from '$lib/stores/preferences.store';
|
||||
import type { AlbumResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { mdiQrcode } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
album: AlbumResponseDto;
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
onViewQrCode: () => void;
|
||||
};
|
||||
|
||||
const { album, sharedLink }: Props = $props();
|
||||
const { album, sharedLink, onViewQrCode }: Props = $props();
|
||||
|
||||
const getShareProperties = () =>
|
||||
[
|
||||
@ -37,5 +40,8 @@
|
||||
<Text size="small">{sharedLink.description || album.albumName}</Text>
|
||||
<Text size="tiny" color="muted">{getShareProperties()}</Text>
|
||||
</div>
|
||||
<SharedLinkCopy link={sharedLink} />
|
||||
<div class="flex">
|
||||
<CircleIconButton title={$t('view_qr_code')} icon={mdiQrcode} onclick={onViewQrCode} />
|
||||
<SharedLinkCopy link={sharedLink} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,7 +3,10 @@
|
||||
import Dropdown from '$lib/components/elements/dropdown.svelte';
|
||||
import Icon from '$lib/components/elements/icon.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 { serverConfig } from '$lib/stores/server-config.store';
|
||||
import { makeSharedLinkUrl } from '$lib/utils';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
getAllSharedLinks,
|
||||
@ -31,6 +34,11 @@
|
||||
let users: UserResponseDto[] = $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 }> = [
|
||||
{ title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil },
|
||||
{ title: $t('role_viewer'), value: AlbumUserRole.Viewer, icon: mdiEye },
|
||||
@ -68,59 +76,24 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<FullScreenModal title={$t('share')} showLogo {onClose}>
|
||||
{#if Object.keys(selectedUsers).length > 0}
|
||||
<div class="mb-2 py-2 sticky">
|
||||
<p class="text-xs font-medium">{$t('selected')}</p>
|
||||
<div class="my-2">
|
||||
{#each Object.values(selectedUsers) as { user } (user.id)}
|
||||
{#key user.id}
|
||||
<div class="flex place-items-center gap-4 p-4">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border bg-immich-dark-success text-3xl text-white dark:border-immich-dark-gray dark:bg-immich-dark-success"
|
||||
>
|
||||
<Icon path={mdiCheck} size={24} />
|
||||
</div>
|
||||
{#if sharedLinkUrl}
|
||||
<QrCodeModal title={$t('view_link')} onClose={() => (sharedLinkUrl = '')} value={sharedLinkUrl} />
|
||||
{:else}
|
||||
<FullScreenModal title={$t('share')} showLogo {onClose}>
|
||||
{#if Object.keys(selectedUsers).length > 0}
|
||||
<div class="mb-2 py-2 sticky">
|
||||
<p class="text-xs font-medium">{$t('selected')}</p>
|
||||
<div class="my-2">
|
||||
{#each Object.values(selectedUsers) as { user } (user.id)}
|
||||
{#key user.id}
|
||||
<div class="flex place-items-center gap-4 p-4">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border bg-immich-dark-success text-3xl text-white dark:border-immich-dark-gray dark:bg-immich-dark-success"
|
||||
>
|
||||
<Icon path={mdiCheck} size={24} />
|
||||
</div>
|
||||
|
||||
<!-- <UserAvatar {user} size="md" /> -->
|
||||
<div class="text-left flex-grow">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{user.name}
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
title={$t('role')}
|
||||
options={roleOptions}
|
||||
render={({ title, icon }) => ({ title, icon })}
|
||||
onSelect={({ value }) => handleChangeRole(user, value)}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if users.length + Object.keys(selectedUsers).length === 0}
|
||||
<p class="p-5 text-sm">
|
||||
{$t('album_share_no_users')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="immich-scrollbar max-h-[500px] overflow-y-auto">
|
||||
{#if users.length > 0 && users.length !== Object.keys(selectedUsers).length}
|
||||
<Text>{$t('users')}</Text>
|
||||
|
||||
<div class="my-2">
|
||||
{#each users as user (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">
|
||||
<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">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{user.name}
|
||||
@ -129,44 +102,87 @@
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<Dropdown
|
||||
title={$t('role')}
|
||||
options={roleOptions}
|
||||
render={({ title, icon }) => ({ title, icon })}
|
||||
onSelect={({ value }) => handleChangeRole(user, value)}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if users.length > 0}
|
||||
<div class="py-3">
|
||||
<Button
|
||||
size="small"
|
||||
fullWidth
|
||||
shape="round"
|
||||
disabled={Object.keys(selectedUsers).length === 0}
|
||||
onclick={() =>
|
||||
onSelect(Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })))}
|
||||
>{$t('add')}</Button
|
||||
>
|
||||
{#if users.length + Object.keys(selectedUsers).length === 0}
|
||||
<p class="p-5 text-sm">
|
||||
{$t('album_share_no_users')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="immich-scrollbar max-h-[500px] overflow-y-auto">
|
||||
{#if users.length > 0 && users.length !== Object.keys(selectedUsers).length}
|
||||
<Text>{$t('users')}</Text>
|
||||
|
||||
<div class="my-2">
|
||||
{#each users as user (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">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleToggle(user)}
|
||||
class="flex w-full place-items-center gap-4 p-4"
|
||||
>
|
||||
<UserAvatar {user} size="md" />
|
||||
<div class="text-left flex-grow">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{user.name}
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<Stack gap={6}>
|
||||
{#if sharedLinks.length > 0}
|
||||
<div class="flex justify-between items-center">
|
||||
<Text>{$t('shared_links')}</Text>
|
||||
<Link href={AppRoute.SHARED_LINKS} class="text-sm">{$t('view_all')}</Link>
|
||||
{#if users.length > 0}
|
||||
<div class="py-3">
|
||||
<Button
|
||||
size="small"
|
||||
fullWidth
|
||||
shape="round"
|
||||
disabled={Object.keys(selectedUsers).length === 0}
|
||||
onclick={() =>
|
||||
onSelect(Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })))}
|
||||
>{$t('add')}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<Stack gap={4}>
|
||||
{#each sharedLinks as sharedLink (sharedLink.id)}
|
||||
<AlbumSharedLink {album} {sharedLink} />
|
||||
{/each}
|
||||
</Stack>
|
||||
{/if}
|
||||
|
||||
<Button leadingIcon={mdiLink} size="small" shape="round" fullWidth onclick={onShare}>{$t('create_link')}</Button>
|
||||
</Stack>
|
||||
</FullScreenModal>
|
||||
<hr class="my-4" />
|
||||
|
||||
<Stack gap={6}>
|
||||
{#if sharedLinks.length > 0}
|
||||
<div class="flex justify-between items-center">
|
||||
<Text>{$t('shared_links')}</Text>
|
||||
<Link href={AppRoute.SHARED_LINKS} class="text-sm">{$t('view_all')}</Link>
|
||||
</div>
|
||||
|
||||
<Stack gap={4}>
|
||||
{#each sharedLinks as sharedLink (sharedLink.id)}
|
||||
<AlbumSharedLink {album} {sharedLink} onViewQrCode={() => handleViewQrCode(sharedLink)} />
|
||||
{/each}
|
||||
</Stack>
|
||||
{/if}
|
||||
|
||||
<Button leadingIcon={mdiLink} size="small" shape="round" fullWidth onclick={onShare}>{$t('create_link')}</Button>
|
||||
</Stack>
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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 { fly, slide } from 'svelte/transition';
|
||||
import { getByteUnitString } from '../../utils/byte-units';
|
||||
@ -13,15 +13,15 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if $isDownloading}
|
||||
{#if downloadStore.isDownloading}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{#each Object.keys($downloadAssets) as downloadKey (downloadKey)}
|
||||
{@const download = $downloadAssets[downloadKey]}
|
||||
{#each Object.keys(downloadStore.assets) as downloadKey (downloadKey)}
|
||||
{@const download = downloadStore.assets[downloadKey]}
|
||||
<div class="mb-2 flex place-items-center" transition:slide>
|
||||
<div class="w-full pr-10">
|
||||
<div class="flex place-items-center justify-between gap-2 text-xs font-medium">
|
||||
@ -31,7 +31,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<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>
|
||||
<p class="min-w-[4em] whitespace-nowrap text-right">
|
||||
|
@ -51,7 +51,7 @@
|
||||
</header>
|
||||
<main
|
||||
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}
|
||||
<AdminSideBar />
|
||||
|
@ -6,7 +6,7 @@
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
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 { handlePromiseError } from '$lib/utils';
|
||||
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||
@ -448,7 +448,7 @@
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ($isSearchEnabled) {
|
||||
if (searchStore.isSearchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -459,7 +459,7 @@
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if ($isSearchEnabled) {
|
||||
if (searchStore.isSearchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -648,7 +648,7 @@
|
||||
|
||||
let shortcutList = $derived(
|
||||
(() => {
|
||||
if ($isSearchEnabled || $showAssetViewer) {
|
||||
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
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 { SettingInputFieldType } from '$lib/constants';
|
||||
import { locale } from '$lib/stores/preferences.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 { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { Button, HStack, IconButton, Input } from '@immich/ui';
|
||||
import { mdiContentCopy, mdiLink } from '@mdi/js';
|
||||
import { Button } from '@immich/ui';
|
||||
import { mdiLink } from '@mdi/js';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { NotificationType, notificationController } from '../notification/notification';
|
||||
import SettingInputField from '../settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '../settings/setting-switch.svelte';
|
||||
import QRCode from '$lib/components/shared-components/qrcode.svelte';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@ -41,7 +41,6 @@
|
||||
let password = $state('');
|
||||
let shouldChangeExpirationTime = $state(false);
|
||||
let enablePassword = $state(false);
|
||||
let modalWidth = $state(0);
|
||||
|
||||
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||
[30, 'minutes'],
|
||||
@ -248,26 +247,5 @@
|
||||
{/snippet}
|
||||
</FullScreenModal>
|
||||
{:else}
|
||||
<FullScreenModal title={getTitle()} icon={mdiLink} {onClose}>
|
||||
<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>
|
||||
<QrCodeModal title={$t('view_link')} {onClose} value={sharedLink} />
|
||||
{/if}
|
||||
|
@ -23,7 +23,7 @@
|
||||
import ThemeButton from '../theme-button.svelte';
|
||||
import UserAvatar from '../user-avatar.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';
|
||||
|
||||
interface Props {
|
||||
@ -62,32 +62,30 @@
|
||||
>
|
||||
<SkipLink text={$t('skip_to_content')} />
|
||||
<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>
|
||||
<IconButton
|
||||
id={menuButtonId}
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
aria-label={$t('main_menu')}
|
||||
icon={mdiMenu}
|
||||
onclick={() => {
|
||||
isSidebarOpen.value = !isSidebarOpen.value;
|
||||
}}
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
if (isSidebarOpen.value) {
|
||||
// stops event from reaching the default handler when clicking outside of the sidebar
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
class="md:hidden"
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
id={menuButtonId}
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
aria-label={$t('main_menu')}
|
||||
icon={mdiMenu}
|
||||
onclick={() => {
|
||||
sidebarStore.toggle();
|
||||
}}
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
if (sidebarStore.isOpen) {
|
||||
// stops event from reaching the default handler when clicking outside of the sidebar
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
class="sidebar:hidden"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
import { AppRoute } from '$lib/constants';
|
||||
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 SearchHistoryBox from './search-history-box.svelte';
|
||||
import SearchFilterModal from './search-filter-modal.svelte';
|
||||
@ -40,41 +40,43 @@
|
||||
|
||||
closeDropdown();
|
||||
showFilter = false;
|
||||
$isSearchEnabled = false;
|
||||
searchStore.isSearchEnabled = false;
|
||||
await goto(`${AppRoute.SEARCH}?${params}`);
|
||||
};
|
||||
|
||||
const clearSearchTerm = (searchTerm: string) => {
|
||||
input?.focus();
|
||||
$savedSearchTerms = $savedSearchTerms.filter((item) => item !== searchTerm);
|
||||
searchStore.savedSearchTerms = searchStore.savedSearchTerms.filter((item) => item !== searchTerm);
|
||||
};
|
||||
|
||||
const saveSearchTerm = (saveValue: string) => {
|
||||
const filteredSearchTerms = $savedSearchTerms.filter((item) => item.toLowerCase() !== saveValue.toLowerCase());
|
||||
$savedSearchTerms = [saveValue, ...filteredSearchTerms];
|
||||
const filteredSearchTerms = searchStore.savedSearchTerms.filter(
|
||||
(item) => item.toLowerCase() !== saveValue.toLowerCase(),
|
||||
);
|
||||
searchStore.savedSearchTerms = [saveValue, ...filteredSearchTerms];
|
||||
|
||||
if ($savedSearchTerms.length > 5) {
|
||||
$savedSearchTerms = $savedSearchTerms.slice(0, 5);
|
||||
if (searchStore.savedSearchTerms.length > 5) {
|
||||
searchStore.savedSearchTerms = searchStore.savedSearchTerms.slice(0, 5);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllSearchTerms = () => {
|
||||
input?.focus();
|
||||
$savedSearchTerms = [];
|
||||
searchStore.savedSearchTerms = [];
|
||||
};
|
||||
|
||||
const onFocusIn = () => {
|
||||
$isSearchEnabled = true;
|
||||
searchStore.isSearchEnabled = true;
|
||||
};
|
||||
|
||||
const onFocusOut = () => {
|
||||
const focusOutTimer = setTimeout(() => {
|
||||
if ($isSearchEnabled) {
|
||||
$preventRaceConditionSearchBar = true;
|
||||
if (searchStore.isSearchEnabled) {
|
||||
searchStore.preventRaceConditionSearchBar = true;
|
||||
}
|
||||
|
||||
closeDropdown();
|
||||
$isSearchEnabled = false;
|
||||
searchStore.isSearchEnabled = false;
|
||||
showFilter = false;
|
||||
}, 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
|
||||
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
|
||||
{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')}
|
||||
required
|
||||
pattern="^(?!m:$).*$"
|
||||
|
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 { fly } from 'svelte/transition';
|
||||
import { t } from 'svelte-i18n';
|
||||
@ -29,7 +29,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
let filteredSearchTerms = $derived(
|
||||
$savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
searchStore.savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
|
@ -110,7 +110,7 @@
|
||||
<div>
|
||||
<Icon
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@ -123,7 +123,7 @@
|
||||
{#if showMessage}
|
||||
<dialog
|
||||
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 }}
|
||||
onmouseover={() => (hoverMessage = true)}
|
||||
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 { focusTrap } from '$lib/actions/focus-trap';
|
||||
import { menuButtonId } from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import { isSidebarOpen } from '$lib/stores/side-bar.svelte';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
const mdBreakpoint = 768;
|
||||
|
||||
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) => {
|
||||
isSidebarOpen.value = width >= mdBreakpoint;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
closeSidebar(innerWidth);
|
||||
onMount(() => {
|
||||
closeSidebar();
|
||||
});
|
||||
|
||||
const isHidden = $derived(!isSidebarOpen.value && innerWidth < mdBreakpoint);
|
||||
const isExpanded = $derived(isSidebarOpen.value && innerWidth < mdBreakpoint);
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (!isSidebarOpen.value) {
|
||||
const closeSidebar = () => {
|
||||
if (!isExpanded) {
|
||||
return;
|
||||
}
|
||||
closeSidebar(innerWidth);
|
||||
sidebarStore.reset();
|
||||
if (isHidden) {
|
||||
document.querySelector<HTMLButtonElement>(`#${menuButtonId}`)?.focus();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
<section
|
||||
id="sidebar"
|
||||
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:dark:border-r-immich-dark-gray={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}
|
||||
use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }}
|
||||
use:clickOutside={{ onOutclick: closeSidebar, onEscape: closeSidebar }}
|
||||
use:focusTrap={{ active: isExpanded }}
|
||||
>
|
||||
<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 maxMd = new MediaQuery('max-width: 767px');
|
||||
const sidebar = new MediaQuery(`min-width: 850px`);
|
||||
|
||||
export const mobileDevice = {
|
||||
get pointerCoarse() {
|
||||
@ -10,4 +11,7 @@ export const mobileDevice = {
|
||||
get maxMd() {
|
||||
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