chore(server,cli,web): housekeeping and stricter code style (#6751)

* add unicorn to eslint

* fix lint errors for cli

* fix merge

* fix album name extraction

* Update cli/src/commands/upload.command.ts

Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com>

* es2k23

* use lowercase os

* return undefined album name

* fix bug in asset response dto

* auto fix issues

* fix server code style

* es2022 and formatting

* fix compilation error

* fix test

* fix config load

* fix last lint errors

* set string type

* bump ts

* start work on web

* web formatting

* Fix UUIDParamDto as UUIDParamDto

* fix library service lint

* fix web errors

* fix errors

* formatting

* wip

* lints fixed

* web can now start

* alphabetical package json

* rename error

* chore: clean up

---------

Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Jonathan Jogenfors 2024-02-02 04:18:00 +01:00 committed by GitHub
parent e4d0560d49
commit f44fa45aa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
218 changed files with 2471 additions and 1244 deletions

View File

@ -6,7 +6,7 @@ module.exports = {
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
}, },
plugins: ['@typescript-eslint/eslint-plugin'], plugins: ['@typescript-eslint/eslint-plugin'],
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'],
root: true, root: true,
env: { env: {
node: true, node: true,
@ -19,6 +19,14 @@ module.exports = {
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off',
curly: 2,
'prettier/prettier': 0, 'prettier/prettier': 0,
'unicorn/prevent-abbreviations': [
'error',
{
ignore: ['\\.e2e-spec$', /^ignore/i],
},
],
}, },
}; };

30
cli/package-lock.json generated
View File

@ -30,15 +30,15 @@
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",
"@vitest/coverage-v8": "^1.2.2", "@vitest/coverage-v8": "^1.2.2",
"eslint": "^8.43.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^50.0.0", "eslint-plugin-unicorn": "^50.0.1",
"immich": "file:../server", "immich": "file:../server",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tslib": "^2.5.3", "tslib": "^2.5.3",
"typescript": "^5.0.0", "typescript": "^5.3.3",
"vitest": "^1.2.1" "vitest": "^1.2.1"
} }
}, },
@ -73,6 +73,7 @@
"@nestjs/typeorm": "^10.0.0", "@nestjs/typeorm": "^10.0.0",
"@nestjs/websockets": "^10.2.2", "@nestjs/websockets": "^10.2.2",
"@socket.io/postgres-adapter": "^0.3.1", "@socket.io/postgres-adapter": "^0.3.1",
"@types/picomatch": "^2.3.3",
"archiver": "^6.0.0", "archiver": "^6.0.0",
"async-lock": "^1.4.0", "async-lock": "^1.4.0",
"axios": "^1.5.0", "axios": "^1.5.0",
@ -96,6 +97,7 @@
"node-addon-api": "^7.0.0", "node-addon-api": "^7.0.0",
"openid-client": "^5.4.3", "openid-client": "^5.4.3",
"pg": "^8.11.3", "pg": "^8.11.3",
"picomatch": "^3.0.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
@ -127,10 +129,12 @@
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1", "@typescript-eslint/parser": "^6.4.1",
"chokidar": "^3.5.3",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"eslint": "^8.48.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^50.0.1",
"jest": "^29.6.4", "jest": "^29.6.4",
"jest-when": "^3.6.0", "jest-when": "^3.6.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
@ -8768,6 +8772,7 @@
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^20.5.7", "@types/node": "^20.5.7",
"@types/picomatch": "^2.3.3",
"@types/sharp": "^0.31.1", "@types/sharp": "^0.31.1",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
@ -8778,13 +8783,15 @@
"axios": "^1.5.0", "axios": "^1.5.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^4.8.0", "bullmq": "^4.8.0",
"chokidar": "^3.5.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"eslint": "^8.48.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^50.0.1",
"exiftool-vendored": "~24.4.0", "exiftool-vendored": "~24.4.0",
"exiftool-vendored.pl": "12.73", "exiftool-vendored.pl": "12.73",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
@ -8803,6 +8810,7 @@
"node-addon-api": "^7.0.0", "node-addon-api": "^7.0.0",
"openid-client": "^5.4.3", "openid-client": "^5.4.3",
"pg": "^8.11.3", "pg": "^8.11.3",
"picomatch": "^3.0.1",
"prettier": "^3.0.2", "prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^3.2.3", "prettier-plugin-organize-imports": "^3.2.3",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",

View File

@ -28,18 +28,18 @@
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.4.1",
"@vitest/coverage-v8": "^1.2.2", "@vitest/coverage-v8": "^1.2.2",
"eslint": "^8.43.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^50.0.0", "eslint-plugin-unicorn": "^50.0.1",
"immich": "file:../server", "immich": "file:../server",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tslib": "^2.5.3", "tslib": "^2.5.3",
"typescript": "^5.0.0", "typescript": "^5.3.3",
"vitest": "^1.2.1" "vitest": "^1.2.1"
}, },
"scripts": { "scripts": {

View File

@ -8,7 +8,7 @@ import { BaseCommand } from './base-command';
import { basename } from 'node:path'; import { basename } from 'node:path';
import { access, constants, stat, unlink } from 'node:fs/promises'; import { access, constants, stat, unlink } from 'node:fs/promises';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import Os from 'os'; import os from 'node:os';
class Asset { class Asset {
readonly path: string; readonly path: string;
@ -27,7 +27,7 @@ class Asset {
async prepare() { async prepare() {
const stats = await stat(this.path); const stats = await stat(this.path);
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replace(/\s+/g, ''); this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replaceAll(/\s+/g, '');
this.fileCreatedAt = stats.mtime.toISOString(); this.fileCreatedAt = stats.mtime.toISOString();
this.fileModifiedAt = stats.mtime.toISOString(); this.fileModifiedAt = stats.mtime.toISOString();
this.fileSize = stats.size; this.fileSize = stats.size;
@ -35,9 +35,15 @@ class Asset {
} }
async getUploadFormData(): Promise<FormData> { async getUploadFormData(): Promise<FormData> {
if (!this.deviceAssetId) throw new Error('Device asset id not set'); if (!this.deviceAssetId) {
if (!this.fileCreatedAt) throw new Error('File created at not set'); throw new Error('Device asset id not set');
if (!this.fileModifiedAt) throw new Error('File modified at not set'); }
if (!this.fileCreatedAt) {
throw new Error('File created at not set');
}
if (!this.fileModifiedAt) {
throw new Error('File modified at not set');
}
// TODO: doesn't xmp replace the file extension? Will need investigation // TODO: doesn't xmp replace the file extension? Will need investigation
const sideCarPath = `${this.path}.xmp`; const sideCarPath = `${this.path}.xmp`;
@ -45,7 +51,7 @@ class Asset {
try { try {
await access(sideCarPath, constants.R_OK); await access(sideCarPath, constants.R_OK);
sidecarData = createReadStream(sideCarPath); sidecarData = createReadStream(sideCarPath);
} catch (error) {} } catch {}
const data: any = { const data: any = {
assetData: createReadStream(this.path), assetData: createReadStream(this.path),
@ -57,8 +63,8 @@ class Asset {
}; };
const formData = new FormData(); const formData = new FormData();
for (const prop in data) { for (const property in data) {
formData.append(prop, data[prop]); formData.append(property, data[property]);
} }
if (sidecarData) { if (sidecarData) {
@ -86,12 +92,8 @@ class Asset {
return await sha1(this.path); return await sha1(this.path);
} }
private extractAlbumName(): string { private extractAlbumName(): string | undefined {
if (Os.platform() === 'win32') { return os.platform() === 'win32' ? this.path.split('\\').at(-2) : this.path.split('/').at(-2);
return this.path.split('\\').slice(-2)[0];
} else {
return this.path.split('/').slice(-2)[0];
}
} }
} }
@ -162,7 +164,7 @@ export class UploadCommand extends BaseCommand {
} }
} }
const existingAlbums = (await this.immichApi.albumApi.getAllAlbums()).data; const { data: existingAlbums } = await this.immichApi.albumApi.getAllAlbums();
uploadProgress.start(totalSize, 0); uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) }); uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
@ -195,32 +197,30 @@ export class UploadCommand extends BaseCommand {
skipAsset = skipUpload && !isDuplicate; skipAsset = skipUpload && !isDuplicate;
} }
if (!skipAsset) { if (!skipAsset && !options.dryRun) {
if (!options.dryRun) { if (!skipUpload) {
if (!skipUpload) { const formData = await asset.getUploadFormData();
const formData = await asset.getUploadFormData(); const { data } = await this.uploadAsset(formData);
const res = await this.uploadAsset(formData); existingAssetId = data.id;
existingAssetId = res.data.id; uploadCounter++;
uploadCounter++; totalSizeUploaded += asset.fileSize;
totalSizeUploaded += asset.fileSize; }
if ((options.album || options.albumName) && asset.albumName !== undefined) {
let album = existingAlbums.find((album) => album.albumName === asset.albumName);
if (!album) {
const { data } = await this.immichApi.albumApi.createAlbum({
createAlbumDto: { albumName: asset.albumName },
});
album = data;
existingAlbums.push(album);
} }
if ((options.album || options.albumName) && asset.albumName !== undefined) { if (existingAssetId) {
let album = existingAlbums.find((album) => album.albumName === asset.albumName); await this.immichApi.albumApi.addAssetsToAlbum({
if (!album) { id: album.id,
const res = await this.immichApi.albumApi.createAlbum({ bulkIdsDto: { ids: [existingAssetId] },
createAlbumDto: { albumName: asset.albumName }, });
});
album = res.data;
existingAlbums.push(album);
}
if (existingAssetId) {
await this.immichApi.albumApi.addAssetsToAlbum({
id: album.id,
bulkIdsDto: { ids: [existingAssetId] },
});
}
} }
} }
} }
@ -233,12 +233,7 @@ export class UploadCommand extends BaseCommand {
uploadProgress.stop(); uploadProgress.stop();
} }
let messageStart; const messageStart = options.dryRun ? 'Would have' : 'Successfully';
if (options.dryRun) {
messageStart = 'Would have';
} else {
messageStart = 'Successfully';
}
if (uploadCounter === 0) { if (uploadCounter === 0) {
console.log('All assets were already uploaded, nothing to do.'); console.log('All assets were already uploaded, nothing to do.');
@ -276,12 +271,11 @@ export class UploadCommand extends BaseCommand {
'x-api-key': this.immichApi.apiKey, 'x-api-key': this.immichApi.apiKey,
...data.getHeaders(), ...data.getHeaders(),
}, },
maxContentLength: Infinity, maxContentLength: Number.POSITIVE_INFINITY,
maxBodyLength: Infinity, maxBodyLength: Number.POSITIVE_INFINITY,
data, data,
}; };
const res = await axios(config); return axios(config);
return res;
} }
} }

View File

@ -1,21 +1,21 @@
#! /usr/bin/env node #! /usr/bin/env node
import { Command, Option } from 'commander'; import { Command, Option } from 'commander';
import path from 'node:path'; import path from 'node:path';
import os from 'os'; import os from 'node:os';
import { version } from '../package.json'; import { version } from '../package.json';
import { LoginCommand } from './commands/login'; import { LoginCommand } from './commands/login';
import { LogoutCommand } from './commands/logout.command'; import { LogoutCommand } from './commands/logout.command';
import { ServerInfoCommand } from './commands/server-info.command'; import { ServerInfoCommand } from './commands/server-info.command';
import { UploadCommand } from './commands/upload.command'; import { UploadCommand } from './commands/upload.command';
const userHomeDir = os.homedir(); const homeDirectory = os.homedir();
const configDir = path.join(userHomeDir, '.config/immich/'); const configDirectory = path.join(homeDirectory, '.config/immich/');
const program = new Command() const program = new Command()
.name('immich') .name('immich')
.version(version) .version(version)
.description('Command line interface for Immich') .description('Command line interface for Immich')
.addOption(new Option('-d, --config', 'Configuration directory').env('IMMICH_CONFIG_DIR').default(configDir)); .addOption(new Option('-d, --config', 'Configuration directory').env('IMMICH_CONFIG_DIR').default(configDirectory));
program program
.command('upload') .command('upload')

View File

@ -1,5 +1,5 @@
import { glob } from 'glob'; import { glob } from 'glob';
import * as fs from 'fs'; import * as fs from 'node:fs';
export class CrawlOptions { export class CrawlOptions {
pathsToCrawl!: string[]; pathsToCrawl!: string[];
@ -12,14 +12,14 @@ export class CrawlService {
private readonly extensions!: string[]; private readonly extensions!: string[];
constructor(image: string[], video: string[]) { constructor(image: string[], video: string[]) {
this.extensions = image.concat(video).map((extension) => extension.replace('.', '')); this.extensions = [...image, ...video].map((extension) => extension.replace('.', ''));
} }
async crawl(options: CrawlOptions): Promise<string[]> { async crawl(options: CrawlOptions): Promise<string[]> {
const { recursive, pathsToCrawl, exclusionPatterns, includeHidden } = options; const { recursive, pathsToCrawl, exclusionPatterns, includeHidden } = options;
if (!pathsToCrawl) { if (!pathsToCrawl) {
return Promise.resolve([]); return [];
} }
const patterns: string[] = []; const patterns: string[] = [];
@ -65,8 +65,6 @@ export class CrawlService {
ignore: exclusionPatterns, ignore: exclusionPatterns,
}); });
const returnedFiles = crawledFiles.concat(globbedFiles); return [...crawledFiles, ...globbedFiles].sort();
returnedFiles.sort();
return returnedFiles;
} }
} }

View File

