Merge remote-tracking branch 'origin/main' into misc_tweaks

This commit is contained in:
Min Idzelis 2025-04-11 21:20:38 +00:00
commit a9f31a2f8d
109 changed files with 1617 additions and 1303 deletions

6
cli/package-lock.json generated
View File

@ -4144,9 +4144,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.2.5", "version": "6.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -63,6 +63,13 @@ If you only want to do web development connected to an existing, remote backend,
IMMICH_SERVER_URL=https://demo.immich.app/ npm run dev IMMICH_SERVER_URL=https://demo.immich.app/ npm run dev
``` ```
If you're using PowerShell on Windows you may need to set the env var separately like so:
```powershell
$env:IMMICH_SERVER_URL = "https://demo.immich.app/"
npm run dev
```
#### `@immich/ui` #### `@immich/ui`
To see local changes to `@immich/ui` in Immich, do the following: To see local changes to `@immich/ui` in Immich, do the following:

View File

@ -1141,7 +1141,7 @@ describe('/asset', () => {
fNumber: 8, fNumber: 8,
focalLength: 97, focalLength: 97,
iso: 100, iso: 100,
lensModel: 'E PZ 18-105mm F4 G OSS', lensModel: 'Sony E PZ 18-105mm F4 G OSS',
fileSizeInByte: 25_001_984, fileSizeInByte: 25_001_984,
dateTimeOriginal: '2016-09-27T10:51:44+00:00', dateTimeOriginal: '2016-09-27T10:51:44+00:00',
orientation: '1', orientation: '1',
@ -1163,7 +1163,7 @@ describe('/asset', () => {
fNumber: 22, fNumber: 22,
focalLength: 25, focalLength: 25,
iso: 100, iso: 100,
lensModel: 'E 25mm F2', lensModel: 'Zeiss Batis 25mm F2',
fileSizeInByte: 49_512_448, fileSizeInByte: 49_512_448,
dateTimeOriginal: '2016-01-08T14:08:01+00:00', dateTimeOriginal: '2016-01-08T14:08:01+00:00',
orientation: '1', orientation: '1',
@ -1234,7 +1234,7 @@ describe('/asset', () => {
focalLength: 18.3, focalLength: 18.3,
iso: 100, iso: 100,
latitude: 36.613_24, latitude: 36.613_24,
lensModel: 'GR LENS 18.3mm F2.8', lensModel: '18.3mm F2.8',
longitude: -121.897_85, longitude: -121.897_85,
make: 'RICOH IMAGING COMPANY, LTD.', make: 'RICOH IMAGING COMPANY, LTD.',
model: 'RICOH GR III', model: 'RICOH GR III',

View File

@ -48,7 +48,7 @@ test.describe('Shared Links', () => {
await page.waitForSelector('[data-group] svg'); await page.waitForSelector('[data-group] svg');
await page.getByRole('checkbox').click(); await page.getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download' }).click(); await page.getByRole('button', { name: 'Download' }).click();
await page.getByText('DOWNLOADING', { exact: true }).waitFor(); await page.waitForEvent('download');
}); });
test('download all from shared link', async ({ page }) => { test('download all from shared link', async ({ page }) => {
@ -56,6 +56,7 @@ test.describe('Shared Links', () => {
await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.getByRole('button', { name: 'Download' }).click(); await page.getByRole('button', { name: 'Download' }).click();
await page.getByText('DOWNLOADING', { exact: true }).waitFor(); await page.getByText('DOWNLOADING', { exact: true }).waitFor();
await page.waitForEvent('download');
}); });
test('enter password for a shared link', async ({ page }) => { test('enter password for a shared link', async ({ page }) => {

View File

@ -1371,6 +1371,7 @@
"view_next_asset": "View next asset", "view_next_asset": "View next asset",
"view_previous_asset": "View previous asset", "view_previous_asset": "View previous asset",
"view_stack": "View Stack", "view_stack": "View Stack",
"view_qr_code": "View QR code",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}", "visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"waiting": "Waiting", "waiting": "Waiting",
"warning": "Warning", "warning": "Warning",

View File

@ -278,8 +278,8 @@ class TestOrtSession:
assert session.provider_options == [] assert session.provider_options == []
def test_sets_default_sess_options(self) -> None: def test_sets_default_sess_options_if_cpu(self) -> None:
session = OrtSession("ViT-B-32__openai") session = OrtSession("ViT-B-32__openai", providers=["CPUExecutionProvider"])
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
assert session.sess_options.inter_op_num_threads == 1 assert session.sess_options.inter_op_num_threads == 1

View File

@ -7,6 +7,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
class TopControlAppBar extends HookConsumerWidget { class TopControlAppBar extends HookConsumerWidget {
const TopControlAppBar({ const TopControlAppBar({
@ -166,6 +167,9 @@ class TopControlAppBar extends HookConsumerWidget {
); );
} }
bool isInHomePage = ref.read(tabProvider.notifier).state == TabEnum.home;
bool? isInTrash = ref.read(currentAssetProvider)?.isTrashed;
return AppBar( return AppBar(
foregroundColor: Colors.grey[100], foregroundColor: Colors.grey[100],
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@ -174,7 +178,7 @@ class TopControlAppBar extends HookConsumerWidget {
shape: const Border(), shape: const Border(),
actions: [ actions: [
if (asset.isRemote && isOwner) buildFavoriteButton(a), if (asset.isRemote && isOwner) buildFavoriteButton(a),
if (isOwner && ref.read(tabProvider.notifier).state != TabEnum.home) if (isOwner && !isInHomePage && !(isInTrash ?? false))
buildLocateButton(), buildLocateButton(),
if (asset.livePhotoVideoId != null) const MotionPhotoButton(), if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(),

View File

@ -4,6 +4,7 @@ FROM ghcr.io/immich-app/base-server-dev:202503251114@sha256:10e8973e8603c5729436
RUN apt-get install --no-install-recommends -yqq tini RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./ COPY server/package.json server/package-lock.json ./
COPY server/patches ./patches
RUN npm ci && \ RUN npm ci && \
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need # exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# they're marked as optional dependencies, so we need to copy them manually after pruning # they're marked as optional dependencies, so we need to copy them manually after pruning
@ -56,7 +57,7 @@ COPY server/resources resources
COPY server/package.json server/package-lock.json ./ COPY server/package.json server/package-lock.json ./
COPY server/start*.sh ./ COPY server/start*.sh ./
COPY "docker/scripts/get-cpus.sh" ./ COPY "docker/scripts/get-cpus.sh" ./
RUN npm link && npm install -g @immich/cli && npm cache clean --force RUN npm install -g @immich/cli && npm cache clean --force
COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE COPY LICENSE /LICENSE
ENV PATH="${PATH}:/usr/src/app/bin" ENV PATH="${PATH}:/usr/src/app/bin"

View File

@ -33,7 +33,7 @@
"sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js", "sync:sql": "node ./dist/bin/sync-sql.js",
"email:dev": "email dev -p 3050 --dir src/emails", "email:dev": "email dev -p 3050 --dir src/emails",
"postinstall": "[ \"$npm_config_global\" != \"true\" ] && patch-package || true" "postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^11.0.1", "@nestjs/bullmq": "^11.0.1",

View File

@ -1,39 +1,48 @@
diff --git a/node_modules/postgres/cf/src/connection.js b/node_modules/postgres/cf/src/connection.js diff --git a/node_modules/postgres/cf/src/connection.js b/node_modules/postgres/cf/src/connection.js
index ee8b1e6..d03b9dd 100644 index ee8b1e6..acf4566 100644
--- a/node_modules/postgres/cf/src/connection.js --- a/node_modules/postgres/cf/src/connection.js
+++ b/node_modules/postgres/cf/src/connection.js +++ b/node_modules/postgres/cf/src/connection.js
@@ -387,6 +387,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose @@ -387,8 +387,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
} }
function queryError(query, err) { function queryError(query, err) {
+ if (!query || typeof query !== 'object') throw err + if (!query || typeof query !== 'object' || !query.reject) throw err
+ +
'query' in err || 'parameters' in err || Object.defineProperties(err, { 'query' in err || 'parameters' in err || Object.defineProperties(err, {
stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
+ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug },
query: { value: query.string, enumerable: options.debug }, query: { value: query.string, enumerable: options.debug },
parameters: { value: query.parameters, enumerable: options.debug },
args: { value: query.args, enumerable: options.debug },
diff --git a/node_modules/postgres/cjs/src/connection.js b/node_modules/postgres/cjs/src/connection.js diff --git a/node_modules/postgres/cjs/src/connection.js b/node_modules/postgres/cjs/src/connection.js
index f7f58d1..8a37571 100644 index f7f58d1..b7f2d65 100644
--- a/node_modules/postgres/cjs/src/connection.js --- a/node_modules/postgres/cjs/src/connection.js
+++ b/node_modules/postgres/cjs/src/connection.js +++ b/node_modules/postgres/cjs/src/connection.js
@@ -385,6 +385,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose @@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
} }
function queryError(query, err) { function queryError(query, err) {
+ if (!query || typeof query !== 'object') throw err + if (!query || typeof query !== 'object' || !query.reject) throw err
+ +
'query' in err || 'parameters' in err || Object.defineProperties(err, { 'query' in err || 'parameters' in err || Object.defineProperties(err, {
stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
+ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug },
query: { value: query.string, enumerable: options.debug }, query: { value: query.string, enumerable: options.debug },
parameters: { value: query.parameters, enumerable: options.debug },
args: { value: query.args, enumerable: options.debug },
diff --git a/node_modules/postgres/src/connection.js b/node_modules/postgres/src/connection.js diff --git a/node_modules/postgres/src/connection.js b/node_modules/postgres/src/connection.js
index 97cc97e..58f5298 100644 index 97cc97e..26f508e 100644
--- a/node_modules/postgres/src/connection.js --- a/node_modules/postgres/src/connection.js
+++ b/node_modules/postgres/src/connection.js +++ b/node_modules/postgres/src/connection.js
@@ -385,6 +385,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose @@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
} }
function queryError(query, err) { function queryError(query, err) {
+ if (!query || typeof query !== 'object') throw err + if (!query || typeof query !== 'object' || !query.reject) throw err
+ +
'query' in err || 'parameters' in err || Object.defineProperties(err, { 'query' in err || 'parameters' in err || Object.defineProperties(err, {
stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
+ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug },
query: { value: query.string, enumerable: options.debug }, query: { value: query.string, enumerable: options.debug },
parameters: { value: query.parameters, enumerable: options.debug },
args: { value: query.args, enumerable: options.debug },

View File

@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from 'src/constants'; import { APP_MEDIA_LOCATION } from 'src/constants';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
@ -85,7 +84,7 @@ export class StorageCore {
return join(APP_MEDIA_LOCATION, folder); return join(APP_MEDIA_LOCATION, folder);
} }
static getPersonThumbnailPath(person: PersonEntity) { static getPersonThumbnailPath(person: { id: string; ownerId: string }) {
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`); return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
} }
@ -135,7 +134,7 @@ export class StorageCore {
}); });
} }
async movePersonFile(person: PersonEntity, pathType: PersonPathType) { async movePersonFile(person: { id: string; ownerId: string; thumbnailPath: string }, pathType: PersonPathType) {
const { id: entityId, thumbnailPath } = person; const { id: entityId, thumbnailPath } = person;
switch (pathType) { switch (pathType) {
case PersonPathType.FACE: { case PersonPathType.FACE: {

View File

@ -1,4 +1,15 @@
import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; import { Selectable } from 'kysely';
import { Exif as DatabaseExif } from 'src/db';
import {
AlbumUserRole,
AssetFileType,
AssetStatus,
AssetType,
MemoryType,
Permission,
SourceType,
UserStatus,
} from 'src/enum';
import { OnThisDayData, UserMetadataItem } from 'src/types'; import { OnThisDayData, UserMetadataItem } from 'src/types';
export type AuthUser = { export type AuthUser = {
@ -10,6 +21,17 @@ export type AuthUser = {
quotaSizeInBytes: number | null; quotaSizeInBytes: number | null;
}; };
export type AlbumUser = {
user: User;
role: AlbumUserRole;
};
export type AssetFile = {
id: string;
type: AssetFileType;
path: string;
};
export type Library = { export type Library = {
id: string; id: string;
ownerId: string; ownerId: string;
@ -184,6 +206,38 @@ export type Session = {
deviceType: string; deviceType: string;
}; };
export type Exif = Omit<Selectable<DatabaseExif>, 'updatedAt' | 'updateId'>;
export type Person = {
createdAt: Date;
id: string;
ownerId: string;
updatedAt: Date;
updateId: string;
isFavorite: boolean;
name: string;
birthDate: Date | null;
color: string | null;
faceAssetId: string | null;
isHidden: boolean;
thumbnailPath: string;
};
export type AssetFace = {
id: string;
deletedAt: Date | null;
assetId: string;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
imageHeight: number;
imageWidth: number;
personId: string | null;
sourceType: SourceType;
person?: Person | null;
};
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
export const columns = { export const columns = {

6
server/src/db.d.ts vendored
View File

@ -17,7 +17,7 @@ import {
SyncEntityType, SyncEntityType,
} from 'src/enum'; } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { OnThisDayData } from 'src/types'; import { OnThisDayData, UserMetadataItem } from 'src/types';
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>; export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
@ -412,10 +412,8 @@ export interface TypeormMetadata {
value: string | null; value: string | null;
} }
export interface UserMetadata { export interface UserMetadata extends UserMetadataItem {
key: string;
userId: string; userId: string;
value: Json;
} }
export interface UsersAudit { export interface UsersAudit {

View File

@ -143,13 +143,11 @@ export class AlbumResponseDto {
} }
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
const sharedUsers: UserResponseDto[] = [];
const albumUsers: AlbumUserResponseDto[] = []; const albumUsers: AlbumUserResponseDto[] = [];
if (entity.albumUsers) { if (entity.albumUsers) {
for (const albumUser of entity.albumUsers) { for (const albumUser of entity.albumUsers) {
const user = mapUser(albumUser.user); const user = mapUser(albumUser.user);
sharedUsers.push(user);
albumUsers.push({ albumUsers.push({
user, user,
role: albumUser.role, role: albumUser.role,
@ -162,7 +160,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
const assets = entity.assets || []; const assets = entity.assets || [];
const hasSharedLink = entity.sharedLinks?.length > 0; const hasSharedLink = entity.sharedLinks?.length > 0;
const hasSharedUser = sharedUsers.length > 0; const hasSharedUser = albumUsers.length > 0;
let startDate = assets.at(0)?.localDateTime; let startDate = assets.at(0)?.localDateTime;
let endDate = assets.at(-1)?.localDateTime; let endDate = assets.at(-1)?.localDateTime;

View File

@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { AssetFace } from 'src/database';
import { PropertyLifecycle } from 'src/decorators'; import { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto'; import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
@ -10,7 +11,6 @@ import {
} from 'src/dtos/person.dto'; } from 'src/dtos/person.dto';
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AssetType } from 'src/enum'; import { AssetType } from 'src/enum';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
@ -71,7 +71,8 @@ export type AssetMapOptions = {
auth?: AuthDto; auth?: AuthDto;
}; };
const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => { // TODO: this is inefficient
const peopleWithFaces = (faces: AssetFace[]): PersonWithFacesResponseDto[] => {
const result: PersonWithFacesResponseDto[] = []; const result: PersonWithFacesResponseDto[] = [];
if (faces) { if (faces) {
for (const face of faces) { for (const face of faces) {

View File

@ -1,8 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser } from 'src/database'; import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import { UserEntity } from 'src/entities/user.entity';
import { ImmichCookie } from 'src/enum'; import { ImmichCookie } from 'src/enum';
import { toEmail } from 'src/validation'; import { toEmail } from 'src/validation';
@ -42,7 +41,7 @@ export class LoginResponseDto {
shouldChangePassword!: boolean; shouldChangePassword!: boolean;
} }
export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto { export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto {
return { return {
accessToken, accessToken,
userId: entity.id, userId: entity.id,

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ExifEntity } from 'src/entities/exif.entity'; import { Exif } from 'src/database';
export class ExifResponseDto { export class ExifResponseDto {
make?: string | null = null; make?: string | null = null;
@ -28,7 +28,7 @@ export class ExifResponseDto {
rating?: number | null = null; rating?: number | null = null;
} }
export function mapExif(entity: ExifEntity): ExifResponseDto { export function mapExif(entity: Exif): ExifResponseDto {
return { return {
make: entity.make, make: entity.make,
model: entity.model, model: entity.model,
@ -55,7 +55,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
}; };
} }
export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto { export function mapSanitizedExif(entity: Exif): ExifResponseDto {
return { return {
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null, fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation, orientation: entity.orientation,

View File

@ -1,11 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator';
import { Selectable } from 'kysely';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { AssetFace, Person } from 'src/database';
import { AssetFaces } from 'src/db';
import { PropertyLifecycle } from 'src/decorators'; import { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum'; import { SourceType } from 'src/enum';
import { asDateString } from 'src/utils/date'; import { asDateString } from 'src/utils/date';
import { import {
@ -219,7 +220,7 @@ export class PeopleResponseDto {
hasNextPage?: boolean; hasNextPage?: boolean;
} }
export function mapPerson(person: PersonEntity): PersonResponseDto { export function mapPerson(person: Person): PersonResponseDto {
return { return {
id: person.id, id: person.id,
name: person.name, name: person.name,
@ -232,7 +233,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
}; };
} }
export function mapFacesWithoutPerson(face: AssetFaceEntity): AssetFaceWithoutPersonResponseDto { export function mapFacesWithoutPerson(face: Selectable<AssetFaces>): AssetFaceWithoutPersonResponseDto {
return { return {
id: face.id, id: face.id,
imageHeight: face.imageHeight, imageHeight: face.imageHeight,
@ -245,9 +246,16 @@ export function mapFacesWithoutPerson(face: AssetFaceEntity): AssetFaceWithoutPe
}; };
} }
export function mapFaces(face: AssetFaceEntity, auth: AuthDto): AssetFaceResponseDto { export function mapFaces(face: AssetFace, auth: AuthDto): AssetFaceResponseDto {
return { return {
...mapFacesWithoutPerson(face), id: face.id,
imageHeight: face.imageHeight,
imageWidth: face.imageWidth,
boundingBoxX1: face.boundingBoxX1,
boundingBoxX2: face.boundingBoxX2,
boundingBoxY1: face.boundingBoxY1,
boundingBoxY2: face.boundingBoxY2,
sourceType: face.sourceType,
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null, person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
}; };
} }

View File

@ -2,7 +2,6 @@ import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import { User, UserAdmin } from 'src/database'; import { User, UserAdmin } from 'src/database';
import { UserEntity } from 'src/entities/user.entity';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types'; import { UserMetadataItem } from 'src/types';
import { getPreferences } from 'src/utils/preferences'; import { getPreferences } from 'src/utils/preferences';
@ -42,13 +41,13 @@ export class UserLicense {
activatedAt!: Date; activatedAt!: Date;
} }
export const mapUser = (entity: UserEntity | User): UserResponseDto => { export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
return { return {
id: entity.id, id: entity.id,
email: entity.email, email: entity.email,
name: entity.name, name: entity.name,
profileImagePath: entity.profileImagePath, profileImagePath: entity.profileImagePath,
avatarColor: getPreferences(entity.email, (entity as UserEntity).metadata || []).avatar.color, avatarColor: getPreferences(entity.email, (entity as UserAdmin).metadata || []).avatar.color,
profileChangedAt: entity.profileChangedAt, profileChangedAt: entity.profileChangedAt,
}; };
}; };
@ -142,7 +141,7 @@ export class UserAdminResponseDto extends UserResponseDto {
license!: UserLicense | null; license!: UserLicense | null;
} }
export function mapUserAdmin(entity: UserEntity | UserAdmin): UserAdminResponseDto { export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
const metadata = entity.metadata || []; const metadata = entity.metadata || [];
const license = metadata.find( const license = metadata.find(
(item): item is UserMetadataItem<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE, (item): item is UserMetadataItem<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE,

View File

@ -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;
}

View File

@ -1,12 +1,11 @@
import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumUser, User } from 'src/database';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AssetOrder } from 'src/enum'; import { AssetOrder } from 'src/enum';
export class AlbumEntity { export class AlbumEntity {
id!: string; id!: string;
owner!: UserEntity; owner!: User;
ownerId!: string; ownerId!: string;
albumName!: string; albumName!: string;
description!: string; description!: string;
@ -16,7 +15,7 @@ export class AlbumEntity {
deletedAt!: Date | null; deletedAt!: Date | null;
albumThumbnailAsset!: AssetEntity | null; albumThumbnailAsset!: AssetEntity | null;
albumThumbnailAssetId!: string | null; albumThumbnailAssetId!: string | null;
albumUsers!: AlbumUserEntity[]; albumUsers!: AlbumUser[];
assets!: AssetEntity[]; assets!: AssetEntity[];
sharedLinks!: SharedLinkEntity[]; sharedLinks!: SharedLinkEntity[];
isActivityEnabled!: boolean; isActivityEnabled!: boolean;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,15 +1,11 @@
import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely'; import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Tag } from 'src/database'; import { AssetFace, AssetFile, Exif, Tag, User } from 'src/database';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { StackEntity } from 'src/entities/stack.entity'; import { StackEntity } from 'src/entities/stack.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimeBucketSize } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
@ -20,14 +16,14 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
export class AssetEntity { export class AssetEntity {
id!: string; id!: string;
deviceAssetId!: string; deviceAssetId!: string;
owner!: UserEntity; owner!: User;
ownerId!: string; ownerId!: string;
libraryId?: string | null; libraryId?: string | null;
deviceId!: string; deviceId!: string;
type!: AssetType; type!: AssetType;
status!: AssetStatus; status!: AssetStatus;
originalPath!: string; originalPath!: string;
files!: AssetFileEntity[]; files!: AssetFile[];
thumbhash!: Buffer | null; thumbhash!: Buffer | null;
encodedVideoPath!: string | null; encodedVideoPath!: string | null;
createdAt!: Date; createdAt!: Date;
@ -48,11 +44,11 @@ export class AssetEntity {
livePhotoVideoId!: string | null; livePhotoVideoId!: string | null;
originalFileName!: string; originalFileName!: string;
sidecarPath!: string | null; sidecarPath!: string | null;
exifInfo?: ExifEntity; exifInfo?: Exif;
tags?: Tag[]; tags?: Tag[];
sharedLinks!: SharedLinkEntity[]; sharedLinks!: SharedLinkEntity[];
albums?: AlbumEntity[]; albums?: AlbumEntity[];
faces!: AssetFaceEntity[]; faces!: AssetFace[];
stackId?: string | null; stackId?: string | null;
stack?: StackEntity | null; stack?: StackEntity | null;
jobStatus?: AssetJobStatusEntity; jobStatus?: AssetJobStatusEntity;
@ -66,7 +62,9 @@ export type AssetEntityPlaceholder = AssetEntity & {
}; };
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')); return qb
.leftJoin('exif', 'assets.id', 'exif.assetId')
.select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif>().as('exifInfo'));
} }
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {

View File

@ -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;
}

View File

@ -1,7 +0,0 @@
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
export class FaceSearchEntity {
face?: AssetFaceEntity;
faceId!: string;
embedding!: string;
}

View File

@ -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;
}

View File

@ -1,6 +1,5 @@
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
export class SharedLinkEntity { export class SharedLinkEntity {
@ -8,7 +7,6 @@ export class SharedLinkEntity {
description!: string | null; description!: string | null;
password!: string | null; password!: string | null;
userId!: string; userId!: string;
user!: UserEntity;
key!: Buffer; // use to access the inidividual asset key!: Buffer; // use to access the inidividual asset
type!: SharedLinkType; type!: SharedLinkType;
createdAt!: Date; createdAt!: Date;

View File

@ -1,9 +1,7 @@
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
export class StackEntity { export class StackEntity {
id!: string; id!: string;
owner!: UserEntity;
ownerId!: string; ownerId!: string;
assets!: AssetEntity[]; assets!: AssetEntity[];
primaryAsset!: AssetEntity; primaryAsset!: AssetEntity;

View File

@ -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');
};

View File

@ -23,7 +23,7 @@ REINDEX TABLE person
-- PersonRepository.delete -- PersonRepository.delete
delete from "person" delete from "person"
where where
"person"."id" in ($1) "person"."id" in $1
-- PersonRepository.deleteFaces -- PersonRepository.deleteFaces
delete from "asset_faces" delete from "asset_faces"
@ -95,41 +95,72 @@ where
"asset_faces"."id" = $1 "asset_faces"."id" = $1
and "asset_faces"."deletedAt" is null and "asset_faces"."deletedAt" is null
-- PersonRepository.getFaceByIdWithAssets -- PersonRepository.getFaceForFacialRecognitionJob
select select
"asset_faces".*, "asset_faces"."id",
"asset_faces"."personId",
"asset_faces"."sourceType",
( (
select select
to_json(obj) to_json(obj)
from from
( (
select select
"person".* "assets"."ownerId",
from "assets"."isArchived",
"person" "assets"."fileCreatedAt"
where
"person"."id" = "asset_faces"."personId"
) as obj
) as "person",
(
select
to_json(obj)
from
(
select
"assets".*
from from
"assets" "assets"
where where
"assets"."id" = "asset_faces"."assetId" "assets"."id" = "asset_faces"."assetId"
) as obj ) as obj
) as "asset" ) as "asset",
(
select
to_json(obj)
from
(
select
"face_search".*
from
"face_search"
where
"face_search"."faceId" = "asset_faces"."id"
) as obj
) as "faceSearch"
from from
"asset_faces" "asset_faces"
where where
"asset_faces"."id" = $1 "asset_faces"."id" = $1
and "asset_faces"."deletedAt" is null and "asset_faces"."deletedAt" is null
-- PersonRepository.getDataForThumbnailGenerationJob
select
"person"."ownerId",
"asset_faces"."boundingBoxX1" as "x1",
"asset_faces"."boundingBoxY1" as "y1",
"asset_faces"."boundingBoxX2" as "x2",
"asset_faces"."boundingBoxY2" as "y2",
"asset_faces"."imageWidth" as "oldWidth",
"asset_faces"."imageHeight" as "oldHeight",
"exif"."exifImageWidth",
"exif"."exifImageHeight",
"assets"."type",
"assets"."originalPath",
"asset_files"."path" as "previewPath"
from
"person"
inner join "asset_faces" on "asset_faces"."id" = "person"."faceAssetId"
inner join "assets" on "asset_faces"."assetId" = "assets"."id"
inner join "exif" on "exif"."assetId" = "assets"."id"
inner join "asset_files" on "asset_files"."assetId" = "assets"."id"
where
"person"."id" = $1
and "asset_faces"."deletedAt" is null
and "asset_files"."type" = $2
and "exif"."exifImageWidth" > $3
and "exif"."exifImageHeight" > $4
-- PersonRepository.reassignFace -- PersonRepository.reassignFace
update "asset_faces" update "asset_faces"
set set

View File

@ -24,7 +24,8 @@ select
from from
( (
select select
"user_metadata".* "user_metadata"."key",
"user_metadata"."value"
from from
"user_metadata" "user_metadata"
where where
@ -54,7 +55,21 @@ select
"shouldChangePassword", "shouldChangePassword",
"storageLabel", "storageLabel",
"quotaSizeInBytes", "quotaSizeInBytes",
"quotaUsageInBytes" "quotaUsageInBytes",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"user_metadata"."key",
"user_metadata"."value"
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
from from
"users" "users"
where where
@ -87,7 +102,21 @@ select
"shouldChangePassword", "shouldChangePassword",
"storageLabel", "storageLabel",
"quotaSizeInBytes", "quotaSizeInBytes",
"quotaUsageInBytes" "quotaUsageInBytes",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"user_metadata"."key",
"user_metadata"."value"
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
from from
"users" "users"
where where
@ -135,7 +164,21 @@ select
"shouldChangePassword", "shouldChangePassword",
"storageLabel", "storageLabel",
"quotaSizeInBytes", "quotaSizeInBytes",
"quotaUsageInBytes" "quotaUsageInBytes",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"user_metadata"."key",
"user_metadata"."value"
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
from from
"users" "users"
where where
@ -174,7 +217,8 @@ select
from from
( (
select select
"user_metadata".* "user_metadata"."key",
"user_metadata"."value"
from from
"user_metadata" "user_metadata"
where where
@ -210,7 +254,8 @@ select
from from
( (
select select
"user_metadata".* "user_metadata"."key",
"user_metadata"."value"
from from
"user_metadata" "user_metadata"
where where
@ -232,15 +277,15 @@ select
count(*) filter ( count(*) filter (
where where
( (
"assets"."type" = $1 "assets"."type" = 'IMAGE'
and "assets"."isVisible" = $2 and "assets"."isVisible" = true
) )
) as "photos", ) as "photos",
count(*) filter ( count(*) filter (
where where
( (
"assets"."type" = $3 "assets"."type" = 'VIDEO'
and "assets"."isVisible" = $4 and "assets"."isVisible" = true
) )
) as "videos", ) as "videos",
coalesce( coalesce(
@ -255,7 +300,7 @@ select
where where
( (
"assets"."libraryId" is null "assets"."libraryId" is null
and "assets"."type" = $5 and "assets"."type" = 'IMAGE'
) )
), ),
0 0
@ -265,7 +310,7 @@ select
where where
( (
"assets"."libraryId" is null "assets"."libraryId" is null
and "assets"."type" = $6 and "assets"."type" = 'VIDEO'
) )
), ),
0 0

View File

@ -69,7 +69,7 @@ export class ActivityRepository {
async getStatistics({ albumId, assetId }: { albumId: string; assetId?: string }): Promise<number> { async getStatistics({ albumId, assetId }: { albumId: string; assetId?: string }): Promise<number> {
const { count } = await this.db const { count } = await this.db
.selectFrom('activity') .selectFrom('activity')
.select((eb) => eb.fn.countAll().as('count')) .select((eb) => eb.fn.countAll<number>().as('count'))
.innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null)) .innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null))
.leftJoin('assets', 'assets.id', 'activity.assetId') .leftJoin('assets', 'assets.id', 'activity.assetId')
.$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!)) .$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!))
@ -81,6 +81,6 @@ export class ActivityRepository {
.where('assets.localDateTime', 'is not', null) .where('assets.localDateTime', 'is not', null)
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return count as number; return count;
} }
} }

View File

@ -470,10 +470,10 @@ export class AssetRepository {
async getLivePhotoCount(motionId: string): Promise<number> { async getLivePhotoCount(motionId: string): Promise<number> {
const [{ count }] = await this.db const [{ count }] = await this.db
.selectFrom('assets') .selectFrom('assets')
.select((eb) => eb.fn.countAll().as('count')) .select((eb) => eb.fn.countAll<number>().as('count'))
.where('livePhotoVideoId', '=', asUuid(motionId)) .where('livePhotoVideoId', '=', asUuid(motionId))
.execute(); .execute();
return count as number; return count;
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
@ -773,10 +773,10 @@ export class AssetRepository {
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> { getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO)) .select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO))
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE)) .select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE))
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO)) .select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO))
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER)) .select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER))
.where('ownerId', '=', asUuid(ownerId)) .where('ownerId', '=', asUuid(ownerId))
.where('assets.fileCreatedAt', 'is not', null) .where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null) .where('assets.fileModifiedAt', 'is not', null)
@ -786,7 +786,7 @@ export class AssetRepository {
.$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!)) .$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!))
.$if(!!isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .$if(!!isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.where('deletedAt', isTrashed ? 'is not' : 'is', null) .where('deletedAt', isTrashed ? 'is not' : 'is', null)
.executeTakeFirst() as Promise<AssetStats>; .executeTakeFirstOrThrow();
} }
getRandom(userIds: string[], take: number): Promise<AssetEntity[]> { getRandom(userIds: string[], take: number): Promise<AssetEntity[]> {
@ -847,7 +847,7 @@ export class AssetRepository {
The line below outputs in YYYY-MM-DD format, but needs a change in the web app to work. The line below outputs in YYYY-MM-DD format, but needs a change in the web app to work.
.select(sql<string>`"timeBucket"::date::text`.as('timeBucket')) .select(sql<string>`"timeBucket"::date::text`.as('timeBucket'))
*/ */
.select((eb) => eb.fn.countAll().as('count')) .select((eb) => eb.fn.countAll<number>().as('count'))
.groupBy('timeBucket') .groupBy('timeBucket')
.orderBy('timeBucket', options.order ?? 'desc') .orderBy('timeBucket', options.order ?? 'desc')
.execute() as any as Promise<TimeBucketItem[]> .execute() as any as Promise<TimeBucketItem[]>
@ -1145,10 +1145,10 @@ export class AssetRepository {
async getLibraryAssetCount(libraryId: string): Promise<number> { async getLibraryAssetCount(libraryId: string): Promise<number> {
const { count } = await this.db const { count } = await this.db
.selectFrom('assets') .selectFrom('assets')
.select((eb) => eb.fn.countAll().as('count')) .select((eb) => eb.fn.countAll<number>().as('count'))
.where('libraryId', '=', asUuid(libraryId)) .where('libraryId', '=', asUuid(libraryId))
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return Number(count); return count;
} }
} }

View File

@ -250,7 +250,7 @@ const getEnv = (): EnvData => {
}, },
bigint: { bigint: {
to: 20, to: 20,
from: [20], from: [20, 1700],
parse: (value: string) => Number.parseInt(value), parse: (value: string) => Number.parseInt(value),
serialize: (value: number) => value.toString(), serialize: (value: number) => value.toString(),
}, },

View File

@ -76,13 +76,13 @@ export class LibraryRepository {
.leftJoin('exif', 'exif.assetId', 'assets.id') .leftJoin('exif', 'exif.assetId', 'assets.id')
.select((eb) => .select((eb) =>
eb.fn eb.fn
.countAll() .countAll<number>()
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)]))
.as('photos'), .as('photos'),
) )
.select((eb) => .select((eb) =>
eb.fn eb.fn
.countAll() .countAll<number>()
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)])) .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)]))
.as('videos'), .as('videos'),
) )
@ -105,10 +105,10 @@ export class LibraryRepository {
} }
return { return {
photos: Number(stats.photos), photos: stats.photos,
videos: Number(stats.videos), videos: stats.videos,
usage: Number(stats.usage), usage: stats.usage,
total: Number(stats.photos) + Number(stats.videos), total: stats.photos + stats.videos,
}; };
} }

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { getName } from 'i18n-iso-countries'; import { getName } from 'i18n-iso-countries';
import { Expression, Insertable, Kysely, sql, SqlBool } from 'kysely'; import { Expression, Insertable, Kysely, NotNull, sql, SqlBool } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { createReadStream, existsSync } from 'node:fs'; import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
@ -87,6 +87,7 @@ export class MapRepository {
.on('exif.longitude', 'is not', null), .on('exif.longitude', 'is not', null),
) )
.select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country']) .select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country'])
.$narrowType<{ lat: NotNull; lon: NotNull }>()
.where('isVisible', '=', true) .where('isVisible', '=', true)
.$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!)) .$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!))
.$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!)) .$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!))
@ -114,7 +115,7 @@ export class MapRepository {
return eb.or(expression); return eb.or(expression);
}) })
.orderBy('fileCreatedAt', 'desc') .orderBy('fileCreatedAt', 'desc')
.execute() as Promise<MapMarker[]>; .execute();
} }
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> { async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {

View File

@ -6,7 +6,7 @@ import fs from 'node:fs/promises';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import sharp from 'sharp'; import sharp from 'sharp';
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
import { ExifEntity } from 'src/entities/exif.entity'; import { Exif } from 'src/database';
import { Colorspace, LogLevel } from 'src/enum'; import { Colorspace, LogLevel } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { import {
@ -66,7 +66,7 @@ export class MediaRepository {
return true; return true;
} }
async writeExif(tags: Partial<ExifEntity>, output: string): Promise<boolean> { async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> {
try { try {
const tagsToWrite: WriteTags = { const tagsToWrite: WriteTags = {
ExifImageWidth: tags.exifImageWidth, ExifImageWidth: tags.exifImageWidth,

View File

@ -63,6 +63,18 @@ export class OAuthRepository {
} }
} }
async getProfilePicture(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch picture: ${response.statusText}`);
}
return {
data: await response.arrayBuffer(),
contentType: response.headers.get('content-type'),
};
}
private async getClient({ private async getClient({
issuerUrl, issuerUrl,
clientId, clientId,

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely'; import { ExpressionBuilder, Insertable, Kysely, NotNull, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns, Partner } from 'src/database'; import { columns } from 'src/database';
import { DB, Partners } from 'src/db'; import { DB, Partners } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
@ -44,7 +44,7 @@ export class PartnerRepository {
return this.builder() return this.builder()
.where('sharedWithId', '=', sharedWithId) .where('sharedWithId', '=', sharedWithId)
.where('sharedById', '=', sharedById) .where('sharedById', '=', sharedById)
.executeTakeFirst() as Promise<Partner | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] }) @GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] })
@ -55,7 +55,8 @@ export class PartnerRepository {
.returningAll() .returningAll()
.returning(withSharedBy) .returning(withSharedBy)
.returning(withSharedWith) .returning(withSharedWith)
.executeTakeFirstOrThrow() as Promise<Partner>; .$narrowType<{ sharedWith: NotNull; sharedBy: NotNull }>()
.executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }, { inTimeline: true }] }) @GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }, { inTimeline: true }] })
@ -68,7 +69,8 @@ export class PartnerRepository {
.returningAll() .returningAll()
.returning(withSharedBy) .returning(withSharedBy)
.returning(withSharedWith) .returning(withSharedWith)
.executeTakeFirstOrThrow() as Promise<Partner>; .$narrowType<{ sharedWith: NotNull; sharedBy: NotNull }>()
.executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] }) @GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] })

View File

@ -1,14 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, Selectable, sql } from 'kysely'; import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { AssetFaces, DB, FaceSearch, Person } from 'src/db'; import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFileType, SourceType } from 'src/enum';
import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum';
import { removeUndefinedKeys } from 'src/utils/database'; import { removeUndefinedKeys } from 'src/utils/database';
import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { PaginationOptions } from 'src/utils/pagination';
export interface PersonSearchOptions { export interface PersonSearchOptions {
minimumFaceCount: number; minimumFaceCount: number;
@ -49,6 +47,19 @@ export interface DeleteFacesOptions {
sourceType: SourceType; sourceType: SourceType;
} }
export interface GetAllPeopleOptions {
ownerId?: string;
thumbnailPath?: string;
faceAssetId?: string | null;
isHidden?: boolean;
}
export interface GetAllFacesOptions {
personId?: string | null;
assetId?: string;
sourceType?: SourceType;
}
export type UnassignFacesOptions = DeleteFacesOptions; export type UnassignFacesOptions = DeleteFacesOptions;
export type SelectFaceOptions = (keyof Selectable<AssetFaces>)[]; export type SelectFaceOptions = (keyof Selectable<AssetFaces>)[];
@ -98,20 +109,13 @@ export class PersonRepository {
await this.vacuum({ reindexVectors: false }); await this.vacuum({ reindexVectors: false });
} }
@GenerateSql({ params: [[{ id: DummyValue.UUID }]] }) @GenerateSql({ params: [DummyValue.UUID] })
async delete(entities: PersonEntity[]): Promise<void> { async delete(ids: string[]): Promise<void> {
if (entities.length === 0) { if (ids.length === 0) {
return; return;
} }
await this.db await this.db.deleteFrom('person').where('person.id', 'in', ids).execute();
.deleteFrom('person')
.where(
'person.id',
'in',
entities.map(({ id }) => id),
)
.execute();
} }
@GenerateSql({ params: [{ sourceType: SourceType.EXIF }] }) @GenerateSql({ params: [{ sourceType: SourceType.EXIF }] })
@ -121,7 +125,7 @@ export class PersonRepository {
await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING });
} }
getAllFaces(options: Partial<AssetFaceEntity> = {}): AsyncIterableIterator<AssetFaceEntity> { getAllFaces(options: GetAllFacesOptions = {}) {
return this.db return this.db
.selectFrom('asset_faces') .selectFrom('asset_faces')
.selectAll('asset_faces') .selectAll('asset_faces')
@ -130,10 +134,10 @@ export class PersonRepository {
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!)) .$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!)) .$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
.where('asset_faces.deletedAt', 'is', null) .where('asset_faces.deletedAt', 'is', null)
.stream() as AsyncIterableIterator<AssetFaceEntity>; .stream();
} }
getAll(options: Partial<PersonEntity> = {}): AsyncIterableIterator<PersonEntity> { getAll(options: GetAllPeopleOptions = {}) {
return this.db return this.db
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
@ -142,15 +146,11 @@ export class PersonRepository {
.$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null)) .$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null))
.$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!)) .$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!))
.$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!)) .$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!))
.stream() as AsyncIterableIterator<PersonEntity>; .stream();
} }
async getAllForUser( async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) {
pagination: PaginationOptions, const items = await this.db
userId: string,
options?: PersonSearchOptions,
): Paginated<PersonEntity> {
const items = (await this.db
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
.innerJoin('asset_faces', 'asset_faces.personId', 'person.id') .innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
@ -198,7 +198,7 @@ export class PersonRepository {
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false)) .$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))
.offset(pagination.skip ?? 0) .offset(pagination.skip ?? 0)
.limit(pagination.take + 1) .limit(pagination.take + 1)
.execute()) as PersonEntity[]; .execute();
if (items.length > pagination.take) { if (items.length > pagination.take) {
return { items: items.slice(0, -1), hasNextPage: true }; return { items: items.slice(0, -1), hasNextPage: true };
@ -208,7 +208,7 @@ export class PersonRepository {
} }
@GenerateSql() @GenerateSql()
getAllWithoutFaces(): Promise<PersonEntity[]> { getAllWithoutFaces() {
return this.db return this.db
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
@ -216,11 +216,11 @@ export class PersonRepository {
.where('asset_faces.deletedAt', 'is', null) .where('asset_faces.deletedAt', 'is', null)
.having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0) .having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0)
.groupBy('person.id') .groupBy('person.id')
.execute() as Promise<PersonEntity[]>; .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string): Promise<AssetFaceEntity[]> { getFaces(assetId: string) {
return this.db return this.db
.selectFrom('asset_faces') .selectFrom('asset_faces')
.selectAll('asset_faces') .selectAll('asset_faces')
@ -228,11 +228,11 @@ export class PersonRepository {
.where('asset_faces.assetId', '=', assetId) .where('asset_faces.assetId', '=', assetId)
.where('asset_faces.deletedAt', 'is', null) .where('asset_faces.deletedAt', 'is', null)
.orderBy('asset_faces.boundingBoxX1', 'asc') .orderBy('asset_faces.boundingBoxX1', 'asc')
.execute() as Promise<AssetFaceEntity[]>; .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getFaceById(id: string): Promise<AssetFaceEntity> { getFaceById(id: string) {
// TODO return null instead of find or fail // TODO return null instead of find or fail
return this.db return this.db
.selectFrom('asset_faces') .selectFrom('asset_faces')
@ -240,25 +240,57 @@ export class PersonRepository {
.select(withPerson) .select(withPerson)
.where('asset_faces.id', '=', id) .where('asset_faces.id', '=', id)
.where('asset_faces.deletedAt', 'is', null) .where('asset_faces.deletedAt', 'is', null)
.executeTakeFirstOrThrow() as Promise<AssetFaceEntity>; .executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getFaceByIdWithAssets( getFaceForFacialRecognitionJob(id: string) {
id: string,
relations?: { faceSearch?: boolean },
select?: SelectFaceOptions,
): Promise<AssetFaceEntity | undefined> {
return this.db return this.db
.selectFrom('asset_faces') .selectFrom('asset_faces')
.$if(!!select, (qb) => qb.select(select!)) .select(['asset_faces.id', 'asset_faces.personId', 'asset_faces.sourceType'])
.$if(!select, (qb) => qb.selectAll('asset_faces')) .select((eb) =>
.select(withPerson) jsonObjectFrom(
.select(withAsset) eb
.$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch)) .selectFrom('assets')
.select(['assets.ownerId', 'assets.isArchived', 'assets.fileCreatedAt'])
.whereRef('assets.id', '=', 'asset_faces.assetId'),
).as('asset'),
)
.select(withFaceSearch)
.where('asset_faces.id', '=', id) .where('asset_faces.id', '=', id)
.where('asset_faces.deletedAt', 'is', null) .where('asset_faces.deletedAt', 'is', null)
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>; .executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getDataForThumbnailGenerationJob(id: string) {
return this.db
.selectFrom('person')
.innerJoin('asset_faces', 'asset_faces.id', 'person.faceAssetId')
.innerJoin('assets', 'asset_faces.assetId', 'assets.id')
.innerJoin('exif', 'exif.assetId', 'assets.id')
.innerJoin('asset_files', 'asset_files.assetId', 'assets.id')
.select([
'person.ownerId',
'asset_faces.boundingBoxX1 as x1',
'asset_faces.boundingBoxY1 as y1',
'asset_faces.boundingBoxX2 as x2',
'asset_faces.boundingBoxY2 as y2',
'asset_faces.imageWidth as oldWidth',
'asset_faces.imageHeight as oldHeight',
'exif.exifImageWidth',
'exif.exifImageHeight',
'assets.type',
'assets.originalPath',
'asset_files.path as previewPath',
])
.where('person.id', '=', id)
.where('asset_faces.deletedAt', 'is', null)
.where('asset_files.type', '=', AssetFileType.PREVIEW)
.where('exif.exifImageWidth', '>', 0)
.where('exif.exifImageHeight', '>', 0)
.$narrowType<{ exifImageWidth: NotNull; exifImageHeight: NotNull }>()
.executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
@ -272,16 +304,16 @@ export class PersonRepository {
return Number(result.numChangedRows ?? 0); return Number(result.numChangedRows ?? 0);
} }
getById(personId: string): Promise<PersonEntity | null> { getById(personId: string) {
return (this.db // return this.db //
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
.where('person.id', '=', personId) .where('person.id', '=', personId)
.executeTakeFirst() ?? null) as Promise<PersonEntity | null>; .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> { getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) {
return this.db return this.db
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
@ -296,7 +328,7 @@ export class PersonRepository {
) )
.limit(1000) .limit(1000)
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
.execute() as Promise<PersonEntity[]>; .execute();
} }
@GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] }) @GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] })
@ -362,8 +394,8 @@ export class PersonRepository {
}; };
} }
create(person: Insertable<Person>): Promise<PersonEntity> { create(person: Insertable<Person>) {
return this.db.insertInto('person').values(person).returningAll().executeTakeFirst() as Promise<PersonEntity>; return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
} }
async createAll(people: Insertable<Person>[]): Promise<string[]> { async createAll(people: Insertable<Person>[]): Promise<string[]> {
@ -399,13 +431,13 @@ export class PersonRepository {
await query.selectFrom(sql`(select 1)`.as('dummy')).execute(); await query.selectFrom(sql`(select 1)`.as('dummy')).execute();
} }
async update(person: Partial<PersonEntity> & { id: string }): Promise<PersonEntity> { async update(person: Updateable<Person> & { id: string }) {
return this.db return this.db
.updateTable('person') .updateTable('person')
.set(person) .set(person)
.where('person.id', '=', person.id) .where('person.id', '=', person.id)
.returningAll() .returningAll()
.executeTakeFirstOrThrow() as Promise<PersonEntity>; .executeTakeFirstOrThrow();
} }
async updateAll(people: Insertable<Person>[]): Promise<void> { async updateAll(people: Insertable<Person>[]): Promise<void> {
@ -437,7 +469,7 @@ export class PersonRepository {
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
@ChunkedArray() @ChunkedArray()
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> { getFacesByIds(ids: AssetFaceId[]) {
if (ids.length === 0) { if (ids.length === 0) {
return Promise.resolve([]); return Promise.resolve([]);
} }
@ -457,17 +489,17 @@ export class PersonRepository {
.where('asset_faces.assetId', 'in', assetIds) .where('asset_faces.assetId', 'in', assetIds)
.where('asset_faces.personId', 'in', personIds) .where('asset_faces.personId', 'in', personIds)
.where('asset_faces.deletedAt', 'is', null) .where('asset_faces.deletedAt', 'is', null)
.execute() as Promise<AssetFaceEntity[]>; .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getRandomFace(personId: string): Promise<AssetFaceEntity | undefined> { getRandomFace(personId: string) {
return this.db return this.db
.selectFrom('asset_faces') .selectFrom('asset_faces')
.selectAll('asset_faces') .selectAll('asset_faces')
.where('asset_faces.personId', '=', personId) .where('asset_faces.personId', '=', personId)
.where('asset_faces.deletedAt', 'is', null) .where('asset_faces.deletedAt', 'is', null)
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql() @GenerateSql()

View File

@ -162,7 +162,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
hasPerson?: boolean; hasPerson?: boolean;
numResults: number; numResults: number;
maxDistance: number; maxDistance: number;
minBirthDate?: Date; minBirthDate?: Date | null;
} }
export interface AssetDuplicateSearch { export interface AssetDuplicateSearch {

View File

@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns, UserAdmin } from 'src/database'; import { columns } from 'src/database';
import { DB, UserMetadata as DbUserMetadata } from 'src/db'; import { DB, UserMetadata as DbUserMetadata } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { UserEntity, withMetadata } from 'src/entities/user.entity';
import { AssetType, UserStatus } from 'src/enum'; import { AssetType, UserStatus } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { UserMetadata, UserMetadataItem } from 'src/types'; import { UserMetadata, UserMetadataItem } from 'src/types';
@ -32,12 +32,21 @@ export interface UserFindOptions {
withDeleted?: boolean; withDeleted?: boolean;
} }
const withMetadata = (eb: ExpressionBuilder<DB, 'users'>) => {
return jsonArrayFrom(
eb
.selectFrom('user_metadata')
.select(['user_metadata.key', 'user_metadata.value'])
.whereRef('users.id', '=', 'user_metadata.userId'),
).as('metadata');
};
@Injectable() @Injectable()
export class UserRepository { export class UserRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] })
get(userId: string, options: UserFindOptions): Promise<UserEntity | undefined> { get(userId: string, options: UserFindOptions) {
options = options || {}; options = options || {};
return this.db return this.db
@ -46,7 +55,7 @@ export class UserRepository {
.select(withMetadata) .select(withMetadata)
.where('users.id', '=', userId) .where('users.id', '=', userId)
.$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) .$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
.executeTakeFirst() as Promise<UserEntity | undefined>; .executeTakeFirst();
} }
getMetadata(userId: string) { getMetadata(userId: string) {
@ -58,13 +67,14 @@ export class UserRepository {
} }
@GenerateSql() @GenerateSql()
getAdmin(): Promise<UserEntity | undefined> { getAdmin() {
return this.db return this.db
.selectFrom('users') .selectFrom('users')
.select(columns.userAdmin) .select(columns.userAdmin)
.select(withMetadata)
.where('users.isAdmin', '=', true) .where('users.isAdmin', '=', true)
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql() @GenerateSql()
@ -80,34 +90,36 @@ export class UserRepository {
} }
@GenerateSql({ params: [DummyValue.EMAIL] }) @GenerateSql({ params: [DummyValue.EMAIL] })
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | undefined> { getByEmail(email: string, withPassword?: boolean) {
return this.db return this.db
.selectFrom('users') .selectFrom('users')
.select(columns.userAdmin) .select(columns.userAdmin)
.select(withMetadata)
.$if(!!withPassword, (eb) => eb.select('password')) .$if(!!withPassword, (eb) => eb.select('password'))
.where('email', '=', email) .where('email', '=', email)
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
getByStorageLabel(storageLabel: string): Promise<UserEntity | undefined> { getByStorageLabel(storageLabel: string) {
return this.db return this.db
.selectFrom('users') .selectFrom('users')
.select(columns.userAdmin) .select(columns.userAdmin)
.where('users.storageLabel', '=', storageLabel) .where('users.storageLabel', '=', storageLabel)
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
getByOAuthId(oauthId: string): Promise<UserEntity | undefined> { getByOAuthId(oauthId: string) {
return this.db return this.db
.selectFrom('users') .selectFrom('users')
.select(columns.userAdmin) .select(columns.userAdmin)
.select(withMetadata)
.where('users.oauthId', '=', oauthId) .where('users.oauthId', '=', oauthId)
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [DateTime.now().minus({ years: 1 })] }) @GenerateSql({ params: [DateTime.now().minus({ years: 1 })] })
@ -126,18 +138,19 @@ export class UserRepository {
.select(withMetadata) .select(withMetadata)
.$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) .$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
.orderBy('createdAt', 'desc') .orderBy('createdAt', 'desc')
.execute() as Promise<UserAdmin[]>; .execute();
} }
async create(dto: Insertable<UserTable>): Promise<UserEntity> { async create(dto: Insertable<UserTable>) {
return this.db return this.db
.insertInto('users') .insertInto('users')
.values(dto) .values(dto)
.returning(columns.userAdmin) .returning(columns.userAdmin)
.executeTakeFirst() as unknown as Promise<UserEntity>; .returning(withMetadata)
.executeTakeFirstOrThrow();
} }
update(id: string, dto: Updateable<UserTable>): Promise<UserEntity> { update(id: string, dto: Updateable<UserTable>) {
return this.db return this.db
.updateTable('users') .updateTable('users')
.set(dto) .set(dto)
@ -145,17 +158,17 @@ export class UserRepository {
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null)
.returning(columns.userAdmin) .returning(columns.userAdmin)
.returning(withMetadata) .returning(withMetadata)
.executeTakeFirst() as unknown as Promise<UserEntity>; .executeTakeFirstOrThrow();
} }
restore(id: string): Promise<UserEntity> { restore(id: string) {
return this.db return this.db
.updateTable('users') .updateTable('users')
.set({ status: UserStatus.ACTIVE, deletedAt: null }) .set({ status: UserStatus.ACTIVE, deletedAt: null })
.where('users.id', '=', asUuid(id)) .where('users.id', '=', asUuid(id))
.returning(columns.userAdmin) .returning(columns.userAdmin)
.returning(withMetadata) .returning(withMetadata)
.executeTakeFirst() as unknown as Promise<UserEntity>; .executeTakeFirstOrThrow();
} }
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) { async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
@ -175,41 +188,41 @@ export class UserRepository {
await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute(); await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute();
} }
delete(user: { id: string }, hard?: boolean): Promise<UserEntity> { delete(user: { id: string }, hard?: boolean) {
return hard return hard
? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise<UserEntity>) ? this.db.deleteFrom('users').where('id', '=', user.id).execute()
: (this.db : this.db.updateTable('users').set({ deletedAt: new Date() }).where('id', '=', user.id).execute();
.updateTable('users')
.set({ deletedAt: new Date() })
.where('id', '=', user.id)
.execute() as unknown as Promise<UserEntity>);
} }
@GenerateSql() @GenerateSql()
async getUserStats(): Promise<UserStatsQueryResponse[]> { getUserStats() {
const stats = (await this.db return this.db
.selectFrom('users') .selectFrom('users')
.leftJoin('assets', 'assets.ownerId', 'users.id') .leftJoin('assets', 'assets.ownerId', 'users.id')
.leftJoin('exif', 'exif.assetId', 'assets.id') .leftJoin('exif', 'exif.assetId', 'assets.id')
.select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes']) .select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes'])
.select((eb) => [ .select((eb) => [
eb.fn eb.fn
.countAll() .countAll<number>()
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) .filterWhere((eb) =>
eb.and([eb('assets.type', '=', sql.lit(AssetType.IMAGE)), eb('assets.isVisible', '=', sql.lit(true))]),
)
.as('photos'), .as('photos'),
eb.fn eb.fn
.countAll() .countAll<number>()
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)])) .filterWhere((eb) =>
eb.and([eb('assets.type', '=', sql.lit(AssetType.VIDEO)), eb('assets.isVisible', '=', sql.lit(true))]),
)
.as('videos'), .as('videos'),
eb.fn eb.fn
.coalesce(eb.fn.sum('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0)) .coalesce(eb.fn.sum<number>('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0))
.as('usage'), .as('usage'),
eb.fn eb.fn
.coalesce( .coalesce(
eb.fn eb.fn
.sum('exif.fileSizeInByte') .sum<number>('exif.fileSizeInByte')
.filterWhere((eb) => .filterWhere((eb) =>
eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.IMAGE)]), eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', sql.lit(AssetType.IMAGE))]),
), ),
eb.lit(0), eb.lit(0),
) )
@ -217,9 +230,9 @@ export class UserRepository {
eb.fn eb.fn
.coalesce( .coalesce(
eb.fn eb.fn
.sum('exif.fileSizeInByte') .sum<number>('exif.fileSizeInByte')
.filterWhere((eb) => .filterWhere((eb) =>
eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.VIDEO)]), eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', sql.lit(AssetType.VIDEO))]),
), ),
eb.lit(0), eb.lit(0),
) )
@ -228,17 +241,7 @@ export class UserRepository {
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.groupBy('users.id') .groupBy('users.id')
.orderBy('users.createdAt', 'asc') .orderBy('users.createdAt', 'asc')
.execute()) as UserStatsQueryResponse[]; .execute();
for (const stat of stats) {
stat.photos = Number(stat.photos);
stat.videos = Number(stat.videos);
stat.usage = Number(stat.usage);
stat.usagePhotos = Number(stat.usagePhotos);
stat.usageVideos = Number(stat.usageVideos);
}
return stats;
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })

