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": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
"version": "6.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
"integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
"dev": true,
"license": "MIT",
"dependencies": {

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
```
If you're using PowerShell on Windows you may need to set the env var separately like so:
```powershell
$env:IMMICH_SERVER_URL = "https://demo.immich.app/"
npm run dev
```
#### `@immich/ui`
To see local changes to `@immich/ui` in Immich, do the following:

View File

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

View File

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

View File

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

View File

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

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

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
WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./
COPY server/patches ./patches
RUN npm ci && \
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# they're marked as optional dependencies, so we need to copy them manually after pruning
@ -56,7 +57,7 @@ COPY server/resources resources
COPY server/package.json server/package-lock.json ./
COPY server/start*.sh ./
COPY "docker/scripts/get-cpus.sh" ./
RUN npm link && npm install -g @immich/cli && npm cache clean --force
RUN npm install -g @immich/cli && npm cache clean --force
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
ENV PATH="${PATH}:/usr/src/app/bin"

View File

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

View File

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

View File

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

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';
export type AuthUser = {
@ -10,6 +21,17 @@ export type AuthUser = {
quotaSizeInBytes: number | null;
};
export type AlbumUser = {
user: User;
role: AlbumUserRole;
};
export type AssetFile = {
id: string;
type: AssetFileType;
path: string;
};
export type Library = {
id: string;
ownerId: string;
@ -184,6 +206,38 @@ export type Session = {
deviceType: string;
};
export type Exif = Omit<Selectable<DatabaseExif>, 'updatedAt' | 'updateId'>;
export type Person = {
createdAt: Date;
id: string;
ownerId: string;
updatedAt: Date;
updateId: string;
isFavorite: boolean;
name: string;
birthDate: Date | null;
color: string | null;
faceAssetId: string | null;
isHidden: boolean;
thumbnailPath: string;
};
export type AssetFace = {
id: string;
deletedAt: Date | null;
assetId: string;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
imageHeight: number;
imageWidth: number;
personId: string | null;
sourceType: SourceType;
person?: Person | null;
};
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
export const columns = {

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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({
issuerUrl,
clientId,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.profile);

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

6
web/package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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 maxMd = new MediaQuery('max-width: 767px');
const sidebar = new MediaQuery(`min-width: 850px`);
export const mobileDevice = {
get pointerCoarse() {
@ -10,4 +11,7 @@ export const mobileDevice = {
get maxMd() {
return maxMd.current;
},
get isFullSidebar() {
return sidebar.current;
},
};

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