@ -1,4 +1,4 @@
import { existsSync } from 'fs'; import { existsSync } from 'node:fs';
import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import yaml from 'yaml'; import yaml from 'yaml';
@ -15,12 +15,12 @@ class LoginError extends Error {
} }
export class SessionService { export class SessionService {
readonly configDir!: string; readonly configDirectory!: string;
readonly authPath!: string; readonly authPath!: string;
constructor(configDir: string) { constructor(configDirectory: string) {
this.configDir = configDir; this.configDirectory = configDirectory;
this.authPath = path.join(configDir, '/auth.yml'); this.authPath = path.join(configDirectory, '/auth.yml');
} }
async connect(): Promise<ImmichApi> { async connect(): Promise<ImmichApi> {
@ -74,11 +74,11 @@ export class SessionService {
console.log(`Logged in as ${userInfo.email}`); console.log(`Logged in as ${userInfo.email}`);
if (!existsSync(this.configDir)) { if (!existsSync(this.configDirectory)) {
// Create config folder if it doesn't exist // Create config folder if it doesn't exist
const created = await mkdir(this.configDir, { recursive: true }); const created = await mkdir(this.configDirectory, { recursive: true });
if (!created) { if (!created) {
throw new Error(`Failed to create config folder ${this.configDir}`); throw new Error(`Failed to create config folder ${this.configDirectory}`);
} }
} }

View File

@ -1,4 +1,4 @@
import pkg from '../package.json'; import { version } from '../package.json';
export interface ICliVersion { export interface ICliVersion {
major: number; major: number;
@ -23,7 +23,7 @@ export class CliVersion implements ICliVersion {
} }
static fromString(version: string): CliVersion { static fromString(version: string): CliVersion {
const regex = /(?:v)?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/i; const regex = /v?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/i;
const matchResult = version.match(regex); const matchResult = version.match(regex);
if (matchResult) { if (matchResult) {
const [, major, minor, patch] = matchResult.map(Number); const [, major, minor, patch] = matchResult.map(Number);
@ -34,4 +34,4 @@ export class CliVersion implements ICliVersion {
} }
} }
export const cliVersion = CliVersion.fromString(pkg.version); export const cliVersion = CliVersion.fromString(version);

View File

@ -10,10 +10,10 @@ describe(`login-key (e2e)`, () => {
beforeAll(async () => { beforeAll(async () => {
await testApp.create(); await testApp.create();
if (!process.env.IMMICH_INSTANCE_URL) { if (process.env.IMMICH_INSTANCE_URL) {
throw new Error('IMMICH_INSTANCE_URL environment variable not set');
} else {
instanceUrl = process.env.IMMICH_INSTANCE_URL; instanceUrl = process.env.IMMICH_INSTANCE_URL;
} else {
throw new Error('IMMICH_INSTANCE_URL environment variable not set');
} }
}); });

View File

@ -1,6 +1,11 @@
import path from 'path'; import path from 'node:path';
import { PostgreSqlContainer } from '@testcontainers/postgresql'; import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { access } from 'fs/promises'; import { access } from 'node:fs/promises';
export const directoryExists = (directory: string) =>
access(directory)
.then(() => true)
.catch(() => false);
export default async () => { export default async () => {
let IMMICH_TEST_ASSET_PATH: string = ''; let IMMICH_TEST_ASSET_PATH: string = '';
@ -12,11 +17,6 @@ export default async () => {
IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH; IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
} }
const directoryExists = async (dirPath: string) =>
await access(dirPath)
.then(() => true)
.catch(() => false);
if (!(await directoryExists(`${IMMICH_TEST_ASSET_PATH}/albums`))) { if (!(await directoryExists(`${IMMICH_TEST_ASSET_PATH}/albums`))) {
throw new Error( throw new Error(
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${IMMICH_TEST_ASSET_PATH} before testing`, `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${IMMICH_TEST_ASSET_PATH} before testing`,

View File

@ -17,6 +17,6 @@ export default defineConfig({
minForks: 1, minForks: 1,
}, },
}, },
testTimeout: 10000, testTimeout: 10_000,
}, },
}); });

View File

@ -9,7 +9,7 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"target": "es2021", "target": "es2022",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"incremental": true, "incremental": true,
@ -30,5 +30,5 @@
}, },
"types": ["vitest/globals"] "types": ["vitest/globals"]
}, },
"exclude": ["dist", "node_modules", "upload"] "exclude": ["dist", "node_modules"]
} }

View File

@ -6,7 +6,7 @@ module.exports = {
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
}, },
plugins: ['@typescript-eslint/eslint-plugin'], plugins: ['@typescript-eslint/eslint-plugin'],
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'],
root: true, root: true,
env: { env: {
node: true, node: true,
@ -19,6 +19,12 @@ module.exports = {
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-floating-promises': 'error',
'unicorn/prevent-abbreviations': 'off',
'unicorn/filename-case': 'off',
'unicorn/no-null': 'off',
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-event-target': 'off',
'unicorn/no-thenable': 'off',
curly: 2, curly: 2,
'prettier/prettier': 0, 'prettier/prettier': 0,
}, },

700
server/package-lock.json generated
View File

@ -80,9 +80,10 @@
"@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1", "@typescript-eslint/parser": "^6.4.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"eslint": "^8.48.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^50.0.1",
"jest": "^29.6.4", "jest": "^29.6.4",
"jest-when": "^3.6.0", "jest-when": "^3.6.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
@ -97,7 +98,7 @@
"ts-loader": "^9.4.4", "ts-loader": "^9.4.4",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.2.2", "typescript": "^5.3.3",
"utimes": "^5.2.1" "utimes": "^5.2.1"
} }
}, },
@ -3233,6 +3234,12 @@
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
}, },
"node_modules/@types/normalize-package-data": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
"dev": true
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.10.9", "version": "8.10.9",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.9.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.9.tgz",
@ -4439,9 +4446,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.22.1", "version": "4.22.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz",
"integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -4458,9 +4465,9 @@
} }
], ],
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001541", "caniuse-lite": "^1.0.30001580",
"electron-to-chromium": "^1.4.535", "electron-to-chromium": "^1.4.648",
"node-releases": "^2.0.13", "node-releases": "^2.0.14",
"update-browserslist-db": "^1.0.13" "update-browserslist-db": "^1.0.13"
}, },
"bin": { "bin": {
@ -4545,6 +4552,18 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
"dev": true,
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bullmq": { "node_modules/bullmq": {
"version": "4.17.0", "version": "4.17.0",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.17.0.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.17.0.tgz",
@ -4664,9 +4683,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001542", "version": "1.0.30001581",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001542.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001581.tgz",
"integrity": "sha512-UrtAXVcj1mvPBFQ4sKd38daP8dEcXXr5sQe6QNNinaPd0iA/cxg9/l3VrSdL73jgw5sKyuQ6jNgiKO12W3SsVA==", "integrity": "sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -4791,6 +4810,27 @@
"validator": "^13.9.0" "validator": "^13.9.0"
} }
}, },
"node_modules/clean-regexp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz",
"integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==",
"dev": true,
"dependencies": {
"escape-string-regexp": "^1.0.5"
},
"engines": {
"node": ">=4"
}
},
"node_modules/clean-regexp/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/cli-cursor": { "node_modules/cli-cursor": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@ -5111,6 +5151,19 @@
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true "dev": true
}, },
"node_modules/core-js-compat": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz",
"integrity": "sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==",
"dev": true,
"dependencies": {
"browserslist": "^4.22.2"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@ -5535,9 +5588,9 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.4.538", "version": "1.4.650",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.538.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.650.tgz",
"integrity": "sha512-1a2m63NEookb1beNFTGDihgF3CKL7ksZ7PSA0VloON5DpTEhnOVgaDes8xkrDhkXRxlcN8JymQDGnv+Nn+uvhg==", "integrity": "sha512-sYSQhJCJa4aGA1wYol5cMQgekDBlbVfTRavlGZVr3WZpDdOPcp6a6xUnFfrt8TqZhsBYYbDxJZCjGfHuGupCRQ==",
"dev": true "dev": true
}, },
"node_modules/emittery": { "node_modules/emittery": {
@ -5751,6 +5804,66 @@
} }
} }
}, },
"node_modules/eslint-plugin-unicorn": {
"version": "50.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-50.0.1.tgz",
"integrity": "sha512-KxenCZxqSYW0GWHH18okDlOQcpezcitm5aOSz6EnobyJ6BIByiPDviQRjJIUAjG/tMN11958MxaQ+qCoU6lfDA==",
"dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.22.20",
"@eslint-community/eslint-utils": "^4.4.0",
"@eslint/eslintrc": "^2.1.4",
"ci-info": "^4.0.0",
"clean-regexp": "^1.0.0",
"core-js-compat": "^3.34.0",
"esquery": "^1.5.0",
"indent-string": "^4.0.0",
"is-builtin-module": "^3.2.1",
"jsesc": "^3.0.2",
"pluralize": "^8.0.0",
"read-pkg-up": "^7.0.1",
"regexp-tree": "^0.1.27",
"regjsparser": "^0.10.0",
"semver": "^7.5.4",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1"
},
"peerDependencies": {
"eslint": ">=8.56.0"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/ci-info": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz",
"integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"engines": {
"node": ">=8"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/jsesc": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
"integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
"dev": true,
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "7.2.2", "version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@ -6884,6 +6997,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/hosted-git-info": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"dev": true
},
"node_modules/html-escaper": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -7019,6 +7138,15 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@ -7114,6 +7242,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-builtin-module": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz",
"integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==",
"dev": true,
"dependencies": {
"builtin-modules": "^3.3.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.13.0", "version": "2.13.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz",
@ -8475,6 +8618,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -8818,9 +8970,9 @@
"dev": true "dev": true
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.13", "version": "2.0.14",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
"integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
"dev": true "dev": true
}, },
"node_modules/nopt": { "node_modules/nopt": {
@ -8837,6 +8989,27 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
"integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
"dev": true,
"dependencies": {
"hosted-git-info": "^2.1.4",
"resolve": "^1.10.0",
"semver": "2 || 3 || 4 || 5",
"validate-npm-package-license": "^3.0.1"
}
},
"node_modules/normalize-package-data/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"bin": {
"semver": "bin/semver"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -9735,6 +9908,108 @@
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"dev": true "dev": true
}, },
"node_modules/read-pkg": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
"integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
"dev": true,
"dependencies": {
"@types/normalize-package-data": "^2.4.0",
"normalize-package-data": "^2.5.0",
"parse-json": "^5.0.0",
"type-fest": "^0.6.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/read-pkg-up": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
"integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
"dev": true,
"dependencies": {
"find-up": "^4.1.0",
"read-pkg": "^5.2.0",
"type-fest": "^0.8.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/read-pkg-up/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/read-pkg-up/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/read-pkg-up/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/read-pkg-up/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/read-pkg-up/node_modules/type-fest": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
"integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/read-pkg/node_modules/type-fest": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
"integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@ -9838,6 +10113,36 @@
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
"integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
}, },
"node_modules/regexp-tree": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
"integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==",
"dev": true,
"bin": {
"regexp-tree": "bin/regexp-tree"
}
},
"node_modules/regjsparser": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.10.0.tgz",
"integrity": "sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==",
"dev": true,
"dependencies": {
"jsesc": "~0.5.0"
},
"bin": {
"regjsparser": "bin/parser"
}
},
"node_modules/regjsparser/node_modules/jsesc": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
"integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
"dev": true,
"bin": {
"jsesc": "bin/jsesc"
}
},
"node_modules/repeat-string": { "node_modules/repeat-string": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
@ -10465,6 +10770,38 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/spdx-correct": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
"integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
"dev": true,
"dependencies": {
"spdx-expression-parse": "^3.0.0",
"spdx-license-ids": "^3.0.0"
}
},
"node_modules/spdx-exceptions": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.4.0.tgz",
"integrity": "sha512-hcjppoJ68fhxA/cjbN4T8N6uCUejN8yFw69ttpqtBeCbF3u13n7mb31NB9jKwGTTWWnt9IbRA/mf1FprYS8wfw==",
"dev": true
},
"node_modules/spdx-expression-parse": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
"integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
"dev": true,
"dependencies": {
"spdx-exceptions": "^2.1.0",
"spdx-license-ids": "^3.0.0"
}
},
"node_modules/spdx-license-ids": {
"version": "3.0.16",
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz",
"integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==",
"dev": true
},
"node_modules/split-ca": { "node_modules/split-ca": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
@ -10672,6 +11009,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -11901,6 +12250,16 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true "dev": true
}, },
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
"integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
"dev": true,
"dependencies": {
"spdx-correct": "^3.0.0",
"spdx-expression-parse": "^3.0.0"
}
},
"node_modules/validator": { "node_modules/validator": {
"version": "13.11.0", "version": "13.11.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz",
@ -14439,6 +14798,12 @@
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
}, },
"@types/normalize-package-data": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
"dev": true
},
"@types/pg": { "@types/pg": {
"version": "8.10.9", "version": "8.10.9",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.9.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.9.tgz",
@ -15402,14 +15767,14 @@
} }
}, },
"browserslist": { "browserslist": {
"version": "4.22.1", "version": "4.22.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz",
"integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==",
"dev": true, "dev": true,
"requires": { "requires": {
"caniuse-lite": "^1.0.30001541", "caniuse-lite": "^1.0.30001580",
"electron-to-chromium": "^1.4.535", "electron-to-chromium": "^1.4.648",
"node-releases": "^2.0.13", "node-releases": "^2.0.14",
"update-browserslist-db": "^1.0.13" "update-browserslist-db": "^1.0.13"
} }
}, },
@ -15462,6 +15827,12 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
"dev": true
},
"bullmq": { "bullmq": {
"version": "4.17.0", "version": "4.17.0",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.17.0.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.17.0.tgz",
@ -15553,9 +15924,9 @@
"dev": true "dev": true
}, },
"caniuse-lite": { "caniuse-lite": {
"version": "1.0.30001542", "version": "1.0.30001581",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001542.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001581.tgz",
"integrity": "sha512-UrtAXVcj1mvPBFQ4sKd38daP8dEcXXr5sQe6QNNinaPd0iA/cxg9/l3VrSdL73jgw5sKyuQ6jNgiKO12W3SsVA==", "integrity": "sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ==",
"dev": true "dev": true
}, },
"chalk": { "chalk": {
@ -15631,6 +16002,23 @@
"validator": "^13.9.0" "validator": "^13.9.0"
} }
}, },
"clean-regexp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz",
"integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==",
"dev": true,
"requires": {
"escape-string-regexp": "^1.0.5"
},
"dependencies": {
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true
}
}
},
"cli-cursor": { "cli-cursor": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@ -15874,6 +16262,15 @@
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true "dev": true
}, },
"core-js-compat": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz",
"integrity": "sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==",
"dev": true,
"requires": {
"browserslist": "^4.22.2"
}
},
"core-util-is": { "core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@ -16185,9 +16582,9 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
}, },
"electron-to-chromium": { "electron-to-chromium": {
"version": "1.4.538", "version": "1.4.650",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.538.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.650.tgz",
"integrity": "sha512-1a2m63NEookb1beNFTGDihgF3CKL7ksZ7PSA0VloON5DpTEhnOVgaDes8xkrDhkXRxlcN8JymQDGnv+Nn+uvhg==", "integrity": "sha512-sYSQhJCJa4aGA1wYol5cMQgekDBlbVfTRavlGZVr3WZpDdOPcp6a6xUnFfrt8TqZhsBYYbDxJZCjGfHuGupCRQ==",
"dev": true "dev": true
}, },
"emittery": { "emittery": {
@ -16369,6 +16766,44 @@
"synckit": "^0.8.6" "synckit": "^0.8.6"
} }
}, },
"eslint-plugin-unicorn": {
"version": "50.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-50.0.1.tgz",
"integrity": "sha512-KxenCZxqSYW0GWHH18okDlOQcpezcitm5aOSz6EnobyJ6BIByiPDviQRjJIUAjG/tMN11958MxaQ+qCoU6lfDA==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.22.20",
"@eslint-community/eslint-utils": "^4.4.0",
"@eslint/eslintrc": "^2.1.4",
"ci-info": "^4.0.0",
"clean-regexp": "^1.0.0",
"core-js-compat": "^3.34.0",
"esquery": "^1.5.0",
"indent-string": "^4.0.0",
"is-builtin-module": "^3.2.1",
"jsesc": "^3.0.2",
"pluralize": "^8.0.0",
"read-pkg-up": "^7.0.1",
"regexp-tree": "^0.1.27",
"regjsparser": "^0.10.0",
"semver": "^7.5.4",
"strip-indent": "^3.0.0"
},
"dependencies": {
"ci-info": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz",
"integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==",
"dev": true
},
"jsesc": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
"integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
"dev": true
}
}
},
"eslint-scope": { "eslint-scope": {
"version": "7.2.2", "version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@ -17192,6 +17627,12 @@
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==" "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="
}, },
"hosted-git-info": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"dev": true
},
"html-escaper": { "html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -17277,6 +17718,12 @@
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true "dev": true
}, },
"indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true
},
"inflight": { "inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@ -17353,6 +17800,15 @@
"binary-extensions": "^2.0.0" "binary-extensions": "^2.0.0"
} }
}, },
"is-builtin-module": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz",
"integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==",
"dev": true,
"requires": {
"builtin-modules": "^3.3.0"
}
},
"is-core-module": { "is-core-module": {
"version": "2.13.0", "version": "2.13.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz",
@ -18401,6 +18857,12 @@
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
}, },
"min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true
},
"minimatch": { "minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -18676,9 +19138,9 @@
"dev": true "dev": true
}, },
"node-releases": { "node-releases": {
"version": "2.0.13", "version": "2.0.14",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
"integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
"dev": true "dev": true
}, },
"nopt": { "nopt": {
@ -18689,6 +19151,26 @@
"abbrev": "1" "abbrev": "1"
} }
}, },
"normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
"integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
"dev": true,
"requires": {
"hosted-git-info": "^2.1.4",
"resolve": "^1.10.0",
"semver": "2 || 3 || 4 || 5",
"validate-npm-package-license": "^3.0.1"
},
"dependencies": {
"semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true
}
}
},
"normalize-path": { "normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -19338,6 +19820,82 @@
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"dev": true "dev": true
}, },
"read-pkg": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
"integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
"dev": true,
"requires": {
"@types/normalize-package-data": "^2.4.0",
"normalize-package-data": "^2.5.0",
"parse-json": "^5.0.0",
"type-fest": "^0.6.0"
},
"dependencies": {
"type-fest": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
"integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
"dev": true
}
}
},
"read-pkg-up": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
"integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
"dev": true,
"requires": {
"find-up": "^4.1.0",
"read-pkg": "^5.2.0",
"type-fest": "^0.8.1"
},
"dependencies": {
"find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"requires": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
}
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"requires": {
"p-locate": "^4.1.0"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"requires": {
"p-limit": "^2.2.0"
}
},
"type-fest": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
"integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
"dev": true
}
}
},
"readable-stream": { "readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@ -19421,6 +19979,29 @@
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
"integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
}, },
"regexp-tree": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
"integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==",
"dev": true
},
"regjsparser": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.10.0.tgz",
"integrity": "sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==",
"dev": true,
"requires": {
"jsesc": "~0.5.0"
},
"dependencies": {
"jsesc": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
"integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
"dev": true
}
}
},
"repeat-string": { "repeat-string": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
@ -19901,6 +20482,38 @@
} }
} }
}, },
"spdx-correct": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
"integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
"dev": true,
"requires": {
"spdx-expression-parse": "^3.0.0",
"spdx-license-ids": "^3.0.0"
}
},
"spdx-exceptions": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.4.0.tgz",
"integrity": "sha512-hcjppoJ68fhxA/cjbN4T8N6uCUejN8yFw69ttpqtBeCbF3u13n7mb31NB9jKwGTTWWnt9IbRA/mf1FprYS8wfw==",
"dev": true
},
"spdx-expression-parse": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
"integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
"dev": true,
"requires": {
"spdx-exceptions": "^2.1.0",
"spdx-license-ids": "^3.0.0"
}
},
"spdx-license-ids": {
"version": "3.0.16",
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz",
"integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==",
"dev": true
},
"split-ca": { "split-ca": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
@ -20063,6 +20676,15 @@
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true "dev": true
}, },
"strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"requires": {
"min-indent": "^1.0.0"
}
},
"strip-json-comments": { "strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -20892,6 +21514,16 @@
} }
} }
}, },
"validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
"integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
"dev": true,
"requires": {
"spdx-correct": "^3.0.0",
"spdx-expression-parse": "^3.0.0"
}
},
"validator": { "validator": {
"version": "13.11.0", "version": "13.11.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz",

View File

@ -105,9 +105,10 @@
"@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1", "@typescript-eslint/parser": "^6.4.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"eslint": "^8.48.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^50.0.1",
"jest": "^29.6.4", "jest": "^29.6.4",
"jest-when": "^3.6.0", "jest-when": "^3.6.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
@ -122,7 +123,7 @@
"ts-loader": "^9.4.4", "ts-loader": "^9.4.4",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.2.2", "typescript": "^5.3.3",
"utimes": "^5.2.1" "utimes": "^5.2.1"
}, },
"jest": { "jest": {

View File

@ -107,42 +107,51 @@ export class AccessCore {
const sharedLinkId = sharedLink.id; const sharedLinkId = sharedLink.id;
switch (permission) { switch (permission) {
case Permission.ASSET_READ: case Permission.ASSET_READ: {
return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids); return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids);
}
case Permission.ASSET_VIEW: case Permission.ASSET_VIEW: {
return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids); return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids);
}
case Permission.ASSET_DOWNLOAD: case Permission.ASSET_DOWNLOAD: {
return !!sharedLink.allowDownload return sharedLink.allowDownload
? await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids) ? await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids)
: new Set(); : new Set();
}
case Permission.ASSET_UPLOAD: case Permission.ASSET_UPLOAD: {
return sharedLink.allowUpload ? ids : new Set(); return sharedLink.allowUpload ? ids : new Set();
}
case Permission.ASSET_SHARE: case Permission.ASSET_SHARE: {
// TODO: fix this to not use sharedLink.userId for access control // TODO: fix this to not use sharedLink.userId for access control
return await this.repository.asset.checkOwnerAccess(sharedLink.userId, ids); return await this.repository.asset.checkOwnerAccess(sharedLink.userId, ids);
}
case Permission.ALBUM_READ: case Permission.ALBUM_READ: {
return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids); return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids);
}
case Permission.ALBUM_DOWNLOAD: case Permission.ALBUM_DOWNLOAD: {
return !!sharedLink.allowDownload return sharedLink.allowDownload
? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids) ? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids)
: new Set(); : new Set();
}
default: default: {
return new Set(); return new Set();
}
} }
} }
private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set<string>) { private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set<string>) {
switch (permission) { switch (permission) {
// uses album id // uses album id
case Permission.ACTIVITY_CREATE: case Permission.ACTIVITY_CREATE: {
return await this.repository.activity.checkCreateAccess(auth.user.id, ids); return await this.repository.activity.checkCreateAccess(auth.user.id, ids);
}
// uses activity id // uses activity id
case Permission.ACTIVITY_DELETE: { case Permission.ACTIVITY_DELETE: {
@ -190,14 +199,17 @@ export class AccessCore {
return setUnion(isOwner, isAlbum, isPartner); return setUnion(isOwner, isAlbum, isPartner);
} }
case Permission.ASSET_UPDATE: case Permission.ASSET_UPDATE: {
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ASSET_DELETE: case Permission.ASSET_DELETE: {
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ASSET_RESTORE: case Permission.ASSET_RESTORE: {
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ALBUM_READ: { case Permission.ALBUM_READ: {
const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids);
@ -205,14 +217,17 @@ export class AccessCore {
return setUnion(isOwner, isShared); return setUnion(isOwner, isShared);
} }
case Permission.ALBUM_UPDATE: case Permission.ALBUM_UPDATE: {
return await this.repository.album.checkOwnerAccess(auth.user.id, ids); return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ALBUM_DELETE: case Permission.ALBUM_DELETE: {
return await this.repository.album.checkOwnerAccess(auth.user.id, ids); return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ALBUM_SHARE: case Permission.ALBUM_SHARE: {
return await this.repository.album.checkOwnerAccess(auth.user.id, ids); return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ALBUM_DOWNLOAD: { case Permission.ALBUM_DOWNLOAD: {
const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids);
@ -220,17 +235,21 @@ export class AccessCore {
return setUnion(isOwner, isShared); return setUnion(isOwner, isShared);
} }
case Permission.ALBUM_REMOVE_ASSET: case Permission.ALBUM_REMOVE_ASSET: {
return await this.repository.album.checkOwnerAccess(auth.user.id, ids); return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ASSET_UPLOAD: case Permission.ASSET_UPLOAD: {
return await this.repository.library.checkOwnerAccess(auth.user.id, ids); return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ARCHIVE_READ: case Permission.ARCHIVE_READ: {
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
}
case Permission.AUTH_DEVICE_DELETE: case Permission.AUTH_DEVICE_DELETE: {
return await this.repository.authDevice.checkOwnerAccess(auth.user.id, ids); return await this.repository.authDevice.checkOwnerAccess(auth.user.id, ids);
}
case Permission.TIMELINE_READ: { case Permission.TIMELINE_READ: {
const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>(); const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>();
@ -238,8 +257,9 @@ export class AccessCore {
return setUnion(isOwner, isPartner); return setUnion(isOwner, isPartner);
} }
case Permission.TIMELINE_DOWNLOAD: case Permission.TIMELINE_DOWNLOAD: {
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
}
case Permission.LIBRARY_READ: { case Permission.LIBRARY_READ: {
const isOwner = await this.repository.library.checkOwnerAccess(auth.user.id, ids); const isOwner = await this.repository.library.checkOwnerAccess(auth.user.id, ids);
@ -247,32 +267,41 @@ export class AccessCore {
return setUnion(isOwner, isPartner); return setUnion(isOwner, isPartner);
} }
case Permission.LIBRARY_UPDATE: case Permission.LIBRARY_UPDATE: {
return await this.repository.library.checkOwnerAccess(auth.user.id, ids); return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
}
case Permission.LIBRARY_DELETE: case Permission.LIBRARY_DELETE: {
return await this.repository.library.checkOwnerAccess(auth.user.id, ids); return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_READ: case Permission.PERSON_READ: {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids); return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_WRITE: case Permission.PERSON_WRITE: {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids); return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_MERGE: case Permission.PERSON_MERGE: {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids); return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_CREATE: case Permission.PERSON_CREATE: {
return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids); return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_REASSIGN: case Permission.PERSON_REASSIGN: {
return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids); return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.PARTNER_UPDATE: case Permission.PARTNER_UPDATE: {
return await this.repository.partner.checkUpdateAccess(auth.user.id, ids); return await this.repository.partner.checkUpdateAccess(auth.user.id, ids);
}
default: default: {
return new Set(); return new Set();
}
} }
} }
} }

View File

@ -35,7 +35,7 @@ export class ActivityService {
isLiked: dto.type && dto.type === ReactionType.LIKE, isLiked: dto.type && dto.type === ReactionType.LIKE,
}); });
return activities.map(mapActivity); return activities.map((activity) => mapActivity(activity));
} }
async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> { async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {

View File

@ -27,10 +27,11 @@ export class AlbumResponseDto {
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => {
const sharedUsers: UserResponseDto[] = []; const sharedUsers: UserResponseDto[] = [];
entity.sharedUsers?.forEach((user) => { if (entity.sharedUsers) {
const userDto = mapUser(user); for (const user of entity.sharedUsers) {
sharedUsers.push(userDto); sharedUsers.push(mapUser(user));
}); }
}
const assets = entity.assets || []; const assets = entity.assets || [];
@ -41,9 +42,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumRespons
let endDate = assets.at(-1)?.fileCreatedAt || undefined; let endDate = assets.at(-1)?.fileCreatedAt || undefined;
// Swap dates if start date is greater than end date. // Swap dates if start date is greater than end date.
if (startDate && endDate && startDate > endDate) { if (startDate && endDate && startDate > endDate) {
const temp = startDate; [startDate, endDate] = [endDate, startDate];
startDate = endDate;
endDate = temp;
} }
return { return {

View File

@ -69,19 +69,17 @@ export class AlbumService {
// Get asset count for each album. Then map the result to an object: // Get asset count for each album. Then map the result to an object:
// { [albumId]: assetCount } // { [albumId]: assetCount }
const albumMetadataForIds = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id)); const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
const albumMetadataForIdsObj: Record<string, AlbumAssetCount> = albumMetadataForIds.reduce( const albumMetadata: Record<string, AlbumAssetCount> = {};
(obj: Record<string, AlbumAssetCount>, { albumId, assetCount, startDate, endDate }) => { for (const metadata of results) {
obj[albumId] = { const { albumId, assetCount, startDate, endDate } = metadata;
albumId, albumMetadata[albumId] = {
assetCount, albumId,
startDate, assetCount,
endDate, startDate,
}; endDate,
return obj; };
}, }
{},
);
return Promise.all( return Promise.all(
albums.map(async (album) => { albums.map(async (album) => {
@ -89,9 +87,9 @@ export class AlbumService {
return { return {
...mapAlbumWithoutAssets(album), ...mapAlbumWithoutAssets(album),
sharedLinks: undefined, sharedLinks: undefined,
startDate: albumMetadataForIdsObj[album.id].startDate, startDate: albumMetadata[album.id].startDate,
endDate: albumMetadataForIdsObj[album.id].endDate, endDate: albumMetadata[album.id].endDate,
assetCount: albumMetadataForIdsObj[album.id].assetCount, assetCount: albumMetadata[album.id].assetCount,
lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt, lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt,
}; };
}), }),

View File

@ -12,7 +12,7 @@ export class APIKeyService {
) {} ) {}
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> { async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const secret = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, ''); const secret = this.crypto.randomBytes(32).toString('base64').replaceAll(/\W/g, '');
const entity = await this.repository.create({ const entity = await this.repository.create({
key: this.crypto.hashSha256(secret), key: this.crypto.hashSha256(secret),
name: dto.name || 'API Key', name: dto.name || 'API Key',

View File

@ -1009,9 +1009,7 @@ describe(AssetService.name, () => {
it('get assets by device id', async () => { it('get assets by device id', async () => {
const assets = [assetStub.image, assetStub.image1]; const assets = [assetStub.image, assetStub.image1];
assetMock.getAllByDeviceId.mockImplementation(() => assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId));
Promise.resolve<string[]>(Array.from(assets.map((asset) => asset.deviceAssetId))),
);
const deviceId = 'device-id'; const deviceId = 'device-id';
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);

View File

@ -3,7 +3,7 @@ import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, Inject } from '@nestjs/common'; import { BadRequestException, Inject } from '@nestjs/common';
import _ from 'lodash'; import _ from 'lodash';
import { DateTime, Duration } from 'luxon'; import { DateTime, Duration } from 'luxon';
import { extname } from 'path'; import { extname } from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { AccessCore, Permission } from '../access'; import { AccessCore, Permission } from '../access';
import { AuthDto } from '../auth'; import { AuthDto } from '../auth';
@ -93,7 +93,7 @@ export class AssetService {
} }
search(auth: AuthDto, dto: AssetSearchDto) { search(auth: AuthDto, dto: AssetSearchDto) {
let checksum: Buffer | undefined = undefined; let checksum: Buffer | undefined;
if (dto.checksum) { if (dto.checksum) {
const encoding = dto.checksum.length === 28 ? 'base64' : 'hex'; const encoding = dto.checksum.length === 28 ? 'base64' : 'hex';
@ -126,29 +126,33 @@ export class AssetService {
const filename = file.originalName; const filename = file.originalName;
switch (fieldName) { switch (fieldName) {
case UploadFieldName.ASSET_DATA: case UploadFieldName.ASSET_DATA: {
if (mimeTypes.isAsset(filename)) { if (mimeTypes.isAsset(filename)) {
return true; return true;
} }
break; break;
}
case UploadFieldName.LIVE_PHOTO_DATA: case UploadFieldName.LIVE_PHOTO_DATA: {
if (mimeTypes.isVideo(filename)) { if (mimeTypes.isVideo(filename)) {
return true; return true;
} }
break; break;
}
case UploadFieldName.SIDECAR_DATA: case UploadFieldName.SIDECAR_DATA: {
if (mimeTypes.isSidecar(filename)) { if (mimeTypes.isSidecar(filename)) {
return true; return true;
} }
break; break;
}
case UploadFieldName.PROFILE_DATA: case UploadFieldName.PROFILE_DATA: {
if (mimeTypes.isProfile(filename)) { if (mimeTypes.isProfile(filename)) {
return true; return true;
} }
break; break;
}
} }
this.logger.error(`Unsupported file type ${filename}`); this.logger.error(`Unsupported file type ${filename}`);
@ -158,13 +162,13 @@ export class AssetService {
getUploadFilename({ auth, fieldName, file }: UploadRequest): string { getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
this.access.requireUploadAccess(auth); this.access.requireUploadAccess(auth);
const originalExt = extname(file.originalName); const originalExtension = extname(file.originalName);
const lookup = { const lookup = {
[UploadFieldName.ASSET_DATA]: originalExt, [UploadFieldName.ASSET_DATA]: originalExtension,
[UploadFieldName.LIVE_PHOTO_DATA]: '.mov', [UploadFieldName.LIVE_PHOTO_DATA]: '.mov',
[UploadFieldName.SIDECAR_DATA]: '.xmp', [UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: originalExt, [UploadFieldName.PROFILE_DATA]: originalExtension,
}; };
return sanitize(`${file.uuid}${lookup[fieldName]}`); return sanitize(`${file.uuid}${lookup[fieldName]}`);
@ -247,11 +251,9 @@ export class AssetService {
await this.timeBucketChecks(auth, dto); await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
if (!auth.sharedLink || auth.sharedLink?.showExif) { return !auth.sharedLink || auth.sharedLink?.showExif
return assets.map((asset) => mapAsset(asset, { withStack: true })); ? assets.map((asset) => mapAsset(asset, { withStack: true }))
} else { : assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
return assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
}
} }
async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> { async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {
@ -371,14 +373,14 @@ export class AssetService {
const assetsWithChildren = assets.filter((a) => a.stack && a.stack.assets.length > 0); const assetsWithChildren = assets.filter((a) => a.stack && a.stack.assets.length > 0);
ids.push(...assetsWithChildren.flatMap((child) => child.stack!.assets.map((gChild) => gChild.id))); ids.push(...assetsWithChildren.flatMap((child) => child.stack!.assets.map((gChild) => gChild.id)));
if (!stack) { if (stack) {
stack = await this.assetStackRepository.create({ await this.assetStackRepository.update({
id: stack.id,
primaryAssetId: primaryAsset.id, primaryAssetId: primaryAsset.id,
assets: ids.map((id) => ({ id }) as AssetEntity), assets: ids.map((id) => ({ id }) as AssetEntity),
}); });
} else { } else {
await this.assetStackRepository.update({ stack = await this.assetStackRepository.create({
id: stack.id,
primaryAssetId: primaryAsset.id, primaryAssetId: primaryAsset.id,
assets: ids.map((id) => ({ id }) as AssetEntity), assets: ids.map((id) => ({ id }) as AssetEntity),
}); });
@ -394,9 +396,10 @@ export class AssetService {
} }
await this.assetRepository.updateAll(ids, options); await this.assetRepository.updateAll(ids, options);
const stacksToDelete = ( const stackIdsToDelete = await Promise.all(
await Promise.all(stackIdsToCheckForDelete.map((id) => this.assetStackRepository.getById(id))) stackIdsToCheckForDelete.map((id) => this.assetStackRepository.getById(id)),
) );
const stacksToDelete = stackIdsToDelete
.flatMap((stack) => (stack ? [stack] : [])) .flatMap((stack) => (stack ? [stack] : []))
.filter((stack) => stack.assets.length < 2); .filter((stack) => stack.assets.length < 2);
await Promise.all(stacksToDelete.map((as) => this.assetStackRepository.delete(as.id))); await Promise.all(stacksToDelete.map((as) => this.assetStackRepository.delete(as.id)));
@ -510,9 +513,8 @@ export class AssetService {
throw new Error('Asset not found or not in a stack'); throw new Error('Asset not found or not in a stack');
} }
if (oldParent != null) { if (oldParent != null) {
childIds.push(oldParent.id);
// Get all children of old parent // Get all children of old parent
childIds.push(...(oldParent.stack?.assets.map((a) => a.id) ?? [])); childIds.push(oldParent.id, ...(oldParent.stack?.assets.map((a) => a.id) ?? []));
} }
await this.assetStackRepository.update({ await this.assetStackRepository.update({
id: oldParent.stackId, id: oldParent.stackId,
@ -530,17 +532,20 @@ export class AssetService {
for (const id of dto.assetIds) { for (const id of dto.assetIds) {
switch (dto.name) { switch (dto.name) {
case AssetJobName.REFRESH_METADATA: case AssetJobName.REFRESH_METADATA: {
jobs.push({ name: JobName.METADATA_EXTRACTION, data: { id } }); jobs.push({ name: JobName.METADATA_EXTRACTION, data: { id } });
break; break;
}
case AssetJobName.REGENERATE_THUMBNAIL: case AssetJobName.REGENERATE_THUMBNAIL: {
jobs.push({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id } }); jobs.push({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id } });
break; break;
}
case AssetJobName.TRANSCODE_VIDEO: case AssetJobName.TRANSCODE_VIDEO: {
jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id } }); jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id } });
break; break;
}
} }
} }

View File

@ -26,7 +26,6 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
libraryId!: string; libraryId!: string;
originalPath!: string; originalPath!: string;
originalFileName!: string; originalFileName!: string;
resized!: boolean;
fileCreatedAt!: Date; fileCreatedAt!: Date;
fileModifiedAt!: Date; fileModifiedAt!: Date;
updatedAt!: Date; updatedAt!: Date;
@ -56,7 +55,7 @@ export type AssetMapOptions = {
const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => { const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => {
const result: PersonWithFacesResponseDto[] = []; const result: PersonWithFacesResponseDto[] = [];
if (faces) { if (faces) {
faces.forEach((face) => { for (const face of faces) {
if (face.person) { if (face.person) {
const existingPersonEntry = result.find((item) => item.id === face.person!.id); const existingPersonEntry = result.find((item) => item.id === face.person!.id);
if (existingPersonEntry) { if (existingPersonEntry) {
@ -65,7 +64,7 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[]
result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face)] }); result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face)] });
} }
} }
}); }
} }
return result; return result;

View File

@ -33,7 +33,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
model: entity.model, model: entity.model,
exifImageWidth: entity.exifImageWidth, exifImageWidth: entity.exifImageWidth,
exifImageHeight: entity.exifImageHeight, exifImageHeight: entity.exifImageHeight,
fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null, fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation, orientation: entity.orientation,
dateTimeOriginal: entity.dateTimeOriginal, dateTimeOriginal: entity.dateTimeOriginal,
modifyDate: entity.modifyDate, modifyDate: entity.modifyDate,
@ -55,7 +55,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto { export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto {
return { return {
fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null, fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation, orientation: entity.orientation,
dateTimeOriginal: entity.dateTimeOriginal, dateTimeOriginal: entity.dateTimeOriginal,
timeZone: entity.timeZone, timeZone: entity.timeZone,

View File

@ -91,40 +91,50 @@ export class AuditService {
} }
switch (pathType) { switch (pathType) {
case AssetPathType.ENCODED_VIDEO: case AssetPathType.ENCODED_VIDEO: {
await this.assetRepository.save({ id, encodedVideoPath: pathValue }); await this.assetRepository.save({ id, encodedVideoPath: pathValue });
break; break;
}
case AssetPathType.JPEG_THUMBNAIL: case AssetPathType.JPEG_THUMBNAIL: {
await this.assetRepository.save({ id, resizePath: pathValue }); await this.assetRepository.save({ id, resizePath: pathValue });
break; break;
}
case AssetPathType.WEBP_THUMBNAIL: case AssetPathType.WEBP_THUMBNAIL: {
await this.assetRepository.save({ id, webpPath: pathValue }); await this.assetRepository.save({ id, webpPath: pathValue });
break; break;
}
case AssetPathType.ORIGINAL: case AssetPathType.ORIGINAL: {
await this.assetRepository.save({ id, originalPath: pathValue }); await this.assetRepository.save({ id, originalPath: pathValue });
break; break;
}
case AssetPathType.SIDECAR: case AssetPathType.SIDECAR: {
await this.assetRepository.save({ id, sidecarPath: pathValue }); await this.assetRepository.save({ id, sidecarPath: pathValue });
break; break;
}
case PersonPathType.FACE: case PersonPathType.FACE: {
await this.personRepository.update({ id, thumbnailPath: pathValue }); await this.personRepository.update({ id, thumbnailPath: pathValue });
break; break;
}
case UserPathType.PROFILE: case UserPathType.PROFILE: {
await this.userRepository.update(id, { profileImagePath: pathValue }); await this.userRepository.update(id, { profileImagePath: pathValue });
break; break;
}
} }
} }
} }
private fullPath(filename: string) {
return resolve(filename);
}
async getFileReport() { async getFileReport() {
const fullPath = (filename: string) => resolve(filename); const hasFile = (items: Set<string>, filename: string) => items.has(filename) || items.has(this.fullPath(filename));
const hasFile = (items: Set<string>, filename: string) => items.has(filename) || items.has(fullPath(filename));
const crawl = async (folder: StorageFolder) => const crawl = async (folder: StorageFolder) =>
new Set( new Set(
await this.storageRepository.crawl({ await this.storageRepository.crawl({
@ -150,7 +160,7 @@ export class AuditService {
return; return;
} }
allFiles.delete(filename); allFiles.delete(filename);
allFiles.delete(fullPath(filename)); allFiles.delete(this.fullPath(filename));
}; };
this.logger.log( this.logger.log(
@ -226,7 +236,7 @@ export class AuditService {
// send as absolute paths // send as absolute paths
for (const orphan of orphans) { for (const orphan of orphans) {
orphan.pathValue = fullPath(orphan.pathValue); orphan.pathValue = this.fullPath(orphan.pathValue);
} }
return { orphans, extras }; return { orphans, extras };

View File

@ -18,7 +18,7 @@ import {
userStub, userStub,
userTokenStub, userTokenStub,
} from '@test'; } from '@test';
import { IncomingHttpHeaders } from 'http'; import { IncomingHttpHeaders } from 'node:http';
import { Issuer, generators } from 'openid-client'; import { Issuer, generators } from 'openid-client';
import { Socket } from 'socket.io'; import { Socket } from 'socket.io';
import { import {

View File

@ -8,8 +8,8 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import cookieParser from 'cookie'; import cookieParser from 'cookie';
import { IncomingHttpHeaders } from 'http';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import { AccessCore, Permission } from '../access'; import { AccessCore, Permission } from '../access';
import { import {
@ -85,7 +85,7 @@ export class AuthService {
this.configCore = SystemConfigCore.create(configRepository); this.configCore = SystemConfigCore.create(configRepository);
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
custom.setHttpOptionsDefaults({ timeout: 30000 }); custom.setHttpOptionsDefaults({ timeout: 30_000 });
} }
async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> { async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> {
@ -213,7 +213,8 @@ export class AuthService {
} }
const { scope, buttonText, autoLaunch } = config.oauth; const { scope, buttonText, autoLaunch } = config.oauth;
const url = (await this.getOAuthClient(config)).authorizationUrl({ const oauthClient = await this.getOAuthClient(config);
const url = oauthClient.authorizationUrl({
redirect_uri: this.normalize(config, dto.redirectUri), redirect_uri: this.normalize(config, dto.redirectUri),
scope, scope,
state: generators.state(), state: generators.state(),
@ -376,12 +377,10 @@ export class AuthService {
const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url'); const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
const sharedLink = await this.sharedLinkRepository.getByKey(bytes); const sharedLink = await this.sharedLinkRepository.getByKey(bytes);
if (sharedLink) { if (sharedLink && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) {
if (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date()) { const user = sharedLink.user;
const user = sharedLink.user; if (user) {
if (user) { return { user, sharedLink };
return { user, sharedLink };
}
} }
} }
throw new UnauthorizedException('Invalid share key'); throw new UnauthorizedException('Invalid share key');
@ -423,7 +422,7 @@ export class AuthService {
} }
private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) { private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
const key = this.cryptoRepository.randomBytes(32).toString('base64').replace(/\W/g, ''); const key = this.cryptoRepository.randomBytes(32).toString('base64').replaceAll(/\W/g, '');
const token = this.cryptoRepository.hashSha256(key); const token = this.cryptoRepository.hashSha256(key);
await this.userTokenRepository.create({ await this.userTokenRepository.create({

View File

@ -50,14 +50,14 @@ export class DatabaseService {
} }
private async createVectors() { private async createVectors() {
await this.databaseRepository.createExtension(DatabaseExtension.VECTORS).catch(async (err: QueryFailedError) => { await this.databaseRepository.createExtension(DatabaseExtension.VECTORS).catch(async (error: QueryFailedError) => {
const image = await this.getVectorsImage(); const image = await this.getVectorsImage();
this.logger.fatal(` this.logger.fatal(`
Failed to create pgvecto.rs extension. Failed to create pgvecto.rs extension.
If you have not updated your Postgres instance to a docker image that supports pgvecto.rs (such as '${image}'), please do so. If you have not updated your Postgres instance to a docker image that supports pgvecto.rs (such as '${image}'), please do so.
See the v1.91.0 release notes for more info: https://github.com/immich-app/immich/releases/tag/v1.91.0' See the v1.91.0 release notes for more info: https://github.com/immich-app/immich/releases/tag/v1.91.0'
`); `);
throw err; throw error;
}); });
} }

View File

@ -108,9 +108,9 @@ describe('mimeTypes', () => {
expect(keys).toEqual([...keys].sort()); expect(keys).toEqual([...keys].sort());
}); });
for (const [ext, v] of Object.entries(mimeTypes.profile)) { for (const [extension, v] of Object.entries(mimeTypes.profile)) {
it(`should lookup ${ext}`, () => { it(`should lookup ${extension}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]); expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]);
}); });
} }
}); });
@ -135,9 +135,9 @@ describe('mimeTypes', () => {
expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('image/'))); expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('image/')));
}); });
for (const [ext, v] of Object.entries(mimeTypes.image)) { for (const [extension, v] of Object.entries(mimeTypes.image)) {
it(`should lookup ${ext}`, () => { it(`should lookup ${extension}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]); expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]);
}); });
} }
}); });
@ -162,9 +162,9 @@ describe('mimeTypes', () => {
expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('video/'))); expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('video/')));
}); });
for (const [ext, v] of Object.entries(mimeTypes.video)) { for (const [extension, v] of Object.entries(mimeTypes.video)) {
it(`should lookup ${ext}`, () => { it(`should lookup ${extension}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]); expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]);
}); });
} }
}); });
@ -188,9 +188,9 @@ describe('mimeTypes', () => {
expect(Object.values(mimeTypes.sidecar).flat()).toEqual(['application/xml', 'text/xml']); expect(Object.values(mimeTypes.sidecar).flat()).toEqual(['application/xml', 'text/xml']);
}); });
for (const [ext, v] of Object.entries(mimeTypes.sidecar)) { for (const [extension, v] of Object.entries(mimeTypes.sidecar)) {
it(`should lookup ${ext}`, () => { it(`should lookup ${extension}`, () => {
expect(mimeTypes.lookup(`it.${ext}`)).toEqual(v[0]); expect(mimeTypes.lookup(`it.${extension}`)).toEqual(v[0]);
}); });
} }
}); });