View File

@ -23,7 +23,7 @@ describe(ActivityService.name, () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([]); mocks.activity.search.mockResolvedValue([]);
await expect(sut.getAll(factory.auth({ id: userId }), { assetId, albumId })).resolves.toEqual([]); await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined }); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined });
}); });
@ -35,7 +35,7 @@ describe(ActivityService.name, () => {
mocks.activity.search.mockResolvedValue([]); mocks.activity.search.mockResolvedValue([]);
await expect( await expect(
sut.getAll(factory.auth({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }), sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }),
).resolves.toEqual([]); ).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true }); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true });
@ -80,7 +80,7 @@ describe(ActivityService.name, () => {
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(activity); mocks.activity.create.mockResolvedValue(activity);
await sut.create(factory.auth({ id: userId }), { await sut.create(factory.auth({ user: { id: userId } }), {
albumId, albumId,
assetId, assetId,
type: ReactionType.COMMENT, type: ReactionType.COMMENT,
@ -116,7 +116,7 @@ describe(ActivityService.name, () => {
mocks.activity.create.mockResolvedValue(activity); mocks.activity.create.mockResolvedValue(activity);
mocks.activity.search.mockResolvedValue([]); mocks.activity.search.mockResolvedValue([]);
await sut.create(factory.auth({ id: userId }), { albumId, assetId, type: ReactionType.LIKE }); await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true }); expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true });
}); });