View File

@ -3,8 +3,6 @@ import { Duration } from 'luxon';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { extname, join } from 'node:path'; import { extname, join } from 'node:path';
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'));
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 }); export const ONE_HOUR = Duration.fromObject({ hours: 1 });
@ -31,7 +29,7 @@ export class Version implements IVersion {
} }
static fromString(version: string): Version { static fromString(version: string): Version {
const regex = /(?:v)?(?<major>\d+)(?:\.(?<minor>\d+))?(?:[\.-](?<patch>\d+))?/i; const regex = /v?(?<major>\d+)(?:\.(?<minor>\d+))?(?:[.-](?<patch>\d+))?/i;
const matchResult = version.match(regex); const matchResult = version.match(regex);
if (matchResult) { if (matchResult) {
const { major, minor = '0', patch = '0' } = matchResult.groups as { [K in keyof IVersion]: string }; const { major, minor = '0', patch = '0' } = matchResult.groups as { [K in keyof IVersion]: string };
@ -68,7 +66,8 @@ export class Version implements IVersion {
export const envName = (process.env.NODE_ENV || 'development').toUpperCase(); export const envName = (process.env.NODE_ENV || 'development').toUpperCase();
export const isDev = process.env.NODE_ENV === 'development'; export const isDev = process.env.NODE_ENV === 'development';
export const serverVersion = Version.fromString(pkg.version); const { version } = JSON.parse(readFileSync('./package.json', 'utf8'));
export const serverVersion = Version.fromString(version);
export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload';
@ -129,9 +128,9 @@ const image: Record<string, string[]> = {
'.x3f': ['image/x3f', 'image/x-sigma-x3f'], '.x3f': ['image/x3f', 'image/x-sigma-x3f'],
}; };
const profileExtensions = ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp']; const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp']);
const profile: Record<string, string[]> = Object.fromEntries( const profile: Record<string, string[]> = Object.fromEntries(
Object.entries(image).filter(([key]) => profileExtensions.includes(key)), Object.entries(image).filter(([key]) => profileExtensions.has(key)),
); );
const video: Record<string, string[]> = { const video: Record<string, string[]> = {
@ -180,5 +179,5 @@ export const mimeTypes = {
} }
return AssetType.OTHER; return AssetType.OTHER;
}, },
getSupportedFileExtensions: () => Object.keys(image).concat(Object.keys(video)), getSupportedFileExtensions: () => [...Object.keys(image), ...Object.keys(video)],
}; };

View File