View File

@ -7,13 +7,13 @@ import {
CreateAlbumDto, CreateAlbumDto,
GetAlbumsDto, GetAlbumsDto,
UpdateAlbumDto, UpdateAlbumDto,
UpdateAlbumUserDto,
mapAlbum, mapAlbum,
mapAlbumWithAssets, mapAlbumWithAssets,
mapAlbumWithoutAssets, mapAlbumWithoutAssets,
} from 'src/dtos/album.dto'; } from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumUserEntity } from 'src/entities/album-user.entity';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
@ -247,7 +247,7 @@ export class AlbumService extends BaseService {
await this.albumUserRepository.delete({ albumsId: id, usersId: userId }); await this.albumUserRepository.delete({ albumsId: id, usersId: userId });
} }
async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial<AlbumUserEntity>): Promise<void> { async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] });
await this.albumUserRepository.update({ albumsId: id, usersId: userId }, { role: dto.role }); await this.albumUserRepository.update({ albumsId: id, usersId: userId }, { role: dto.role });
} }

View File

@ -54,7 +54,7 @@ describe(ApiKeyService.name, () => {
}); });
it('should throw an error if the api key does not have sufficient permissions', async () => { it('should throw an error if the api key does not have sufficient permissions', async () => {
const auth = factory.auth({ apiKey: factory.authApiKey({ permissions: [Permission.ASSET_READ] }) }); const auth = factory.auth({ apiKey: { permissions: [Permission.ASSET_READ] } });
await expect(sut.create(auth, { permissions: [Permission.ASSET_UPDATE] })).rejects.toBeInstanceOf( await expect(sut.create(auth, { permissions: [Permission.ASSET_UPDATE] })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,

View File

@ -5,9 +5,9 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { AssetFile } from 'src/database';
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard'; import { AuthRequest } from 'src/middleware/auth.guard';
@ -166,7 +166,7 @@ const assetEntity = Object.freeze({
isArchived: false, isArchived: false,
encodedVideoPath: '', encodedVideoPath: '',
duration: '0:00:00.000000', duration: '0:00:00.000000',
files: [] as AssetFileEntity[], files: [] as AssetFile[],
exifInfo: { exifInfo: {
latitude: 49.533_547, latitude: 49.533_547,
longitude: 10.703_075, longitude: 10.703_075,
@ -535,12 +535,9 @@ describe(AssetMediaService.name, () => {
...assetStub.image, ...assetStub.image,
files: [ files: [
{ {
assetId: assetStub.image.id,
createdAt: assetStub.image.fileCreatedAt,
id: '42', id: '42',
path: '/path/to/preview', path: '/path/to/preview',
type: AssetFileType.THUMBNAIL, type: AssetFileType.THUMBNAIL,
updatedAt: new Date(),
}, },
], ],
}); });
@ -555,12 +552,9 @@ describe(AssetMediaService.name, () => {
...assetStub.image, ...assetStub.image,
files: [ files: [
{ {
assetId: assetStub.image.id,
createdAt: assetStub.image.fileCreatedAt,
id: '42', id: '42',
path: '/path/to/preview.jpg', path: '/path/to/preview.jpg',
type: AssetFileType.PREVIEW, type: AssetFileType.PREVIEW,
updatedAt: new Date(),
}, },
], ],
}); });

View File

@ -88,7 +88,7 @@ describe(AssetService.name, () => {
it('should get memories with partners with inTimeline enabled', async () => { it('should get memories with partners with inTimeline enabled', async () => {
const partner = factory.partner(); const partner = factory.partner();
const auth = factory.auth({ id: partner.sharedWithId }); const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.partner.getAll.mockResolvedValue([partner]); mocks.partner.getAll.mockResolvedValue([partner]);
mocks.asset.getByDayOfYear.mockResolvedValue([]); mocks.asset.getByDayOfYear.mockResolvedValue([]);
@ -139,7 +139,7 @@ describe(AssetService.name, () => {
it('should not include partner assets if not in timeline', async () => { it('should not include partner assets if not in timeline', async () => {
const partner = factory.partner({ inTimeline: false }); const partner = factory.partner({ inTimeline: false });
const auth = factory.auth({ id: partner.sharedWithId }); const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.asset.getRandom.mockResolvedValue([assetStub.image]); mocks.asset.getRandom.mockResolvedValue([assetStub.image]);
mocks.partner.getAll.mockResolvedValue([partner]); mocks.partner.getAll.mockResolvedValue([partner]);
@ -151,7 +151,7 @@ describe(AssetService.name, () => {
it('should include partner assets if in timeline', async () => { it('should include partner assets if in timeline', async () => {
const partner = factory.partner({ inTimeline: true }); const partner = factory.partner({ inTimeline: true });
const auth = factory.auth({ id: partner.sharedWithId }); const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.asset.getRandom.mockResolvedValue([assetStub.image]); mocks.asset.getRandom.mockResolvedValue([assetStub.image]);
mocks.partner.getAll.mockResolvedValue([partner]); mocks.partner.getAll.mockResolvedValue([partner]);

View File

@ -43,7 +43,7 @@ export class AssetService extends BaseService {
yearsAgo, yearsAgo,
// TODO move this to clients // TODO move this to clients
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
assets: assets.map((asset) => mapAsset(asset as AssetEntity, { auth })), assets: assets.map((asset) => mapAsset(asset as unknown as AssetEntity, { auth })),
}; };
}); });
} }

View File

@ -1,25 +1,34 @@
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { UserAdmin } from 'src/database';
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum'; import { AuthType, Permission } from 'src/enum';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { UserMetadataItem } from 'src/types'; import { UserMetadataItem } from 'src/types';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub'; import { factory, newUuid } from 'test/small.factory';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
const oauthResponse = { const oauthResponse = ({
id,
email,
name,
profileImagePath,
}: {
id: string;
email: string;
name: string;
profileImagePath?: string;
}) => ({
accessToken: 'cmFuZG9tLWJ5dGVz', accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'user-id', userId: id,
userEmail: 'immich@test.com', userEmail: email,
name: 'immich_name', name,
profileImagePath: '', profileImagePath,
isAdmin: false, isAdmin: false,
shouldChangePassword: false, shouldChangePassword: false,
}; });
// const token = Buffer.from('my-api-key', 'utf8').toString('base64'); // const token = Buffer.from('my-api-key', 'utf8').toString('base64');
@ -39,15 +48,7 @@ const fixtures = {
}, },
}; };
const oauthUserWithDefaultQuota = { describe(AuthService.name, () => {
email,
name: ' ',
oauthId: sub,
quotaSizeInBytes: '1073741824',
storageLabel: null,
};
describe('AuthService', () => {
let sut: AuthService; let sut: AuthService;
let mocks: ServiceMocks; let mocks: ServiceMocks;
@ -89,7 +90,7 @@ describe('AuthService', () => {
}); });
it('should check the user has a password', async () => { it('should check the user has a password', async () => {
mocks.user.getByEmail.mockResolvedValue({} as UserEntity); mocks.user.getByEmail.mockResolvedValue({} as UserAdmin);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
@ -97,7 +98,7 @@ describe('AuthService', () => {
}); });
it('should successfully log the user in', async () => { it('should successfully log the user in', async () => {
const user = { ...factory.user(), password: 'immich_password' } as UserEntity; const user = { ...(factory.user() as UserAdmin), password: 'immich_password' };
const session = factory.session(); const session = factory.session();
mocks.user.getByEmail.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session); mocks.session.create.mockResolvedValue(session);
@ -118,14 +119,12 @@ describe('AuthService', () => {
describe('changePassword', () => { describe('changePassword', () => {
it('should change the password', async () => { it('should change the password', async () => {
const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const user = factory.userAdmin();
const auth = factory.auth({ user });
const dto = { password: 'old-password', newPassword: 'new-password' }; const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getByEmail.mockResolvedValue({ mocks.user.getByEmail.mockResolvedValue({ ...user, password: 'hash-password' });
email: 'test@immich.com', mocks.user.update.mockResolvedValue(user);
password: 'hash-password',
} as UserEntity);
mocks.user.update.mockResolvedValue(userStub.user1);
await sut.changePassword(auth, dto); await sut.changePassword(auth, dto);
@ -143,7 +142,7 @@ describe('AuthService', () => {
}); });
it('should throw when password does not match existing password', async () => { it('should throw when password does not match existing password', async () => {
const auth = { user: { email: 'test@imimch.com' } as UserEntity }; const auth = { user: { email: 'test@imimch.com' } as UserAdmin };
const dto = { password: 'old-password', newPassword: 'new-password' }; const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.crypto.compareBcrypt.mockReturnValue(false); mocks.crypto.compareBcrypt.mockReturnValue(false);
@ -151,7 +150,7 @@ describe('AuthService', () => {
mocks.user.getByEmail.mockResolvedValue({ mocks.user.getByEmail.mockResolvedValue({
email: 'test@immich.com', email: 'test@immich.com',
password: 'hash-password', password: 'hash-password',
} as UserEntity); } as UserAdmin & { password: string });
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
}); });
@ -163,7 +162,7 @@ describe('AuthService', () => {
mocks.user.getByEmail.mockResolvedValue({ mocks.user.getByEmail.mockResolvedValue({
email: 'test@immich.com', email: 'test@immich.com',
password: '', password: '',
} as UserEntity); } as UserAdmin & { password: string });
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
}); });
@ -217,7 +216,7 @@ describe('AuthService', () => {
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' }; const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' };
it('should only allow one admin', async () => { it('should only allow one admin', async () => {
mocks.user.getAdmin.mockResolvedValue({} as UserEntity); mocks.user.getAdmin.mockResolvedValue({} as UserAdmin);
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
@ -231,7 +230,7 @@ describe('AuthService', () => {
id: 'admin', id: 'admin',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
metadata: [] as UserMetadataItem[], metadata: [] as UserMetadataItem[],
} as UserEntity); } as unknown as UserAdmin);
await expect(sut.adminSignUp(dto)).resolves.toMatchObject({ await expect(sut.adminSignUp(dto)).resolves.toMatchObject({
avatarColor: expect.any(String), avatarColor: expect.any(String),
@ -294,7 +293,7 @@ describe('AuthService', () => {
}); });
it('should not accept an expired key', async () => { it('should not accept an expired key', async () => {
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired as any);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@ -306,7 +305,7 @@ describe('AuthService', () => {
}); });
it('should not accept a key on a non-shared route', async () => { it('should not accept a key on a non-shared route', async () => {
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid as any);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@ -318,7 +317,7 @@ describe('AuthService', () => {
}); });
it('should not accept a key without a user', async () => { it('should not accept a key without a user', async () => {
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired as any);
mocks.user.get.mockResolvedValue(void 0); mocks.user.get.mockResolvedValue(void 0);
await expect( await expect(
@ -331,37 +330,39 @@ describe('AuthService', () => {
}); });
it('should accept a base64url key', async () => { it('should accept a base64url key', async () => {
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); const user = factory.userAdmin();
mocks.user.get.mockResolvedValue(userStub.admin); const sharedLink = { ...sharedLinkStub.valid, user } as any;
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
mocks.user.get.mockResolvedValue(user);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') }, headers: { 'x-immich-share-key': sharedLink.key.toString('base64url') },
queryParams: {}, queryParams: {},
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
}), }),
).resolves.toEqual({ ).resolves.toEqual({ user, sharedLink });
user: userStub.admin,
sharedLink: sharedLinkStub.valid, expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key);
});
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
}); });
it('should accept a hex key', async () => { it('should accept a hex key', async () => {
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); const user = factory.userAdmin();
mocks.user.get.mockResolvedValue(userStub.admin); const sharedLink = { ...sharedLinkStub.valid, user } as any;
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
mocks.user.get.mockResolvedValue(user);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') }, headers: { 'x-immich-share-key': sharedLink.key.toString('hex') },
queryParams: {}, queryParams: {},
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
}), }),
).resolves.toEqual({ ).resolves.toEqual({ user, sharedLink });
user: userStub.admin,
sharedLink: sharedLinkStub.valid, expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key);
});
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
}); });
}); });
@ -533,24 +534,28 @@ describe('AuthService', () => {
}); });
it('should link an existing user', async () => { it('should link an existing user', async () => {
const user = factory.userAdmin();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(userStub.user1); mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(userStub.user1); mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session()); mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse(user),
); );
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
}); });
it('should not link to a user with a different oauth sub', async () => { it('should not link to a user with a different oauth sub', async () => {
const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.user.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' }); mocks.user.getByEmail.mockResolvedValueOnce(user);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.getAdmin.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(user);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow(
BadRequestException, BadRequestException,
@ -561,14 +566,16 @@ describe('AuthService', () => {
}); });
it('should allow auto registering by default', async () => { it('should allow auto registering by default', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.create.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session()); mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse(user),
); );
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
@ -576,10 +583,12 @@ describe('AuthService', () => {
}); });
it('should throw an error if user should be auto registered but the email claim does not exist', async () => { it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
const user = factory.userAdmin({ isAdmin: true });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.getAdmin.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session()); mocks.session.create.mockResolvedValue(factory.session());
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
@ -600,8 +609,10 @@ describe('AuthService', () => {
'app.immich:///oauth-callback?code=abc123', 'app.immich:///oauth-callback?code=abc123',
]) { ]) {
it(`should use the mobile redirect override for a url of ${url}`, async () => { it(`should use the mobile redirect override for a url of ${url}`, async () => {
const user = factory.userAdmin();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(userStub.user1); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session()); mocks.session.create.mockResolvedValue(factory.session());
await sut.callback({ url }, loginDetails); await sut.callback({ url }, loginDetails);
@ -611,100 +622,162 @@ describe('AuthService', () => {
} }
it('should use the default quota', async () => { it('should use the default quota', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.create.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session()); mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse(user),
); );
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
}); });
it('should ignore an invalid storage quota', async () => { it('should ignore an invalid storage quota', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(userStub.user1);
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' });
mocks.session.create.mockResolvedValue(factory.session()); mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse(user),
); );
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
}); });
it('should ignore a negative quota', async () => { it('should ignore a negative quota', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
mocks.user.getAdmin.mockResolvedValue(user);
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(userStub.user1);
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 });
mocks.session.create.mockResolvedValue(factory.session()); mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse(user),
); );
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
}); });
it('should not set quota for 0 quota', async () => { it('should not set quota for 0 quota', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(userStub.user1);
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 });
mocks.session.create.mockResolvedValue(factory.session()); mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse(user),
); );
expect(mocks.user.create).toHaveBeenCalledWith({ expect(mocks.user.create).toHaveBeenCalledWith({
email, email: user.email,
name: ' ', name: ' ',
oauthId: sub, oauthId: user.oauthId,
quotaSizeInBytes: null, quotaSizeInBytes: null,
storageLabel: null, storageLabel: null,
}); });
}); });
it('should use a valid storage quota', async () => { it('should use a valid storage quota', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.create.mockResolvedValue(userStub.user1); mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session()); mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse(user),
); );
expect(mocks.user.create).toHaveBeenCalledWith({ expect(mocks.user.create).toHaveBeenCalledWith({
email, email: user.email,
name: ' ', name: ' ',
oauthId: sub, oauthId: user.oauthId,
quotaSizeInBytes: 5_368_709_120, quotaSizeInBytes: 5_368_709_120,
storageLabel: null, storageLabel: null,
}); });
}); });
it('should sync the profile picture', async () => {
const fileId = newUuid();
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue({
sub: user.oauthId,
email: user.email,
picture: pictureUrl,
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.crypto.randomUUID.mockReturnValue(fileId);
mocks.oauth.getProfilePicture.mockResolvedValue({
contentType: 'image/jpeg',
data: new Uint8Array([1, 2, 3, 4, 5]),
});
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse(user),
);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
profileImagePath: `upload/profile/${user.id}/${fileId}.jpg`,
profileChangedAt: expect.any(Date),
});
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl);
});
it('should not sync the profile picture if the user already has one', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue({
sub: user.oauthId,
email: user.email,
picture: 'https://auth.immich.cloud/profiles/1.jpg',
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse(user),
);
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled();
});
}); });
describe('link', () => { describe('link', () => {
it('should link an account', async () => { it('should link an account', async () => {
const authUser = factory.authUser(); const user = factory.userAdmin();
const authApiKey = factory.authApiKey({ permissions: [] }); const auth = factory.auth({ apiKey: { permissions: [] }, user });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.update.mockResolvedValue(userStub.user1); mocks.user.update.mockResolvedValue(user);
await sut.link(auth, { url: 'http://immich/user-settings?code=abc123' }); await sut.link(auth, { url: 'http://immich/user-settings?code=abc123' });
@ -717,7 +790,7 @@ describe('AuthService', () => {
const auth = { user: authUser, apiKey: authApiKey }; const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
await expect(sut.link(auth, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( await expect(sut.link(auth, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
@ -729,12 +802,11 @@ describe('AuthService', () => {
describe('unlink', () => { describe('unlink', () => {
it('should unlink an account', async () => { it('should unlink an account', async () => {
const authUser = factory.authUser(); const user = factory.userAdmin();
const authApiKey = factory.authApiKey({ permissions: [] }); const auth = factory.auth({ user, apiKey: { permissions: [] } });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.update.mockResolvedValue(userStub.user1); mocks.user.update.mockResolvedValue(user);
await sut.unlink(auth); await sut.unlink(auth);

View File

@ -3,7 +3,10 @@ import { isString } from 'class-validator';
import { parse } from 'cookie'; import { parse } from 'cookie';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http'; import { IncomingHttpHeaders } from 'node:http';
import { join } from 'node:path';
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { UserAdmin } from 'src/database';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { import {
AuthDto, AuthDto,
@ -17,13 +20,12 @@ import {
mapLoginResponse, mapLoginResponse,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity'; import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission, StorageFolder } from 'src/enum';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum';
import { OAuthProfile } from 'src/repositories/oauth.repository'; import { OAuthProfile } from 'src/repositories/oauth.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { isGranted } from 'src/utils/access'; import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes'; import { HumanReadableSize } from 'src/utils/bytes';
import { mimeTypes } from 'src/utils/mime-types';
export interface LoginDetails { export interface LoginDetails {
isSecure: boolean; isSecure: boolean;
clientIp: string; clientIp: string;
@ -190,7 +192,7 @@ export class AuthService extends BaseService {
const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.resolveRedirectUri(oauth, dto.url)); const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.resolveRedirectUri(oauth, dto.url));
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth; const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user = await this.userRepository.getByOAuthId(profile.sub); let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
// link by email // link by email
if (!user && profile.email) { if (!user && profile.email) {
@ -239,9 +241,36 @@ export class AuthService extends BaseService {
}); });
} }
if (!user.profileImagePath && profile.picture) {
await this.syncProfilePicture(user, profile.picture);
}
return this.createLoginResponse(user, loginDetails); return this.createLoginResponse(user, loginDetails);
} }
private async syncProfilePicture(user: UserAdmin, url: string) {
try {
const oldPath = user.profileImagePath;
const { contentType, data } = await this.oauthRepository.getProfilePicture(url);
const extensionWithDot = mimeTypes.toExtension(contentType || 'image/jpeg') ?? 'jpg';
const profileImagePath = join(
StorageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
`${this.cryptoRepository.randomUUID()}${extensionWithDot}`,
);
this.storageCore.ensureFolders(profileImagePath);
await this.storageRepository.createFile(profileImagePath, Buffer.from(data));
await this.userRepository.update(user.id, { profileImagePath, profileChangedAt: new Date() });
if (oldPath) {
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldPath] } });
}
} catch (error: Error | any) {
this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack);
}
}
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserAdminResponseDto> { async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
const { oauth } = await this.getConfig({ withCache: false }); const { oauth } = await this.getConfig({ withCache: false });
const { sub: oauthId } = await this.oauthRepository.getProfile( const { sub: oauthId } = await this.oauthRepository.getProfile(
@ -318,7 +347,7 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Invalid API key'); throw new UnauthorizedException('Invalid API key');
} }
private validatePassword(inputPassword: string, user: UserEntity): boolean { private validatePassword(inputPassword: string, user: { password?: string }): boolean {
if (!user || !user.password) { if (!user || !user.password) {
return false; return false;
} }
@ -347,7 +376,7 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Invalid user token'); throw new UnauthorizedException('Invalid user token');
} }
private async createLoginResponse(user: UserEntity, loginDetails: LoginDetails) { private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
const key = this.cryptoRepository.newPassword(32); const key = this.cryptoRepository.newPassword(32);
const token = this.cryptoRepository.hashSha256(key); const token = this.cryptoRepository.hashSha256(key);

View File

@ -4,7 +4,7 @@ import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { SALT_ROUNDS } from 'src/constants'; import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { UserEntity } from 'src/entities/user.entity'; import { UserAdmin } from 'src/database';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository'; import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@ -138,7 +138,7 @@ export class BaseService {
return checkAccess(this.accessRepository, request); return checkAccess(this.accessRepository, request);
} }
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserEntity> { async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserAdmin> {
const user = await this.userRepository.getByEmail(dto.email); const user = await this.userRepository.getByEmail(dto.email);
if (user) { if (user) {
throw new BadRequestException('User exists'); throw new BadRequestException('User exists');

View File

@ -1,5 +1,5 @@
import { CliService } from 'src/services/cli.service'; import { CliService } from 'src/services/cli.service';
import { userStub } from 'test/fixtures/user.stub'; import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
import { describe, it } from 'vitest'; import { describe, it } from 'vitest';
@ -13,7 +13,7 @@ describe(CliService.name, () => {
describe('listUsers', () => { describe('listUsers', () => {
it('should list users', async () => { it('should list users', async () => {
mocks.user.getList.mockResolvedValue([userStub.admin]); mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]);
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true }); expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true });
}); });
@ -30,8 +30,10 @@ describe(CliService.name, () => {
}); });
it('should default to a random password', async () => { it('should default to a random password', async () => {
mocks.user.getAdmin.mockResolvedValue(userStub.admin); const admin = factory.userAdmin({ isAdmin: true });
mocks.user.update.mockResolvedValue(userStub.admin);
mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
const ask = vitest.fn().mockImplementation(() => {}); const ask = vitest.fn().mockImplementation(() => {});
@ -41,13 +43,15 @@ describe(CliService.name, () => {
expect(response.provided).toBe(false); expect(response.provided).toBe(false);
expect(ask).toHaveBeenCalled(); expect(ask).toHaveBeenCalled();
expect(id).toEqual(userStub.admin.id); expect(id).toEqual(admin.id);
expect(update.password).toBeDefined(); expect(update.password).toBeDefined();
}); });
it('should use the supplied password', async () => { it('should use the supplied password', async () => {
mocks.user.getAdmin.mockResolvedValue(userStub.admin); const admin = factory.userAdmin({ isAdmin: true });
mocks.user.update.mockResolvedValue(userStub.admin);
mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(admin);
const ask = vitest.fn().mockResolvedValue('new-password'); const ask = vitest.fn().mockResolvedValue('new-password');
@ -57,7 +61,7 @@ describe(CliService.name, () => {
expect(response.provided).toBe(true); expect(response.provided).toBe(true);
expect(ask).toHaveBeenCalled(); expect(ask).toHaveBeenCalled();
expect(id).toEqual(userStub.admin.id); expect(id).toEqual(admin.id);
expect(update.password).toBeDefined(); expect(update.password).toBeDefined();
}); });
}); });

View File

@ -35,7 +35,7 @@ describe(MapService.name, () => {
it('should include partner assets', async () => { it('should include partner assets', async () => {
const partner = factory.partner(); const partner = factory.partner();
const auth = factory.auth({ id: partner.sharedWithId }); const auth = factory.auth({ user: { id: partner.sharedWithId } });
const asset = assetStub.withLocation; const asset = assetStub.withLocation;
const marker = { const marker = {

View File

@ -1,8 +1,8 @@
import { OutputInfo } from 'sharp'; import { OutputInfo } from 'sharp';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { Exif } from 'src/database';
import { AssetMediaSize } from 'src/dtos/asset-media.dto'; import { AssetMediaSize } from 'src/dtos/asset-media.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { import {
AssetFileType, AssetFileType,
AssetPathType, AssetPathType,
@ -319,7 +319,7 @@ describe(MediaService.name, () => {
it('should generate P3 thumbnails for a wide gamut image', async () => { it('should generate P3 thumbnails for a wide gamut image', async () => {
mocks.asset.getById.mockResolvedValue({ mocks.asset.getById.mockResolvedValue({
...assetStub.image, ...assetStub.image,
exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as Exif,
}); });
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
@ -2608,47 +2608,47 @@ describe(MediaService.name, () => {
describe('isSRGB', () => { describe('isSRGB', () => {
it('should return true for srgb colorspace', () => { it('should return true for srgb colorspace', () => {
const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as ExifEntity }; const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as Exif };
expect(sut.isSRGB(asset)).toEqual(true); expect(sut.isSRGB(asset)).toEqual(true);
}); });
it('should return true for srgb profile description', () => { it('should return true for srgb profile description', () => {
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as ExifEntity }; const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as Exif };
expect(sut.isSRGB(asset)).toEqual(true); expect(sut.isSRGB(asset)).toEqual(true);
}); });
it('should return true for 8-bit image with no colorspace metadata', () => { it('should return true for 8-bit image with no colorspace metadata', () => {
const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as ExifEntity }; const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as Exif };
expect(sut.isSRGB(asset)).toEqual(true); expect(sut.isSRGB(asset)).toEqual(true);
}); });
it('should return true for image with no colorspace or bit depth metadata', () => { it('should return true for image with no colorspace or bit depth metadata', () => {
const asset = { ...assetStub.image, exifInfo: {} as ExifEntity }; const asset = { ...assetStub.image, exifInfo: {} as Exif };
expect(sut.isSRGB(asset)).toEqual(true); expect(sut.isSRGB(asset)).toEqual(true);
}); });
it('should return false for non-srgb colorspace', () => { it('should return false for non-srgb colorspace', () => {
const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as ExifEntity }; const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as Exif };
expect(sut.isSRGB(asset)).toEqual(false); expect(sut.isSRGB(asset)).toEqual(false);
}); });
it('should return false for non-srgb profile description', () => { it('should return false for non-srgb profile description', () => {
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as ExifEntity }; const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as Exif };
expect(sut.isSRGB(asset)).toEqual(false); expect(sut.isSRGB(asset)).toEqual(false);
}); });
it('should return false for 16-bit image with no colorspace metadata', () => { it('should return false for 16-bit image with no colorspace metadata', () => {
const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as ExifEntity }; const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as Exif };
expect(sut.isSRGB(asset)).toEqual(false); expect(sut.isSRGB(asset)).toEqual(false);
}); });
it('should return true for 16-bit image with sRGB colorspace', () => { it('should return true for 16-bit image with sRGB colorspace', () => {
const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as ExifEntity }; const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as Exif };
expect(sut.isSRGB(asset)).toEqual(true); expect(sut.isSRGB(asset)).toEqual(true);
}); });
it('should return true for 16-bit image with sRGB profile', () => { it('should return true for 16-bit image with sRGB profile', () => {
const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as ExifEntity }; const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as Exif };
expect(sut.isSRGB(asset)).toEqual(true); expect(sut.isSRGB(asset)).toEqual(true);
}); });
}); });

View File

@ -24,7 +24,7 @@ describe(MemoryService.name, () => {
mocks.memory.search.mockResolvedValue([memory1, memory2]); mocks.memory.search.mockResolvedValue([memory1, memory2]);
await expect(sut.search(factory.auth({ id: userId }), {})).resolves.toEqual( await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ id: memory1.id, assets: [expect.objectContaining({ id: asset.id })] }), expect.objectContaining({ id: memory1.id, assets: [expect.objectContaining({ id: asset.id })] }),
expect.objectContaining({ id: memory2.id, assets: [] }), expect.objectContaining({ id: memory2.id, assets: [] }),
@ -60,7 +60,9 @@ describe(MemoryService.name, () => {
mocks.memory.get.mockResolvedValue(memory); mocks.memory.get.mockResolvedValue(memory);
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
await expect(sut.get(factory.auth({ id: userId }), memory.id)).resolves.toMatchObject({ id: memory.id }); await expect(sut.get(factory.auth({ user: { id: userId } }), memory.id)).resolves.toMatchObject({
id: memory.id,
});
expect(mocks.memory.get).toHaveBeenCalledWith(memory.id); expect(mocks.memory.get).toHaveBeenCalledWith(memory.id);
expect(mocks.access.memory.checkOwnerAccess).toHaveBeenCalledWith(memory.ownerId, new Set([memory.id])); expect(mocks.access.memory.checkOwnerAccess).toHaveBeenCalledWith(memory.ownerId, new Set([memory.id]));
@ -75,7 +77,7 @@ describe(MemoryService.name, () => {
mocks.memory.create.mockResolvedValue(memory); mocks.memory.create.mockResolvedValue(memory);
await expect( await expect(
sut.create(factory.auth({ id: userId }), { sut.create(factory.auth({ user: { id: userId } }), {
type: memory.type, type: memory.type,
data: memory.data, data: memory.data,
memoryAt: memory.memoryAt, memoryAt: memory.memoryAt,
@ -105,7 +107,7 @@ describe(MemoryService.name, () => {
mocks.memory.create.mockResolvedValue(memory); mocks.memory.create.mockResolvedValue(memory);
await expect( await expect(
sut.create(factory.auth({ id: userId }), { sut.create(factory.auth({ user: { id: userId } }), {
type: memory.type, type: memory.type,
data: memory.data, data: memory.data,
assetIds: memory.assets.map((asset) => asset.id), assetIds: memory.assets.map((asset) => asset.id),

View File

@ -3,8 +3,8 @@ import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { constants } from 'node:fs/promises'; import { constants } from 'node:fs/promises';
import { defaults } from 'src/config'; import { defaults } from 'src/config';
import { Exif } from 'src/database';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository'; import { WithoutProperty } from 'src/repositories/asset.repository';
import { ImmichTags } from 'src/repositories/metadata.repository'; import { ImmichTags } from 'src/repositories/metadata.repository';
@ -12,12 +12,34 @@ import { MetadataService } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { fileStub } from 'test/fixtures/file.stub'; import { fileStub } from 'test/fixtures/file.stub';
import { probeStub } from 'test/fixtures/media.stub'; import { probeStub } from 'test/fixtures/media.stub';
import { metadataStub } from 'test/fixtures/metadata.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
import { tagStub } from 'test/fixtures/tag.stub'; import { tagStub } from 'test/fixtures/tag.stub';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
const makeFaceTags = (face: Partial<{ Name: string }> = {}) => ({
RegionInfo: {
AppliedToDimensions: {
W: 100,
H: 100,
Unit: 'normalized',
},
RegionList: [
{
Type: 'face',
Area: {
X: 0.05,
Y: 0.05,
W: 0.1,
H: 0.1,
Unit: 'normalized',
},
...face,
},
],
},
});
describe(MetadataService.name, () => { describe(MetadataService.name, () => {
let sut: MetadataService; let sut: MetadataService;
let mocks: ServiceMocks; let mocks: ServiceMocks;
@ -969,7 +991,7 @@ describe(MetadataService.name, () => {
it('should skip importing metadata when the feature is disabled', async () => { it('should skip importing metadata when the feature is disabled', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } });
mockReadTags(metadataStub.withFace); mockReadTags(makeFaceTags({ Name: 'Person 1' }));
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); expect(mocks.person.getDistinctNames).not.toHaveBeenCalled();
}); });
@ -977,7 +999,7 @@ describe(MetadataService.name, () => {
it('should skip importing metadata face for assets without tags.RegionInfo', async () => { it('should skip importing metadata face for assets without tags.RegionInfo', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(metadataStub.empty); mockReadTags();
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); expect(mocks.person.getDistinctNames).not.toHaveBeenCalled();
}); });
@ -985,7 +1007,7 @@ describe(MetadataService.name, () => {
it('should skip importing faces without name', async () => { it('should skip importing faces without name', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(metadataStub.withFaceNoName); mockReadTags(makeFaceTags());
mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -997,7 +1019,7 @@ describe(MetadataService.name, () => {
it('should skip importing faces with empty name', async () => { it('should skip importing faces with empty name', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(metadataStub.withFaceEmptyName); mockReadTags(makeFaceTags({ Name: '' }));
mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -1009,7 +1031,7 @@ describe(MetadataService.name, () => {
it('should apply metadata face tags creating new persons', async () => { it('should apply metadata face tags creating new persons', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(metadataStub.withFace); mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([personStub.withName.id]); mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
mocks.person.update.mockResolvedValue(personStub.withName); mocks.person.update.mockResolvedValue(personStub.withName);
@ -1050,7 +1072,7 @@ describe(MetadataService.name, () => {
it('should assign metadata face tags to existing persons', async () => { it('should assign metadata face tags to existing persons', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(metadataStub.withFace); mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
mocks.person.createAll.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]);
mocks.person.update.mockResolvedValue(personStub.withName); mocks.person.update.mockResolvedValue(personStub.withName);
@ -1190,7 +1212,7 @@ describe(MetadataService.name, () => {
mocks.asset.getByIds.mockResolvedValue([ mocks.asset.getByIds.mockResolvedValue([
{ {
...assetStub.livePhotoStillAsset, ...assetStub.livePhotoStillAsset,
exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as Exif,
}, },
]); ]);
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
@ -1229,18 +1251,51 @@ describe(MetadataService.name, () => {
}); });
it.each([ it.each([
{ Make: '1', Model: '2', Device: { Manufacturer: '3', ModelName: '4' }, AndroidMake: '4', AndroidModel: '5' }, {
{ Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' }, exif: {
{ AndroidMake: '1', AndroidModel: '2' }, Make: '1',
])('should read camera make and model correct place %s', async (metaData) => { Model: '2',
Device: { Manufacturer: '3', ModelName: '4' },
AndroidMake: '4',
AndroidModel: '5',
},
expected: { make: '1', model: '2' },
},
{
exif: { Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' },
expected: { make: '1', model: '2' },
},
{ exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } },
])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
mockReadTags(metaData); mockReadTags(exif);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected));
});
it.each([
{ exif: {}, expected: null },
{ exif: { LensID: '1', LensSpec: '2', LensType: '3', LensModel: '4' }, expected: '1' },
{ exif: { LensSpec: '2', LensType: '3', LensModel: '4' }, expected: '3' },
{ exif: { LensSpec: '2', LensModel: '4' }, expected: '2' },
{ exif: { LensModel: '4' }, expected: '4' },
{ exif: { LensID: '----' }, expected: null },
{ exif: { LensID: 'Unknown (0 ff ff)' }, expected: null },
{
exif: { LensID: 'Unknown (E1 40 19 36 2C 35 DF 0E) Tamron 10-24mm f/3.5-4.5 Di II VC HLD (B023) ?' },
expected: null,
},
{ exif: { LensID: ' Unknown 6-30mm' }, expected: null },
{ exif: { LensID: '' }, expected: null },
])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
mockReadTags(exif);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
make: '1', lensModel: expected,
model: '2',
}), }),
); );
}); });

View File

@ -9,11 +9,9 @@ import { constants } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { Exif } from 'src/db'; import { AssetFaces, Exif, Person } from 'src/db';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { import {
AssetType, AssetType,
DatabaseLock, DatabaseLock,
@ -76,6 +74,19 @@ const validateRange = (value: number | undefined, min: number, max: number): Non
return val; return val;
}; };
const getLensModel = (exifTags: ImmichTags): string | null => {
const lensModel = String(
exifTags.LensID ?? exifTags.LensType ?? exifTags.LensSpec ?? exifTags.LensModel ?? '',
).trim();
if (lensModel === '----') {
return null;
}
if (lensModel.startsWith('Unknown')) {
return null;
}
return lensModel || null;
};
type ImmichTagsWithFaces = ImmichTags & { RegionInfo: NonNullable<ImmichTags['RegionInfo']> }; type ImmichTagsWithFaces = ImmichTags & { RegionInfo: NonNullable<ImmichTags['RegionInfo']> };
type Dates = { type Dates = {
@ -228,7 +239,7 @@ export class MetadataService extends BaseService {
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
iso: validate(exifTags.ISO) as number, iso: validate(exifTags.ISO) as number,
exposureTime: exifTags.ExposureTime ?? null, exposureTime: exifTags.ExposureTime ?? null,
lensModel: exifTags.LensModel ?? null, lensModel: getLensModel(exifTags),
fNumber: validate(exifTags.FNumber), fNumber: validate(exifTags.FNumber),
focalLength: validate(exifTags.FocalLength), focalLength: validate(exifTags.FocalLength),
@ -574,10 +585,10 @@ export class MetadataService extends BaseService {
return; return;
} }
const facesToAdd: (Partial<AssetFaceEntity> & { assetId: string })[] = []; const facesToAdd: (Insertable<AssetFaces> & { assetId: string })[] = [];
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
const missing: (Partial<PersonEntity> & { ownerId: string })[] = []; const missing: (Insertable<Person> & { ownerId: string })[] = [];
const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = []; const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = [];
for (const region of tags.RegionInfo.RegionList) { for (const region of tags.RegionInfo.RegionList) {
if (!region.Name) { if (!region.Name) {

View File

@ -1,8 +1,7 @@
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/config';
import { AlbumUser } from 'src/database';
import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { AlbumUserEntity } from 'src/entities/album-user.entity';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
import { EmailTemplate } from 'src/repositories/notification.repository'; import { EmailTemplate } from 'src/repositories/notification.repository';
import { NotificationService } from 'src/services/notification.service'; import { NotificationService } from 'src/services/notification.service';
@ -442,7 +441,7 @@ describe(NotificationService.name, () => {
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.asset.getById.mockResolvedValue({ mocks.asset.getById.mockResolvedValue({
...assetStub.image, ...assetStub.image,
files: [{ assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' } as AssetFileEntity], files: [{ id: '1', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' }],
}); });
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
@ -503,7 +502,7 @@ describe(NotificationService.name, () => {
it('should skip recipient that could not be looked up', async () => { it('should skip recipient that could not be looked up', async () => {
mocks.album.getById.mockResolvedValue({ mocks.album.getById.mockResolvedValue({
...albumStub.emptyWithValidThumbnail, ...albumStub.emptyWithValidThumbnail,
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
}); });
mocks.user.get.mockResolvedValueOnce(userStub.user1); mocks.user.get.mockResolvedValueOnce(userStub.user1);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
@ -516,7 +515,7 @@ describe(NotificationService.name, () => {
it('should skip recipient with disabled email notifications', async () => { it('should skip recipient with disabled email notifications', async () => {
mocks.album.getById.mockResolvedValue({ mocks.album.getById.mockResolvedValue({
...albumStub.emptyWithValidThumbnail, ...albumStub.emptyWithValidThumbnail,
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
}); });
mocks.user.get.mockResolvedValue({ mocks.user.get.mockResolvedValue({
...userStub.user1, ...userStub.user1,
@ -537,7 +536,7 @@ describe(NotificationService.name, () => {
it('should skip recipient with disabled email notifications for the album update event', async () => { it('should skip recipient with disabled email notifications for the album update event', async () => {
mocks.album.getById.mockResolvedValue({ mocks.album.getById.mockResolvedValue({
...albumStub.emptyWithValidThumbnail, ...albumStub.emptyWithValidThumbnail,
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
}); });
mocks.user.get.mockResolvedValue({ mocks.user.get.mockResolvedValue({
...userStub.user1, ...userStub.user1,
@ -558,7 +557,7 @@ describe(NotificationService.name, () => {
it('should send email', async () => { it('should send email', async () => {
mocks.album.getById.mockResolvedValue({ mocks.album.getById.mockResolvedValue({
...albumStub.emptyWithValidThumbnail, ...albumStub.emptyWithValidThumbnail,
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
}); });
mocks.user.get.mockResolvedValue(userStub.user1); mocks.user.get.mockResolvedValue(userStub.user1);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });

View File

@ -22,7 +22,7 @@ describe(PartnerService.name, () => {
const user2 = factory.user(); const user2 = factory.user();
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 }); const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 }); const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 });
const auth = factory.auth({ id: user1.id }); const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]); mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]);
@ -35,7 +35,7 @@ describe(PartnerService.name, () => {
const user2 = factory.user(); const user2 = factory.user();
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 }); const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 }); const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 });
const auth = factory.auth({ id: user1.id }); const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]); mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]);
await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
@ -48,7 +48,7 @@ describe(PartnerService.name, () => {
const user1 = factory.user(); const user1 = factory.user();
const user2 = factory.user(); const user2 = factory.user();
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
const auth = factory.auth({ id: user1.id }); const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.get.mockResolvedValue(void 0); mocks.partner.get.mockResolvedValue(void 0);
mocks.partner.create.mockResolvedValue(partner); mocks.partner.create.mockResolvedValue(partner);
@ -65,7 +65,7 @@ describe(PartnerService.name, () => {
const user1 = factory.user(); const user1 = factory.user();
const user2 = factory.user(); const user2 = factory.user();
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
const auth = factory.auth({ id: user1.id }); const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.get.mockResolvedValue(partner); mocks.partner.get.mockResolvedValue(partner);
@ -80,7 +80,7 @@ describe(PartnerService.name, () => {
const user1 = factory.user(); const user1 = factory.user();
const user2 = factory.user(); const user2 = factory.user();
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
const auth = factory.auth({ id: user1.id }); const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.get.mockResolvedValue(partner); mocks.partner.get.mockResolvedValue(partner);
@ -113,7 +113,7 @@ describe(PartnerService.name, () => {
const user1 = factory.user(); const user1 = factory.user();
const user2 = factory.user(); const user2 = factory.user();
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
const auth = factory.auth({ id: user1.id }); const auth = factory.auth({ user: { id: user1.id } });
mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id])); mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id]));
mocks.partner.update.mockResolvedValue(partner); mocks.partner.update.mockResolvedValue(partner);

View File

@ -1,7 +1,7 @@
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { AssetFace } from 'src/database';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository'; import { WithoutProperty } from 'src/repositories/asset.repository';
import { DetectedFaces } from 'src/repositories/machine-learning.repository'; import { DetectedFaces } from 'src/repositories/machine-learning.repository';
@ -11,8 +11,9 @@ import { ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { faceStub } from 'test/fixtures/face.stub'; import { faceStub } from 'test/fixtures/face.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const responseDto: PersonResponseDto = { const responseDto: PersonResponseDto = {
@ -23,6 +24,7 @@ const responseDto: PersonResponseDto = {
isHidden: false, isHidden: false,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
isFavorite: false, isFavorite: false,
color: expect.any(String),
}; };
const statistics = { assets: 3 }; const statistics = { assets: 3 };
@ -89,6 +91,7 @@ describe(PersonService.name, () => {
isHidden: true, isHidden: true,
isFavorite: false, isFavorite: false,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
color: expect.any(String),
}, },
], ],
}); });
@ -117,6 +120,7 @@ describe(PersonService.name, () => {
isHidden: false, isHidden: false,
isFavorite: true, isFavorite: true,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
color: personStub.isFavorite.color,
}, },
responseDto, responseDto,
], ],
@ -136,7 +140,6 @@ describe(PersonService.name, () => {
}); });
it('should throw a bad request when person is not found', async () => { it('should throw a bad request when person is not found', async () => {
mocks.person.getById.mockResolvedValue(null);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
@ -160,7 +163,6 @@ describe(PersonService.name, () => {
}); });
it('should throw an error when personId is invalid', async () => { it('should throw an error when personId is invalid', async () => {
mocks.person.getById.mockResolvedValue(null);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
@ -230,6 +232,7 @@ describe(PersonService.name, () => {
isHidden: false, isHidden: false,
isFavorite: false, isFavorite: false,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
color: expect.any(String),
}); });
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') }); expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
@ -345,7 +348,6 @@ describe(PersonService.name, () => {
describe('handlePersonMigration', () => { describe('handlePersonMigration', () => {
it('should not move person files', async () => { it('should not move person files', async () => {
mocks.person.getById.mockResolvedValue(null);
await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED); await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED);
}); });
}); });
@ -399,6 +401,7 @@ describe(PersonService.name, () => {
name: personStub.noName.name, name: personStub.noName.name,
thumbnailPath: personStub.noName.thumbnailPath, thumbnailPath: personStub.noName.thumbnailPath,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
color: personStub.noName.color,
}); });
expect(mocks.job.queue).not.toHaveBeenCalledWith(); expect(mocks.job.queue).not.toHaveBeenCalledWith();
@ -437,7 +440,7 @@ describe(PersonService.name, () => {
await sut.handlePersonCleanup(); await sut.handlePersonCleanup();
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath);
}); });
}); });
@ -479,7 +482,7 @@ describe(PersonService.name, () => {
await sut.handleQueueDetectFaces({ force: true }); await sut.handleQueueDetectFaces({ force: true });
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING });
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(mocks.asset.getAll).toHaveBeenCalled();
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
@ -530,7 +533,7 @@ describe(PersonService.name, () => {
data: { id: assetStub.image.id }, data: { id: assetStub.image.id },
}, },
]); ]);
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
}); });
}); });
@ -697,7 +700,7 @@ describe(PersonService.name, () => {
data: { id: faceStub.face1.id, deferred: false }, data: { id: faceStub.face1.id, deferred: false },
}, },
]); ]);
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
}); });
}); });
@ -730,7 +733,7 @@ describe(PersonService.name, () => {
id: 'asset-face-1', id: 'asset-face-1',
assetId: assetStub.noResizePath.id, assetId: assetStub.noResizePath.id,
personId: faceStub.face1.personId, personId: faceStub.face1.personId,
} as AssetFaceEntity, } as AssetFace,
], ],
}, },
]); ]);
@ -847,8 +850,8 @@ describe(PersonService.name, () => {
}); });
it('should fail if face does not have asset', async () => { it('should fail if face does not have asset', async () => {
const face = { ...faceStub.face1, asset: null } as AssetFaceEntity & { asset: null }; const face = { ...faceStub.face1, asset: null };
mocks.person.getFaceByIdWithAssets.mockResolvedValue(face); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(face);
expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED);
@ -857,7 +860,7 @@ describe(PersonService.name, () => {
}); });
it('should skip if face already has an assigned person', async () => { it('should skip if face already has an assigned person', async () => {
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.face1);
expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.SKIPPED); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.SKIPPED);
@ -879,7 +882,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
mocks.search.searchFaces.mockResolvedValue(faces); mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
@ -909,7 +912,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
mocks.search.searchFaces.mockResolvedValue(faces); mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
@ -939,7 +942,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
mocks.search.searchFaces.mockResolvedValue(faces); mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
@ -964,7 +967,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
mocks.search.searchFaces.mockResolvedValue(faces); mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
mocks.person.create.mockResolvedValue(personStub.withName); mocks.person.create.mockResolvedValue(personStub.withName);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
@ -983,7 +986,7 @@ describe(PersonService.name, () => {
const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[]; const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
mocks.search.searchFaces.mockResolvedValue(faces); mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
mocks.person.create.mockResolvedValue(personStub.withName); mocks.person.create.mockResolvedValue(personStub.withName);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
@ -1002,7 +1005,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
mocks.search.searchFaces.mockResolvedValue(faces); mocks.search.searchFaces.mockResolvedValue(faces);
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
mocks.person.create.mockResolvedValue(personStub.withName); mocks.person.create.mockResolvedValue(personStub.withName);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
@ -1024,7 +1027,7 @@ describe(PersonService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1);
mocks.person.create.mockResolvedValue(personStub.withName); mocks.person.create.mockResolvedValue(personStub.withName);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true });
@ -1046,7 +1049,6 @@ describe(PersonService.name, () => {
}); });
it('should skip a person not found', async () => { it('should skip a person not found', async () => {
mocks.person.getById.mockResolvedValue(null);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
}); });
@ -1057,30 +1059,18 @@ describe(PersonService.name, () => {
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
}); });
it('should skip a person with a face asset id not found', async () => { it('should skip a person with face not found', async () => {
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id });
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should skip a person with a face asset id without a thumbnail', async () => {
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
}); });
it('should generate a thumbnail', async () => { it('should generate a thumbnail', async () => {
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle);
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
mocks.media.generateThumbnail.mockResolvedValue(); mocks.media.generateThumbnail.mockResolvedValue();
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mocks.asset.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true }); expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
assetStub.primaryImage.originalPath, assetStub.primaryImage.originalPath,
@ -1106,9 +1096,7 @@ describe(PersonService.name, () => {
}); });
it('should generate a thumbnail without going negative', async () => { it('should generate a thumbnail without going negative', async () => {
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId }); mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart);
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.start);
mocks.asset.getById.mockResolvedValue(assetStub.image);
mocks.media.generateThumbnail.mockResolvedValue(); mocks.media.generateThumbnail.mockResolvedValue();
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
@ -1133,10 +1121,8 @@ describe(PersonService.name, () => {
}); });
it('should generate a thumbnail without overflowing', async () => { it('should generate a thumbnail without overflowing', async () => {
mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd);
mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.end);
mocks.person.update.mockResolvedValue(personStub.primaryPerson); mocks.person.update.mockResolvedValue(personStub.primaryPerson);
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
mocks.media.generateThumbnail.mockResolvedValue(); mocks.media.generateThumbnail.mockResolvedValue();
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
@ -1219,7 +1205,6 @@ describe(PersonService.name, () => {
}); });
it('should throw an error when the primary person is not found', async () => { it('should throw an error when the primary person is not found', async () => {
mocks.person.getById.mockResolvedValue(null);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
@ -1232,7 +1217,6 @@ describe(PersonService.name, () => {
it('should handle invalid merge ids', async () => { it('should handle invalid merge ids', async () => {
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
mocks.person.getById.mockResolvedValueOnce(null);
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
@ -1279,7 +1263,8 @@ describe(PersonService.name, () => {
describe('mapFace', () => { describe('mapFace', () => {
it('should map a face', () => { it('should map a face', () => {
expect(mapFaces(faceStub.face1, { user: personStub.withName.owner })).toEqual({ const authDto = factory.auth({ user: { id: faceStub.face1.person.ownerId } });
expect(mapFaces(faceStub.face1, authDto)).toEqual({
boundingBoxX1: 0, boundingBoxX1: 0,
boundingBoxX2: 1, boundingBoxX2: 1,
boundingBoxY1: 0, boundingBoxY1: 0,

View File

@ -1,6 +1,8 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Insertable, Updateable } from 'kysely';
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { AssetFaces, FaceSearch, Person } from 'src/db';
import { Chunked, OnJob } from 'src/decorators'; import { Chunked, OnJob } from 'src/decorators';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@ -21,10 +23,6 @@ import {
PersonStatisticsResponseDto, PersonStatisticsResponseDto,
PersonUpdateDto, PersonUpdateDto,
} from 'src/dtos/person.dto'; } from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { FaceSearchEntity } from 'src/entities/face-search.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { import {
AssetFileType, AssetFileType,
AssetType, AssetType,
@ -243,9 +241,9 @@ export class PersonService extends BaseService {
} }
@Chunked() @Chunked()
private async delete(people: PersonEntity[]) { private async delete(people: { id: string; thumbnailPath: string }[]) {
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath))); await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
await this.personRepository.delete(people); await this.personRepository.delete(people.map((person) => person.id));
this.logger.debug(`Deleted ${people.length} people`); this.logger.debug(`Deleted ${people.length} people`);
} }
@ -317,8 +315,8 @@ export class PersonService extends BaseService {
); );
this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`); this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`);
const facesToAdd: (Partial<AssetFaceEntity> & { id: string; assetId: string })[] = []; const facesToAdd: (Insertable<AssetFaces> & { id: string })[] = [];
const embeddings: FaceSearchEntity[] = []; const embeddings: FaceSearch[] = [];
const mlFaceIds = new Set<string>(); const mlFaceIds = new Set<string>();
for (const face of asset.faces) { for (const face of asset.faces) {
if (face.sourceType === SourceType.MACHINE_LEARNING) { if (face.sourceType === SourceType.MACHINE_LEARNING) {
@ -377,7 +375,10 @@ export class PersonService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
private iou(face: AssetFaceEntity, newBox: BoundingBox): number { private iou(
face: { boundingBoxX1: number; boundingBoxY1: number; boundingBoxX2: number; boundingBoxY2: number },
newBox: BoundingBox,
): number {
const x1 = Math.max(face.boundingBoxX1, newBox.x1); const x1 = Math.max(face.boundingBoxX1, newBox.x1);
const y1 = Math.max(face.boundingBoxY1, newBox.y1); const y1 = Math.max(face.boundingBoxY1, newBox.y1);
const x2 = Math.min(face.boundingBoxX2, newBox.x2); const x2 = Math.min(face.boundingBoxX2, newBox.x2);
@ -453,11 +454,7 @@ export class PersonService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const face = await this.personRepository.getFaceByIdWithAssets(id, { faceSearch: true }, [ const face = await this.personRepository.getFaceForFacialRecognitionJob(id);
'id',
'personId',
'sourceType',
]);
if (!face || !face.asset) { if (!face || !face.asset) {
this.logger.warn(`Face ${id} not found`); this.logger.warn(`Face ${id} not found`);
return JobStatus.FAILED; return JobStatus.FAILED;
@ -545,46 +542,23 @@ export class PersonService extends BaseService {
} }
@OnJob({ name: JobName.GENERATE_PERSON_THUMBNAIL, queue: QueueName.THUMBNAIL_GENERATION }) @OnJob({ name: JobName.GENERATE_PERSON_THUMBNAIL, queue: QueueName.THUMBNAIL_GENERATION })
async handleGeneratePersonThumbnail(data: JobOf<JobName.GENERATE_PERSON_THUMBNAIL>): Promise<JobStatus> { async handleGeneratePersonThumbnail({ id }: JobOf<JobName.GENERATE_PERSON_THUMBNAIL>): Promise<JobStatus> {
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true }); const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) { if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const person = await this.personRepository.getById(data.id); const data = await this.personRepository.getDataForThumbnailGenerationJob(id);
if (!person?.faceAssetId) { if (!data) {
this.logger.error(`Could not generate person thumbnail: person ${person?.id} has no face asset`); this.logger.error(`Could not generate person thumbnail for ${id}: missing data`);
return JobStatus.FAILED; return JobStatus.FAILED;
} }
const face = await this.personRepository.getFaceByIdWithAssets(person.faceAssetId); const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight } = data;
if (!face) {
this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`);
return JobStatus.FAILED;
}
const { const { width, height, inputPath } = await this.getInputDimensions(data);
assetId,
boundingBoxX1: x1,
boundingBoxX2: x2,
boundingBoxY1: y1,
boundingBoxY2: y2,
imageWidth: oldWidth,
imageHeight: oldHeight,
} = face;
const asset = await this.assetRepository.getById(assetId, { const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId });
exifInfo: true,
files: true,
});
if (!asset) {
this.logger.error(`Could not generate person thumbnail: asset ${assetId} does not exist`);
return JobStatus.FAILED;
}
const { width, height, inputPath } = await this.getInputDimensions(asset, { width: oldWidth, height: oldHeight });
const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
this.storageCore.ensureFolders(thumbnailPath); this.storageCore.ensureFolders(thumbnailPath);
const thumbnailOptions = { const thumbnailOptions = {
@ -597,7 +571,7 @@ export class PersonService extends BaseService {
}; };
await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath); await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath);
await this.personRepository.update({ id: person.id, thumbnailPath }); await this.personRepository.update({ id, thumbnailPath });
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@ -634,7 +608,7 @@ export class PersonService extends BaseService {
continue; continue;
} }
const update: Partial<PersonEntity> = {}; const update: Updateable<Person> & { id: string } = { id: primaryPerson.id };
if (!primaryPerson.name && mergePerson.name) { if (!primaryPerson.name && mergePerson.name) {
update.name = mergePerson.name; update.name = mergePerson.name;
} }
@ -644,7 +618,7 @@ export class PersonService extends BaseService {
} }
if (Object.keys(update).length > 0) { if (Object.keys(update).length > 0) {
primaryPerson = await this.personRepository.update({ id: primaryPerson.id, ...update }); primaryPerson = await this.personRepository.update(update);
} }
const mergeName = mergePerson.name || mergePerson.id; const mergeName = mergePerson.name || mergePerson.id;
@ -672,27 +646,26 @@ export class PersonService extends BaseService {
return person; return person;
} }
private async getInputDimensions(asset: AssetEntity, oldDims: ImageDimensions): Promise<InputDimensions> { private async getInputDimensions(asset: {
if (!asset.exifInfo?.exifImageHeight || !asset.exifInfo.exifImageWidth) { type: AssetType;
throw new Error(`Asset ${asset.id} dimensions are unknown`); exifImageWidth: number;
} exifImageHeight: number;
previewPath: string;
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW); originalPath: string;
if (!previewFile) { oldWidth: number;
throw new Error(`Asset ${asset.id} has no preview path`); oldHeight: number;
} }): Promise<InputDimensions> {
if (asset.type === AssetType.IMAGE) { if (asset.type === AssetType.IMAGE) {
let { exifImageWidth: width, exifImageHeight: height } = asset.exifInfo; let { exifImageWidth: width, exifImageHeight: height } = asset;
if (oldDims.height > oldDims.width !== height > width) { if (asset.oldHeight > asset.oldWidth !== height > width) {
[width, height] = [height, width]; [width, height] = [height, width];
} }
return { width, height, inputPath: asset.originalPath }; return { width, height, inputPath: asset.originalPath };
} }
const { width, height } = await this.mediaRepository.getImageDimensions(previewFile.path); const { width, height } = await this.mediaRepository.getImageDimensions(asset.previewPath);
return { width, height, inputPath: previewFile.path }; return { width, height, inputPath: asset.previewPath };
} }
private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions { private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions {

View File

@ -7,6 +7,7 @@ import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
describe(SharedLinkService.name, () => { describe(SharedLinkService.name, () => {
@ -46,7 +47,13 @@ describe(SharedLinkService.name, () => {
}); });
it('should not return metadata', async () => { it('should not return metadata', async () => {
const authDto = authStub.adminSharedLinkNoExif; const authDto = factory.auth({
sharedLink: {
showExif: false,
allowDownload: true,
allowUpload: true,
},
});
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
@ -208,7 +215,9 @@ describe(SharedLinkService.name, () => {
it('should update a shared link', async () => { it('should update a shared link', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid);
await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false });
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(mocks.sharedLink.update).toHaveBeenCalledWith({ expect(mocks.sharedLink.update).toHaveBeenCalledWith({
id: sharedLinkStub.valid.id, id: sharedLinkStub.valid.id,
@ -242,6 +251,7 @@ describe(SharedLinkService.name, () => {
describe('addAssets', () => { describe('addAssets', () => {
it('should not work on album shared links', async () => { it('should not work on album shared links', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
@ -273,6 +283,7 @@ describe(SharedLinkService.name, () => {
describe('removeAssets', () => { describe('removeAssets', () => {
it('should not work on album shared links', async () => { it('should not work on album shared links', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
@ -297,31 +308,39 @@ describe(SharedLinkService.name, () => {
describe('getMetadataTags', () => { describe('getMetadataTags', () => {
it('should return null when auth is not a shared link', async () => { it('should return null when auth is not a shared link', async () => {
await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null); await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null);
expect(mocks.sharedLink.get).not.toHaveBeenCalled(); expect(mocks.sharedLink.get).not.toHaveBeenCalled();
}); });
it('should return null when shared link has a password', async () => { it('should return null when shared link has a password', async () => {
await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null); const auth = factory.auth({ user: {}, sharedLink: { password: 'password' } });
await expect(sut.getMetadataTags(auth)).resolves.toBe(null);
expect(mocks.sharedLink.get).not.toHaveBeenCalled(); expect(mocks.sharedLink.get).not.toHaveBeenCalled();
}); });
it('should return metadata tags', async () => { it('should return metadata tags', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual);
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
description: '1 shared photos & videos', description: '1 shared photos & videos',
imageUrl: `https://my.immich.app/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, imageUrl: `https://my.immich.app/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`,
title: 'Public Share', title: 'Public Share',
}); });
expect(mocks.sharedLink.get).toHaveBeenCalled(); expect(mocks.sharedLink.get).toHaveBeenCalled();
}); });
it('should return metadata tags with a default image path if the asset id is not set', async () => { it('should return metadata tags with a default image path if the asset id is not set', async () => {
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] }); mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] });
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
description: '0 shared photos & videos', description: '0 shared photos & videos',
imageUrl: `https://my.immich.app/feature-panel.png`, imageUrl: `https://my.immich.app/feature-panel.png`,
title: 'Public Share', title: 'Public Share',
}); });
expect(mocks.sharedLink.get).toHaveBeenCalled(); expect(mocks.sharedLink.get).toHaveBeenCalled();
}); });
}); });

View File

@ -5,6 +5,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const motionAsset = assetStub.storageAsset({}); const motionAsset = assetStub.storageAsset({});
@ -426,15 +427,16 @@ describe(StorageTemplateService.name, () => {
}); });
it('should use the user storage label', async () => { it('should use the user storage label', async () => {
const asset = assetStub.storageAsset(); const user = factory.userAdmin({ storageLabel: 'label-1' });
const asset = assetStub.storageAsset({ ownerId: user.id });
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
mocks.user.getList.mockResolvedValue([userStub.storageLabel]); mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({ mocks.move.create.mockResolvedValue({
id: '123', id: '123',
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: asset.originalPath, oldPath: asset.originalPath,
newPath: `upload/library/user-id/2023/2023-02-23/${asset.originalFileName}`, newPath: `upload/library/${user.storageLabel}/2023/2023-02-23/${asset.originalFileName}`,
}); });
await sut.handleMigration(); await sut.handleMigration();
@ -442,11 +444,11 @@ describe(StorageTemplateService.name, () => {
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg', '/original/path.jpg',
`upload/library/label-1/2022/2022-06-19/${asset.originalFileName}`, `upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`,
); );
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: asset.id, id: asset.id,
originalPath: `upload/library/label-1/2022/2022-06-19/${asset.originalFileName}`, originalPath: `upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`,
}); });
}); });
@ -551,98 +553,106 @@ describe(StorageTemplateService.name, () => {
describe('file rename correctness', () => { describe('file rename correctness', () => {
it('should not create double extensions when filename has lower extension', async () => { it('should not create double extensions when filename has lower extension', async () => {
const user = factory.userAdmin({ storageLabel: 'label-1' });
const asset = assetStub.storageAsset({ const asset = assetStub.storageAsset({
originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.heic', ownerId: user.id,
originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
originalFileName: 'IMG_7065.HEIC', originalFileName: 'IMG_7065.HEIC',
}); });
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
mocks.user.getList.mockResolvedValue([userStub.storageLabel]); mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({ mocks.move.create.mockResolvedValue({
id: '123', id: '123',
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.heic', oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.heic', newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`,
}); });
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
'upload/library/user-id/2022/2022-06-19/IMG_7065.heic', `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
'upload/library/label-1/2022/2022-06-19/IMG_7065.heic', `upload/library/${user.storageLabel}/2022/2022-06-19/IMG_7065.heic`,
); );
}); });
it('should not create double extensions when filename has uppercase extension', async () => { it('should not create double extensions when filename has uppercase extension', async () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({ const asset = assetStub.storageAsset({
originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.HEIC', ownerId: user.id,
originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
originalFileName: 'IMG_7065.HEIC', originalFileName: 'IMG_7065.HEIC',
}); });
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
mocks.user.getList.mockResolvedValue([userStub.storageLabel]); mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({ mocks.move.create.mockResolvedValue({
id: '123', id: '123',
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.HEIC', oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.heic', newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`,
}); });
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
'upload/library/user-id/2022/2022-06-19/IMG_7065.HEIC', `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
'upload/library/label-1/2022/2022-06-19/IMG_7065.heic', `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
); );
}); });
it('should normalize the filename to lowercase (JPEG > jpg)', async () => { it('should normalize the filename to lowercase (JPEG > jpg)', async () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({ const asset = assetStub.storageAsset({
originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPEG', ownerId: user.id,
originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
originalFileName: 'IMG_7065.JPEG', originalFileName: 'IMG_7065.JPEG',
}); });
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
mocks.user.getList.mockResolvedValue([userStub.storageLabel]); mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({ mocks.move.create.mockResolvedValue({
id: '123', id: '123',
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPEG', oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.jpg', newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`,
}); });
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
'upload/library/user-id/2022/2022-06-19/IMG_7065.JPEG', `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
'upload/library/label-1/2022/2022-06-19/IMG_7065.jpg', `upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`,
); );
}); });
it('should normalize the filename to lowercase (JPG > jpg)', async () => { it('should normalize the filename to lowercase (JPG > jpg)', async () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({ const asset = assetStub.storageAsset({
ownerId: user.id,
originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG', originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG',
originalFileName: 'IMG_7065.JPG', originalFileName: 'IMG_7065.JPG',
}); });
mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset]));
mocks.user.getList.mockResolvedValue([userStub.storageLabel]); mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({ mocks.move.create.mockResolvedValue({
id: '123', id: '123',
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG', oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`,
newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.jpg', newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`,
}); });
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG', `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`,
'upload/library/label-1/2022/2022-06-19/IMG_7065.jpg', `upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`,
); );
}); });
}); });

View File

@ -39,7 +39,7 @@ describe(SyncService.name, () => {
describe('getChangesForDeltaSync', () => { describe('getChangesForDeltaSync', () => {
it('should return a response requiring a full sync when partners are out of sync', async () => { it('should return a response requiring a full sync when partners are out of sync', async () => {
const partner = factory.partner(); const partner = factory.partner();
const auth = factory.auth({ id: partner.sharedWithId }); const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.partner.getAll.mockResolvedValue([partner]); mocks.partner.getAll.mockResolvedValue([partner]);

View File

@ -3,6 +3,7 @@ import { TimeBucketSize } from 'src/repositories/asset.repository';
import { TimelineService } from 'src/services/timeline.service'; import { TimelineService } from 'src/services/timeline.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
describe(TimelineService.name, () => { describe(TimelineService.name, () => {
@ -114,15 +115,15 @@ describe(TimelineService.name, () => {
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id']));
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
const buckets = await sut.getTimeBucket( const auth = factory.auth({ sharedLink: { showExif: false } });
{ ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
{ const buckets = await sut.getTimeBucket(auth, {
size: TimeBucketSize.DAY, size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
isArchived: true, isArchived: true,
albumId: 'album-id', albumId: 'album-id',
}, });
);
expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]); expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]);
expect(buckets[0]).not.toHaveProperty('exif'); expect(buckets[0]).not.toHaveProperty('exif');
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {

View File

@ -1,5 +1,5 @@
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { UserEntity } from 'src/entities/user.entity'; import { UserAdmin } from 'src/database';
import { CacheControl, JobName, UserMetadataKey } from 'src/enum'; import { CacheControl, JobName, UserMetadataKey } from 'src/enum';
import { UserService } from 'src/services/user.service'; import { UserService } from 'src/services/user.service';
import { ImmichFileResponse } from 'src/utils/file'; import { ImmichFileResponse } from 'src/utils/file';
@ -29,7 +29,7 @@ describe(UserService.name, () => {
describe('getAll', () => { describe('getAll', () => {
it('admin should get all users', async () => { it('admin should get all users', async () => {
const user = factory.userAdmin(); const user = factory.userAdmin();
const auth = factory.auth(user); const auth = factory.auth({ user });
mocks.user.getList.mockResolvedValue([user]); mocks.user.getList.mockResolvedValue([user]);
@ -39,14 +39,12 @@ describe(UserService.name, () => {
}); });
it('non-admin should get all users when publicUsers enabled', async () => { it('non-admin should get all users when publicUsers enabled', async () => {
mocks.user.getList.mockResolvedValue([userStub.user1]); const user = factory.userAdmin();
const auth = factory.auth({ user });
await expect(sut.search(authStub.user1)).resolves.toEqual([ mocks.user.getList.mockResolvedValue([user]);
expect.objectContaining({
id: authStub.user1.user.id, await expect(sut.search(auth)).resolves.toEqual([expect.objectContaining({ id: user.id, email: user.email })]);
email: authStub.user1.user.email,
}),
]);
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false }); expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false });
}); });
@ -107,17 +105,19 @@ describe(UserService.name, () => {
it('should throw an error if the user profile could not be updated with the new image', async () => { it('should throw an error if the user profile could not be updated with the new image', async () => {
const file = { path: '/profile/path' } as Express.Multer.File; const file = { path: '/profile/path' } as Express.Multer.File;
mocks.user.get.mockResolvedValue(userStub.profilePath); const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
mocks.user.get.mockResolvedValue(user);
mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error')); mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error'));
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException); await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException);
}); });
it('should delete the previous profile image', async () => { it('should delete the previous profile image', async () => {
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
const file = { path: '/profile/path' } as Express.Multer.File; const file = { path: '/profile/path' } as Express.Multer.File;
const files = [userStub.profilePath.profileImagePath]; const files = [user.profileImagePath];
mocks.user.get.mockResolvedValue(userStub.profilePath); mocks.user.get.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
await sut.createProfileImage(authStub.admin, file); await sut.createProfileImage(authStub.admin, file);
@ -149,8 +149,10 @@ describe(UserService.name, () => {
}); });
it('should delete the profile image if user has one', async () => { it('should delete the profile image if user has one', async () => {
mocks.user.get.mockResolvedValue(userStub.profilePath); const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
const files = [userStub.profilePath.profileImagePath]; const files = [user.profileImagePath];
mocks.user.get.mockResolvedValue(user);
await sut.deleteProfileImage(authStub.admin); await sut.deleteProfileImage(authStub.admin);
@ -176,9 +178,10 @@ describe(UserService.name, () => {
}); });
it('should return the profile picture', async () => { it('should return the profile picture', async () => {
mocks.user.get.mockResolvedValue(userStub.profilePath); const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
mocks.user.get.mockResolvedValue(user);
await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual( await expect(sut.getProfileImage(user.id)).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: '/path/to/profile.jpg', path: '/path/to/profile.jpg',
contentType: 'image/jpeg', contentType: 'image/jpeg',
@ -186,7 +189,7 @@ describe(UserService.name, () => {
}), }),
); );
expect(mocks.user.get).toHaveBeenCalledWith(userStub.profilePath.id, {}); expect(mocks.user.get).toHaveBeenCalledWith(user.id, {});
}); });
}); });
@ -214,7 +217,7 @@ describe(UserService.name, () => {
describe('handleUserDelete', () => { describe('handleUserDelete', () => {
it('should skip users not ready for deletion', async () => { it('should skip users not ready for deletion', async () => {
const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserEntity; const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserAdmin;
mocks.user.get.mockResolvedValue(user); mocks.user.get.mockResolvedValue(user);
@ -225,7 +228,7 @@ describe(UserService.name, () => {
}); });
it('should delete the user and associated assets', async () => { it('should delete the user and associated assets', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserAdmin;
const options = { force: true, recursive: true }; const options = { force: true, recursive: true };
mocks.user.get.mockResolvedValue(user); mocks.user.get.mockResolvedValue(user);
@ -242,7 +245,7 @@ describe(UserService.name, () => {
}); });
it('should delete the library path for a storage label', async () => { it('should delete the library path for a storage label', async () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity; const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserAdmin;
mocks.user.get.mockResolvedValue(user); mocks.user.get.mockResolvedValue(user);

View File

@ -1,4 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Updateable } from 'kysely';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { SALT_ROUNDS } from 'src/constants'; import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
@ -8,9 +9,9 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum'; import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
import { UserFindOptions } from 'src/repositories/user.repository'; import { UserFindOptions } from 'src/repositories/user.repository';
import { UserTable } from 'src/schema/tables/user.table';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf, UserMetadataItem } from 'src/types'; import { JobOf, UserMetadataItem } from 'src/types';
import { ImmichFileResponse } from 'src/utils/file'; import { ImmichFileResponse } from 'src/utils/file';
@ -49,7 +50,7 @@ export class UserService extends BaseService {
} }
} }
const update: Partial<UserEntity> = { const update: Updateable<UserTable> = {
email: dto.email, email: dto.email,
name: dto.name, name: dto.name,
}; };
@ -229,7 +230,7 @@ export class UserService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean { private isReadyForDeletion(user: { id: string; deletedAt?: Date | null }, deleteDelay: number): boolean {
if (!user.deletedAt) { if (!user.deletedAt) {
return false; return false;
} }

View File

@ -1,9 +1,9 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core'; import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
import { AssetFile } from 'src/database';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, AssetType, Permission } from 'src/enum'; import { AssetFileType, AssetType, Permission } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard'; import { AuthRequest } from 'src/middleware/auth.guard';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
@ -20,7 +20,7 @@ export const getAssetFile = <T extends { type: AssetFileType }>(
return (files || []).find((file) => file.type === type); return (files || []).find((file) => file.type === type);
}; };
export const getAssetFiles = (files: AssetFileEntity[]) => ({ export const getAssetFiles = (files: AssetFile[]) => ({
fullsizeFile: getAssetFile(files, AssetFileType.FULLSIZE), fullsizeFile: getAssetFile(files, AssetFileType.FULLSIZE),
previewFile: getAssetFile(files, AssetFileType.PREVIEW), previewFile: getAssetFile(files, AssetFileType.PREVIEW),
thumbnailFile: getAssetFile(files, AssetFileType.THUMBNAIL), thumbnailFile: getAssetFile(files, AssetFileType.THUMBNAIL),

View File

@ -1,4 +1,4 @@
import { Expression, sql } from 'kysely'; import { Expression, ExpressionBuilder, ExpressionWrapper, Nullable, Selectable, Simplify, sql } from 'kysely';
export const asUuid = (id: string | Expression<string>) => sql<string>`${id}::uuid`; export const asUuid = (id: string | Expression<string>) => sql<string>`${id}::uuid`;
@ -17,3 +17,25 @@ export const removeUndefinedKeys = <T extends object>(update: T, template: unkno
return update; return update;
}; };
/** Modifies toJson return type to not set all properties as nullable */
export function toJson<DB, TB extends keyof DB & string, T extends TB | Expression<unknown>>(
eb: ExpressionBuilder<DB, TB>,
table: T,
) {
return eb.fn.toJson<T>(table) as ExpressionWrapper<
DB,
TB,
Simplify<
T extends TB
? Selectable<DB[T]> extends Nullable<infer N>
? N | null
: Selectable<DB[T]>
: T extends Expression<infer O>
? O extends Nullable<infer N>
? N | null
: O
: never
>
>;
}

View File

@ -101,6 +101,20 @@ describe('mimeTypes', () => {
}); });
} }
describe('toExtension', () => {
it('should get an extension for a png file', () => {
expect(mimeTypes.toExtension('image/png')).toEqual('.png');
});
it('should get an extension for a jpeg file', () => {
expect(mimeTypes.toExtension('image/jpeg')).toEqual('.jpg');
});
it('should get an extension from a webp file', () => {
expect(mimeTypes.toExtension('image/webp')).toEqual('.webp');
});
});
describe('profile', () => { describe('profile', () => {
it('should contain only lowercase mime types', () => { it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.profile); const keys = Object.keys(mimeTypes.profile);

View File

@ -55,6 +55,10 @@ const image: Record<string, string[]> = {
'.webp': ['image/webp'], '.webp': ['image/webp'],
}; };
const extensionOverrides: Record<string, string> = {
'image/jpeg': '.jpg',
};
/** /**
* list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg * list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg
* @TODO share with the client * @TODO share with the client
@ -104,6 +108,11 @@ const types = { ...image, ...video, ...sidecar };
const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r; const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r;
const lookup = (filename: string) => types[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream'; const lookup = (filename: string) => types[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream';
const toExtension = (mimeType: string) => {
return (
extensionOverrides[mimeType] || Object.entries(types).find(([, mimeTypes]) => mimeTypes.includes(mimeType))?.[0]
);
};
export const mimeTypes = { export const mimeTypes = {
image, image,
@ -120,6 +129,8 @@ export const mimeTypes = {
isVideo: (filename: string) => isType(filename, video), isVideo: (filename: string) => isType(filename, video),
isRaw: (filename: string) => isType(filename, raw), isRaw: (filename: string) => isType(filename, raw),
lookup, lookup,
/** return an extension (including a leading `.`) for a mime-type */
toExtension,
assetType: (filename: string) => { assetType: (filename: string) => {
const contentType = lookup(filename); const contentType = lookup(filename);
if (contentType.startsWith('image/')) { if (contentType.startsWith('image/')) {

View File

@ -38,10 +38,7 @@ export const albumStub = {
albumUsers: [ albumUsers: [
{ {
user: userStub.user1, user: userStub.user1,
album: undefined as unknown as AlbumEntity,
role: AlbumUserRole.EDITOR, role: AlbumUserRole.EDITOR,
userId: userStub.user1.id,
albumId: 'album-2',
}, },
], ],
isActivityEnabled: true, isActivityEnabled: true,
@ -63,17 +60,11 @@ export const albumStub = {
albumUsers: [ albumUsers: [
{ {
user: userStub.user1, user: userStub.user1,
album: undefined as unknown as AlbumEntity,
role: AlbumUserRole.EDITOR, role: AlbumUserRole.EDITOR,
userId: userStub.user1.id,
albumId: 'album-3',
}, },
{ {
user: userStub.user2, user: userStub.user2,
album: undefined as unknown as AlbumEntity,
role: AlbumUserRole.EDITOR, role: AlbumUserRole.EDITOR,
userId: userStub.user2.id,
albumId: 'album-3',
}, },
], ],
isActivityEnabled: true, isActivityEnabled: true,
@ -95,10 +86,7 @@ export const albumStub = {
albumUsers: [ albumUsers: [
{ {
user: userStub.admin, user: userStub.admin,
album: undefined as unknown as AlbumEntity,
role: AlbumUserRole.EDITOR, role: AlbumUserRole.EDITOR,
userId: userStub.admin.id,
albumId: 'album-3',
}, },
], ],
isActivityEnabled: true, isActivityEnabled: true,

View File

@ -1,6 +1,5 @@
import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFile, Exif } from 'src/database';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { StackEntity } from 'src/entities/stack.entity'; import { StackEntity } from 'src/entities/stack.entity';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { StorageAsset } from 'src/types'; import { StorageAsset } from 'src/types';
@ -8,40 +7,30 @@ import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub'; import { fileStub } from 'test/fixtures/file.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
const previewFile: AssetFileEntity = { export const previewFile: AssetFile = {
id: 'file-1', id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.PREVIEW, type: AssetFileType.PREVIEW,
path: '/uploads/user-id/thumbs/path.jpg', path: '/uploads/user-id/thumbs/path.jpg',
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
}; };
const thumbnailFile: AssetFileEntity = { const thumbnailFile: AssetFile = {
id: 'file-2', id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.THUMBNAIL, type: AssetFileType.THUMBNAIL,
path: '/uploads/user-id/webp/path.ext', path: '/uploads/user-id/webp/path.ext',
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
}; };
const fullsizeFile: AssetFileEntity = { const fullsizeFile: AssetFile = {
id: 'file-3', id: 'file-3',
assetId: 'asset-id',
type: AssetFileType.FULLSIZE, type: AssetFileType.FULLSIZE,
path: '/uploads/user-id/fullsize/path.webp', path: '/uploads/user-id/fullsize/path.webp',
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
}; };
const files: AssetFileEntity[] = [fullsizeFile, previewFile, thumbnailFile]; const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => { export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => {
return { return {
id: stackId, id: stackId,
assets, assets,
owner: assets[0].owner,
ownerId: assets[0].ownerId, ownerId: assets[0].ownerId,
primaryAsset: assets[0], primaryAsset: assets[0],
primaryAssetId: assets[0].id, primaryAssetId: assets[0].id,
@ -129,7 +118,7 @@ export const assetStub = {
isExternal: false, isExternal: false,
exifInfo: { exifInfo: {
fileSizeInByte: 123_000, fileSizeInByte: 123_000,
} as ExifEntity, } as Exif,
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
@ -203,7 +192,7 @@ export const assetStub = {
fileSizeInByte: 5000, fileSizeInByte: 5000,
exifImageHeight: 1000, exifImageHeight: 1000,
exifImageWidth: 1000, exifImageWidth: 1000,
} as ExifEntity, } as Exif,
stackId: 'stack-1', stackId: 'stack-1',
stack: stackStub('stack-1', [ stack: stackStub('stack-1', [
{ id: 'primary-asset-id' } as AssetEntity, { id: 'primary-asset-id' } as AssetEntity,
@ -248,7 +237,7 @@ export const assetStub = {
fileSizeInByte: 5000, fileSizeInByte: 5000,
exifImageHeight: 3840, exifImageHeight: 3840,
exifImageWidth: 2160, exifImageWidth: 2160,
} as ExifEntity, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
}), }),
@ -286,7 +275,7 @@ export const assetStub = {
fileSizeInByte: 5000, fileSizeInByte: 5000,
exifImageHeight: 3840, exifImageHeight: 3840,
exifImageWidth: 2160, exifImageWidth: 2160,
} as ExifEntity, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
status: AssetStatus.TRASHED, status: AssetStatus.TRASHED,
@ -327,7 +316,7 @@ export const assetStub = {
fileSizeInByte: 5000, fileSizeInByte: 5000,
exifImageHeight: 3840, exifImageHeight: 3840,
exifImageWidth: 2160, exifImageWidth: 2160,
} as ExifEntity, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: true, isOffline: true,
}), }),
@ -365,7 +354,7 @@ export const assetStub = {
fileSizeInByte: 5000, fileSizeInByte: 5000,
exifImageHeight: 3840, exifImageHeight: 3840,
exifImageWidth: 2160, exifImageWidth: 2160,
} as ExifEntity, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
}), }),
@ -403,7 +392,7 @@ export const assetStub = {
sidecarPath: null, sidecarPath: null,
exifInfo: { exifInfo: {
fileSizeInByte: 5000, fileSizeInByte: 5000,
} as ExifEntity, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
}), }),
@ -440,7 +429,7 @@ export const assetStub = {
sidecarPath: null, sidecarPath: null,
exifInfo: { exifInfo: {
fileSizeInByte: 5000, fileSizeInByte: 5000,
} as ExifEntity, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
}), }),
@ -476,7 +465,7 @@ export const assetStub = {
sidecarPath: null, sidecarPath: null,
exifInfo: { exifInfo: {
fileSizeInByte: 5000, fileSizeInByte: 5000,
} as ExifEntity, } as Exif,
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
@ -515,7 +504,7 @@ export const assetStub = {
fileSizeInByte: 100_000, fileSizeInByte: 100_000,
exifImageHeight: 2160, exifImageHeight: 2160,
exifImageWidth: 3840, exifImageWidth: 3840,
} as ExifEntity, } as Exif,
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
@ -606,7 +595,7 @@ export const assetStub = {
city: 'test-city', city: 'test-city',
state: 'test-state', state: 'test-state',
country: 'test-country', country: 'test-country',
} as ExifEntity, } as Exif,
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
@ -711,7 +700,7 @@ export const assetStub = {
sidecarPath: null, sidecarPath: null,
exifInfo: { exifInfo: {
fileSizeInByte: 100_000, fileSizeInByte: 100_000,
} as ExifEntity, } as Exif,
deletedAt: null, deletedAt: null,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
@ -750,7 +739,7 @@ export const assetStub = {
sidecarPath: null, sidecarPath: null,
exifInfo: { exifInfo: {
fileSizeInByte: 5000, fileSizeInByte: 5000,
} as ExifEntity, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
}), }),
@ -789,7 +778,7 @@ export const assetStub = {
fileSizeInByte: 5000, fileSizeInByte: 5000,
profileDescription: 'Adobe RGB', profileDescription: 'Adobe RGB',
bitsPerSample: 14, bitsPerSample: 14,
} as ExifEntity, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
}), }),
@ -828,7 +817,7 @@ export const assetStub = {
fileSizeInByte: 5000, fileSizeInByte: 5000,
profileDescription: 'Adobe RGB', profileDescription: 'Adobe RGB',
bitsPerSample: 14, bitsPerSample: 14,
} as ExifEntity, } as Exif,
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
}), }),

View File

@ -52,24 +52,4 @@ export const authStub = {
key: Buffer.from('shared-link-key'), key: Buffer.from('shared-link-key'),
} as SharedLinkEntity, } as SharedLinkEntity,
}), }),
adminSharedLinkNoExif: Object.freeze<AuthDto>({
user: authUser.admin,
sharedLink: {
id: '123',
showExif: false,
allowDownload: true,
allowUpload: true,
key: Buffer.from('shared-link-key'),
} as SharedLinkEntity,
}),
passwordSharedLink: Object.freeze<AuthDto>({
user: authUser.admin,
sharedLink: {
id: '123',
allowUpload: false,
allowDownload: false,
password: 'password-123',
showExif: true,
} as SharedLinkEntity,
}),
}; };

View File

@ -1,15 +1,17 @@
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { SourceType } from 'src/enum'; import { SourceType } from 'src/enum';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
type NonNullableProperty<T> = { [P in keyof T]: NonNullable<T[P]> };
export const faceStub = { export const faceStub = {
face1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({ face1: Object.freeze({
id: 'assetFaceId1', id: 'assetFaceId1',
assetId: assetStub.image.id, assetId: assetStub.image.id,
asset: assetStub.image, asset: {
...assetStub.image,
libraryId: null,
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
stackId: null,
},
personId: personStub.withName.id, personId: personStub.withName.id,
person: personStub.withName, person: personStub.withName,
boundingBoxX1: 0, boundingBoxX1: 0,
@ -22,7 +24,7 @@ export const faceStub = {
faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' },
deletedAt: new Date(), deletedAt: new Date(),
}), }),
primaryFace1: Object.freeze<AssetFaceEntity>({ primaryFace1: Object.freeze({
id: 'assetFaceId2', id: 'assetFaceId2',
assetId: assetStub.image.id, assetId: assetStub.image.id,
asset: assetStub.image, asset: assetStub.image,
@ -38,7 +40,7 @@ export const faceStub = {
faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' },
deletedAt: null, deletedAt: null,
}), }),
mergeFace1: Object.freeze<AssetFaceEntity>({ mergeFace1: Object.freeze({
id: 'assetFaceId3', id: 'assetFaceId3',
assetId: assetStub.image.id, assetId: assetStub.image.id,
asset: assetStub.image, asset: assetStub.image,
@ -54,55 +56,7 @@ export const faceStub = {
faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' },
deletedAt: null, deletedAt: null,
}), }),
start: Object.freeze<AssetFaceEntity>({ noPerson1: Object.freeze({
id: 'assetFaceId5',
assetId: assetStub.image.id,
asset: assetStub.image,
personId: personStub.newThumbnail.id,
person: personStub.newThumbnail,
boundingBoxX1: 5,
boundingBoxY1: 5,
boundingBoxX2: 505,
boundingBoxY2: 505,
imageHeight: 2880,
imageWidth: 2160,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId5', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
}),
middle: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId6',
assetId: assetStub.image.id,
asset: assetStub.image,
personId: personStub.newThumbnail.id,
person: personStub.newThumbnail,
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageHeight: 500,
imageWidth: 400,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId6', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
}),
end: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId7',
assetId: assetStub.image.id,
asset: assetStub.image,
personId: personStub.newThumbnail.id,
person: personStub.newThumbnail,
boundingBoxX1: 300,
boundingBoxY1: 300,
boundingBoxX2: 495,
boundingBoxY2: 495,
imageHeight: 500,
imageWidth: 500,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId7', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
}),
noPerson1: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId8', id: 'assetFaceId8',
assetId: assetStub.image.id, assetId: assetStub.image.id,
asset: assetStub.image, asset: assetStub.image,
@ -118,7 +72,7 @@ export const faceStub = {
faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' },
deletedAt: null, deletedAt: null,
}), }),
noPerson2: Object.freeze<AssetFaceEntity>({ noPerson2: Object.freeze({
id: 'assetFaceId9', id: 'assetFaceId9',
assetId: assetStub.image.id, assetId: assetStub.image.id,
asset: assetStub.image, asset: assetStub.image,
@ -134,7 +88,7 @@ export const faceStub = {
faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' },
deletedAt: null, deletedAt: null,
}), }),
fromExif1: Object.freeze<AssetFaceEntity>({ fromExif1: Object.freeze({
id: 'assetFaceId9', id: 'assetFaceId9',
assetId: assetStub.image.id, assetId: assetStub.image.id,
asset: assetStub.image, asset: assetStub.image,
@ -149,7 +103,7 @@ export const faceStub = {
sourceType: SourceType.EXIF, sourceType: SourceType.EXIF,
deletedAt: null, deletedAt: null,
}), }),
fromExif2: Object.freeze<AssetFaceEntity>({ fromExif2: Object.freeze({
id: 'assetFaceId9', id: 'assetFaceId9',
assetId: assetStub.image.id, assetId: assetStub.image.id,
asset: assetStub.image, asset: assetStub.image,
@ -164,7 +118,7 @@ export const faceStub = {
sourceType: SourceType.EXIF, sourceType: SourceType.EXIF,
deletedAt: null, deletedAt: null,
}), }),
withBirthDate: Object.freeze<AssetFaceEntity>({ withBirthDate: Object.freeze({
id: 'assetFaceId10', id: 'assetFaceId10',
assetId: assetStub.image.id, assetId: assetStub.image.id,
asset: assetStub.image, asset: assetStub.image,

View File

@ -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',
},
},
],
},
}),
};

View File

@ -1,13 +1,16 @@
import { PersonEntity } from 'src/entities/person.entity'; import { AssetType } from 'src/enum';
import { previewFile } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125';
export const personStub = { export const personStub = {
noName: Object.freeze<PersonEntity>({ noName: Object.freeze({
id: 'person-1', id: 'person-1',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id, ownerId: userStub.admin.id,
owner: userStub.admin,
name: '', name: '',
birthDate: null, birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg', thumbnailPath: '/path/to/thumbnail.jpg',
@ -16,13 +19,14 @@ export const personStub = {
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
isFavorite: false, isFavorite: false,
color: 'red',
}), }),
hidden: Object.freeze<PersonEntity>({ hidden: Object.freeze({
id: 'person-1', id: 'person-1',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id, ownerId: userStub.admin.id,
owner: userStub.admin,
name: '', name: '',
birthDate: null, birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg', thumbnailPath: '/path/to/thumbnail.jpg',
@ -31,13 +35,14 @@ export const personStub = {
faceAsset: null, faceAsset: null,
isHidden: true, isHidden: true,
isFavorite: false, isFavorite: false,
color: 'red',
}), }),
withName: Object.freeze<PersonEntity>({ withName: Object.freeze({
id: 'person-1', id: 'person-1',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id, ownerId: userStub.admin.id,
owner: userStub.admin,
name: 'Person 1', name: 'Person 1',
birthDate: null, birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg', thumbnailPath: '/path/to/thumbnail.jpg',
@ -46,28 +51,30 @@ export const personStub = {
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
isFavorite: false, isFavorite: false,
color: 'red',
}), }),
withBirthDate: Object.freeze<PersonEntity>({ withBirthDate: Object.freeze({
id: 'person-1', id: 'person-1',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id, ownerId: userStub.admin.id,
owner: userStub.admin,
name: 'Person 1', name: 'Person 1',
birthDate: '1976-06-30', birthDate: new Date('1976-06-30'),
thumbnailPath: '/path/to/thumbnail.jpg', thumbnailPath: '/path/to/thumbnail.jpg',
faces: [], faces: [],
faceAssetId: null, faceAssetId: null,
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
isFavorite: false, isFavorite: false,
color: 'red',
}), }),
noThumbnail: Object.freeze<PersonEntity>({ noThumbnail: Object.freeze({
id: 'person-1', id: 'person-1',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id, ownerId: userStub.admin.id,
owner: userStub.admin,
name: '', name: '',
birthDate: null, birthDate: null,
thumbnailPath: '', thumbnailPath: '',
@ -76,13 +83,14 @@ export const personStub = {
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
isFavorite: false, isFavorite: false,
color: 'red',
}), }),
newThumbnail: Object.freeze<PersonEntity>({ newThumbnail: Object.freeze({
id: 'person-1', id: 'person-1',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id, ownerId: userStub.admin.id,
owner: userStub.admin,
name: '', name: '',
birthDate: null, birthDate: null,
thumbnailPath: '/new/path/to/thumbnail.jpg', thumbnailPath: '/new/path/to/thumbnail.jpg',
@ -91,13 +99,14 @@ export const personStub = {
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
isFavorite: false, isFavorite: false,
color: 'red',
}), }),
primaryPerson: Object.freeze<PersonEntity>({ primaryPerson: Object.freeze({
id: 'person-1', id: 'person-1',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id, ownerId: userStub.admin.id,
owner: userStub.admin,
name: 'Person 1', name: 'Person 1',
birthDate: null, birthDate: null,
thumbnailPath: '/path/to/thumbnail', thumbnailPath: '/path/to/thumbnail',
@ -106,13 +115,14 @@ export const personStub = {
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
isFavorite: false, isFavorite: false,
color: 'red',
}), }),
mergePerson: Object.freeze<PersonEntity>({ mergePerson: Object.freeze({
id: 'person-2', id: 'person-2',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id, ownerId: userStub.admin.id,
owner: userStub.admin,
name: 'Person 2', name: 'Person 2',
birthDate: null, birthDate: null,
thumbnailPath: '/path/to/thumbnail', thumbnailPath: '/path/to/thumbnail',
@ -121,13 +131,14 @@ export const personStub = {
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
isFavorite: false, isFavorite: false,
color: 'red',
}), }),
randomPerson: Object.freeze<PersonEntity>({ randomPerson: Object.freeze({
id: 'person-3', id: 'person-3',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id, ownerId: userStub.admin.id,
owner: userStub.admin,
name: '', name: '',
birthDate: null, birthDate: null,
thumbnailPath: '/path/to/thumbnail', thumbnailPath: '/path/to/thumbnail',
@ -136,13 +147,14 @@ export const personStub = {
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
isFavorite: false, isFavorite: false,
color: 'red',
}), }),
isFavorite: Object.freeze<PersonEntity>({ isFavorite: Object.freeze({
id: 'person-4', id: 'person-4',
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id, ownerId: userStub.admin.id,
owner: userStub.admin,
name: 'Person 1', name: 'Person 1',
birthDate: null, birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg', thumbnailPath: '/path/to/thumbnail.jpg',
@ -151,5 +163,51 @@ export const personStub = {
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
isFavorite: true, isFavorite: true,
color: 'red',
}),
};
export const personThumbnailStub = {
newThumbnailStart: Object.freeze({
ownerId: userStub.admin.id,
x1: 5,
y1: 5,
x2: 505,
y2: 505,
oldHeight: 2880,
oldWidth: 2160,
type: AssetType.IMAGE,
originalPath: '/original/path.jpg',
exifImageHeight: 3840,
exifImageWidth: 2160,
previewPath: previewFile.path,
}),
newThumbnailMiddle: Object.freeze({
ownerId: userStub.admin.id,
x1: 100,
y1: 100,
x2: 200,
y2: 200,
oldHeight: 500,
oldWidth: 400,
type: AssetType.IMAGE,
originalPath: '/original/path.jpg',
exifImageHeight: 1000,
exifImageWidth: 1000,
previewPath: previewFile.path,
}),
newThumbnailEnd: Object.freeze({
ownerId: userStub.admin.id,
x1: 300,
y1: 300,
x2: 495,
y2: 495,
oldHeight: 500,
oldWidth: 500,
type: AssetType.IMAGE,
originalPath: '/original/path.jpg',
exifImageHeight: 1000,
exifImageWidth: 1000,
previewPath: previewFile.path,
}), }),
}; };

View File

@ -1,10 +1,10 @@
import { UserAdmin } from 'src/database';
import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { ExifResponseDto } from 'src/dtos/exif.dto'; import { ExifResponseDto } from 'src/dtos/exif.dto';
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { mapUser } from 'src/dtos/user.dto'; import { mapUser } from 'src/dtos/user.dto';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum'; import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
@ -106,7 +106,6 @@ export const sharedLinkStub = {
individual: Object.freeze({ individual: Object.freeze({
id: '123', id: '123',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
user: userStub.admin,
key: sharedLinkBytes, key: sharedLinkBytes,
type: SharedLinkType.INDIVIDUAL, type: SharedLinkType.INDIVIDUAL,
createdAt: today, createdAt: today,
@ -154,7 +153,6 @@ export const sharedLinkStub = {
readonlyNoExif: Object.freeze<SharedLinkEntity>({ readonlyNoExif: Object.freeze<SharedLinkEntity>({
id: '123', id: '123',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
user: userStub.admin,
key: sharedLinkBytes, key: sharedLinkBytes,
type: SharedLinkType.ALBUM, type: SharedLinkType.ALBUM,
createdAt: today, createdAt: today,
@ -185,7 +183,7 @@ export const sharedLinkStub = {
{ {
id: 'id_1', id: 'id_1',
status: AssetStatus.ACTIVE, status: AssetStatus.ACTIVE,
owner: undefined as unknown as UserEntity, owner: undefined as unknown as UserAdmin,
ownerId: 'user_id_1', ownerId: 'user_id_1',
deviceAssetId: 'device_asset_id_1', deviceAssetId: 'device_asset_id_1',
deviceId: 'device_id_1', deviceId: 'device_id_1',
@ -234,7 +232,6 @@ export const sharedLinkStub = {
iso: 100, iso: 100,
exposureTime: '1/16', exposureTime: '1/16',
fps: 100, fps: 100,
asset: null as any,
profileDescription: 'sRGB', profileDescription: 'sRGB',
bitsPerSample: 8, bitsPerSample: 8,
colorspace: 'sRGB', colorspace: 'sRGB',
@ -253,7 +250,6 @@ export const sharedLinkStub = {
passwordRequired: Object.freeze<SharedLinkEntity>({ passwordRequired: Object.freeze<SharedLinkEntity>({
id: '123', id: '123',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
user: userStub.admin,
key: sharedLinkBytes, key: sharedLinkBytes,
type: SharedLinkType.ALBUM, type: SharedLinkType.ALBUM,
createdAt: today, createdAt: today,

View File

@ -1,13 +1,12 @@
import { UserEntity } from 'src/entities/user.entity'; import { UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
export const userStub = { export const userStub = {
admin: Object.freeze<UserEntity>({ admin: <UserAdmin>{
...authStub.admin.user, ...authStub.admin.user,
status: UserStatus.ACTIVE, status: UserStatus.ACTIVE,
profileChangedAt: new Date('2021-01-01'), profileChangedAt: new Date('2021-01-01'),
password: 'admin_password',
name: 'admin_name', name: 'admin_name',
id: 'admin_id', id: 'admin_id',
storageLabel: 'admin', storageLabel: 'admin',
@ -17,16 +16,14 @@ export const userStub = {
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
deletedAt: null, deletedAt: null,
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
assets: [],
metadata: [], metadata: [],
quotaSizeInBytes: null, quotaSizeInBytes: null,
quotaUsageInBytes: 0, quotaUsageInBytes: 0,
}), },
user1: Object.freeze<UserEntity>({ user1: <UserAdmin>{
...authStub.user1.user, ...authStub.user1.user,
status: UserStatus.ACTIVE, status: UserStatus.ACTIVE,
profileChangedAt: new Date('2021-01-01'), profileChangedAt: new Date('2021-01-01'),
password: 'immich_password',
name: 'immich_name', name: 'immich_name',
storageLabel: null, storageLabel: null,
oauthId: '', oauthId: '',
@ -35,7 +32,6 @@ export const userStub = {
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
deletedAt: null, deletedAt: null,
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
assets: [],
metadata: [ metadata: [
{ {
key: UserMetadataKey.PREFERENCES, key: UserMetadataKey.PREFERENCES,
@ -44,13 +40,12 @@ export const userStub = {
], ],
quotaSizeInBytes: null, quotaSizeInBytes: null,
quotaUsageInBytes: 0, quotaUsageInBytes: 0,
}), },
user2: Object.freeze<UserEntity>({ user2: <UserAdmin>{
...authStub.user2.user, ...authStub.user2.user,
status: UserStatus.ACTIVE, status: UserStatus.ACTIVE,
profileChangedAt: new Date('2021-01-01'), profileChangedAt: new Date('2021-01-01'),
metadata: [], metadata: [],
password: 'immich_password',
name: 'immich_name', name: 'immich_name',
storageLabel: null, storageLabel: null,
oauthId: '', oauthId: '',
@ -59,44 +54,7 @@ export const userStub = {
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
deletedAt: null, deletedAt: null,
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
assets: [],
quotaSizeInBytes: null, quotaSizeInBytes: null,
quotaUsageInBytes: 0, quotaUsageInBytes: 0,
}), },
storageLabel: Object.freeze<UserEntity>({
...authStub.user1.user,
status: UserStatus.ACTIVE,
profileChangedAt: new Date('2021-01-01'),
metadata: [],
password: 'immich_password',
name: 'immich_name',
storageLabel: 'label-1',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
assets: [],
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}),
profilePath: Object.freeze<UserEntity>({
...authStub.user1.user,
status: UserStatus.ACTIVE,
profileChangedAt: new Date('2021-01-01'),
metadata: [],
password: 'immich_password',
name: 'immich_name',
storageLabel: 'label-1',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '/path/to/profile.jpg',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
assets: [],
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}),
}; };

View File

@ -14,7 +14,8 @@ export const newPersonRepositoryMock = (): Mocked<RepositoryInterface<PersonRepo
getAllWithoutFaces: vitest.fn(), getAllWithoutFaces: vitest.fn(),
getFaces: vitest.fn(), getFaces: vitest.fn(),
getFaceById: vitest.fn(), getFaceById: vitest.fn(),
getFaceByIdWithAssets: vitest.fn(), getFaceForFacialRecognitionJob: vitest.fn(),
getDataForThumbnailGenerationJob: vitest.fn(),
reassignFace: vitest.fn(), reassignFace: vitest.fn(),
getById: vitest.fn(), getById: vitest.fn(),
getByName: vitest.fn(), getByName: vitest.fn(),

View File

@ -4,6 +4,7 @@ import {
ApiKey, ApiKey,
Asset, Asset,
AuthApiKey, AuthApiKey,
AuthSharedLink,
AuthUser, AuthUser,
Library, Library,
Memory, Memory,
@ -35,12 +36,20 @@ export const newEmbedding = () => {
const authFactory = ({ const authFactory = ({
apiKey, apiKey,
session, session,
...user sharedLink,
}: Partial<AuthUser> & { apiKey?: Partial<AuthApiKey>; session?: { id: string } } = {}) => { user,
}: {
apiKey?: Partial<AuthApiKey>;
session?: { id: string };
user?: Partial<UserAdmin>;
sharedLink?: Partial<AuthSharedLink>;
} = {}) => {
const auth: AuthDto = { const auth: AuthDto = {
user: authUserFactory(user), user: authUserFactory(userAdminFactory(user ?? {})),
}; };
const userId = auth.user.id;
if (apiKey) { if (apiKey) {
auth.apiKey = authApiKeyFactory(apiKey); auth.apiKey = authApiKeyFactory(apiKey);
} }
@ -49,24 +58,45 @@ const authFactory = ({
auth.session = { id: session.id }; auth.session = { id: session.id };
} }
if (sharedLink) {
auth.sharedLink = authSharedLinkFactory({ ...sharedLink, userId });
}
return auth; return auth;
}; };
const authSharedLinkFactory = (sharedLink: Partial<AuthSharedLink> = {}) => {
const {
id = newUuid(),
expiresAt = null,
userId = newUuid(),
showExif = true,
allowUpload = false,
allowDownload = true,
password = null,
} = sharedLink;
return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password };
};
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({ const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({
id: newUuid(), id: newUuid(),
permissions: [Permission.ALL], permissions: [Permission.ALL],
...apiKey, ...apiKey,
}); });
const authUserFactory = (authUser: Partial<AuthUser> = {}) => ({ const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
id: newUuid(), const {
isAdmin: false, id = newUuid(),
name: 'Test User', isAdmin = false,
email: 'test@immich.cloud', name = 'Test User',
quotaUsageInBytes: 0, email = 'test@immich.cloud',
quotaSizeInBytes: null, quotaUsageInBytes = 0,
...authUser, quotaSizeInBytes = null,
}); } = authUser;
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes };
};
const partnerFactory = (partner: Partial<Partner> = {}) => { const partnerFactory = (partner: Partial<Partner> = {}) => {
const sharedBy = userFactory(partner.sharedBy || {}); const sharedBy = userFactory(partner.sharedBy || {});
@ -112,25 +142,44 @@ const userFactory = (user: Partial<User> = {}) => ({
...user, ...user,
}); });
const userAdminFactory = (user: Partial<UserAdmin> = {}) => ({ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
id: newUuid(), const {
name: 'Test User', id = newUuid(),
email: 'test@immich.cloud', name = 'Test User',
profileImagePath: '', email = 'test@immich.cloud',
profileChangedAt: newDate(), profileImagePath = '',
storageLabel: null, profileChangedAt = newDate(),
shouldChangePassword: false, storageLabel = null,
isAdmin: false, shouldChangePassword = false,
createdAt: newDate(), isAdmin = false,
updatedAt: newDate(), createdAt = newDate(),
deletedAt: null, updatedAt = newDate(),
oauthId: '', deletedAt = null,
quotaSizeInBytes: null, oauthId = '',
quotaUsageInBytes: 0, quotaSizeInBytes = null,
status: UserStatus.ACTIVE, quotaUsageInBytes = 0,
metadata: [], status = UserStatus.ACTIVE,
...user, metadata = [],
}); } = user;
return {
id,
name,
email,
profileImagePath,
profileChangedAt,
storageLabel,
shouldChangePassword,
isAdmin,
createdAt,
updatedAt,
deletedAt,
oauthId,
quotaSizeInBytes,
quotaUsageInBytes,
status,
metadata,
};
};
const assetFactory = (asset: Partial<Asset> = {}) => ({ const assetFactory = (asset: Partial<Asset> = {}) => ({
id: newUuid(), id: newUuid(),

6
web/package-lock.json generated
View File

@ -9492,9 +9492,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.2.5", "version": "6.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -1,17 +1,20 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import SharedLinkCopy from '$lib/components/sharedlinks-page/actions/shared-link-copy.svelte'; import SharedLinkCopy from '$lib/components/sharedlinks-page/actions/shared-link-copy.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import type { AlbumResponseDto, SharedLinkResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { Text } from '@immich/ui'; import { Text } from '@immich/ui';
import { mdiQrcode } from '@mdi/js';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
album: AlbumResponseDto; album: AlbumResponseDto;
sharedLink: SharedLinkResponseDto; sharedLink: SharedLinkResponseDto;
onViewQrCode: () => void;
}; };
const { album, sharedLink }: Props = $props(); const { album, sharedLink, onViewQrCode }: Props = $props();
const getShareProperties = () => const getShareProperties = () =>
[ [
@ -37,5 +40,8 @@
<Text size="small">{sharedLink.description || album.albumName}</Text> <Text size="small">{sharedLink.description || album.albumName}</Text>
<Text size="tiny" color="muted">{getShareProperties()}</Text> <Text size="tiny" color="muted">{getShareProperties()}</Text>
</div> </div>
<SharedLinkCopy link={sharedLink} /> <div class="flex">
<CircleIconButton title={$t('view_qr_code')} icon={mdiQrcode} onclick={onViewQrCode} />
<SharedLinkCopy link={sharedLink} />
</div>
</div> </div>

View File

@ -3,7 +3,10 @@
import Dropdown from '$lib/components/elements/dropdown.svelte'; import Dropdown from '$lib/components/elements/dropdown.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import QrCodeModal from '$lib/components/shared-components/qr-code-modal.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { serverConfig } from '$lib/stores/server-config.store';
import { makeSharedLinkUrl } from '$lib/utils';
import { import {
AlbumUserRole, AlbumUserRole,
getAllSharedLinks, getAllSharedLinks,
@ -31,6 +34,11 @@
let users: UserResponseDto[] = $state([]); let users: UserResponseDto[] = $state([]);
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({}); let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({});
let sharedLinkUrl = $state('');
const handleViewQrCode = (sharedLink: SharedLinkResponseDto) => {
sharedLinkUrl = makeSharedLinkUrl($serverConfig.externalDomain, sharedLink.key);
};
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [ const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
{ title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil }, { title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil },
{ title: $t('role_viewer'), value: AlbumUserRole.Viewer, icon: mdiEye }, { title: $t('role_viewer'), value: AlbumUserRole.Viewer, icon: mdiEye },
@ -68,59 +76,24 @@
}; };
</script> </script>
<FullScreenModal title={$t('share')} showLogo {onClose}> {#if sharedLinkUrl}
{#if Object.keys(selectedUsers).length > 0} <QrCodeModal title={$t('view_link')} onClose={() => (sharedLinkUrl = '')} value={sharedLinkUrl} />
<div class="mb-2 py-2 sticky"> {:else}
<p class="text-xs font-medium">{$t('selected')}</p> <FullScreenModal title={$t('share')} showLogo {onClose}>
<div class="my-2"> {#if Object.keys(selectedUsers).length > 0}
{#each Object.values(selectedUsers) as { user } (user.id)} <div class="mb-2 py-2 sticky">
{#key user.id} <p class="text-xs font-medium">{$t('selected')}</p>
<div class="flex place-items-center gap-4 p-4"> <div class="my-2">
<div {#each Object.values(selectedUsers) as { user } (user.id)}
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" {#key user.id}
> <div class="flex place-items-center gap-4 p-4">
<Icon path={mdiCheck} size={24} /> <div
</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" /> --> <!-- <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" />
<div class="text-left flex-grow"> <div class="text-left flex-grow">
<p class="text-immich-fg dark:text-immich-dark-fg"> <p class="text-immich-fg dark:text-immich-dark-fg">
{user.name} {user.name}
@ -129,44 +102,87 @@
{user.email} {user.email}
</p> </p>
</div> </div>
</button>
</div> <Dropdown
{/if} title={$t('role')}
{/each} options={roleOptions}
render={({ title, icon }) => ({ title, icon })}
onSelect={({ value }) => handleChangeRole(user, value)}
/>
</div>
{/key}
{/each}
</div>
</div> </div>
{/if} {/if}
</div>
{#if users.length > 0} {#if users.length + Object.keys(selectedUsers).length === 0}
<div class="py-3"> <p class="p-5 text-sm">
<Button {$t('album_share_no_users')}
size="small" </p>
fullWidth {/if}
shape="round"
disabled={Object.keys(selectedUsers).length === 0} <div class="immich-scrollbar max-h-[500px] overflow-y-auto">
onclick={() => {#if users.length > 0 && users.length !== Object.keys(selectedUsers).length}
onSelect(Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })))} <Text>{$t('users')}</Text>
>{$t('add')}</Button
> <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> </div>
{/if}
<hr class="my-4" /> {#if users.length > 0}
<div class="py-3">
<Stack gap={6}> <Button
{#if sharedLinks.length > 0} size="small"
<div class="flex justify-between items-center"> fullWidth
<Text>{$t('shared_links')}</Text> shape="round"
<Link href={AppRoute.SHARED_LINKS} class="text-sm">{$t('view_all')}</Link> disabled={Object.keys(selectedUsers).length === 0}
onclick={() =>
onSelect(Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })))}
>{$t('add')}</Button
>
</div> </div>
<Stack gap={4}>
{#each sharedLinks as sharedLink (sharedLink.id)}
<AlbumSharedLink {album} {sharedLink} />
{/each}
</Stack>
{/if} {/if}
<Button leadingIcon={mdiLink} size="small" shape="round" fullWidth onclick={onShare}>{$t('create_link')}</Button> <hr class="my-4" />
</Stack>
</FullScreenModal> <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}

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { type DownloadProgress, downloadAssets, downloadManager, isDownloading } from '$lib/stores/download'; import { type DownloadProgress, downloadManager, downloadStore } from '$lib/stores/download-store.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { fly, slide } from 'svelte/transition'; import { fly, slide } from 'svelte/transition';
import { getByteUnitString } from '../../utils/byte-units'; import { getByteUnitString } from '../../utils/byte-units';
@ -13,15 +13,15 @@
}; };
</script> </script>
{#if $isDownloading} {#if downloadStore.isDownloading}
<div <div
transition:fly={{ x: -100, duration: 350 }} transition:fly={{ x: -100, duration: 350 }}
class="fixed bottom-10 left-2 z-[10000] max-h-[270px] w-[315px] rounded-2xl border bg-immich-bg p-4 text-sm shadow-sm" class="fixed bottom-10 left-2 z-[10000] max-h-[270px] w-[315px] rounded-2xl border bg-immich-bg p-4 text-sm shadow-sm"
> >
<p class="mb-2 text-xs text-gray-500">{$t('downloading').toUpperCase()}</p> <p class="mb-2 text-xs text-gray-500">{$t('downloading').toUpperCase()}</p>
<div class="my-2 mb-2 flex max-h-[200px] flex-col overflow-y-auto text-sm"> <div class="my-2 mb-2 flex max-h-[200px] flex-col overflow-y-auto text-sm">
{#each Object.keys($downloadAssets) as downloadKey (downloadKey)} {#each Object.keys(downloadStore.assets) as downloadKey (downloadKey)}
{@const download = $downloadAssets[downloadKey]} {@const download = downloadStore.assets[downloadKey]}
<div class="mb-2 flex place-items-center" transition:slide> <div class="mb-2 flex place-items-center" transition:slide>
<div class="w-full pr-10"> <div class="w-full pr-10">
<div class="flex place-items-center justify-between gap-2 text-xs font-medium"> <div class="flex place-items-center justify-between gap-2 text-xs font-medium">
@ -31,7 +31,7 @@
{/if} {/if}
</div> </div>
<div class="flex place-items-center gap-2"> <div class="flex place-items-center gap-2">
<div class="h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700"> <div class="h-[7px] w-full rounded-full bg-gray-200">
<div class="h-[7px] rounded-full bg-immich-primary" style={`width: ${download.percentage}%`}></div> <div class="h-[7px] rounded-full bg-immich-primary" style={`width: ${download.percentage}%`}></div>
</div> </div>
<p class="min-w-[4em] whitespace-nowrap text-right"> <p class="min-w-[4em] whitespace-nowrap text-right">

View File

@ -51,7 +51,7 @@
</header> </header>
<main <main
tabindex="-1" tabindex="-1"
class="relative grid h-dvh grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]" class="relative grid h-dvh grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]"
> >
{#if sidebar}{@render sidebar()}{:else if admin} {#if sidebar}{@render sidebar()}{:else if admin}
<AdminSideBar /> <AdminSideBar />

View File

@ -6,7 +6,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store'; import { searchStore } from '$lib/stores/search.svelte';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
@ -448,7 +448,7 @@
}; };
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if ($isSearchEnabled) { if (searchStore.isSearchEnabled) {
return; return;
} }
@ -459,7 +459,7 @@
}; };
const onKeyUp = (event: KeyboardEvent) => { const onKeyUp = (event: KeyboardEvent) => {
if ($isSearchEnabled) { if (searchStore.isSearchEnabled) {
return; return;
} }
@ -648,7 +648,7 @@
let shortcutList = $derived( let shortcutList = $derived(
(() => { (() => {
if ($isSearchEnabled || $showAssetViewer) { if (searchStore.isSearchEnabled || $showAssetViewer) {
return []; return [];
} }

View File

@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import QrCodeModal from '$lib/components/shared-components/qr-code-modal.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { SettingInputFieldType } from '$lib/constants'; import { SettingInputFieldType } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { serverConfig } from '$lib/stores/server-config.store'; import { serverConfig } from '$lib/stores/server-config.store';
import { copyToClipboard, makeSharedLinkUrl } from '$lib/utils'; import { makeSharedLinkUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk'; import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
import { Button, HStack, IconButton, Input } from '@immich/ui'; import { Button } from '@immich/ui';
import { mdiContentCopy, mdiLink } from '@mdi/js'; import { mdiLink } from '@mdi/js';
import { DateTime, Duration } from 'luxon'; import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { NotificationType, notificationController } from '../notification/notification'; import { NotificationType, notificationController } from '../notification/notification';
import SettingInputField from '../settings/setting-input-field.svelte'; import SettingInputField from '../settings/setting-input-field.svelte';
import SettingSwitch from '../settings/setting-switch.svelte'; import SettingSwitch from '../settings/setting-switch.svelte';
import QRCode from '$lib/components/shared-components/qrcode.svelte';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
@ -41,7 +41,6 @@
let password = $state(''); let password = $state('');
let shouldChangeExpirationTime = $state(false); let shouldChangeExpirationTime = $state(false);
let enablePassword = $state(false); let enablePassword = $state(false);
let modalWidth = $state(0);
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [ const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
[30, 'minutes'], [30, 'minutes'],
@ -248,26 +247,5 @@
{/snippet} {/snippet}
</FullScreenModal> </FullScreenModal>
{:else} {:else}
<FullScreenModal title={getTitle()} icon={mdiLink} {onClose}> <QrCodeModal title={$t('view_link')} {onClose} value={sharedLink} />
<div class="w-full">
<div class="w-full py-2 px-10">
<div bind:clientWidth={modalWidth} class="w-full">
<QRCode value={sharedLink} width={modalWidth} />
</div>
</div>
<HStack class="w-full pt-3" gap={1}>
<Input bind:value={sharedLink} disabled class="flex flex-row" />
<div>
<IconButton
variant="ghost"
shape="round"
color="secondary"
icon={mdiContentCopy}
onclick={() => (sharedLink ? copyToClipboard(sharedLink) : '')}
aria-label={$t('copy_link_to_clipboard')}
/>
</div>
</HStack>
</div>
</FullScreenModal>
{/if} {/if}

View File

@ -23,7 +23,7 @@
import ThemeButton from '../theme-button.svelte'; import ThemeButton from '../theme-button.svelte';
import UserAvatar from '../user-avatar.svelte'; import UserAvatar from '../user-avatar.svelte';
import AccountInfoPanel from './account-info-panel.svelte'; import AccountInfoPanel from './account-info-panel.svelte';
import { isSidebarOpen } from '$lib/stores/side-bar.svelte'; import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
interface Props { interface Props {
@ -62,32 +62,30 @@
> >
<SkipLink text={$t('skip_to_content')} /> <SkipLink text={$t('skip_to_content')} />
<div <div
class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]" class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]"
> >
<div class="flex flex-row gap-1 mx-4 items-center"> <div class="flex flex-row gap-1 mx-4 items-center">
<div> <IconButton
<IconButton id={menuButtonId}
id={menuButtonId} shape="round"
shape="round" color="secondary"
color="secondary" variant="ghost"
variant="ghost" size="medium"
size="medium" aria-label={$t('main_menu')}
aria-label={$t('main_menu')} icon={mdiMenu}
icon={mdiMenu} onclick={() => {
onclick={() => { sidebarStore.toggle();
isSidebarOpen.value = !isSidebarOpen.value; }}
}} onmousedown={(event: MouseEvent) => {
onmousedown={(event: MouseEvent) => { if (sidebarStore.isOpen) {
if (isSidebarOpen.value) { // stops event from reaching the default handler when clicking outside of the sidebar
// stops event from reaching the default handler when clicking outside of the sidebar event.stopPropagation();
event.stopPropagation(); }
} }}
}} class="sidebar:hidden"
class="md:hidden" />
/>
</div>
<a data-sveltekit-preload-data="hover" href={AppRoute.PHOTOS}> <a data-sveltekit-preload-data="hover" href={AppRoute.PHOTOS}>
<ImmichLogo class="max-md:h-[48px] h-[50px]" noText={mobileDevice.maxMd} /> <ImmichLogo class="max-md:h-[48px] h-[50px]" noText={!mobileDevice.isFullSidebar} />
</a> </a>
</div> </div>
<div class="flex justify-between gap-4 lg:gap-8 pr-6"> <div class="flex justify-between gap-4 lg:gap-8 pr-6">

View File

@ -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>

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store'; import { searchStore } from '$lib/stores/search.svelte';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js'; import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import SearchHistoryBox from './search-history-box.svelte'; import SearchHistoryBox from './search-history-box.svelte';
import SearchFilterModal from './search-filter-modal.svelte'; import SearchFilterModal from './search-filter-modal.svelte';
@ -40,41 +40,43 @@
closeDropdown(); closeDropdown();
showFilter = false; showFilter = false;
$isSearchEnabled = false; searchStore.isSearchEnabled = false;
await goto(`${AppRoute.SEARCH}?${params}`); await goto(`${AppRoute.SEARCH}?${params}`);
}; };
const clearSearchTerm = (searchTerm: string) => { const clearSearchTerm = (searchTerm: string) => {
input?.focus(); input?.focus();
$savedSearchTerms = $savedSearchTerms.filter((item) => item !== searchTerm); searchStore.savedSearchTerms = searchStore.savedSearchTerms.filter((item) => item !== searchTerm);
}; };
const saveSearchTerm = (saveValue: string) => { const saveSearchTerm = (saveValue: string) => {
const filteredSearchTerms = $savedSearchTerms.filter((item) => item.toLowerCase() !== saveValue.toLowerCase()); const filteredSearchTerms = searchStore.savedSearchTerms.filter(
$savedSearchTerms = [saveValue, ...filteredSearchTerms]; (item) => item.toLowerCase() !== saveValue.toLowerCase(),
);
searchStore.savedSearchTerms = [saveValue, ...filteredSearchTerms];
if ($savedSearchTerms.length > 5) { if (searchStore.savedSearchTerms.length > 5) {
$savedSearchTerms = $savedSearchTerms.slice(0, 5); searchStore.savedSearchTerms = searchStore.savedSearchTerms.slice(0, 5);
} }
}; };
const clearAllSearchTerms = () => { const clearAllSearchTerms = () => {
input?.focus(); input?.focus();
$savedSearchTerms = []; searchStore.savedSearchTerms = [];
}; };
const onFocusIn = () => { const onFocusIn = () => {
$isSearchEnabled = true; searchStore.isSearchEnabled = true;
}; };
const onFocusOut = () => { const onFocusOut = () => {
const focusOutTimer = setTimeout(() => { const focusOutTimer = setTimeout(() => {
if ($isSearchEnabled) { if (searchStore.isSearchEnabled) {
$preventRaceConditionSearchBar = true; searchStore.preventRaceConditionSearchBar = true;
} }
closeDropdown(); closeDropdown();
$isSearchEnabled = false; searchStore.isSearchEnabled = false;
showFilter = false; showFilter = false;
}, 100); }, 100);
@ -225,7 +227,9 @@
class="w-full transition-all border-2 px-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg class="w-full transition-all border-2 px-14 py-4 max-md:py-2 text-immich-fg/75 dark:text-immich-dark-fg
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'} {grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
{showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'} {showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
{$isSearchEnabled && !showFilter ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}" {searchStore.isSearchEnabled && !showFilter
? 'border-gray-200 dark:border-gray-700 bg-white'
: 'border-transparent'}"
placeholder={$t('search_your_photos')} placeholder={$t('search_your_photos')}
required required
pattern="^(?!m:$).*$" pattern="^(?!m:$).*$"

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { savedSearchTerms } from '$lib/stores/search.store'; import { searchStore } from '$lib/stores/search.svelte';
import { mdiMagnify, mdiClose } from '@mdi/js'; import { mdiMagnify, mdiClose } from '@mdi/js';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -29,7 +29,7 @@
}: Props = $props(); }: Props = $props();
let filteredSearchTerms = $derived( let filteredSearchTerms = $derived(
$savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())), searchStore.savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())),
); );
$effect(() => { $effect(() => {

View File

@ -110,7 +110,7 @@
<div> <div>
<Icon <Icon
path={mdiInformationOutline} path={mdiInformationOutline}
class="hidden md:flex text-immich-primary dark:text-immich-dark-primary font-medium" class="hidden sidebar:flex text-immich-primary dark:text-immich-dark-primary font-medium"
size="18" size="18"
/> />
</div> </div>
@ -123,7 +123,7 @@
{#if showMessage} {#if showMessage}
<dialog <dialog
open open
class="hidden md:block w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6" class="hidden sidebar:block w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
transition:fade={{ duration: 150 }} transition:fade={{ duration: 150 }}
onmouseover={() => (hoverMessage = true)} onmouseover={() => (hoverMessage = true)}
onmouseleave={() => (hoverMessage = false)} onmouseleave={() => (hoverMessage = false)}

View File

@ -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();
});
});

View File

@ -2,52 +2,45 @@
import { clickOutside } from '$lib/actions/click-outside'; import { clickOutside } from '$lib/actions/click-outside';
import { focusTrap } from '$lib/actions/focus-trap'; import { focusTrap } from '$lib/actions/focus-trap';
import { menuButtonId } from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; import { menuButtonId } from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import { isSidebarOpen } from '$lib/stores/side-bar.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { type Snippet } from 'svelte'; import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { onMount, type Snippet } from 'svelte';
interface Props { interface Props {
children?: Snippet; children?: Snippet;
} }
const mdBreakpoint = 768;
let { children }: Props = $props(); let { children }: Props = $props();
let innerWidth: number = $state(0); const isHidden = $derived(!sidebarStore.isOpen && !mobileDevice.isFullSidebar);
const isExpanded = $derived(sidebarStore.isOpen && !mobileDevice.isFullSidebar);
const closeSidebar = (width: number) => { onMount(() => {
isSidebarOpen.value = width >= mdBreakpoint; closeSidebar();
};
$effect(() => {
closeSidebar(innerWidth);
}); });
const isHidden = $derived(!isSidebarOpen.value && innerWidth < mdBreakpoint); const closeSidebar = () => {
const isExpanded = $derived(isSidebarOpen.value && innerWidth < mdBreakpoint); if (!isExpanded) {
const handleClickOutside = () => {
if (!isSidebarOpen.value) {
return; return;
} }
closeSidebar(innerWidth); sidebarStore.reset();
if (isHidden) { if (isHidden) {
document.querySelector<HTMLButtonElement>(`#${menuButtonId}`)?.focus(); document.querySelector<HTMLButtonElement>(`#${menuButtonId}`)?.focus();
} }
}; };
</script> </script>
<svelte:window bind:innerWidth />
<section <section
id="sidebar" id="sidebar"
tabindex="-1" tabindex="-1"
class="immich-scrollbar relative z-10 w-0 md:w-[16rem] overflow-y-auto overflow-x-hidden bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg" class="immich-scrollbar relative z-10 w-0 sidebar:w-[16rem] overflow-y-auto overflow-x-hidden bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg"
class:shadow-2xl={isExpanded} class:shadow-2xl={isExpanded}
class:dark:border-r-immich-dark-gray={isExpanded} class:dark:border-r-immich-dark-gray={isExpanded}
class:border-r={isExpanded} class:border-r={isExpanded}
class:w-[min(100vw,16rem)]={isSidebarOpen.value} class:w-[min(100vw,16rem)]={sidebarStore.isOpen}
data-testid="sidebar-parent"
inert={isHidden} inert={isHidden}
use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }} use:clickOutside={{ onOutclick: closeSidebar, onEscape: closeSidebar }}
use:focusTrap={{ active: isExpanded }} use:focusTrap={{ active: isExpanded }}
> >
<div class="pr-6 flex flex-col gap-1 h-max min-h-full"> <div class="pr-6 flex flex-col gap-1 h-max min-h-full">

View 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),
};

View File

@ -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);
},
};

View File

@ -2,6 +2,7 @@ import { MediaQuery } from 'svelte/reactivity';
const pointerCoarse = new MediaQuery('pointer:coarse'); const pointerCoarse = new MediaQuery('pointer:coarse');
const maxMd = new MediaQuery('max-width: 767px'); const maxMd = new MediaQuery('max-width: 767px');
const sidebar = new MediaQuery(`min-width: 850px`);
export const mobileDevice = { export const mobileDevice = {
get pointerCoarse() { get pointerCoarse() {
@ -10,4 +11,7 @@ export const mobileDevice = {
get maxMd() { get maxMd() {
return maxMd.current; return maxMd.current;
}, },
get isFullSidebar() {
return sidebar.current;
},
}; };

View File

@ -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