@ -46,7 +46,8 @@ export type Options = {
export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED';
export function ValidateUUID({ optional, each }: Options = { optional: false, each: false }) { export function ValidateUUID(options?: Options) {
const { optional, each } = { optional: false, each: false, ...options };
return applyDecorators( return applyDecorators(
IsUUID('4', { each }), IsUUID('4', { each }),
ApiProperty({ format: 'uuid' }), ApiProperty({ format: 'uuid' }),
@ -58,7 +59,7 @@ export function ValidateUUID({ optional, each }: Options = { optional: false, ea
export function validateCronExpression(expression: string) { export function validateCronExpression(expression: string) {
try { try {
new CronJob(expression, () => {}); new CronJob(expression, () => {});
} catch (error) { } catch {
return false; return false;
} }
@ -96,7 +97,7 @@ export const toBoolean = ({ value }: IValue) => {
export const toEmail = ({ value }: IValue) => value?.toLowerCase(); export const toEmail = ({ value }: IValue) => value?.toLowerCase();
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replace(/\./g, '')); export const toSanitized = ({ value }: IValue) => sanitize((value || '').replaceAll('.', ''));
export function getFileNameWithoutExtension(path: string): string { export function getFileNameWithoutExtension(path: string): string {
return basename(path, extname(path)); return basename(path, extname(path));
@ -173,7 +174,7 @@ export function Optional({ nullable, ...validationOptions }: OptionalOptions = {
return IsOptional(validationOptions); return IsOptional(validationOptions);
} }
return ValidateIf((obj: any, v: any) => v !== undefined, validationOptions); return ValidateIf((object: any, v: any) => v !== undefined, validationOptions);
} }
/** /**
@ -186,8 +187,8 @@ export function chunks<T>(collection: Array<T> | Set<T>, size: number): T[][] {
if (collection instanceof Set) { if (collection instanceof Set) {
const result = []; const result = [];
let chunk = []; let chunk = [];
for (const elem of collection) { for (const element of collection) {
chunk.push(elem); chunk.push(element);
if (chunk.length === size) { if (chunk.length === size) {
result.push(chunk); result.push(chunk);
chunk = []; chunk = [];
@ -209,8 +210,8 @@ export function chunks<T>(collection: Array<T> | Set<T>, size: number): T[][] {
export const setUnion = <T>(...sets: Set<T>[]): Set<T> => { export const setUnion = <T>(...sets: Set<T>[]): Set<T> => {
const union = new Set(sets[0]); const union = new Set(sets[0]);
for (const set of sets.slice(1)) { for (const set of sets.slice(1)) {
for (const elem of set) { for (const element of set) {
union.add(elem); union.add(element);
} }
} }
return union; return union;
@ -219,16 +220,16 @@ export const setUnion = <T>(...sets: Set<T>[]): Set<T> => {
export const setDifference = <T>(setA: Set<T>, ...sets: Set<T>[]): Set<T> => { export const setDifference = <T>(setA: Set<T>, ...sets: Set<T>[]): Set<T> => {
const difference = new Set(setA); const difference = new Set(setA);
for (const set of sets) { for (const set of sets) {
for (const elem of set) { for (const element of set) {
difference.delete(elem); difference.delete(element);
} }
} }
return difference; return difference;
}; };
export const setIsSuperset = <T>(set: Set<T>, subset: Set<T>): boolean => { export const setIsSuperset = <T>(set: Set<T>, subset: Set<T>): boolean => {
for (const elem of subset) { for (const element of subset) {
if (!set.has(elem)) { if (!set.has(element)) {
return false; return false;
} }
} }

View File

@ -1,6 +1,6 @@
import { AssetEntity } from '@app/infra/entities'; import { AssetEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { extname } from 'path'; import { extname } from 'node:path';
import { AccessCore, Permission } from '../access'; import { AccessCore, Permission } from '../access';
import { AssetIdsDto } from '../asset'; import { AssetIdsDto } from '../asset';
import { AuthDto } from '../auth'; import { AuthDto } from '../auth';
@ -68,10 +68,12 @@ export class DownloadService {
} }
} }
return { let totalSize = 0;
totalSize: archives.reduce((total, item) => (total += item.size), 0), for (const archive of archives) {
archives, totalSize += archive.size;
}; }
return { totalSize, archives };
} }
async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> { async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
@ -82,12 +84,12 @@ export class DownloadService {
const paths: Record<string, number> = {}; const paths: Record<string, number> = {};
for (const { originalPath, originalFileName } of assets) { for (const { originalPath, originalFileName } of assets) {
const ext = extname(originalPath); const extension = extname(originalPath);
let filename = `${originalFileName}${ext}`; let filename = `${originalFileName}${extension}`;
const count = paths[filename] || 0; const count = paths[filename] || 0;
paths[filename] = count + 1; paths[filename] = count + 1;
if (count !== 0) { if (count !== 0) {
filename = `${originalFileName}+${count}${ext}`; filename = `${originalFileName}+${count}${extension}`;
} }
zip.addFile(originalPath, filename); zip.addFile(originalPath, filename);

View File

@ -23,7 +23,7 @@ import { JobService } from './job.service';
const makeMockHandlers = (success: boolean) => { const makeMockHandlers = (success: boolean) => {
const mock = jest.fn().mockResolvedValue(success); const mock = jest.fn().mockResolvedValue(success);
return Object.values(JobName).reduce((map, jobName) => ({ ...map, [jobName]: mock }), {}) as Record< return Object.fromEntries(Object.values(JobName).map((jobName) => [jobName, mock])) as unknown as Record<
JobName, JobName,
JobHandler JobHandler
>; >;

View File

@ -36,26 +36,31 @@ export class JobService {
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`); this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
switch (dto.command) { switch (dto.command) {
case JobCommand.START: case JobCommand.START: {
await this.start(queueName, dto); await this.start(queueName, dto);
break; break;
}
case JobCommand.PAUSE: case JobCommand.PAUSE: {
await this.jobRepository.pause(queueName); await this.jobRepository.pause(queueName);
break; break;
}
case JobCommand.RESUME: case JobCommand.RESUME: {
await this.jobRepository.resume(queueName); await this.jobRepository.resume(queueName);
break; break;
}
case JobCommand.EMPTY: case JobCommand.EMPTY: {
await this.jobRepository.empty(queueName); await this.jobRepository.empty(queueName);
break; break;
}
case JobCommand.CLEAR_FAILED: case JobCommand.CLEAR_FAILED: {
const failedJobs = await this.jobRepository.clear(queueName, QueueCleanType.FAILED); const failedJobs = await this.jobRepository.clear(queueName, QueueCleanType.FAILED);
this.logger.debug(`Cleared failed jobs: ${failedJobs}`); this.logger.debug(`Cleared failed jobs: ${failedJobs}`);
break; break;
}
} }
return this.getJobStatus(queueName); return this.getJobStatus(queueName);
@ -85,42 +90,53 @@ export class JobService {
} }
switch (name) { switch (name) {
case QueueName.VIDEO_CONVERSION: case QueueName.VIDEO_CONVERSION: {
return this.jobRepository.queue({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force } }); return this.jobRepository.queue({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force } });
}
case QueueName.STORAGE_TEMPLATE_MIGRATION: case QueueName.STORAGE_TEMPLATE_MIGRATION: {
return this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); return this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
}
case QueueName.MIGRATION: case QueueName.MIGRATION: {
return this.jobRepository.queue({ name: JobName.QUEUE_MIGRATION }); return this.jobRepository.queue({ name: JobName.QUEUE_MIGRATION });
}
case QueueName.SMART_SEARCH: case QueueName.SMART_SEARCH: {
await this.configCore.requireFeature(FeatureFlag.SMART_SEARCH); await this.configCore.requireFeature(FeatureFlag.SMART_SEARCH);
return this.jobRepository.queue({ name: JobName.QUEUE_SMART_SEARCH, data: { force } }); return this.jobRepository.queue({ name: JobName.QUEUE_SMART_SEARCH, data: { force } });
}
case QueueName.METADATA_EXTRACTION: case QueueName.METADATA_EXTRACTION: {
return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } }); return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } });
}
case QueueName.SIDECAR: case QueueName.SIDECAR: {
await this.configCore.requireFeature(FeatureFlag.SIDECAR); await this.configCore.requireFeature(FeatureFlag.SIDECAR);
return this.jobRepository.queue({ name: JobName.QUEUE_SIDECAR, data: { force } }); return this.jobRepository.queue({ name: JobName.QUEUE_SIDECAR, data: { force } });
}
case QueueName.THUMBNAIL_GENERATION: case QueueName.THUMBNAIL_GENERATION: {
return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } }); return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } });
}
case QueueName.FACE_DETECTION: case QueueName.FACE_DETECTION: {
await this.configCore.requireFeature(FeatureFlag.FACIAL_RECOGNITION); await this.configCore.requireFeature(FeatureFlag.FACIAL_RECOGNITION);
return this.jobRepository.queue({ name: JobName.QUEUE_FACE_DETECTION, data: { force } }); return this.jobRepository.queue({ name: JobName.QUEUE_FACE_DETECTION, data: { force } });
}
case QueueName.FACIAL_RECOGNITION: case QueueName.FACIAL_RECOGNITION: {
await this.configCore.requireFeature(FeatureFlag.FACIAL_RECOGNITION); await this.configCore.requireFeature(FeatureFlag.FACIAL_RECOGNITION);
return this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force } }); return this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force } });
}
case QueueName.LIBRARY: case QueueName.LIBRARY: {
return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } }); return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } });
}
default: default: {
throw new BadRequestException(`Invalid job name: ${name}`); throw new BadRequestException(`Invalid job name: ${name}`);
}
} }
} }
@ -184,17 +200,19 @@ export class JobService {
private async onDone(item: JobItem) { private async onDone(item: JobItem) {
switch (item.name) { switch (item.name) {
case JobName.SIDECAR_SYNC: case JobName.SIDECAR_SYNC:
case JobName.SIDECAR_DISCOVERY: case JobName.SIDECAR_DISCOVERY: {
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: item.data }); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: item.data });
break; break;
}
case JobName.SIDECAR_WRITE: case JobName.SIDECAR_WRITE: {
await this.jobRepository.queue({ await this.jobRepository.queue({
name: JobName.METADATA_EXTRACTION, name: JobName.METADATA_EXTRACTION,
data: { id: item.data.id, source: 'sidecar-write' }, data: { id: item.data.id, source: 'sidecar-write' },
}); });
}
case JobName.METADATA_EXTRACTION: case JobName.METADATA_EXTRACTION: {
if (item.data.source === 'sidecar-write') { if (item.data.source === 'sidecar-write') {
const [asset] = await this.assetRepository.getByIds([item.data.id]); const [asset] = await this.assetRepository.getByIds([item.data.id]);
if (asset) { if (asset) {
@ -203,24 +221,28 @@ export class JobService {
} }
await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data }); await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data });
break; break;
}
case JobName.LINK_LIVE_PHOTOS: case JobName.LINK_LIVE_PHOTOS: {
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: item.data }); await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: item.data });
break; break;
}
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
if (item.data.source === 'upload') { if (item.data.source === 'upload') {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: item.data }); await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: item.data });
} }
break; break;
}
case JobName.GENERATE_PERSON_THUMBNAIL: case JobName.GENERATE_PERSON_THUMBNAIL: {
const { id } = item.data; const { id } = item.data;
const person = await this.personRepository.getById(id); const person = await this.personRepository.getById(id);
if (person) { if (person) {
this.communicationRepository.send(ClientEvent.PERSON_THUMBNAIL, person.ownerId, person.id); this.communicationRepository.send(ClientEvent.PERSON_THUMBNAIL, person.ownerId, person.id);
} }
break; break;
}
case JobName.GENERATE_JPEG_THUMBNAIL: { case JobName.GENERATE_JPEG_THUMBNAIL: {
const jobs: JobItem[] = [ const jobs: JobItem[] = [

View File

@ -19,7 +19,7 @@ import {
} from '@test'; } from '@test';
import { newFSWatcherMock } from '@test/mocks'; import { newFSWatcherMock } from '@test/mocks';
import { Stats } from 'fs'; import { Stats } from 'node:fs';
import { ILibraryFileJob, ILibraryRefreshJob, JobName } from '../job'; import { ILibraryFileJob, ILibraryRefreshJob, JobName } from '../job';
import { import {
IAssetRepository, IAssetRepository,
@ -116,12 +116,15 @@ describe(LibraryService.name, () => {
libraryMock.get.mockImplementation(async (id) => { libraryMock.get.mockImplementation(async (id) => {
switch (id) { switch (id) {
case libraryStub.externalLibraryWithImportPaths1.id: case libraryStub.externalLibraryWithImportPaths1.id: {
return libraryStub.externalLibraryWithImportPaths1; return libraryStub.externalLibraryWithImportPaths1;
case libraryStub.externalLibraryWithImportPaths2.id: }
case libraryStub.externalLibraryWithImportPaths2.id: {
return libraryStub.externalLibraryWithImportPaths2; return libraryStub.externalLibraryWithImportPaths2;
default: }
default: {
return null; return null;
}
} }
}); });
@ -532,7 +535,7 @@ describe(LibraryService.name, () => {
}); });
it('should set a missing asset to offline', async () => { it('should set a missing asset to offline', async () => {
storageMock.stat.mockRejectedValue(new Error()); storageMock.stat.mockRejectedValue(new Error('Path not found'));
const mockLibraryJob: ILibraryFileJob = { const mockLibraryJob: ILibraryFileJob = {
id: assetStub.image.id, id: assetStub.image.id,
@ -1430,12 +1433,15 @@ describe(LibraryService.name, () => {
libraryMock.get.mockImplementation(async (id) => { libraryMock.get.mockImplementation(async (id) => {
switch (id) { switch (id) {
case libraryStub.externalLibraryWithImportPaths1.id: case libraryStub.externalLibraryWithImportPaths1.id: {
return libraryStub.externalLibraryWithImportPaths1; return libraryStub.externalLibraryWithImportPaths1;
case libraryStub.externalLibraryWithImportPaths2.id: }
case libraryStub.externalLibraryWithImportPaths2.id: {
return libraryStub.externalLibraryWithImportPaths2; return libraryStub.externalLibraryWithImportPaths2;
default: }
default: {
return null; return null;
}
} }
}); });

View File

@ -1,17 +1,17 @@
import { AssetType, LibraryType } from '@app/infra/entities'; import { AssetType, LibraryType } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger'; import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { EventEmitter } from 'events';
import { R_OK } from 'node:constants'; import { R_OK } from 'node:constants';
import { EventEmitter } from 'node:events';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import path from 'node:path'; import path, { basename, parse } from 'node:path';
import { basename, parse } from 'path';
import picomatch from 'picomatch'; import picomatch from 'picomatch';
import { AccessCore, Permission } from '../access'; import { AccessCore, Permission } from '../access';
import { AuthDto } from '../auth'; import { AuthDto } from '../auth';
import { mimeTypes } from '../domain.constant'; import { mimeTypes } from '../domain.constant';
import { usePagination, validateCronExpression } from '../domain.util'; import { usePagination, validateCronExpression } from '../domain.util';
import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import { import {
IAccessRepository, IAccessRepository,
IAssetRepository, IAssetRepository,
@ -84,11 +84,7 @@ export class LibraryService extends EventEmitter {
if (library.watch.enabled !== this.watchLibraries) { if (library.watch.enabled !== this.watchLibraries) {
this.watchLibraries = library.watch.enabled; this.watchLibraries = library.watch.enabled;
if (this.watchLibraries) { await (this.watchLibraries ? this.watchAll() : this.unwatchAll());
await this.watchAll();
} else {
await this.unwatchAll();
}
} }
}); });
} }
@ -227,12 +223,13 @@ export class LibraryService extends EventEmitter {
async create(auth: AuthDto, dto: CreateLibraryDto): Promise<LibraryResponseDto> { async create(auth: AuthDto, dto: CreateLibraryDto): Promise<LibraryResponseDto> {
switch (dto.type) { switch (dto.type) {
case LibraryType.EXTERNAL: case LibraryType.EXTERNAL: {
if (!dto.name) { if (!dto.name) {
dto.name = 'New External Library'; dto.name = 'New External Library';
} }
break; break;
case LibraryType.UPLOAD: }
case LibraryType.UPLOAD: {
if (!dto.name) { if (!dto.name) {
dto.name = 'New Upload Library'; dto.name = 'New Upload Library';
} }
@ -246,6 +243,7 @@ export class LibraryService extends EventEmitter {
throw new BadRequestException('Upload libraries cannot be watched'); throw new BadRequestException('Upload libraries cannot be watched');
} }
break; break;
}
} }
const library = await this.repository.create({ const library = await this.repository.create({
@ -401,7 +399,7 @@ export class LibraryService extends EventEmitter {
sidecarPath = `${assetPath}.xmp`; sidecarPath = `${assetPath}.xmp`;
} }
const deviceAssetId = `${basename(assetPath)}`.replace(/\s+/g, ''); const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, '');
let assetId; let assetId;
if (doImport) { if (doImport) {
@ -533,17 +531,17 @@ export class LibraryService extends EventEmitter {
} }
this.logger.verbose(`Refreshing library: ${job.id}`); this.logger.verbose(`Refreshing library: ${job.id}`);
const crawledAssetPaths = ( const rawPaths = await this.storageRepository.crawl({
await this.storageRepository.crawl({ pathsToCrawl: library.importPaths,
pathsToCrawl: library.importPaths, exclusionPatterns: library.exclusionPatterns,
exclusionPatterns: library.exclusionPatterns, });
})
) const crawledAssetPaths = rawPaths
.map(path.normalize) .map((filePath) => path.normalize(filePath))
.filter((assetPath) => .filter((assetPath) =>
// Filter out paths that are not within the user's external path // Filter out paths that are not within the user's external path
assetPath.match(new RegExp(`^${user.externalPath}`)), assetPath.match(new RegExp(`^${user.externalPath}`)),
); ) as string[];
this.logger.debug(`Found ${crawledAssetPaths.length} asset(s) when crawling import paths ${library.importPaths}`); this.logger.debug(`Found ${crawledAssetPaths.length} asset(s) when crawling import paths ${library.importPaths}`);
const assetsInLibrary = await this.assetRepository.getByLibraryId([job.id]); const assetsInLibrary = await this.assetRepository.getByLibraryId([job.id]);

View File

@ -181,13 +181,14 @@ export class MediaService {
this.storageCore.ensureFolders(path); this.storageCore.ensureFolders(path);
switch (asset.type) { switch (asset.type) {
case AssetType.IMAGE: case AssetType.IMAGE: {
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : thumbnail.colorspace; const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : thumbnail.colorspace;
const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality }; const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality };
await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions); await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
break; break;
}
case AssetType.VIDEO: case AssetType.VIDEO: {
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
const mainVideoStream = this.getMainStream(videoStreams); const mainVideoStream = this.getMainStream(videoStreams);
if (!mainVideoStream) { if (!mainVideoStream) {
@ -199,9 +200,11 @@ export class MediaService {
const options = new ThumbnailConfig(config).getOptions(mainVideoStream, mainAudioStream); const options = new ThumbnailConfig(config).getOptions(mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(asset.originalPath, path, options); await this.mediaRepository.transcode(asset.originalPath, path, options);
break; break;
}
default: default: {
throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`); throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`);
}
} }
this.logger.log( this.logger.log(
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} thumbnail for asset ${asset.id}`, `Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} thumbnail for asset ${asset.id}`,
@ -297,16 +300,16 @@ export class MediaService {
let transcodeOptions; let transcodeOptions;
try { try {
transcodeOptions = await this.getCodecConfig(config).then((c) => c.getOptions(mainVideoStream, mainAudioStream)); transcodeOptions = await this.getCodecConfig(config).then((c) => c.getOptions(mainVideoStream, mainAudioStream));
} catch (err) { } catch (error) {
this.logger.error(`An error occurred while configuring transcoding options: ${err}`); this.logger.error(`An error occurred while configuring transcoding options: ${error}`);
return false; return false;
} }
this.logger.log(`Start encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`); this.logger.log(`Start encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`);
try { try {
await this.mediaRepository.transcode(input, output, transcodeOptions); await this.mediaRepository.transcode(input, output, transcodeOptions);
} catch (err) { } catch (error) {
this.logger.error(err); this.logger.error(error);
if (config.accel !== TranscodeHWAccel.DISABLED) { if (config.accel !== TranscodeHWAccel.DISABLED) {
this.logger.error( this.logger.error(
`Error occurred during transcoding. Retrying with ${config.accel.toUpperCase()} acceleration disabled.`, `Error occurred during transcoding. Retrying with ${config.accel.toUpperCase()} acceleration disabled.`,
@ -354,23 +357,29 @@ export class MediaService {
const isLargerThanTargetBitrate = bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate); const isLargerThanTargetBitrate = bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate);
switch (ffmpegConfig.transcode) { switch (ffmpegConfig.transcode) {
case TranscodePolicy.DISABLED: case TranscodePolicy.DISABLED: {
return false; return false;
}
case TranscodePolicy.ALL: case TranscodePolicy.ALL: {
return true; return true;
}
case TranscodePolicy.REQUIRED: case TranscodePolicy.REQUIRED: {
return !allTargetsMatching || videoStream.isHDR; return !allTargetsMatching || videoStream.isHDR;
}
case TranscodePolicy.OPTIMAL: case TranscodePolicy.OPTIMAL: {
return !allTargetsMatching || isLargerThanTargetRes || videoStream.isHDR; return !allTargetsMatching || isLargerThanTargetRes || videoStream.isHDR;
}
case TranscodePolicy.BITRATE: case TranscodePolicy.BITRATE: {
return !allTargetsMatching || isLargerThanTargetBitrate || videoStream.isHDR; return !allTargetsMatching || isLargerThanTargetBitrate || videoStream.isHDR;
}
default: default: {
return false; return false;
}
} }
} }
@ -383,14 +392,18 @@ export class MediaService {
private getSWCodecConfig(config: SystemConfigFFmpegDto) { private getSWCodecConfig(config: SystemConfigFFmpegDto) {
switch (config.targetVideoCodec) { switch (config.targetVideoCodec) {
case VideoCodec.H264: case VideoCodec.H264: {
return new H264Config(config); return new H264Config(config);
case VideoCodec.HEVC: }
case VideoCodec.HEVC: {
return new HEVCConfig(config); return new HEVCConfig(config);
case VideoCodec.VP9: }
case VideoCodec.VP9: {
return new VP9Config(config); return new VP9Config(config);
default: }
default: {
throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`); throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`);
}
} }
} }
@ -398,23 +411,28 @@ export class MediaService {
let handler: VideoCodecHWConfig; let handler: VideoCodecHWConfig;
let devices: string[]; let devices: string[];
switch (config.accel) { switch (config.accel) {
case TranscodeHWAccel.NVENC: case TranscodeHWAccel.NVENC: {
handler = new NVENCConfig(config); handler = new NVENCConfig(config);
break; break;
case TranscodeHWAccel.QSV: }
case TranscodeHWAccel.QSV: {
devices = await this.storageRepository.readdir('/dev/dri'); devices = await this.storageRepository.readdir('/dev/dri');
handler = new QSVConfig(config, devices); handler = new QSVConfig(config, devices);
break; break;
case TranscodeHWAccel.VAAPI: }
case TranscodeHWAccel.VAAPI: {
devices = await this.storageRepository.readdir('/dev/dri'); devices = await this.storageRepository.readdir('/dev/dri');
handler = new VAAPIConfig(config, devices); handler = new VAAPIConfig(config, devices);
break; break;
case TranscodeHWAccel.RKMPP: }
case TranscodeHWAccel.RKMPP: {
devices = await this.storageRepository.readdir('/dev/dri'); devices = await this.storageRepository.readdir('/dev/dri');
handler = new RKMPPConfig(config, devices); handler = new RKMPPConfig(config, devices);
break; break;
default: }
default: {
throw new UnsupportedMediaTypeException(`${config.accel.toUpperCase()} acceleration is unsupported`); throw new UnsupportedMediaTypeException(`${config.accel.toUpperCase()} acceleration is unsupported`);
}
} }
if (!handler.getSupportedCodecs().includes(config.targetVideoCodec)) { if (!handler.getSupportedCodecs().includes(config.targetVideoCodec)) {
throw new UnsupportedMediaTypeException( throw new UnsupportedMediaTypeException(
@ -441,14 +459,14 @@ export class MediaService {
parseBitrateToBps(bitrateString: string) { parseBitrateToBps(bitrateString: string) {
const bitrateValue = Number.parseInt(bitrateString); const bitrateValue = Number.parseInt(bitrateString);
if (isNaN(bitrateValue)) { if (Number.isNaN(bitrateValue)) {
return 0; return 0;
} }
if (bitrateString.toLowerCase().endsWith('k')) { if (bitrateString.toLowerCase().endsWith('k')) {
return bitrateValue * 1000; // Kilobits per second to bits per second return bitrateValue * 1000; // Kilobits per second to bits per second
} else if (bitrateString.toLowerCase().endsWith('m')) { } else if (bitrateString.toLowerCase().endsWith('m')) {
return bitrateValue * 1000000; // Megabits per second to bits per second return bitrateValue * 1_000_000; // Megabits per second to bits per second
} else { } else {
return bitrateValue; return bitrateValue;
} }

View File

@ -15,16 +15,14 @@ class BaseConfig implements VideoCodecSWConfig {
getOptions(videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { getOptions(videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const options = { const options = {
inputOptions: this.getBaseInputOptions(), inputOptions: this.getBaseInputOptions(),
outputOptions: this.getBaseOutputOptions(videoStream, audioStream).concat('-v verbose'), outputOptions: [...this.getBaseOutputOptions(videoStream, audioStream), '-v verbose'],
twoPass: this.eligibleForTwoPass(), twoPass: this.eligibleForTwoPass(),
} as TranscodeOptions; } as TranscodeOptions;
const filters = this.getFilterOptions(videoStream); const filters = this.getFilterOptions(videoStream);
if (filters.length > 0) { if (filters.length > 0) {
options.outputOptions.push(`-vf ${filters.join(',')}`); options.outputOptions.push(`-vf ${filters.join(',')}`);
} }
options.outputOptions.push(...this.getPresetOptions()); options.outputOptions.push(...this.getPresetOptions(), ...this.getThreadOptions(), ...this.getBitrateOptions());
options.outputOptions.push(...this.getThreadOptions());
options.outputOptions.push(...this.getBitrateOptions());
return options; return options;
} }
@ -129,11 +127,10 @@ class BaseConfig implements VideoCodecSWConfig {
getTargetResolution(videoStream: VideoStreamInfo) { getTargetResolution(videoStream: VideoStreamInfo) {
let target; let target;
if (this.config.targetResolution === 'original') { target =
target = Math.min(videoStream.height, videoStream.width); this.config.targetResolution === 'original'
} else { ? Math.min(videoStream.height, videoStream.width)
target = Number.parseInt(this.config.targetResolution); : Number.parseInt(this.config.targetResolution);
}
if (target % 2 !== 0) { if (target % 2 !== 0) {
target -= 1; target -= 1;
@ -182,7 +179,7 @@ class BaseConfig implements VideoCodecSWConfig {
getBitrateUnit() { getBitrateUnit() {
const maxBitrate = this.getMaxBitrateValue(); const maxBitrate = this.getMaxBitrateValue();
return this.config.maxBitrate.trim().substring(maxBitrate.toString().length); // use inputted unit if provided return this.config.maxBitrate.trim().slice(maxBitrate.toString().length); // use inputted unit if provided
} }
getMaxBitrateValue() { getMaxBitrateValue() {
@ -411,8 +408,7 @@ export class NVENCConfig extends BaseHWConfig {
...super.getBaseOutputOptions(videoStream, audioStream), ...super.getBaseOutputOptions(videoStream, audioStream),
]; ];
if (this.getBFrames() > 0) { if (this.getBFrames() > 0) {
options.push('-b_ref_mode middle'); options.push('-b_ref_mode middle', '-b_qfactor 1.1');
options.push('-b_qfactor 1.1');
} }
if (this.config.temporalAQ) { if (this.config.temporalAQ) {
options.push('-temporal-aq 1'); options.push('-temporal-aq 1');
@ -474,8 +470,8 @@ export class NVENCConfig extends BaseHWConfig {
export class QSVConfig extends BaseHWConfig { export class QSVConfig extends BaseHWConfig {
getBaseInputOptions() { getBaseInputOptions() {
if (!this.devices.length) { if (this.devices.length === 0) {
throw Error('No QSV device found'); throw new Error('No QSV device found');
} }
let qsvString = ''; let qsvString = '';
@ -519,8 +515,7 @@ export class QSVConfig extends BaseHWConfig {
options.push(`-${this.useCQP() ? 'q:v' : 'global_quality'} ${this.config.crf}`); options.push(`-${this.useCQP() ? 'q:v' : 'global_quality'} ${this.config.crf}`);
const bitrates = this.getBitrateDistribution(); const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) { if (bitrates.max > 0) {
options.push(`-maxrate ${bitrates.max}${bitrates.unit}`); options.push(`-maxrate ${bitrates.max}${bitrates.unit}`, `-bufsize ${bitrates.max * 2}${bitrates.unit}`);
options.push(`-bufsize ${bitrates.max * 2}${bitrates.unit}`);
} }
return options; return options;
} }
@ -623,7 +618,7 @@ export class RKMPPConfig extends BaseHWConfig {
getBaseInputOptions() { getBaseInputOptions() {
if (this.devices.length === 0) { if (this.devices.length === 0) {
throw Error('No RKMPP device found'); throw new Error('No RKMPP device found');
} }
return []; return [];
} }
@ -642,14 +637,17 @@ export class RKMPPConfig extends BaseHWConfig {
getPresetOptions() { getPresetOptions() {
switch (this.config.targetVideoCodec) { switch (this.config.targetVideoCodec) {
case VideoCodec.H264: case VideoCodec.H264: {
// from ffmpeg_mpp help, commonly referred to as H264 level 5.1 // from ffmpeg_mpp help, commonly referred to as H264 level 5.1
return ['-level 51']; return ['-level 51'];
case VideoCodec.HEVC: }
case VideoCodec.HEVC: {
// from ffmpeg_mpp help, commonly referred to as HEVC level 5.1 // from ffmpeg_mpp help, commonly referred to as HEVC level 5.1
return ['-level 153']; return ['-level 153'];
default: }
throw Error(`Incompatible video codec for RKMPP: ${this.config.targetVideoCodec}`); default: {
throw new Error(`Incompatible video codec for RKMPP: ${this.config.targetVideoCodec}`);
}
} }
} }

View File

@ -16,11 +16,11 @@ import {
newSystemConfigRepositoryMock, newSystemConfigRepositoryMock,
probeStub, probeStub,
} from '@test'; } from '@test';
import { randomBytes } from 'crypto';
import { BinaryField } from 'exiftool-vendored'; import { BinaryField } from 'exiftool-vendored';
import { Stats } from 'fs';
import { constants } from 'fs/promises';
import { when } from 'jest-when'; import { when } from 'jest-when';
import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { constants } from 'node:fs/promises';
import { JobName } from '../job'; import { JobName } from '../job';
import { import {
ClientEvent, ClientEvent,
@ -234,7 +234,7 @@ describe(MetadataService.name, () => {
describe('handleMetadataExtraction', () => { describe('handleMetadataExtraction', () => {
beforeEach(() => { beforeEach(() => {
storageMock.stat.mockResolvedValue({ size: 123456 } as Stats); storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats);
}); });
it('should handle an asset that could not be found', async () => { it('should handle an asset that could not be found', async () => {
@ -507,7 +507,7 @@ describe(MetadataService.name, () => {
exifImageWidth: null, exifImageWidth: null,
exposureTime: tags.ExposureTime, exposureTime: tags.ExposureTime,
fNumber: null, fNumber: null,
fileSizeInByte: 123456, fileSizeInByte: 123_456,
focalLength: tags.FocalLength, focalLength: tags.FocalLength,
fps: null, fps: null,
iso: tags.ISO, iso: tags.ISO,
@ -565,7 +565,7 @@ describe(MetadataService.name, () => {
it('should handle duration with scale', async () => { it('should handle duration with scale', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } }); metadataMock.readTags.mockResolvedValue({ Duration: { Scale: 1.111_111_111_111_11e-5, Value: 558_720 } });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });

View File

@ -3,9 +3,9 @@ import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ExifDateTime, Tags } from 'exiftool-vendored'; import { ExifDateTime, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import { constants } from 'fs/promises';
import _ from 'lodash'; import _ from 'lodash';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import { constants } from 'node:fs/promises';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { usePagination } from '../domain.util'; import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job'; import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
@ -85,7 +85,7 @@ const validate = <T>(value: T): NonNullable<T> | null => {
return null; return null;
} }
if (typeof value === 'number' && (isNaN(value) || !isFinite(value))) { if (typeof value === 'number' && (Number.isNaN(value) || !Number.isFinite(value))) {
return null; return null;
} }
@ -217,18 +217,22 @@ export class MetadataService {
if (videoStreams[0]) { if (videoStreams[0]) {
switch (videoStreams[0].rotation) { switch (videoStreams[0].rotation) {
case -90: case -90: {
exifData.orientation = Orientation.Rotate90CW; exifData.orientation = Orientation.Rotate90CW;
break; break;
case 0: }
case 0: {
exifData.orientation = Orientation.Horizontal; exifData.orientation = Orientation.Horizontal;
break; break;
case 90: }
case 90: {
exifData.orientation = Orientation.Rotate270CW; exifData.orientation = Orientation.Rotate270CW;
break; break;
case 180: }
case 180: {
exifData.orientation = Orientation.Rotate180; exifData.orientation = Orientation.Rotate180;
break; break;
}
} }
} }
} }
@ -243,7 +247,7 @@ export class MetadataService {
const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0; const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0;
if (dateTimeOriginal && timeZoneOffset) { if (dateTimeOriginal && timeZoneOffset) {
localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60000); localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
} }
await this.assetRepository.save({ await this.assetRepository.save({
id: asset.id, id: asset.id,
@ -413,7 +417,13 @@ export class MetadataService {
const checksum = this.cryptoRepository.hashSha1(video); const checksum = this.cryptoRepository.hashSha1(video);
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum); let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
if (!motionAsset) { if (motionAsset) {
this.logger.debug(
`Asset ${asset.id}'s motion photo video with checksum ${checksum.toString(
'base64',
)} already exists in the repository`,
);
} else {
// We create a UUID in advance so that each extracted video can have a unique filename // We create a UUID in advance so that each extracted video can have a unique filename
// (allowing us to delete old ones if necessary) // (allowing us to delete old ones if necessary)
const motionAssetId = this.cryptoRepository.randomUUID(); const motionAssetId = this.cryptoRepository.randomUUID();
@ -448,12 +458,6 @@ export class MetadataService {
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } }); await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`); this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);
} }
} else {
this.logger.debug(
`Asset ${asset.id}'s motion photo video with checksum ${checksum.toString(
'base64',
)} already exists in the repository`,
);
} }
this.logger.debug(`Finished motion photo video extraction (${asset.id})`); this.logger.debug(`Finished motion photo video extraction (${asset.id})`);
@ -494,7 +498,7 @@ export class MetadataService {
fileSizeInByte: stats.size, fileSizeInByte: stats.size,
fNumber: validate(tags.FNumber), fNumber: validate(tags.FNumber),
focalLength: validate(tags.FocalLength), focalLength: validate(tags.FocalLength),
fps: validate(parseFloat(tags.VideoFrameRate!)), fps: validate(Number.parseFloat(tags.VideoFrameRate!)),
iso: validate(tags.ISO), iso: validate(tags.ISO),
latitude: validate(tags.GPSLatitude), latitude: validate(tags.GPSLatitude),
lensModel: tags.LensModel ?? null, lensModel: tags.LensModel ?? null,

View File

@ -24,7 +24,7 @@ export class PartnerService {
} }
const partner = await this.repository.create(partnerId); const partner = await this.repository.create(partnerId);
return this.map(partner, PartnerDirection.SharedBy); return this.mapToPartnerEntity(partner, PartnerDirection.SharedBy);
} }
async remove(auth: AuthDto, sharedWithId: string): Promise<void> { async remove(auth: AuthDto, sharedWithId: string): Promise<void> {
@ -43,7 +43,7 @@ export class PartnerService {
return partners return partners
.filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users .filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users
.filter((partner) => partner[key] === auth.user.id) .filter((partner) => partner[key] === auth.user.id)
.map((partner) => this.map(partner, direction)); .map((partner) => this.mapToPartnerEntity(partner, direction));
} }
async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> { async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
@ -51,10 +51,10 @@ export class PartnerService {
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline }); const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline });
return this.map(entity, PartnerDirection.SharedWith); return this.mapToPartnerEntity(entity, PartnerDirection.SharedWith);
} }
private map(partner: PartnerEntity, direction: PartnerDirection): PartnerResponseDto { private mapToPartnerEntity(partner: PartnerEntity, direction: PartnerDirection): PartnerResponseDto {
// this is opposite to return the non-me user of the "partner" // this is opposite to return the non-me user of the "partner"
const user = mapUser( const user = mapUser(
direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy, direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy,

View File

@ -814,7 +814,7 @@ describe(PersonService.name, () => {
} }
const faces = [ const faces = [
{ face: faceStub.noPerson1, distance: 0.0 }, { face: faceStub.noPerson1, distance: 0 },
{ face: faceStub.primaryFace1, distance: 0.2 }, { face: faceStub.primaryFace1, distance: 0.2 },
{ face: faceStub.noPerson2, distance: 0.3 }, { face: faceStub.noPerson2, distance: 0.3 },
{ face: faceStub.face1, distance: 0.4 }, { face: faceStub.face1, distance: 0.4 },
@ -843,7 +843,7 @@ describe(PersonService.name, () => {
it('should create a new person if the face is a core point with no person', async () => { it('should create a new person if the face is a core point with no person', async () => {
const faces = [ const faces = [
{ face: faceStub.noPerson1, distance: 0.0 }, { face: faceStub.noPerson1, distance: 0 },
{ face: faceStub.noPerson2, distance: 0.3 }, { face: faceStub.noPerson2, distance: 0.3 },
] as FaceSearchResult[]; ] as FaceSearchResult[];
@ -867,7 +867,7 @@ describe(PersonService.name, () => {
}); });
it('should defer non-core faces to end of queue', async () => { it('should defer non-core faces to end of queue', async () => {
const faces = [{ face: faceStub.noPerson1, distance: 0.0 }] as FaceSearchResult[]; const faces = [{ face: faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
configMock.load.mockResolvedValue([ configMock.load.mockResolvedValue([
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 2 }, { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 2 },
@ -888,7 +888,7 @@ describe(PersonService.name, () => {
}); });
it('should not assign person to non-core face with no matching person', async () => { it('should not assign person to non-core face with no matching person', async () => {
const faces = [{ face: faceStub.noPerson1, distance: 0.0 }] as FaceSearchResult[]; const faces = [{ face: faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
configMock.load.mockResolvedValue([ configMock.load.mockResolvedValue([
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 2 }, { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 2 },

View File

@ -122,7 +122,7 @@ export class PersonService {
} }
if (changeFeaturePhoto.length > 0) { if (changeFeaturePhoto.length > 0) {
// Remove duplicates // Remove duplicates
await this.createNewFeaturePhoto(Array.from(new Set(changeFeaturePhoto))); await this.createNewFeaturePhoto([...new Set(changeFeaturePhoto)]);
} }
return result; return result;
} }
@ -332,7 +332,7 @@ export class PersonService {
this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`); this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`);
this.logger.verbose(faces.map((face) => ({ ...face, embedding: `vector(${face.embedding.length})` }))); this.logger.verbose(faces.map((face) => ({ ...face, embedding: `vector(${face.embedding.length})` })));
if (faces.length) { if (faces.length > 0) {
await this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); await this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } });
const mappedFaces = faces.map((face) => ({ const mappedFaces = faces.map((face) => ({
@ -417,7 +417,7 @@ export class PersonService {
numResults: machineLearning.facialRecognition.minFaces, numResults: machineLearning.facialRecognition.minFaces,
}); });
this.logger.debug(`Face ${id} has ${matches.length} match${matches.length != 1 ? 'es' : ''}`); this.logger.debug(`Face ${id} has ${matches.length} match${matches.length == 1 ? '' : 'es'}`);
const isCore = matches.length >= machineLearning.facialRecognition.minFaces; const isCore = matches.length >= machineLearning.facialRecognition.minFaces;
if (!isCore && !deferred) { if (!isCore && !deferred) {

View File

@ -15,7 +15,7 @@ export enum DatabaseLock {
export const IDatabaseRepository = 'IDatabaseRepository'; export const IDatabaseRepository = 'IDatabaseRepository';
export interface IDatabaseRepository { export interface IDatabaseRepository {
getExtensionVersion(extName: string): Promise<Version | null>; getExtensionVersion(extensionName: string): Promise<Version | null>;
getPostgresVersion(): Promise<Version>; getPostgresVersion(): Promise<Version>;
createExtension(extension: DatabaseExtension): Promise<void>; createExtension(extension: DatabaseExtension): Promise<void>;
runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise<void>; runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise<void>;

View File

@ -1,5 +1,5 @@
import { VideoCodec } from '@app/infra/entities'; import { VideoCodec } from '@app/infra/entities';
import { Writable } from 'stream'; import { Writable } from 'node:stream';
export const IMediaRepository = 'IMediaRepository'; export const IMediaRepository = 'IMediaRepository';

View File

@ -1,7 +1,7 @@
import { FSWatcher, WatchOptions } from 'chokidar'; import { FSWatcher, WatchOptions } from 'chokidar';
import { Stats } from 'fs'; import { Stats } from 'node:fs';
import { FileReadOptions } from 'fs/promises'; import { FileReadOptions } from 'node:fs/promises';
import { Readable } from 'stream'; import { Readable } from 'node:stream';
import { CrawlOptionsDto } from '../library'; import { CrawlOptionsDto } from '../library';
export interface ImmichReadStream { export interface ImmichReadStream {

View File

@ -46,7 +46,7 @@ export class SearchService {
this.assetRepository.getAssetIdByTag(auth.user.id, options), this.assetRepository.getAssetIdByTag(auth.user.id, options),
]); ]);
const assetIds = new Set<string>(results.flatMap((field) => field.items.map((item) => item.data))); const assetIds = new Set<string>(results.flatMap((field) => field.items.map((item) => item.data)));
const assets = await this.assetRepository.getByIds(Array.from(assetIds)); const assets = await this.assetRepository.getByIds([...assetIds]);
const assetMap = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, mapAsset(asset)])); const assetMap = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, mapAsset(asset)]));
return results.map(({ fieldName, items }) => ({ return results.map(({ fieldName, items }) => ({
@ -75,7 +75,7 @@ export class SearchService {
let assets: AssetEntity[] = []; let assets: AssetEntity[] = [];
switch (strategy) { switch (strategy) {
case SearchStrategy.SMART: case SearchStrategy.SMART: {
const embedding = await this.machineLearning.encodeText( const embedding = await this.machineLearning.encodeText(
machineLearning.url, machineLearning.url,
{ text: query }, { text: query },
@ -88,10 +88,13 @@ export class SearchService {
withArchived, withArchived,
}); });
break; break;
case SearchStrategy.TEXT: }
case SearchStrategy.TEXT: {
assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: 250 }); assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: 250 });
default: }
default: {
break; break;
}
} }
return { return {

View File

@ -71,12 +71,12 @@ describe(ServerInfoService.name, () => {
await expect(sut.getInfo()).resolves.toEqual({ await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '293.0 KiB', diskAvailable: '293.0 KiB',
diskAvailableRaw: 300000, diskAvailableRaw: 300_000,
diskSize: '488.3 KiB', diskSize: '488.3 KiB',
diskSizeRaw: 500000, diskSizeRaw: 500_000,
diskUsagePercentage: 60, diskUsagePercentage: 60,
diskUse: '293.0 KiB', diskUse: '293.0 KiB',
diskUseRaw: 300000, diskUseRaw: 300_000,
}); });
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library');
@ -87,12 +87,12 @@ describe(ServerInfoService.name, () => {
await expect(sut.getInfo()).resolves.toEqual({ await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '286.1 MiB', diskAvailable: '286.1 MiB',
diskAvailableRaw: 300000000, diskAvailableRaw: 300_000_000,
diskSize: '476.8 MiB', diskSize: '476.8 MiB',
diskSizeRaw: 500000000, diskSizeRaw: 500_000_000,
diskUsagePercentage: 60, diskUsagePercentage: 60,
diskUse: '286.1 MiB', diskUse: '286.1 MiB',
diskUseRaw: 300000000, diskUseRaw: 300_000_000,
}); });
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library');
@ -107,12 +107,12 @@ describe(ServerInfoService.name, () => {
await expect(sut.getInfo()).resolves.toEqual({ await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '279.4 GiB', diskAvailable: '279.4 GiB',
diskAvailableRaw: 300000000000, diskAvailableRaw: 300_000_000_000,
diskSize: '465.7 GiB', diskSize: '465.7 GiB',
diskSizeRaw: 500000000000, diskSizeRaw: 500_000_000_000,
diskUsagePercentage: 60, diskUsagePercentage: 60,
diskUse: '279.4 GiB', diskUse: '279.4 GiB',
diskUseRaw: 300000000000, diskUseRaw: 300_000_000_000,
}); });
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library');
@ -127,12 +127,12 @@ describe(ServerInfoService.name, () => {
await expect(sut.getInfo()).resolves.toEqual({ await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '272.8 TiB', diskAvailable: '272.8 TiB',
diskAvailableRaw: 300000000000000, diskAvailableRaw: 300_000_000_000_000,
diskSize: '454.7 TiB', diskSize: '454.7 TiB',
diskSizeRaw: 500000000000000, diskSizeRaw: 500_000_000_000_000,
diskUsagePercentage: 60, diskUsagePercentage: 60,
diskUse: '272.8 TiB', diskUse: '272.8 TiB',
diskUseRaw: 300000000000000, diskUseRaw: 300_000_000_000_000,
}); });
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library');
@ -147,12 +147,12 @@ describe(ServerInfoService.name, () => {
await expect(sut.getInfo()).resolves.toEqual({ await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '266.5 PiB', diskAvailable: '266.5 PiB',
diskAvailableRaw: 300000000000000000, diskAvailableRaw: 300_000_000_000_000_000,
diskSize: '444.1 PiB', diskSize: '444.1 PiB',
diskSizeRaw: 500000000000000000, diskSizeRaw: 500_000_000_000_000_000,
diskUsagePercentage: 60, diskUsagePercentage: 60,
diskUse: '266.5 PiB', diskUse: '266.5 PiB',
diskUseRaw: 300000000000000000, diskUseRaw: 300_000_000_000_000_000,
}); });
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library');
@ -219,7 +219,7 @@ describe(ServerInfoService.name, () => {
userName: '1 User', userName: '1 User',
photos: 10, photos: 10,
videos: 11, videos: 11,
usage: 12345, usage: 12_345,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
}, },
{ {
@ -227,7 +227,7 @@ describe(ServerInfoService.name, () => {
userName: '2 User', userName: '2 User',
photos: 10, photos: 10,
videos: 20, videos: 20,
usage: 123456, usage: 123_456,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
}, },
{ {
@ -235,7 +235,7 @@ describe(ServerInfoService.name, () => {
userName: '3 User', userName: '3 User',
photos: 100, photos: 100,
videos: 0, videos: 0,
usage: 987654, usage: 987_654,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
}, },
]); ]);
@ -243,12 +243,12 @@ describe(ServerInfoService.name, () => {
await expect(sut.getStatistics()).resolves.toEqual({ await expect(sut.getStatistics()).resolves.toEqual({
photos: 120, photos: 120,
videos: 31, videos: 31,
usage: 1123455, usage: 1_123_455,
usageByUser: [ usageByUser: [
{ {
photos: 10, photos: 10,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
usage: 12345, usage: 12_345,
userName: '1 User', userName: '1 User',
userId: 'user1', userId: 'user1',
videos: 11, videos: 11,
@ -256,7 +256,7 @@ describe(ServerInfoService.name, () => {
{ {
photos: 10, photos: 10,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
usage: 123456, usage: 123_456,
userName: '2 User', userName: '2 User',
userId: 'user2', userId: 'user2',
videos: 20, videos: 20,
@ -264,7 +264,7 @@ describe(ServerInfoService.name, () => {
{ {
photos: 100, photos: 100,
quotaSizeInBytes: 0, quotaSizeInBytes: 0,
usage: 987654, usage: 987_654,
userName: '3 User', userName: '3 User',
userId: 'user3', userId: 'user3',
videos: 0, videos: 0,

View File

@ -67,7 +67,7 @@ export class ServerInfoService {
serverInfo.diskAvailableRaw = diskInfo.available; serverInfo.diskAvailableRaw = diskInfo.available;
serverInfo.diskSizeRaw = diskInfo.total; serverInfo.diskSizeRaw = diskInfo.total;
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free; serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
serverInfo.diskUsagePercentage = parseFloat(usagePercentage); serverInfo.diskUsagePercentage = Number.parseFloat(usagePercentage);
return serverInfo; return serverInfo;
} }

View File

@ -21,7 +21,7 @@ export class SharedLinkService {
} }
getAll(auth: AuthDto): Promise<SharedLinkResponseDto[]> { getAll(auth: AuthDto): Promise<SharedLinkResponseDto[]> {
return this.repository.getAll(auth.user.id).then((links) => links.map(mapSharedLink)); return this.repository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link)));
} }
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> { async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
@ -30,7 +30,7 @@ export class SharedLinkService {
} }
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id); const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
const response = this.map(sharedLink, { withExif: sharedLink.showExif }); const response = this.mapToSharedLink(sharedLink, { withExif: sharedLink.showExif });
if (sharedLink.password) { if (sharedLink.password) {
response.token = this.validateAndRefreshToken(sharedLink, dto); response.token = this.validateAndRefreshToken(sharedLink, dto);
} }
@ -40,19 +40,20 @@ export class SharedLinkService {
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> { async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
const sharedLink = await this.findOrFail(auth.user.id, id); const sharedLink = await this.findOrFail(auth.user.id, id);
return this.map(sharedLink, { withExif: true }); return this.mapToSharedLink(sharedLink, { withExif: true });
} }
async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> { async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> {
switch (dto.type) { switch (dto.type) {
case SharedLinkType.ALBUM: case SharedLinkType.ALBUM: {
if (!dto.albumId) { if (!dto.albumId) {
throw new BadRequestException('Invalid albumId'); throw new BadRequestException('Invalid albumId');
} }
await this.access.requirePermission(auth, Permission.ALBUM_SHARE, dto.albumId); await this.access.requirePermission(auth, Permission.ALBUM_SHARE, dto.albumId);
break; break;
}
case SharedLinkType.INDIVIDUAL: case SharedLinkType.INDIVIDUAL: {
if (!dto.assetIds || dto.assetIds.length === 0) { if (!dto.assetIds || dto.assetIds.length === 0) {
throw new BadRequestException('Invalid assetIds'); throw new BadRequestException('Invalid assetIds');
} }
@ -60,6 +61,7 @@ export class SharedLinkService {
await this.access.requirePermission(auth, Permission.ASSET_SHARE, dto.assetIds); await this.access.requirePermission(auth, Permission.ASSET_SHARE, dto.assetIds);
break; break;
}
} }
const sharedLink = await this.repository.create({ const sharedLink = await this.repository.create({
@ -76,7 +78,7 @@ export class SharedLinkService {
showExif: dto.showMetadata ?? true, showExif: dto.showMetadata ?? true,
}); });
return this.map(sharedLink, { withExif: true }); return this.mapToSharedLink(sharedLink, { withExif: true });
} }
async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) { async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) {
@ -91,7 +93,7 @@ export class SharedLinkService {
allowDownload: dto.allowDownload, allowDownload: dto.allowDownload,
showExif: dto.showMetadata, showExif: dto.showMetadata,
}); });
return this.map(sharedLink, { withExif: true }); return this.mapToSharedLink(sharedLink, { withExif: true });
} }
async remove(auth: AuthDto, id: string): Promise<void> { async remove(auth: AuthDto, id: string): Promise<void> {
@ -173,7 +175,7 @@ export class SharedLinkService {
const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id); const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id);
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id; const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
const assetCount = sharedLink.assets.length || sharedLink.album?.assets.length || 0; const assetCount = sharedLink.assets.length ?? sharedLink.album?.assets.length ?? 0;
return { return {
title: sharedLink.album ? sharedLink.album.albumName : 'Public Share', title: sharedLink.album ? sharedLink.album.albumName : 'Public Share',
@ -184,7 +186,7 @@ export class SharedLinkService {
}; };
} }
private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) { private mapToSharedLink(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink); return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
} }

View File

@ -111,8 +111,12 @@ export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
}; };
export function cleanModelName(modelName: string): string { export function cleanModelName(modelName: string): string {
const tokens = modelName.split('/'); const token = modelName.split('/').at(-1);
return tokens[tokens.length - 1].replace(/:/g, '_'); if (!token) {
throw new Error(`Invalid model name: ${modelName}`);
}
return token.replaceAll(':', '_');
} }
export function getCLIPModelInfo(modelName: string): ModelInfo { export function getCLIPModelInfo(modelName: string): ModelInfo {

View File

@ -269,7 +269,7 @@ describe(StorageTemplateService.name, () => {
when(storageMock.stat) when(storageMock.stat)
.calledWith(newPath) .calledWith(newPath)
.mockResolvedValue({ size: 5000 } as Stats); .mockResolvedValue({ size: 5000 } as Stats);
when(cryptoMock.hashFile).calledWith(newPath).mockResolvedValue(Buffer.from('different-hash', 'utf-8')); when(cryptoMock.hashFile).calledWith(newPath).mockResolvedValue(Buffer.from('different-hash', 'utf8'));
when(assetMock.save) when(assetMock.save)
.calledWith({ id: assetStub.image.id, originalPath: newPath }) .calledWith({ id: assetStub.image.id, originalPath: newPath })
@ -311,9 +311,9 @@ describe(StorageTemplateService.name, () => {
}); });
it.each` it.each`
failedPathChecksum | failedPathSize | reason failedPathChecksum | failedPathSize | reason
${assetStub.image.checksum} | ${500} | ${'file size'} ${assetStub.image.checksum} | ${500} | ${'file size'}
${Buffer.from('bad checksum', 'utf-8')} | ${assetStub.image.exifInfo?.fileSizeInByte} | ${'checksum'} ${Buffer.from('bad checksum', 'utf8')} | ${assetStub.image.exifInfo?.fileSizeInByte} | ${'checksum'}
`( `(
'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails', 'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails',
async ({ failedPathChecksum, failedPathSize }) => { async ({ failedPathChecksum, failedPathSize }) => {

View File

@ -86,7 +86,8 @@ export class StorageTemplateService {
} }
async handleMigrationSingle({ id }: IEntityJob) { async handleMigrationSingle({ id }: IEntityJob) {
const storageTemplateEnabled = (await this.configCore.getConfig()).storageTemplate.enabled; const config = await this.configCore.getConfig();
const storageTemplateEnabled = config.storageTemplate.enabled;
if (!storageTemplateEnabled) { if (!storageTemplateEnabled) {
return true; return true;
} }
@ -109,8 +110,9 @@ export class StorageTemplateService {
async handleMigration() { async handleMigration() {
this.logger.log('Starting storage template migration'); this.logger.log('Starting storage template migration');
const storageTemplateEnabled = (await this.configCore.getConfig()).storageTemplate.enabled; const { storageTemplate } = await this.configCore.getConfig();
if (!storageTemplateEnabled) { const { enabled } = storageTemplate;
if (!enabled) {
this.logger.log('Storage template migration disabled, skipping'); this.logger.log('Storage template migration disabled, skipping');
return true; return true;
} }
@ -145,7 +147,7 @@ export class StorageTemplateService {
} }
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => { return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
const { id, sidecarPath, originalPath, exifInfo } = asset; const { id, sidecarPath, originalPath, exifInfo, checksum } = asset;
const oldPath = originalPath; const oldPath = originalPath;
const newPath = await this.getTemplatePath(asset, metadata); const newPath = await this.getTemplatePath(asset, metadata);
@ -160,7 +162,7 @@ export class StorageTemplateService {
pathType: AssetPathType.ORIGINAL, pathType: AssetPathType.ORIGINAL,
oldPath, oldPath,
newPath, newPath,
assetInfo: { sizeInBytes: exifInfo.fileSizeInByte, checksum: asset.checksum }, assetInfo: { sizeInBytes: exifInfo.fileSizeInByte, checksum },
}); });
if (sidecarPath) { if (sidecarPath) {
await this.storageCore.moveFile({ await this.storageCore.moveFile({
@ -171,7 +173,7 @@ export class StorageTemplateService {
}); });
} }
} catch (error: any) { } catch (error: any) {
this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, oldPath, newPath }); this.logger.error(`Problem applying storage template`, error?.stack, { id, oldPath, newPath });
} }
}); });
} }
@ -181,8 +183,8 @@ export class StorageTemplateService {
try { try {
const source = asset.originalPath; const source = asset.originalPath;
const ext = path.extname(source).split('.').pop() as string; const extension = path.extname(source).split('.').pop() as string;
const sanitized = sanitize(path.basename(filename, `.${ext}`)); const sanitized = sanitize(path.basename(filename, `.${extension}`));
const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel }); const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel });
let albumName = null; let albumName = null;
@ -194,11 +196,11 @@ export class StorageTemplateService {
const storagePath = this.render(this.template.compiled, { const storagePath = this.render(this.template.compiled, {
asset, asset,
filename: sanitized, filename: sanitized,
extension: ext, extension: extension,
albumName, albumName,
}); });
const fullPath = path.normalize(path.join(rootPath, storagePath)); const fullPath = path.normalize(path.join(rootPath, storagePath));
let destination = `${fullPath}.${ext}`; let destination = `${fullPath}.${extension}`;
if (!fullPath.startsWith(rootPath)) { if (!fullPath.startsWith(rootPath)) {
this.logger.warn(`Skipped attempt to access an invalid path: ${fullPath}. Path should start with ${rootPath}`); this.logger.warn(`Skipped attempt to access an invalid path: ${fullPath}. Path should start with ${rootPath}`);
@ -223,8 +225,8 @@ export class StorageTemplateService {
* The lines below will be used to check if the differences between the source and destination is only the * The lines below will be used to check if the differences between the source and destination is only the
* +7 suffix, and if so, it will be considered as already migrated. * +7 suffix, and if so, it will be considered as already migrated.
*/ */
if (source.startsWith(fullPath) && source.endsWith(`.${ext}`)) { if (source.startsWith(fullPath) && source.endsWith(`.${extension}`)) {
const diff = source.replace(fullPath, '').replace(`.${ext}`, ''); const diff = source.replace(fullPath, '').replace(`.${extension}`, '');
const hasDuplicationAnnotation = /^\+\d+$/.test(diff); const hasDuplicationAnnotation = /^\+\d+$/.test(diff);
if (hasDuplicationAnnotation) { if (hasDuplicationAnnotation) {
return source; return source;
@ -240,7 +242,7 @@ export class StorageTemplateService {
} }
duplicateCount++; duplicateCount++;
destination = `${fullPath}+${duplicateCount}.${ext}`; destination = `${fullPath}+${duplicateCount}.${extension}`;
} }
return destination; return destination;
@ -264,9 +266,9 @@ export class StorageTemplateService {
extension: 'jpg', extension: 'jpg',
albumName: 'album', albumName: 'album',
}); });
} catch (e) { } catch (error) {
this.logger.warn(`Storage template validation failed: ${JSON.stringify(e)}`); this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`);
throw new Error(`Invalid storage template: ${e}`); throw new Error(`Invalid storage template: ${error}`);
} }
} }
@ -282,7 +284,7 @@ export class StorageTemplateService {
return { return {
raw: template, raw: template,
compiled: handlebar.compile(template, { knownHelpers: undefined, strict: true }), compiled: handlebar.compile(template, { knownHelpers: undefined, strict: true }),
needsAlbum: template.indexOf('{{album}}') !== -1, needsAlbum: template.includes('{{album}}'),
}; };
} }
@ -295,7 +297,7 @@ export class StorageTemplateService {
filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO',
assetId: asset.id, assetId: asset.id,
//just throw into the root if it doesn't belong to an album //just throw into the root if it doesn't belong to an album
album: (albumName && sanitize(albumName.replace(/\.+/g, ''))) || '.', album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '.',
}; };
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;

View File

@ -118,40 +118,44 @@ export class StorageCore {
async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) { async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) {
const { id: entityId, resizePath, webpPath, encodedVideoPath } = asset; const { id: entityId, resizePath, webpPath, encodedVideoPath } = asset;
switch (pathType) { switch (pathType) {
case AssetPathType.JPEG_THUMBNAIL: case AssetPathType.JPEG_THUMBNAIL: {
return this.moveFile({ return this.moveFile({
entityId, entityId,
pathType, pathType,
oldPath: resizePath, oldPath: resizePath,
newPath: StorageCore.getLargeThumbnailPath(asset), newPath: StorageCore.getLargeThumbnailPath(asset),
}); });
case AssetPathType.WEBP_THUMBNAIL: }
case AssetPathType.WEBP_THUMBNAIL: {
return this.moveFile({ return this.moveFile({
entityId, entityId,
pathType, pathType,
oldPath: webpPath, oldPath: webpPath,
newPath: StorageCore.getSmallThumbnailPath(asset), newPath: StorageCore.getSmallThumbnailPath(asset),
}); });
case AssetPathType.ENCODED_VIDEO: }
case AssetPathType.ENCODED_VIDEO: {
return this.moveFile({ return this.moveFile({
entityId, entityId,
pathType, pathType,
oldPath: encodedVideoPath, oldPath: encodedVideoPath,
newPath: StorageCore.getEncodedVideoPath(asset), newPath: StorageCore.getEncodedVideoPath(asset),
}); });
}
} }
} }
async movePersonFile(person: PersonEntity, pathType: PersonPathType) { async movePersonFile(person: PersonEntity, pathType: PersonPathType) {
const { id: entityId, thumbnailPath } = person; const { id: entityId, thumbnailPath } = person;
switch (pathType) { switch (pathType) {
case PersonPathType.FACE: case PersonPathType.FACE: {
await this.moveFile({ await this.moveFile({
entityId, entityId,
pathType, pathType,
oldPath: thumbnailPath, oldPath: thumbnailPath,
newPath: StorageCore.getPersonThumbnailPath(person), newPath: StorageCore.getPersonThumbnailPath(person),
}); });
}
} }
} }
@ -168,7 +172,8 @@ export class StorageCore {
this.logger.log(`Attempting to finish incomplete move: ${move.oldPath} => ${move.newPath}`); this.logger.log(`Attempting to finish incomplete move: ${move.oldPath} => ${move.newPath}`);
const oldPathExists = await this.repository.checkFileExists(move.oldPath); const oldPathExists = await this.repository.checkFileExists(move.oldPath);
const newPathExists = await this.repository.checkFileExists(move.newPath); const newPathExists = await this.repository.checkFileExists(move.newPath);
const actualPath = oldPathExists ? move.oldPath : newPathExists ? move.newPath : null; const newPathCheck = newPathExists ? move.newPath : null;
const actualPath = oldPathExists ? move.oldPath : newPathCheck;
if (!actualPath) { if (!actualPath) {
this.logger.warn('Unable to complete move. File does not exist at either location.'); this.logger.warn('Unable to complete move. File does not exist at either location.');
return; return;
@ -177,13 +182,14 @@ export class StorageCore {
const fileAtNewLocation = actualPath === move.newPath; const fileAtNewLocation = actualPath === move.newPath;
this.logger.log(`Found file at ${fileAtNewLocation ? 'new' : 'old'} location`); this.logger.log(`Found file at ${fileAtNewLocation ? 'new' : 'old'} location`);
if (fileAtNewLocation) { if (
if (!(await this.verifyNewPathContentsMatchesExpected(move.oldPath, move.newPath, assetInfo))) { fileAtNewLocation &&
this.logger.fatal( !(await this.verifyNewPathContentsMatchesExpected(move.oldPath, move.newPath, assetInfo))
`Skipping move as file verification failed, old file is missing and new file is different to what was expected`, ) {
); this.logger.fatal(
return; `Skipping move as file verification failed, old file is missing and new file is different to what was expected`,
} );
return;
} }
move = await this.moveRepository.update({ id: move.id, oldPath: actualPath, newPath }); move = await this.moveRepository.update({ id: move.id, oldPath: actualPath, newPath });
@ -200,10 +206,10 @@ export class StorageCore {
try { try {
this.logger.debug(`Attempting to rename file: ${move.oldPath} => ${newPath}`); this.logger.debug(`Attempting to rename file: ${move.oldPath} => ${newPath}`);
await this.repository.rename(move.oldPath, newPath); await this.repository.rename(move.oldPath, newPath);
} catch (err: any) { } catch (error: any) {
if (err.code !== 'EXDEV') { if (error.code !== 'EXDEV') {
this.logger.warn( this.logger.warn(
`Unable to complete move. Error renaming file with code ${err.code} and message: ${err.message}`, `Unable to complete move. Error renaming file with code ${error.code} and message: ${error.message}`,
); );
return; return;
} }
@ -218,8 +224,8 @@ export class StorageCore {
try { try {
await this.repository.unlink(move.oldPath); await this.repository.unlink(move.oldPath);
} catch (err: any) { } catch (error: any) {
this.logger.warn(`Unable to delete old file, it will now no longer be tracked by Immich: ${err.message}`); this.logger.warn(`Unable to delete old file, it will now no longer be tracked by Immich: ${error.message}`);
} }
} }
} }
@ -233,14 +239,17 @@ export class StorageCore {
newPath: string, newPath: string,
assetInfo?: { sizeInBytes: number; checksum: Buffer }, assetInfo?: { sizeInBytes: number; checksum: Buffer },
) { ) {
const oldPathSize = assetInfo ? assetInfo.sizeInBytes : (await this.repository.stat(oldPath)).size; const oldStat = await this.repository.stat(oldPath);
const newPathSize = (await this.repository.stat(newPath)).size; const newStat = await this.repository.stat(newPath);
const oldPathSize = assetInfo ? assetInfo.sizeInBytes : oldStat.size;
const newPathSize = newStat.size;
this.logger.debug(`File size check: ${newPathSize} === ${oldPathSize}`); this.logger.debug(`File size check: ${newPathSize} === ${oldPathSize}`);
if (newPathSize !== oldPathSize) { if (newPathSize !== oldPathSize) {
this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`); this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`);
return false; return false;
} }
if (assetInfo && (await this.configCore.getConfig()).storageTemplate.hashVerificationEnabled) { const config = await this.configCore.getConfig();
if (assetInfo && config.storageTemplate.hashVerificationEnabled) {
const { checksum } = assetInfo; const { checksum } = assetInfo;
const newChecksum = await this.cryptoRepository.hashFile(newPath); const newChecksum = await this.cryptoRepository.hashFile(newPath);
if (!newChecksum.equals(checksum)) { if (!newChecksum.equals(checksum)) {
@ -266,23 +275,29 @@ export class StorageCore {
private savePath(pathType: PathType, id: string, newPath: string) { private savePath(pathType: PathType, id: string, newPath: string) {
switch (pathType) { switch (pathType) {
case AssetPathType.ORIGINAL: case AssetPathType.ORIGINAL: {
return this.assetRepository.save({ id, originalPath: newPath }); return this.assetRepository.save({ id, originalPath: newPath });
case AssetPathType.JPEG_THUMBNAIL: }
case AssetPathType.JPEG_THUMBNAIL: {
return this.assetRepository.save({ id, resizePath: newPath }); return this.assetRepository.save({ id, resizePath: newPath });
case AssetPathType.WEBP_THUMBNAIL: }
case AssetPathType.WEBP_THUMBNAIL: {
return this.assetRepository.save({ id, webpPath: newPath }); return this.assetRepository.save({ id, webpPath: newPath });
case AssetPathType.ENCODED_VIDEO: }
case AssetPathType.ENCODED_VIDEO: {
return this.assetRepository.save({ id, encodedVideoPath: newPath }); return this.assetRepository.save({ id, encodedVideoPath: newPath });
case AssetPathType.SIDECAR: }
case AssetPathType.SIDECAR: {
return this.assetRepository.save({ id, sidecarPath: newPath }); return this.assetRepository.save({ id, sidecarPath: newPath });
case PersonPathType.FACE: }
case PersonPathType.FACE: {
return this.personRepository.update({ id, thumbnailPath: newPath }); return this.personRepository.update({ id, thumbnailPath: newPath });
}
} }
} }
static getNestedFolder(folder: StorageFolder, ownerId: string, filename: string): string { static getNestedFolder(folder: StorageFolder, ownerId: string, filename: string): string {
return join(StorageCore.getFolderLocation(folder, ownerId), filename.substring(0, 2), filename.substring(2, 4)); return join(StorageCore.getFolderLocation(folder, ownerId), filename.slice(0, 2), filename.slice(2, 4));
} }
static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string { static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {

View File

@ -132,7 +132,7 @@ export const defaults = Object.freeze<SystemConfig>({
watch: { watch: {
enabled: false, enabled: false,
usePolling: false, usePolling: false,
interval: 10000, interval: 10_000,
}, },
}, },
server: { server: {
@ -184,22 +184,30 @@ export class SystemConfigCore {
const hasFeature = await this.hasFeature(feature); const hasFeature = await this.hasFeature(feature);
if (!hasFeature) { if (!hasFeature) {
switch (feature) { switch (feature) {
case FeatureFlag.SMART_SEARCH: case FeatureFlag.SMART_SEARCH: {
throw new BadRequestException('Smart search is not enabled'); throw new BadRequestException('Smart search is not enabled');
case FeatureFlag.FACIAL_RECOGNITION: }
case FeatureFlag.FACIAL_RECOGNITION: {
throw new BadRequestException('Facial recognition is not enabled'); throw new BadRequestException('Facial recognition is not enabled');
case FeatureFlag.SIDECAR: }
case FeatureFlag.SIDECAR: {
throw new BadRequestException('Sidecar is not enabled'); throw new BadRequestException('Sidecar is not enabled');
case FeatureFlag.SEARCH: }
case FeatureFlag.SEARCH: {
throw new BadRequestException('Search is not enabled'); throw new BadRequestException('Search is not enabled');
case FeatureFlag.OAUTH: }
case FeatureFlag.OAUTH: {
throw new BadRequestException('OAuth is not enabled'); throw new BadRequestException('OAuth is not enabled');
case FeatureFlag.PASSWORD_LOGIN: }
case FeatureFlag.PASSWORD_LOGIN: {
throw new BadRequestException('Password login is not enabled'); throw new BadRequestException('Password login is not enabled');
case FeatureFlag.CONFIG_FILE: }
case FeatureFlag.CONFIG_FILE: {
throw new BadRequestException('Config file is not set'); throw new BadRequestException('Config file is not set');
default: }
default: {
throw new ForbiddenException(`Missing required feature: ${feature}`); throw new ForbiddenException(`Missing required feature: ${feature}`);
}
} }
} }
} }
@ -278,9 +286,9 @@ export class SystemConfigCore {
for (const validator of this.validators) { for (const validator of this.validators) {
await validator(newConfig, oldConfig); await validator(newConfig, oldConfig);
} }
} catch (e) { } catch (error) {
this.logger.warn(`Unable to save system config due to a validation error: ${e}`); this.logger.warn(`Unable to save system config due to a validation error: ${error}`);
throw new BadRequestException(e instanceof Error ? e.message : e); throw new BadRequestException(error instanceof Error ? error.message : error);
} }
const updates: SystemConfigEntity[] = []; const updates: SystemConfigEntity[] = [];
@ -330,19 +338,20 @@ export class SystemConfigCore {
private async loadFromFile(filepath: string, force = false) { private async loadFromFile(filepath: string, force = false) {
if (force || !this.configCache) { if (force || !this.configCache) {
try { try {
const file = JSON.parse((await this.repository.readFile(filepath)).toString()); const file = await this.repository.readFile(filepath);
const json = JSON.parse(file.toString());
const overrides: SystemConfigEntity<SystemConfigValue>[] = []; const overrides: SystemConfigEntity<SystemConfigValue>[] = [];
for (const key of Object.values(SystemConfigKey)) { for (const key of Object.values(SystemConfigKey)) {
const value = _.get(file, key); const value = _.get(json, key);
this.unsetDeep(file, key); this.unsetDeep(json, key);
if (value !== undefined) { if (value !== undefined) {
overrides.push({ key, value }); overrides.push({ key, value });
} }
} }
if (!_.isEmpty(file)) { if (!_.isEmpty(json)) {
this.logger.warn(`Unknown keys found: ${JSON.stringify(file, null, 2)}`); this.logger.warn(`Unknown keys found: ${JSON.stringify(json, null, 2)}`);
} }
this.configCache = overrides; this.configCache = overrides;

View File

@ -136,7 +136,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
watch: { watch: {
enabled: false, enabled: false,
usePolling: false, usePolling: false,
interval: 10000, interval: 10_000,
}, },
}, },
}); });

View File

@ -121,7 +121,7 @@ export class SystemConfigService {
private async setLogLevel({ logging }: SystemConfig) { private async setLogLevel({ logging }: SystemConfig) {
const envLevel = this.getEnvLogLevel(); const envLevel = this.getEnvLogLevel();
const configLevel = logging.enabled ? logging.level : false; const configLevel = logging.enabled ? logging.level : false;
const level = envLevel ? envLevel : configLevel; const level = envLevel ?? configLevel;
ImmichLogger.setLogLevel(level); ImmichLogger.setLogLevel(level);
this.logger.log(`LogLevel=${level} ${envLevel ? '(set via LOG_LEVEL)' : '(set via system config)'}`); this.logger.log(`LogLevel=${level} ${envLevel ? '(set via LOG_LEVEL)' : '(set via system config)'}`);
} }

View File

@ -10,7 +10,7 @@ export class TagService {
constructor(@Inject(ITagRepository) private repository: ITagRepository) {} constructor(@Inject(ITagRepository) private repository: ITagRepository) {}
getAll(auth: AuthDto) { getAll(auth: AuthDto) {
return this.repository.getAll(auth.user.id).then((tags) => tags.map(mapTag)); return this.repository.getAll(auth.user.id).then((tags) => tags.map((tag) => mapTag(tag)));
} }
async getById(auth: AuthDto, id: string): Promise<TagResponseDto> { async getById(auth: AuthDto, id: string): Promise<TagResponseDto> {
@ -78,10 +78,10 @@ export class TagService {
const results: AssetIdsResponseDto[] = []; const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) { for (const assetId of dto.assetIds) {
const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId); const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId);
if (!hasAsset) { if (hasAsset) {
results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
} else {
results.push({ assetId, success: true }); results.push({ assetId, success: true });
} else {
results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
} }
} }

View File

@ -5,10 +5,7 @@ import { IsEnum } from 'class-validator';
export const getRandomAvatarColor = (user: UserEntity): UserAvatarColor => { export const getRandomAvatarColor = (user: UserEntity): UserAvatarColor => {
const values = Object.values(UserAvatarColor); const values = Object.values(UserAvatarColor);
const randomIndex = Math.floor( const randomIndex = Math.floor(
user.email [...user.email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length,
.split('')
.map((letter) => letter.charCodeAt(0))
.reduce((a, b) => a + b, 0) % values.length,
); );
return values[randomIndex] as UserAvatarColor; return values[randomIndex] as UserAvatarColor;
}; };

View File

@ -1,6 +1,6 @@
import { LibraryType, UserEntity } from '@app/infra/entities'; import { LibraryType, UserEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { BadRequestException, ForbiddenException } from '@nestjs/common';
import path from 'path'; import path from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { ICryptoRepository, ILibraryRepository, IUserRepository } from '../repositories'; import { ICryptoRepository, ILibraryRepository, IUserRepository } from '../repositories';
import { UserResponseDto } from './response-dto'; import { UserResponseDto } from './response-dto';
@ -97,7 +97,7 @@ export class UserCore {
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS); payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
} }
if (payload.storageLabel) { if (payload.storageLabel) {
payload.storageLabel = sanitize(payload.storageLabel.replace(/\./g, '')); payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', ''));
} }
const userEntity = await this.userRepository.create(payload); const userEntity = await this.userRepository.create(payload);
await this.libraryRepository.create({ await this.libraryRepository.create({

View File

@ -418,7 +418,7 @@ describe(UserService.name, () => {
it('should default to a random password', async () => { it('should default to a random password', async () => {
userMock.getAdmin.mockResolvedValue(userStub.admin); userMock.getAdmin.mockResolvedValue(userStub.admin);
const ask = jest.fn().mockResolvedValue(undefined); const ask = jest.fn().mockImplementation(() => {});
const response = await sut.resetAdminPassword(ask); const response = await sut.resetAdminPassword(ask);

View File

@ -1,7 +1,7 @@
import { UserEntity } from '@app/infra/entities'; import { UserEntity } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger'; import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { randomBytes } from 'crypto'; import { randomBytes } from 'node:crypto';
import { AuthDto } from '../auth'; import { AuthDto } from '../auth';
import { CacheControl, ImmichFileResponse } from '../domain.util'; import { CacheControl, ImmichFileResponse } from '../domain.util';
import { IEntityJob, JobName } from '../job'; import { IEntityJob, JobName } from '../job';
@ -39,7 +39,7 @@ export class UserService {
async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> { async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: !isAll }); const users = await this.userRepository.getList({ withDeleted: !isAll });
return users.map(mapUser); return users.map((user) => mapUser(user));
} }
async get(userId: string): Promise<UserResponseDto> { async get(userId: string): Promise<UserResponseDto> {
@ -125,7 +125,7 @@ export class UserService {
} }
const providedPassword = await ask(mapUser(admin)); const providedPassword = await ask(mapUser(admin));
const password = providedPassword || randomBytes(24).toString('base64').replace(/\W/g, ''); const password = providedPassword || randomBytes(24).toString('base64').replaceAll(/\W/g, '');
await this.userCore.updateUser(admin, admin.id, { password }); await this.userCore.updateUser(admin, admin.id, { password });
@ -188,9 +188,10 @@ export class UserService {
return false; return false;
} }
const msInDay = 86400000; // TODO use luxon for date calculation
const msInDay = 86_400_000;
const msDeleteWait = msInDay * 7; const msDeleteWait = msInDay * 7;
const msSinceDelete = new Date().getTime() - (Date.parse(user.deletedAt.toString()) || 0); const msSinceDelete = Date.now() - (Date.parse(user.deletedAt.toString()) || 0);
return msSinceDelete >= msDeleteWait; return msSinceDelete >= msDeleteWait;
} }

View File

@ -13,20 +13,20 @@ export class ResetAdminPasswordCommand extends CommandRunner {
super(); super();
} }
async run(): Promise<void> { ask = (admin: UserResponseDto) => {
const ask = (admin: UserResponseDto) => { const { id, oauthId, email, name } = admin;
const { id, oauthId, email, name } = admin; console.log(`Found Admin:
console.log(`Found Admin:
- ID=${id} - ID=${id}
- OAuth ID=${oauthId} - OAuth ID=${oauthId}
- Email=${email} - Email=${email}
- Name=${name}`); - Name=${name}`);
return this.inquirer.ask<{ password: string }>('prompt-password', undefined).then(({ password }) => password); return this.inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password);
}; };
async run(): Promise<void> {
try { try {
const { password, provided } = await this.userService.resetAdminPassword(ask); const { password, provided } = await this.userService.resetAdminPassword(this.ask);
if (provided) { if (provided) {
console.log(`The admin password has been updated.`); console.log(`The admin password has been updated.`);
@ -46,7 +46,7 @@ export class PromptPasswordQuestions {
message: 'Please choose a new password (optional)', message: 'Please choose a new password (optional)',
name: 'password', name: 'password',
}) })
parsePassword(val: string) { parsePassword(value: string) {
return val; return value;
} }
} }

View File

@ -38,7 +38,7 @@ export class AssetCore {
isArchived: dto.isArchived ?? false, isArchived: dto.isArchived ?? false,
duration: dto.duration || null, duration: dto.duration || null,
isVisible: dto.isVisible ?? true, isVisible: dto.isVisible ?? true,
livePhotoVideo: livePhotoAssetId != null ? ({ id: livePhotoAssetId } as AssetEntity) : null, livePhotoVideo: livePhotoAssetId === null ? null : ({ id: livePhotoAssetId } as AssetEntity),
resizePath: null, resizePath: null,
webpPath: null, webpPath: null,
thumbhash: null, thumbhash: null,

View File

@ -51,8 +51,8 @@ const _getAsset_1 = () => {
asset_1.encodedVideoPath = ''; asset_1.encodedVideoPath = '';
asset_1.duration = '0:00:00.000000'; asset_1.duration = '0:00:00.000000';
asset_1.exifInfo = new ExifEntity(); asset_1.exifInfo = new ExifEntity();
asset_1.exifInfo.latitude = 49.533547; asset_1.exifInfo.latitude = 49.533_547;
asset_1.exifInfo.longitude = 10.703075; asset_1.exifInfo.longitude = 10.703_075;
return asset_1; return asset_1;
}; };

View File

@ -27,7 +27,6 @@ import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto'; import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
import { SearchPropertiesDto } from './dto/search-properties.dto';
import { ServeFileDto } from './dto/serve-file.dto'; import { ServeFileDto } from './dto/serve-file.dto';
import { import {
AssetBulkUploadCheckResponseDto, AssetBulkUploadCheckResponseDto,
@ -163,7 +162,8 @@ export class AssetService {
const possibleSearchTerm = new Set<string>(); const possibleSearchTerm = new Set<string>();
const rows = await this.assetRepositoryV1.getSearchPropertiesByUserId(auth.user.id); const rows = await this.assetRepositoryV1.getSearchPropertiesByUserId(auth.user.id);
rows.forEach((row: SearchPropertiesDto) => {
for (const row of rows) {
// tags // tags
row.tags?.map((tag: string) => possibleSearchTerm.add(tag?.toLowerCase())); row.tags?.map((tag: string) => possibleSearchTerm.add(tag?.toLowerCase()));
@ -187,9 +187,9 @@ export class AssetService {
possibleSearchTerm.add(row.city?.toLowerCase() || ''); possibleSearchTerm.add(row.city?.toLowerCase() || '');
possibleSearchTerm.add(row.state?.toLowerCase() || ''); possibleSearchTerm.add(row.state?.toLowerCase() || '');
possibleSearchTerm.add(row.country?.toLowerCase() || ''); possibleSearchTerm.add(row.country?.toLowerCase() || '');
}); }
return Array.from(possibleSearchTerm).filter((x) => x != null && x != ''); return [...possibleSearchTerm].filter((x) => x != null && x != '');
} }
async getCuratedLocation(auth: AuthDto): Promise<CuratedLocationsResponseDto[]> { async getCuratedLocation(auth: AuthDto): Promise<CuratedLocationsResponseDto[]> {
@ -249,18 +249,18 @@ export class AssetService {
private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) { private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) {
switch (format) { switch (format) {
case GetAssetThumbnailFormatEnum.WEBP: case GetAssetThumbnailFormatEnum.WEBP: {
if (asset.webpPath) { if (asset.webpPath) {
return asset.webpPath; return asset.webpPath;
} }
this.logger.warn(`WebP thumbnail requested but not found for asset ${asset.id}, falling back to JPEG`); this.logger.warn(`WebP thumbnail requested but not found for asset ${asset.id}, falling back to JPEG`);
}
case GetAssetThumbnailFormatEnum.JPEG: case GetAssetThumbnailFormatEnum.JPEG: {
default:
if (!asset.resizePath) { if (!asset.resizePath) {
throw new NotFoundException(`No thumbnail found for asset ${asset.id}`); throw new NotFoundException(`No thumbnail found for asset ${asset.id}`);
} }
return asset.resizePath; return asset.resizePath;
}
} }
} }

View File

@ -50,8 +50,8 @@ export const SharedLinkRoute = () =>
applyDecorators(SetMetadata(Metadata.SHARED_ROUTE, true), ApiQuery({ name: 'key', type: String, required: false })); applyDecorators(SetMetadata(Metadata.SHARED_ROUTE, true), ApiQuery({ name: 'key', type: String, required: false }));
export const AdminRoute = (value = true) => SetMetadata(Metadata.ADMIN_ROUTE, value); export const AdminRoute = (value = true) => SetMetadata(Metadata.ADMIN_ROUTE, value);
export const Auth = createParamDecorator((data, ctx: ExecutionContext): AuthDto => { export const Auth = createParamDecorator((data, context: ExecutionContext): AuthDto => {
return ctx.switchToHttp().getRequest<{ user: AuthDto }>().user; return context.switchToHttp().getRequest<{ user: AuthDto }>().user;
}); });
export const FileResponse = () => export const FileResponse = () =>
@ -59,15 +59,15 @@ export const FileResponse = () =>
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } }, content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
}); });
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => { export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
const req = ctx.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<Request>();
const userAgent = UAParser(req.headers['user-agent']); const userAgent = UAParser(request.headers['user-agent']);
return { return {
clientIp: req.ip, clientIp: request.ip,
isSecure: req.secure, isSecure: request.secure,
deviceType: userAgent.browser.name || userAgent.device.type || (req.headers.devicemodel as string) || '', deviceType: userAgent.browser.name || userAgent.device.type || (request.headers.devicemodel as string) || '',
deviceOS: userAgent.os.name || (req.headers.devicetype as string) || '', deviceOS: userAgent.os.name || (request.headers.devicetype as string) || '',
}; };
}); });
@ -95,20 +95,20 @@ export class AppGuard implements CanActivate {
return true; return true;
} }
const req = context.switchToHttp().getRequest<AuthRequest>(); const request = context.switchToHttp().getRequest<AuthRequest>();
const authDto = await this.authService.validate(req.headers, req.query as Record<string, string>); const authDto = await this.authService.validate(request.headers, request.query as Record<string, string>);
if (authDto.sharedLink && !isSharedRoute) { if (authDto.sharedLink && !isSharedRoute) {
this.logger.warn(`Denied access to non-shared route: ${req.path}`); this.logger.warn(`Denied access to non-shared route: ${request.path}`);
return false; return false;
} }
if (isAdminRoute && !authDto.user.isAdmin) { if (isAdminRoute && !authDto.user.isAdmin) {
this.logger.warn(`Denied access to admin only route: ${req.path}`); this.logger.warn(`Denied access to admin only route: ${request.path}`);
return false; return false;
} }
req.user = authDto; request.user = authDto;
return true; return true;
} }

View File

@ -15,7 +15,7 @@ import { ImmichLogger } from '@app/infra/logger';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression, Interval } from '@nestjs/schedule'; import { Cron, CronExpression, Interval } from '@nestjs/schedule';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { readFileSync } from 'fs'; import { readFileSync } from 'node:fs';
const render = (index: string, meta: OpenGraphTags) => { const render = (index: string, meta: OpenGraphTags) => {
const tags = ` const tags = `
@ -79,15 +79,15 @@ export class AppService {
let index = ''; let index = '';
try { try {
index = readFileSync(WEB_ROOT_PATH).toString(); index = readFileSync(WEB_ROOT_PATH).toString();
} catch (error: Error | any) { } catch {
this.logger.warn('Unable to open `www/index.html, skipping SSR.'); this.logger.warn('Unable to open `www/index.html, skipping SSR.');
} }
return async (req: Request, res: Response, next: NextFunction) => { return async (request: Request, res: Response, next: NextFunction) => {
if ( if (
req.url.startsWith('/api') || request.url.startsWith('/api') ||
req.method.toLowerCase() !== 'get' || request.method.toLowerCase() !== 'get' ||
excludePaths.find((item) => req.url.startsWith(item)) excludePaths.some((item) => request.url.startsWith(item))
) { ) {
return next(); return next();
} }
@ -107,7 +107,7 @@ export class AppService {
try { try {
for (const { regex, onMatch } of targets) { for (const { regex, onMatch } of targets) {
const matches = req.url.match(regex); const matches = request.url.match(regex);
if (matches) { if (matches) {
const meta = await onMatch(matches); const meta = await onMatch(matches);
if (meta) { if (meta) {

View File

@ -18,11 +18,11 @@ import {
SwaggerModule, SwaggerModule,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { NextFunction, Response } from 'express'; import { NextFunction, Response } from 'express';
import { writeFileSync } from 'fs';
import { access, constants } from 'fs/promises';
import _ from 'lodash'; import _ from 'lodash';
import path, { isAbsolute } from 'path'; import { writeFileSync } from 'node:fs';
import { promisify } from 'util'; import { access, constants } from 'node:fs/promises';
import path, { isAbsolute } from 'node:path';
import { promisify } from 'node:util';
import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common'; import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common';
import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'; import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
@ -55,13 +55,15 @@ export const sendFile = async (
try { try {
const file = await handler(); const file = await handler();
switch (file.cacheControl) { switch (file.cacheControl) {
case CacheControl.PRIVATE_WITH_CACHE: case CacheControl.PRIVATE_WITH_CACHE: {
res.set('Cache-Control', 'private, max-age=86400, no-transform'); res.set('Cache-Control', 'private, max-age=86400, no-transform');
break; break;
}
case CacheControl.PRIVATE_WITHOUT_CACHE: case CacheControl.PRIVATE_WITHOUT_CACHE: {
res.set('Cache-Control', 'private, no-cache, no-transform'); res.set('Cache-Control', 'private, no-cache, no-transform');
break; break;
}
} }
res.header('Content-Type', file.contentType); res.header('Content-Type', file.contentType);
@ -94,21 +96,21 @@ export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) =>
return new StreamableFile(stream, { type, length }); return new StreamableFile(stream, { type, length });
}; };
function sortKeys<T>(obj: T): T { function sortKeys<T>(target: T): T {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { if (!target || typeof target !== 'object' || Array.isArray(target)) {
return obj; return target;
} }
const result: Partial<T> = {}; const result: Partial<T> = {};
const keys = Object.keys(obj).sort() as Array<keyof T>; const keys = Object.keys(target).sort() as Array<keyof T>;
for (const key of keys) { for (const key of keys) {
result[key] = sortKeys(obj[key]); result[key] = sortKeys(target[key]);
} }
return result as T; return result as T;
} }
export const routeToErrorMessage = (methodName: string) => export const routeToErrorMessage = (methodName: string) =>
'Failed to ' + methodName.replace(/[A-Z]+/g, (letter) => ` ${letter.toLowerCase()}`); 'Failed to ' + methodName.replaceAll(/[A-Z]+/g, (letter) => ` ${letter.toLowerCase()}`);
const patchOpenAPI = (document: OpenAPIObject) => { const patchOpenAPI = (document: OpenAPIObject) => {
document.paths = sortKeys(document.paths); document.paths = sortKeys(document.paths);
@ -152,7 +154,7 @@ const patchOpenAPI = (document: OpenAPIObject) => {
continue; continue;
} }
if ((operation.security || []).find((item) => !!item[Metadata.PUBLIC_SECURITY])) { if ((operation.security || []).some((item) => !!item[Metadata.PUBLIC_SECURITY])) {
delete operation.security; delete operation.security;
} }
@ -177,7 +179,7 @@ const patchOpenAPI = (document: OpenAPIObject) => {
return document; return document;
}; };
export const useSwagger = (app: INestApplication, isDev: boolean) => { export const useSwagger = (app: INestApplication, isDevelopment: boolean) => {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('Immich') .setTitle('Immich')
.setDescription('Immich API') .setDescription('Immich API')
@ -203,7 +205,7 @@ export const useSwagger = (app: INestApplication, isDev: boolean) => {
operationIdFactory: (controllerKey: string, methodKey: string) => methodKey, operationIdFactory: (controllerKey: string, methodKey: string) => methodKey,
}; };
const doc = SwaggerModule.createDocument(app, config, options); const specification = SwaggerModule.createDocument(app, config, options);
const customOptions: SwaggerCustomOptions = { const customOptions: SwaggerCustomOptions = {
swaggerOptions: { swaggerOptions: {
@ -212,11 +214,11 @@ export const useSwagger = (app: INestApplication, isDev: boolean) => {
customSiteTitle: 'Immich API Documentation', customSiteTitle: 'Immich API Documentation',
}; };
SwaggerModule.setup('doc', app, doc, customOptions); SwaggerModule.setup('doc', app, specification, customOptions);
if (isDev) { if (isDevelopment) {
// Generate API Documentation only in development mode // Generate API Documentation only in development mode
const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json'); const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json');
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(doc), null, 2), { encoding: 'utf8' }); writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' });
} }
}; };

View File

@ -78,13 +78,13 @@ export class AuthController {
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
logout( logout(
@Req() req: Request, @Req() request: Request,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
): Promise<LogoutResponseDto> { ): Promise<LogoutResponseDto> {
res.clearCookie(IMMICH_ACCESS_COOKIE); res.clearCookie(IMMICH_ACCESS_COOKIE);
res.clearCookie(IMMICH_AUTH_TYPE_COOKIE); res.clearCookie(IMMICH_AUTH_TYPE_COOKIE);
return this.service.logout(auth, (req.cookies || {})[IMMICH_AUTH_TYPE_COOKIE]); return this.service.logout(auth, (request.cookies || {})[IMMICH_AUTH_TYPE_COOKIE]);
} }
} }

View File

@ -25,9 +25,9 @@ export class OAuthController {
@PublicRoute() @PublicRoute()
@Get('mobile-redirect') @Get('mobile-redirect')
@Redirect() @Redirect()
redirectOAuthToMobile(@Req() req: Request) { redirectOAuthToMobile(@Req() request: Request) {
return { return {
url: this.service.getMobileRedirect(req.url), url: this.service.getMobileRedirect(request.url),
statusCode: HttpStatus.TEMPORARY_REDIRECT, statusCode: HttpStatus.TEMPORARY_REDIRECT,
}; };
} }

View File

@ -33,10 +33,10 @@ export class SharedLinkController {
async getMySharedLink( async getMySharedLink(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Query() dto: SharedLinkPasswordDto, @Query() dto: SharedLinkPasswordDto,
@Req() req: Request, @Req() request: Request,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
): Promise<SharedLinkResponseDto> { ): Promise<SharedLinkResponseDto> {
const sharedLinkToken = req.cookies?.[IMMICH_SHARED_LINK_ACCESS_COOKIE]; const sharedLinkToken = request.cookies?.[IMMICH_SHARED_LINK_ACCESS_COOKIE];
if (sharedLinkToken) { if (sharedLinkToken) {
dto.token = sharedLinkToken; dto.token = sharedLinkToken;
} }

View File

@ -4,9 +4,9 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nes
import { PATH_METADATA } from '@nestjs/common/constants'; import { PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils'; import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
import { createHash, randomUUID } from 'crypto';
import { NextFunction, RequestHandler } from 'express'; import { NextFunction, RequestHandler } from 'express';
import multer, { StorageEngine, diskStorage } from 'multer'; import multer, { StorageEngine, diskStorage } from 'multer';
import { createHash, randomUUID } from 'node:crypto';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AuthRequest } from '../app.guard'; import { AuthRequest } from '../app.guard';
@ -40,17 +40,17 @@ interface Callback<T> {
(error: null, result: T): void; (error: null, result: T): void;
} }
const callbackify = async <T>(fn: (...args: any[]) => T, callback: Callback<T>) => { const callbackify = async <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
try { try {
return callback(null, await fn()); return callback(null, await target());
} catch (error: Error | any) { } catch (error: Error | any) {
return callback(error); return callback(error);
} }
}; };
const asRequest = (req: AuthRequest, file: Express.Multer.File) => { const asRequest = (request: AuthRequest, file: Express.Multer.File) => {
return { return {
auth: req.user || null, auth: request.user || null,
fieldName: file.fieldname as UploadFieldName, fieldName: file.fieldname as UploadFieldName,
file: mapToUploadFile(file as ImmichFile), file: mapToUploadFile(file as ImmichFile),
}; };
@ -94,14 +94,14 @@ export class FileUploadInterceptor implements NestInterceptor {
} }
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> { async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
const ctx = context.switchToHttp(); const context_ = context.switchToHttp();
const route = this.reflect.get<string>(PATH_METADATA, context.getClass()); const route = this.reflect.get<string>(PATH_METADATA, context.getClass());
const handler: RequestHandler | null = this.getHandler(route as Route); const handler: RequestHandler | null = this.getHandler(route as Route);
if (handler) { if (handler) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve()); const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve());
handler(ctx.getRequest(), ctx.getResponse(), next); handler(context_.getRequest(), context_.getResponse(), next);
}); });
} else { } else {
this.logger.warn(`Skipping invalid file upload route: ${route}`); this.logger.warn(`Skipping invalid file upload route: ${route}`);
@ -110,28 +110,31 @@ export class FileUploadInterceptor implements NestInterceptor {
return next.handle(); return next.handle();
} }
private fileFilter(req: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) { private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
return callbackify(() => this.assetService.canUploadFile(asRequest(req, file)), callback); return callbackify(() => this.assetService.canUploadFile(asRequest(request, file)), callback);
} }
private filename(req: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(() => this.assetService.getUploadFilename(asRequest(req, file)), callback as Callback<string>); return callbackify(
() => this.assetService.getUploadFilename(asRequest(request, file)),
callback as Callback<string>,
);
} }
private destination(req: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(() => this.assetService.getUploadFolder(asRequest(req, file)), callback as Callback<string>); return callbackify(() => this.assetService.getUploadFolder(asRequest(request, file)), callback as Callback<string>);
} }
private handleFile(req: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) { private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
(file as ImmichMulterFile).uuid = randomUUID(); (file as ImmichMulterFile).uuid = randomUUID();
if (!this.isAssetUploadFile(file)) { if (!this.isAssetUploadFile(file)) {
this.defaultStorage._handleFile(req, file, callback); this.defaultStorage._handleFile(request, file, callback);
return; return;
} }
const hash = createHash('sha1'); const hash = createHash('sha1');
file.stream.on('data', (chunk) => hash.update(chunk)); file.stream.on('data', (chunk) => hash.update(chunk));
this.defaultStorage._handleFile(req, file, (error, info) => { this.defaultStorage._handleFile(request, file, (error, info) => {
if (error) { if (error) {
hash.destroy(); hash.destroy();
callback(error); callback(error);
@ -141,15 +144,16 @@ export class FileUploadInterceptor implements NestInterceptor {
}); });
} }
private removeFile(req: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) { private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
this.defaultStorage._removeFile(req, file, callback); this.defaultStorage._removeFile(request, file, callback);
} }
private isAssetUploadFile(file: Express.Multer.File) { private isAssetUploadFile(file: Express.Multer.File) {
switch (file.fieldname as UploadFieldName) { switch (file.fieldname as UploadFieldName) {
case UploadFieldName.ASSET_DATA: case UploadFieldName.ASSET_DATA:
case UploadFieldName.LIVE_PHOTO_DATA: case UploadFieldName.LIVE_PHOTO_DATA: {
return true; return true;
}
} }
return false; return false;
@ -157,14 +161,17 @@ export class FileUploadInterceptor implements NestInterceptor {
private getHandler(route: Route) { private getHandler(route: Route) {
switch (route) { switch (route) {
case Route.ASSET: case Route.ASSET: {
return this.handlers.assetUpload; return this.handlers.assetUpload;
}
case Route.USER: case Route.USER: {
return this.handlers.userProfile; return this.handlers.userProfile;
}
default: default: {
return null; return null;
}
} }
} }
} }

View File

@ -6,12 +6,13 @@ const urlOrParts = url
? { url } ? { url }
: { : {
host: process.env.DB_HOSTNAME || 'localhost', host: process.env.DB_HOSTNAME || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'), port: Number.parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USERNAME || 'postgres', username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'postgres', password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_DATABASE_NAME || 'immich', database: process.env.DB_DATABASE_NAME || 'immich',
}; };
/* eslint unicorn/prefer-module: "off" -- We can fix this when migrating to ESM*/
export const databaseConfig: PostgresConnectionOptions = { export const databaseConfig: PostgresConnectionOptions = {
type: 'postgres', type: 'postgres',
entities: [__dirname + '/entities/*.entity.{js,ts}'], entities: [__dirname + '/entities/*.entity.{js,ts}'],
@ -19,7 +20,7 @@ export const databaseConfig: PostgresConnectionOptions = {
migrations: [__dirname + '/migrations/*.{js,ts}'], migrations: [__dirname + '/migrations/*.{js,ts}'],
subscribers: [__dirname + '/subscribers/*.{js,ts}'], subscribers: [__dirname + '/subscribers/*.{js,ts}'],
migrationsRun: false, migrationsRun: false,
connectTimeoutMS: 10000, // 10 seconds connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true, parseInt8: true,
...urlOrParts, ...urlOrParts,
}; };

View File

@ -15,8 +15,8 @@ function parseRedisConfig(): RedisOptions {
} }
return { return {
host: process.env.REDIS_HOSTNAME || 'immich_redis', host: process.env.REDIS_HOSTNAME || 'immich_redis',
port: parseInt(process.env.REDIS_PORT || '6379'), port: Number.parseInt(process.env.REDIS_PORT || '6379'),
db: parseInt(process.env.REDIS_DBINDEX || '0'), db: Number.parseInt(process.env.REDIS_DBINDEX || '0'),
username: process.env.REDIS_USERNAME || undefined, username: process.env.REDIS_USERNAME || undefined,
password: process.env.REDIS_PASSWORD || undefined, password: process.env.REDIS_PASSWORD || undefined,
path: process.env.REDIS_SOCKET || undefined, path: process.env.REDIS_SOCKET || undefined,

View File

@ -27,4 +27,4 @@ export const DummyValue = {
// maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching // maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching
// by a list of IDs) requires splitting the query into multiple chunks. // by a list of IDs) requires splitting the query into multiple chunks.
// We are rounding down this limit, as queries commonly include other filters and parameters. // We are rounding down this limit, as queries commonly include other filters and parameters.
export const DATABASE_PARAMETER_CHUNK_SIZE = 65500; export const DATABASE_PARAMETER_CHUNK_SIZE = 65_500;

View File

@ -59,21 +59,25 @@ export const isValidInteger = (value: number, options: { min?: number; max?: num
export function Chunked(options: { paramIndex?: number; mergeFn?: (results: any) => any } = {}): MethodDecorator { export function Chunked(options: { paramIndex?: number; mergeFn?: (results: any) => any } = {}): MethodDecorator {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value; const originalMethod = descriptor.value;
const paramIndex = options.paramIndex ?? 0; const parameterIndex = options.paramIndex ?? 0;
descriptor.value = async function (...args: any[]) { descriptor.value = async function (...arguments_: any[]) {
const arg = args[paramIndex]; const argument = arguments_[parameterIndex];
// Early return if argument length is less than or equal to the chunk size. // Early return if argument length is less than or equal to the chunk size.
if ( if (
(arg instanceof Array && arg.length <= DATABASE_PARAMETER_CHUNK_SIZE) || (Array.isArray(argument) && argument.length <= DATABASE_PARAMETER_CHUNK_SIZE) ||
(arg instanceof Set && arg.size <= DATABASE_PARAMETER_CHUNK_SIZE) (argument instanceof Set && argument.size <= DATABASE_PARAMETER_CHUNK_SIZE)
) { ) {
return await originalMethod.apply(this, args); return await originalMethod.apply(this, arguments_);
} }
return Promise.all( return Promise.all(
chunks(arg, DATABASE_PARAMETER_CHUNK_SIZE).map(async (chunk) => { chunks(argument, DATABASE_PARAMETER_CHUNK_SIZE).map(async (chunk) => {
await originalMethod.apply(this, [...args.slice(0, paramIndex), chunk, ...args.slice(paramIndex + 1)]); await Reflect.apply(originalMethod, this, [
...arguments_.slice(0, parameterIndex),
chunk,
...arguments_.slice(parameterIndex + 1),
]);
}), }),
).then((results) => (options.mergeFn ? options.mergeFn(results) : results)); ).then((results) => (options.mergeFn ? options.mergeFn(results) : results));
}; };

View File

@ -24,7 +24,8 @@ export class AddLibraries1688392120838 implements MigrationInterface {
); );
// Create default library for each user and assign all assets to it // Create default library for each user and assign all assets to it
const userIds: string[] = (await queryRunner.query(`SELECT id FROM "users"`)).map((user: any) => user.id); const users = await queryRunner.query(`SELECT id FROM "users"`);
const userIds: string[] = users.map((user: any) => user.id);
for (const userId of userIds) { for (const userId of userIds) {
await queryRunner.query( await queryRunner.query(

View File

@ -14,7 +14,7 @@ export class UsePgVectors1700713871511 implements MigrationInterface {
const clipModelNameQuery = await queryRunner.query(`SELECT value FROM system_config WHERE key = 'machineLearning.clip.modelName'`); const clipModelNameQuery = await queryRunner.query(`SELECT value FROM system_config WHERE key = 'machineLearning.clip.modelName'`);
const clipModelName: string = clipModelNameQuery?.[0]?.['value'] ?? 'ViT-B-32__openai'; const clipModelName: string = clipModelNameQuery?.[0]?.['value'] ?? 'ViT-B-32__openai';
const clipDimSize = getCLIPModelInfo(clipModelName.replace(/"/g, '')).dimSize; const clipDimSize = getCLIPModelInfo(clipModelName.replaceAll('"', '')).dimSize;
await queryRunner.query(` await queryRunner.query(`
ALTER TABLE asset_faces ALTER TABLE asset_faces

View File

@ -167,7 +167,7 @@ class AlbumAccess implements IAlbumAccess {
}) })
.then( .then(
(sharedLinks) => (sharedLinks) =>
new Set(sharedLinks.flatMap((sharedLink) => (!!sharedLink.albumId ? [sharedLink.albumId] : []))), new Set(sharedLinks.flatMap((sharedLink) => (sharedLink.albumId ? [sharedLink.albumId] : []))),
), ),
), ),
).then((results) => setUnion(...results)); ).then((results) => setUnion(...results));

View File

@ -71,7 +71,7 @@ export class AlbumRepository implements IAlbumRepository {
@ChunkedArray() @ChunkedArray()
async getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]> { async getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]> {
// Guard against running invalid query when ids list is empty. // Guard against running invalid query when ids list is empty.
if (!ids.length) { if (ids.length === 0) {
return []; return [];
} }

View File

@ -24,7 +24,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import _ from 'lodash'; import _ from 'lodash';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import path from 'path'; import path from 'node:path';
import { import {
And, And,
Brackets, Brackets,
@ -471,7 +471,7 @@ export class AssetRepository implements IAssetRepository {
let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {}; let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
switch (property) { switch (property) {
case WithoutProperty.THUMBNAIL: case WithoutProperty.THUMBNAIL: {
where = [ where = [
{ resizePath: IsNull(), isVisible: true }, { resizePath: IsNull(), isVisible: true },
{ resizePath: '', isVisible: true }, { resizePath: '', isVisible: true },
@ -480,15 +480,17 @@ export class AssetRepository implements IAssetRepository {
{ thumbhash: IsNull(), isVisible: true }, { thumbhash: IsNull(), isVisible: true },
]; ];
break; break;
}
case WithoutProperty.ENCODED_VIDEO: case WithoutProperty.ENCODED_VIDEO: {
where = [ where = [
{ type: AssetType.VIDEO, encodedVideoPath: IsNull() }, { type: AssetType.VIDEO, encodedVideoPath: IsNull() },
{ type: AssetType.VIDEO, encodedVideoPath: '' }, { type: AssetType.VIDEO, encodedVideoPath: '' },
]; ];
break; break;
}
case WithoutProperty.EXIF: case WithoutProperty.EXIF: {
relations = { relations = {
exifInfo: true, exifInfo: true,
jobStatus: true, jobStatus: true,
@ -500,8 +502,9 @@ export class AssetRepository implements IAssetRepository {
}, },
}; };
break; break;
}
case WithoutProperty.SMART_SEARCH: case WithoutProperty.SMART_SEARCH: {
relations = { relations = {
smartSearch: true, smartSearch: true,
}; };
@ -513,8 +516,9 @@ export class AssetRepository implements IAssetRepository {
}, },
}; };
break; break;
}
case WithoutProperty.OBJECT_TAGS: case WithoutProperty.OBJECT_TAGS: {
relations = { relations = {
smartInfo: true, smartInfo: true,
}; };
@ -526,8 +530,9 @@ export class AssetRepository implements IAssetRepository {
}, },
}; };
break; break;
}
case WithoutProperty.FACES: case WithoutProperty.FACES: {
relations = { relations = {
faces: true, faces: true,
jobStatus: true, jobStatus: true,
@ -544,8 +549,9 @@ export class AssetRepository implements IAssetRepository {
}, },
}; };
break; break;
}
case WithoutProperty.PERSON: case WithoutProperty.PERSON: {
relations = { relations = {
faces: true, faces: true,
}; };
@ -558,16 +564,19 @@ export class AssetRepository implements IAssetRepository {
}, },
}; };
break; break;
}
case WithoutProperty.SIDECAR: case WithoutProperty.SIDECAR: {
where = [ where = [
{ sidecarPath: IsNull(), isVisible: true }, { sidecarPath: IsNull(), isVisible: true },
{ sidecarPath: '', isVisible: true }, { sidecarPath: '', isVisible: true },
]; ];
break; break;
}
default: default: {
throw new Error(`Invalid getWithout property: ${property}`); throw new Error(`Invalid getWithout property: ${property}`);
}
} }
return paginate(this.repository, pagination, { return paginate(this.repository, pagination, {
@ -584,18 +593,21 @@ export class AssetRepository implements IAssetRepository {
let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {}; let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
switch (property) { switch (property) {
case WithProperty.SIDECAR: case WithProperty.SIDECAR: {
where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; where = [{ sidecarPath: Not(IsNull()), isVisible: true }];
break; break;
case WithProperty.IS_OFFLINE: }
case WithProperty.IS_OFFLINE: {
if (!libraryId) { if (!libraryId) {
throw new Error('Library id is required when finding offline assets'); throw new Error('Library id is required when finding offline assets');
} }
where = [{ isOffline: true, libraryId: libraryId }]; where = [{ isOffline: true, libraryId: libraryId }];
break; break;
}
default: default: {
throw new Error(`Invalid getWith property: ${property}`); throw new Error(`Invalid getWith property: ${property}`);
}
} }
return paginate(this.repository, pagination, { return paginate(this.repository, pagination, {

View File

@ -51,13 +51,15 @@ export class CommunicationRepository
on(event: 'connect' | ServerEvent, callback: OnConnectCallback | OnServerEventCallback) { on(event: 'connect' | ServerEvent, callback: OnConnectCallback | OnServerEventCallback) {
switch (event) { switch (event) {
case 'connect': case 'connect': {
this.onConnectCallbacks.push(callback); this.onConnectCallbacks.push(callback);
break; break;
}
default: default: {
this.onServerEventCallbacks[event].push(callback as OnServerEventCallback); this.onServerEventCallbacks[event].push(callback as OnServerEventCallback);
break; break;
}
} }
} }

View File

@ -1,8 +1,8 @@
import { ICryptoRepository } from '@app/domain'; import { ICryptoRepository } from '@app/domain';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { compareSync, hash } from 'bcrypt'; import { compareSync, hash } from 'bcrypt';
import { createHash, randomBytes, randomUUID } from 'crypto'; import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { createReadStream } from 'fs'; import { createReadStream } from 'node:fs';
@Injectable() @Injectable()
export class CryptoRepository implements ICryptoRepository { export class CryptoRepository implements ICryptoRepository {
@ -24,7 +24,7 @@ export class CryptoRepository implements ICryptoRepository {
return new Promise<Buffer>((resolve, reject) => { return new Promise<Buffer>((resolve, reject) => {
const hash = createHash('sha1'); const hash = createHash('sha1');
const stream = createReadStream(filepath); const stream = createReadStream(filepath);
stream.on('error', (err) => reject(err)); stream.on('error', (error) => reject(error));
stream.on('data', (chunk) => hash.update(chunk)); stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest())); stream.on('end', () => resolve(hash.digest()));
}); });

View File

@ -10,10 +10,10 @@ import {
import { ImmichLogger } from '@app/infra/logger'; import { ImmichLogger } from '@app/infra/logger';
import archiver from 'archiver'; import archiver from 'archiver';
import chokidar, { WatchOptions } from 'chokidar'; import chokidar, { WatchOptions } from 'chokidar';
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
import fs, { copyFile, readdir, rename, writeFile } from 'fs/promises';
import { glob } from 'glob'; import { glob } from 'glob';
import path from 'path'; import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs';
import fs, { copyFile, readdir, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
export class FilesystemProvider implements IStorageRepository { export class FilesystemProvider implements IStorageRepository {
private logger = new ImmichLogger(FilesystemProvider.name); private logger = new ImmichLogger(FilesystemProvider.name);
@ -60,7 +60,7 @@ export class FilesystemProvider implements IStorageRepository {
try { try {
await fs.access(filepath, mode); await fs.access(filepath, mode);
return true; return true;
} catch (_) { } catch {
return false; return false;
} }
} }
@ -68,11 +68,11 @@ export class FilesystemProvider implements IStorageRepository {
async unlink(file: string) { async unlink(file: string) {
try { try {
await fs.unlink(file); await fs.unlink(file);
} catch (err) { } catch (error) {
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
this.logger.warn(`File ${file} does not exist.`); this.logger.warn(`File ${file} does not exist.`);
} else { } else {
throw err; throw error;
} }
} }
} }

View File

@ -15,7 +15,7 @@ import { ModuleRef } from '@nestjs/core';
import { SchedulerRegistry } from '@nestjs/schedule'; import { SchedulerRegistry } from '@nestjs/schedule';
import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq'; import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq';
import { CronJob, CronTime } from 'cron'; import { CronJob, CronTime } from 'cron';
import { setTimeout } from 'timers/promises'; import { setTimeout } from 'node:timers/promises';
import { bullConfig } from '../infra.config'; import { bullConfig } from '../infra.config';
@Injectable() @Injectable()
@ -24,7 +24,7 @@ export class JobRepository implements IJobRepository {
private logger = new ImmichLogger(JobRepository.name); private logger = new ImmichLogger(JobRepository.name);
constructor( constructor(
private moduleRef: ModuleRef, private moduleReference: ModuleRef,
private schedulerReqistry: SchedulerRegistry, private schedulerReqistry: SchedulerRegistry,
) {} ) {}
@ -118,7 +118,7 @@ export class JobRepository implements IJobRepository {
} }
async queueAll(items: JobItem[]): Promise<void> { async queueAll(items: JobItem[]): Promise<void> {
if (!items.length) { if (items.length === 0) {
return; return;
} }
@ -167,19 +167,23 @@ export class JobRepository implements IJobRepository {
private getJobOptions(item: JobItem): JobsOptions | null { private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) { switch (item.name) {
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
return { jobId: item.data.id }; return { jobId: item.data.id };
case JobName.GENERATE_PERSON_THUMBNAIL: }
case JobName.GENERATE_PERSON_THUMBNAIL: {
return { priority: 1 }; return { priority: 1 };
case JobName.QUEUE_FACIAL_RECOGNITION: }
case JobName.QUEUE_FACIAL_RECOGNITION: {
return { jobId: JobName.QUEUE_FACIAL_RECOGNITION }; return { jobId: JobName.QUEUE_FACIAL_RECOGNITION };
}
default: default: {
return null; return null;
}
} }
} }
private getQueue(queue: QueueName): Queue { private getQueue(queue: QueueName): Queue {
return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false }); return this.moduleReference.get<Queue>(getQueueToken(queue), { strict: false });
} }
} }

View File

@ -10,7 +10,7 @@ import {
VisionModelInput, VisionModelInput,
} from '@app/domain'; } from '@app/domain';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { readFile } from 'fs/promises'; import { readFile } from 'node:fs/promises';
const errorPrefix = 'Machine learning request'; const errorPrefix = 'Machine learning request';

View File

@ -2,10 +2,10 @@ import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoIn
import { Colorspace } from '@app/infra/entities'; import { Colorspace } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger'; import { ImmichLogger } from '@app/infra/logger';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import fs from 'fs/promises'; import fs from 'node:fs/promises';
import { Writable } from 'node:stream';
import { promisify } from 'node:util';
import sharp from 'sharp'; import sharp from 'sharp';
import { Writable } from 'stream';
import { promisify } from 'util';
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe); const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
sharp.concurrency(0); sharp.concurrency(0);
@ -91,7 +91,7 @@ export class MediaRepository implements IMediaRepository {
} }
if (typeof output !== 'string') { if (typeof output !== 'string') {
throw new Error('Two-pass transcoding does not support writing to a stream'); throw new TypeError('Two-pass transcoding does not support writing to a stream');
} }
// two-pass allows for precise control of bitrate at the cost of running twice // two-pass allows for precise control of bitrate at the cost of running twice
@ -124,12 +124,12 @@ export class MediaRepository implements IMediaRepository {
.inputOptions(options.inputOptions) .inputOptions(options.inputOptions)
.outputOptions(options.outputOptions) .outputOptions(options.outputOptions)
.output(output) .output(output)
.on('error', (err, stdout, stderr) => this.logger.error(stderr || err)); .on('error', (error, stdout, stderr) => this.logger.error(stderr || error));
} }
chainPath(existing: string, path: string) { chainPath(existing: string, path: string) {
const sep = existing.endsWith(':') ? '' : ':'; const separator = existing.endsWith(':') ? '' : ':';
return `${existing}${sep}${path}`; return `${existing}${separator}${path}`;
} }
async generateThumbhash(imagePath: string): Promise<Buffer> { async generateThumbhash(imagePath: string): Promise<Buffer> {

View File

@ -15,11 +15,11 @@ import { ImmichLogger } from '@app/infra/logger';
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DefaultReadTaskOptions, exiftool, Tags } from 'exiftool-vendored'; import { DefaultReadTaskOptions, exiftool, Tags } from 'exiftool-vendored';
import { createReadStream, existsSync } from 'fs';
import { readFile } from 'fs/promises';
import * as geotz from 'geo-tz'; import * as geotz from 'geo-tz';
import { getName } from 'i18n-iso-countries'; import { getName } from 'i18n-iso-countries';
import * as readLine from 'readline'; import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import * as readLine from 'node:readline';
import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm'; import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm';
type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity; type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
@ -69,10 +69,10 @@ export class MetadataRepository implements IMetadataRepository {
await this.loadAdmin2(queryRunner); await this.loadAdmin2(queryRunner);
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
} catch (e) { } catch (error) {
this.logger.fatal('Error importing geodata', e); this.logger.fatal('Error importing geodata', error);
await queryRunner.rollbackTransaction(); await queryRunner.rollbackTransaction();
throw e; throw error;
} finally { } finally {
await queryRunner.release(); await queryRunner.release();
} }
@ -110,10 +110,10 @@ export class MetadataRepository implements IMetadataRepository {
queryRunner, queryRunner,
(lineSplit: string[]) => (lineSplit: string[]) =>
this.geodataPlacesRepository.create({ this.geodataPlacesRepository.create({
id: parseInt(lineSplit[0]), id: Number.parseInt(lineSplit[0]),
name: lineSplit[1], name: lineSplit[1],
latitude: parseFloat(lineSplit[4]), latitude: Number.parseFloat(lineSplit[4]),
longitude: parseFloat(lineSplit[5]), longitude: Number.parseFloat(lineSplit[5]),
countryCode: lineSplit[8], countryCode: lineSplit[8],
admin1Code: lineSplit[10], admin1Code: lineSplit[10],
admin2Code: lineSplit[11], admin2Code: lineSplit[11],
@ -192,7 +192,8 @@ export class MetadataRepository implements IMetadataRepository {
backfillTimezones: true, backfillTimezones: true,
inferTimezoneFromDatestamps: true, inferTimezoneFromDatestamps: true,
useMWG: true, useMWG: true,
numericTags: DefaultReadTaskOptions.numericTags.concat(['FocalLength']), numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength'],
/* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */
geoTz: (lat, lon) => geotz.find(lat, lon)[0], geoTz: (lat, lon) => geotz.find(lat, lon)[0],
}) })
.catch((error) => { .catch((error) => {

View File

@ -28,12 +28,7 @@ export class PersonRepository implements IPersonRepository {
.createQueryBuilder() .createQueryBuilder()
.update() .update()
.set({ personId: newPersonId }) .set({ personId: newPersonId })
.where( .where(_.omitBy({ personId: oldPersonId ?? undefined, id: faceIds ? In(faceIds) : undefined }, _.isUndefined))
_.omitBy(
{ personId: oldPersonId ? oldPersonId : undefined, id: faceIds ? In(faceIds) : undefined },
_.isUndefined,
),
)
.execute(); .execute();
return result.affected ?? 0; return result.affected ?? 0;

View File

@ -31,11 +31,11 @@ export class SmartInfoRepository implements ISmartInfoRepository {
throw new Error(`Invalid CLIP model name: ${modelName}`); throw new Error(`Invalid CLIP model name: ${modelName}`);
} }
const curDimSize = await this.getDimSize(); const currentDimSize = await this.getDimSize();
this.logger.verbose(`Current database CLIP dimension size is ${curDimSize}`); this.logger.verbose(`Current database CLIP dimension size is ${currentDimSize}`);
if (dimSize != curDimSize) { if (dimSize != currentDimSize) {
this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${curDimSize}.`); this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${currentDimSize}.`);
await this.updateDimSize(dimSize); await this.updateDimSize(dimSize);
} }
} }
@ -119,7 +119,9 @@ export class SmartInfoRepository implements ISmartInfoRepository {
cte = cte.andWhere('faces."personId" IS NOT NULL'); cte = cte.andWhere('faces."personId" IS NOT NULL');
} }
this.faceColumns.forEach((col) => cte.addSelect(`faces.${col}`, col)); for (const col of this.faceColumns) {
cte.addSelect(`faces.${col}`, col);
}
results = await manager results = await manager
.createQueryBuilder() .createQueryBuilder()
@ -157,8 +159,8 @@ export class SmartInfoRepository implements ISmartInfoRepository {
throw new Error(`Invalid CLIP dimension size: ${dimSize}`); throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
} }
const curDimSize = await this.getDimSize(); const currentDimSize = await this.getDimSize();
if (curDimSize === dimSize) { if (currentDimSize === dimSize) {
return; return;
} }
@ -181,7 +183,7 @@ export class SmartInfoRepository implements ISmartInfoRepository {
$$)`); $$)`);
}); });
this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`); this.logger.log(`Successfully updated database CLIP dimension size from ${currentDimSize} to ${dimSize}.`);
} }
private async getDimSize(): Promise<number> { private async getDimSize(): Promise<number> {

View File

@ -1,7 +1,7 @@
import { ISystemConfigRepository } from '@app/domain'; import { ISystemConfigRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios'; import axios from 'axios';
import { readFile } from 'fs/promises'; import { readFile } from 'node:fs/promises';
import { In, Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { SystemConfigEntity } from '../entities'; import { SystemConfigEntity } from '../entities';
import { DummyValue, GenerateSql } from '../infra.util'; import { DummyValue, GenerateSql } from '../infra.util';
@ -22,7 +22,7 @@ export class SystemConfigRepository implements ISystemConfigRepository {
} }
readFile(filename: string): Promise<string> { readFile(filename: string): Promise<string> {
return readFile(filename, { encoding: 'utf-8' }); return readFile(filename, { encoding: 'utf8' });
} }
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> { saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {

View File

@ -74,11 +74,7 @@ export class UserRepository implements IUserRepository {
} }
async delete(user: UserEntity, hard?: boolean): Promise<UserEntity> { async delete(user: UserEntity, hard?: boolean): Promise<UserEntity> {
if (hard) { return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user);
return this.userRepository.remove(user);
} else {
return this.userRepository.softRemove(user);
}
} }
async restore(user: UserEntity): Promise<UserEntity> { async restore(user: UserEntity): Promise<UserEntity> {

View File

@ -1,10 +1,11 @@
#!/usr/bin/env node
import { ISystemConfigRepository } from '@app/domain'; import { ISystemConfigRepository } from '@app/domain';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { mkdir, rm, writeFile } from 'fs/promises'; import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'path'; import { join } from 'node:path';
import { databaseConfig } from '../database.config'; import { databaseConfig } from '../database.config';
import { databaseEntities } from '../entities'; import { databaseEntities } from '../entities';
import { GENERATE_SQL_KEY, GenerateSqlQueries } from '../infra.util'; import { GENERATE_SQL_KEY, GenerateSqlQueries } from '../infra.util';
@ -157,7 +158,7 @@ class SqlGenerator {
private async write() { private async write() {
for (const [repoName, data] of Object.entries(this.results)) { for (const [repoName, data] of Object.entries(this.results)) {
const filename = repoName.replace(/[A-Z]/g, (letter) => `.${letter.toLowerCase()}`).replace('.', ''); const filename = repoName.replaceAll(/[A-Z]/g, (letter) => `.${letter.toLowerCase()}`).replace('.', '');
const file = join(this.options.targetDir, `${filename}.sql`); const file = join(this.options.targetDir, `${filename}.sql`);
await writeFile(file, data.join('\n\n') + '\n'); await writeFile(file, data.join('\n\n') + '\n');
} }

View File

@ -1,8 +1,6 @@
import { format } from 'sql-formatter';
import { Logger } from 'typeorm'; import { Logger } from 'typeorm';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { format } = require('sql-formatter');
export class SqlLogger implements Logger { export class SqlLogger implements Logger {
queries: string[] = []; queries: string[] = [];
errors: Array<{ error: string | Error; query: string }> = []; errors: Array<{ error: string | Error; query: string }> = [];

View File

@ -16,21 +16,23 @@ export class AuditSubscriber implements EntitySubscriberInterface<AssetEntity |
private getAudit(entityName: string, entity: any): Partial<AuditEntity> | null { private getAudit(entityName: string, entity: any): Partial<AuditEntity> | null {
switch (entityName) { switch (entityName) {
case AssetEntity.name: case AssetEntity.name: {
const asset = entity as AssetEntity; const asset = entity as AssetEntity;
return { return {
entityType: EntityType.ASSET, entityType: EntityType.ASSET,
entityId: asset.id, entityId: asset.id,
ownerId: asset.ownerId, ownerId: asset.ownerId,
}; };
}
case AlbumEntity.name: case AlbumEntity.name: {
const album = entity as AlbumEntity; const album = entity as AlbumEntity;
return { return {
entityType: EntityType.ALBUM, entityType: EntityType.ALBUM,
entityId: album.id, entityId: album.id,
ownerId: album.ownerId, ownerId: album.ownerId,
}; };
}
} }
return null; return null;

View File

@ -10,18 +10,21 @@ if (process.argv[2] === immichApp) {
function bootstrap() { function bootstrap() {
switch (immichApp) { switch (immichApp) {
case 'immich': case 'immich': {
process.title = 'immich_server'; process.title = 'immich_server';
return server(); return server();
case 'microservices': }
case 'microservices': {
process.title = 'immich_microservices'; process.title = 'immich_microservices';
return microservices(); return microservices();
case 'immich-admin': }
case 'immich-admin': {
process.title = 'immich_admin_cli'; process.title = 'immich_admin_cli';
return admin(); return admin();
default: }
console.log(`Invalid app name: ${immichApp}. Expected one of immich|microservices|cli`); default: {
process.exit(1); throw new Error(`Invalid app name: ${immichApp}. Expected one of immich|microservices|cli`);
}
} }
} }
void bootstrap(); void bootstrap();

View File

@ -7,7 +7,7 @@ describe('parsing latitude from string input', () => {
expect(parseLatitude('Infinity')).toBeNull(); expect(parseLatitude('Infinity')).toBeNull();
expect(parseLatitude('-Infinity')).toBeNull(); expect(parseLatitude('-Infinity')).toBeNull();
expect(parseLatitude('90.001')).toBeNull(); expect(parseLatitude('90.001')).toBeNull();
expect(parseLatitude(-90.000001)).toBeNull(); expect(parseLatitude(-90.000_001)).toBeNull();
expect(parseLatitude('1000')).toBeNull(); expect(parseLatitude('1000')).toBeNull();
expect(parseLatitude(-1000)).toBeNull(); expect(parseLatitude(-1000)).toBeNull();
}); });
@ -15,10 +15,10 @@ describe('parsing latitude from string input', () => {
it('returns the numeric coordinate for valid inputs', () => { it('returns the numeric coordinate for valid inputs', () => {
expect(parseLatitude('90')).toBeCloseTo(90); expect(parseLatitude('90')).toBeCloseTo(90);
expect(parseLatitude('-90')).toBeCloseTo(-90); expect(parseLatitude('-90')).toBeCloseTo(-90);
expect(parseLatitude(89.999999)).toBeCloseTo(89.999999); expect(parseLatitude(89.999_999)).toBeCloseTo(89.999_999);
expect(parseLatitude('-89.9')).toBeCloseTo(-89.9); expect(parseLatitude('-89.9')).toBeCloseTo(-89.9);
expect(parseLatitude(0)).toBeCloseTo(0); expect(parseLatitude(0)).toBeCloseTo(0);
expect(parseLatitude('-0.0')).toBeCloseTo(-0.0); expect(parseLatitude('-0.0')).toBeCloseTo(-0);
}); });
}); });
@ -32,7 +32,7 @@ describe('parsing longitude from string input', () => {
it('returns null for invalid inputs', () => { it('returns null for invalid inputs', () => {
expect(parseLongitude('')).toBeNull(); expect(parseLongitude('')).toBeNull();
expect(parseLongitude('NaN')).toBeNull(); expect(parseLongitude('NaN')).toBeNull();
expect(parseLongitude(Infinity)).toBeNull(); expect(parseLongitude(Number.POSITIVE_INFINITY)).toBeNull();
expect(parseLongitude('-Infinity')).toBeNull(); expect(parseLongitude('-Infinity')).toBeNull();
expect(parseLongitude('180.001')).toBeNull(); expect(parseLongitude('180.001')).toBeNull();
expect(parseLongitude('-180.000001')).toBeNull(); expect(parseLongitude('-180.000001')).toBeNull();
@ -43,10 +43,10 @@ describe('parsing longitude from string input', () => {
it('returns the numeric coordinate for valid inputs', () => { it('returns the numeric coordinate for valid inputs', () => {
expect(parseLongitude(180)).toBeCloseTo(180); expect(parseLongitude(180)).toBeCloseTo(180);
expect(parseLongitude('-180')).toBeCloseTo(-180); expect(parseLongitude('-180')).toBeCloseTo(-180);
expect(parseLongitude('179.999999')).toBeCloseTo(179.999999); expect(parseLongitude('179.999999')).toBeCloseTo(179.999_999);
expect(parseLongitude(-179.9)).toBeCloseTo(-179.9); expect(parseLongitude(-179.9)).toBeCloseTo(-179.9);
expect(parseLongitude('0')).toBeCloseTo(0); expect(parseLongitude('0')).toBeCloseTo(0);
expect(parseLongitude('-0.0')).toBeCloseTo(-0.0); expect(parseLongitude('-0.0')).toBeCloseTo(-0);
}); });
}); });

View File

@ -2,15 +2,15 @@ import { isDecimalNumber, isNumberInRange, toNumberOrNull } from './numbers';
describe('checks if a number is a decimal number', () => { describe('checks if a number is a decimal number', () => {
it('returns false for non-decimal numbers', () => { it('returns false for non-decimal numbers', () => {
expect(isDecimalNumber(NaN)).toBe(false); expect(isDecimalNumber(Number.NaN)).toBe(false);
expect(isDecimalNumber(Infinity)).toBe(false); expect(isDecimalNumber(Number.POSITIVE_INFINITY)).toBe(false);
expect(isDecimalNumber(-Infinity)).toBe(false); expect(isDecimalNumber(Number.NEGATIVE_INFINITY)).toBe(false);
}); });
it('returns true for decimal numbers', () => { it('returns true for decimal numbers', () => {
expect(isDecimalNumber(0)).toBe(true); expect(isDecimalNumber(0)).toBe(true);
expect(isDecimalNumber(-0)).toBe(true); expect(isDecimalNumber(-0)).toBe(true);
expect(isDecimalNumber(10.12345)).toBe(true); expect(isDecimalNumber(10.123_45)).toBe(true);
expect(isDecimalNumber(Number.MAX_VALUE)).toBe(true); expect(isDecimalNumber(Number.MAX_VALUE)).toBe(true);
expect(isDecimalNumber(Number.MIN_VALUE)).toBe(true); expect(isDecimalNumber(Number.MIN_VALUE)).toBe(true);
}); });
@ -26,16 +26,17 @@ describe('checks if a number is within a range', () => {
it('returns true for numbers inside the range', () => { it('returns true for numbers inside the range', () => {
expect(isNumberInRange(0, 0, 50)).toBe(true); expect(isNumberInRange(0, 0, 50)).toBe(true);
expect(isNumberInRange(50, 0, 50)).toBe(true); expect(isNumberInRange(50, 0, 50)).toBe(true);
expect(isNumberInRange(-50.12345, -50.12345, 0)).toBe(true); expect(isNumberInRange(-50.123_45, -50.123_45, 0)).toBe(true);
}); });
}); });
describe('converts input to a number or null', () => { describe('converts input to a number or null', () => {
it('returns null for invalid inputs', () => { it('returns null for invalid inputs', () => {
expect(toNumberOrNull(null)).toBeNull(); expect(toNumberOrNull(null)).toBeNull();
// eslint-disable-next-line unicorn/no-useless-undefined
expect(toNumberOrNull(undefined)).toBeNull(); expect(toNumberOrNull(undefined)).toBeNull();
expect(toNumberOrNull('')).toBeNull(); expect(toNumberOrNull('')).toBeNull();
expect(toNumberOrNull(NaN)).toBeNull(); expect(toNumberOrNull(Number.NaN)).toBeNull();
}); });
it('returns a number for valid inputs', () => { it('returns a number for valid inputs', () => {

View File

@ -1,12 +1,12 @@
export function isDecimalNumber(num: number): boolean { export function isDecimalNumber(number_: number): boolean {
return !Number.isNaN(num) && Number.isFinite(num); return !Number.isNaN(number_) && Number.isFinite(number_);
} }
/** /**
* Check if `num` is a valid number and is between `start` and `end` (inclusive) * Check if `num` is a valid number and is between `start` and `end` (inclusive)
*/ */
export function isNumberInRange(num: number, start: number, end: number): boolean { export function isNumberInRange(number_: number, start: number, end: number): boolean {
return isDecimalNumber(num) && num >= start && num <= end; return isDecimalNumber(number_) && number_ >= start && number_ <= end;
} }
export function toNumberOrNull(input: number | string | null | undefined): number | null { export function toNumberOrNull(input: number | string | null | undefined): number | null {
@ -14,6 +14,6 @@ export function toNumberOrNull(input: number | string | null | undefined): numbe
return null; return null;
} }
const num = typeof input === 'string' ? Number.parseFloat(input) : input; const number_ = typeof input === 'string' ? Number.parseFloat(input) : input;
return isDecimalNumber(num) ? num : null; return isDecimalNumber(number_) ? number_ : null;
} }

Some files were not shown because too many files have changed in this diff Show More