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

@ -107,42 +107,51 @@ export class AccessCore {
const sharedLinkId = sharedLink.id;
switch (permission) {
case Permission.ASSET_READ:
case Permission.ASSET_READ: {
return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids);
}
case Permission.ASSET_VIEW:
case Permission.ASSET_VIEW: {
return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids);
}
case Permission.ASSET_DOWNLOAD:
return !!sharedLink.allowDownload
case Permission.ASSET_DOWNLOAD: {
return sharedLink.allowDownload
? await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids)
: new Set();
}
case Permission.ASSET_UPLOAD:
case Permission.ASSET_UPLOAD: {
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
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);
}
case Permission.ALBUM_DOWNLOAD:
return !!sharedLink.allowDownload
case Permission.ALBUM_DOWNLOAD: {
return sharedLink.allowDownload
? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids)
: new Set();
}
default:
default: {
return new Set();
}
}
}
private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set<string>) {
switch (permission) {
// uses album id
case Permission.ACTIVITY_CREATE:
case Permission.ACTIVITY_CREATE: {
return await this.repository.activity.checkCreateAccess(auth.user.id, ids);
}
// uses activity id
case Permission.ACTIVITY_DELETE: {
@ -190,14 +199,17 @@ export class AccessCore {
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.ASSET_UPDATE:
case Permission.ASSET_UPDATE: {
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);
}
case Permission.ASSET_RESTORE:
case Permission.ASSET_RESTORE: {
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ALBUM_READ: {
const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids);
@ -205,14 +217,17 @@ export class AccessCore {
return setUnion(isOwner, isShared);
}
case Permission.ALBUM_UPDATE:
case Permission.ALBUM_UPDATE: {
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);
}
case Permission.ALBUM_SHARE:
case Permission.ALBUM_SHARE: {
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
}
case Permission.ALBUM_DOWNLOAD: {
const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids);
@ -220,17 +235,21 @@ export class AccessCore {
return setUnion(isOwner, isShared);
}
case Permission.ALBUM_REMOVE_ASSET:
case Permission.ALBUM_REMOVE_ASSET: {
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);
}
case Permission.ARCHIVE_READ:
case Permission.ARCHIVE_READ: {
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);
}
case Permission.TIMELINE_READ: {
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);
}
case Permission.TIMELINE_DOWNLOAD:
case Permission.TIMELINE_DOWNLOAD: {
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
}
case Permission.LIBRARY_READ: {
const isOwner = await this.repository.library.checkOwnerAccess(auth.user.id, ids);
@ -247,32 +267,41 @@ export class AccessCore {
return setUnion(isOwner, isPartner);
}
case Permission.LIBRARY_UPDATE:
case Permission.LIBRARY_UPDATE: {
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);
}
case Permission.PERSON_READ:
case Permission.PERSON_READ: {
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);
}
case Permission.PERSON_MERGE:
case Permission.PERSON_MERGE: {
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);
}
case Permission.PERSON_REASSIGN:
case Permission.PERSON_REASSIGN: {
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);
}
default:
default: {
return new Set();
}
}
}
}

View file

@ -35,7 +35,7 @@ export class ActivityService {
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> {

View file

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

View file

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

View file

@ -12,7 +12,7 @@ export class APIKeyService {
) {}
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({
key: this.crypto.hashSha256(secret),
name: dto.name || 'API Key',

View file

@ -1009,9 +1009,7 @@ describe(AssetService.name, () => {
it('get assets by device id', async () => {
const assets = [assetStub.image, assetStub.image1];
assetMock.getAllByDeviceId.mockImplementation(() =>
Promise.resolve<string[]>(Array.from(assets.map((asset) => asset.deviceAssetId))),
);
assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId));
const deviceId = 'device-id';
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 _ from 'lodash';
import { DateTime, Duration } from 'luxon';
import { extname } from 'path';
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { AccessCore, Permission } from '../access';
import { AuthDto } from '../auth';
@ -93,7 +93,7 @@ export class AssetService {
}
search(auth: AuthDto, dto: AssetSearchDto) {
let checksum: Buffer | undefined = undefined;
let checksum: Buffer | undefined;
if (dto.checksum) {
const encoding = dto.checksum.length === 28 ? 'base64' : 'hex';
@ -126,29 +126,33 @@ export class AssetService {
const filename = file.originalName;
switch (fieldName) {
case UploadFieldName.ASSET_DATA:
case UploadFieldName.ASSET_DATA: {
if (mimeTypes.isAsset(filename)) {
return true;
}
break;
}
case UploadFieldName.LIVE_PHOTO_DATA:
case UploadFieldName.LIVE_PHOTO_DATA: {
if (mimeTypes.isVideo(filename)) {
return true;
}
break;
}
case UploadFieldName.SIDECAR_DATA:
case UploadFieldName.SIDECAR_DATA: {
if (mimeTypes.isSidecar(filename)) {
return true;
}
break;
}
case UploadFieldName.PROFILE_DATA:
case UploadFieldName.PROFILE_DATA: {
if (mimeTypes.isProfile(filename)) {
return true;
}
break;
}
}
this.logger.error(`Unsupported file type ${filename}`);
@ -158,13 +162,13 @@ export class AssetService {
getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
this.access.requireUploadAccess(auth);
const originalExt = extname(file.originalName);
const originalExtension = extname(file.originalName);
const lookup = {
[UploadFieldName.ASSET_DATA]: originalExt,
[UploadFieldName.ASSET_DATA]: originalExtension,
[UploadFieldName.LIVE_PHOTO_DATA]: '.mov',
[UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: originalExt,
[UploadFieldName.PROFILE_DATA]: originalExtension,
};
return sanitize(`${file.uuid}${lookup[fieldName]}`);
@ -247,11 +251,9 @@ export class AssetService {
await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
if (!auth.sharedLink || auth.sharedLink?.showExif) {
return assets.map((asset) => mapAsset(asset, { withStack: true }));
} else {
return assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
}
return !auth.sharedLink || auth.sharedLink?.showExif
? assets.map((asset) => mapAsset(asset, { withStack: true }))
: assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
}
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);
ids.push(...assetsWithChildren.flatMap((child) => child.stack!.assets.map((gChild) => gChild.id)));
if (!stack) {
stack = await this.assetStackRepository.create({
if (stack) {
await this.assetStackRepository.update({
id: stack.id,
primaryAssetId: primaryAsset.id,
assets: ids.map((id) => ({ id }) as AssetEntity),
});
} else {
await this.assetStackRepository.update({
id: stack.id,
stack = await this.assetStackRepository.create({
primaryAssetId: primaryAsset.id,
assets: ids.map((id) => ({ id }) as AssetEntity),
});
@ -394,9 +396,10 @@ export class AssetService {
}
await this.assetRepository.updateAll(ids, options);
const stacksToDelete = (
await Promise.all(stackIdsToCheckForDelete.map((id) => this.assetStackRepository.getById(id)))
)
const stackIdsToDelete = await Promise.all(
stackIdsToCheckForDelete.map((id) => this.assetStackRepository.getById(id)),
);
const stacksToDelete = stackIdsToDelete
.flatMap((stack) => (stack ? [stack] : []))
.filter((stack) => stack.assets.length < 2);
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');
}
if (oldParent != null) {
childIds.push(oldParent.id);
// 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({
id: oldParent.stackId,
@ -530,17 +532,20 @@ export class AssetService {
for (const id of dto.assetIds) {
switch (dto.name) {
case AssetJobName.REFRESH_METADATA:
case AssetJobName.REFRESH_METADATA: {
jobs.push({ name: JobName.METADATA_EXTRACTION, data: { id } });
break;
}
case AssetJobName.REGENERATE_THUMBNAIL:
case AssetJobName.REGENERATE_THUMBNAIL: {
jobs.push({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id } });
break;
}
case AssetJobName.TRANSCODE_VIDEO:
case AssetJobName.TRANSCODE_VIDEO: {
jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id } });
break;
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -8,8 +8,8 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import cookieParser from 'cookie';
import { IncomingHttpHeaders } from 'http';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import { AccessCore, Permission } from '../access';
import {
@ -85,7 +85,7 @@ export class AuthService {
this.configCore = SystemConfigCore.create(configRepository);
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
custom.setHttpOptionsDefaults({ timeout: 30000 });
custom.setHttpOptionsDefaults({ timeout: 30_000 });
}
async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> {
@ -213,7 +213,8 @@ export class AuthService {
}
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),
scope,
state: generators.state(),
@ -376,12 +377,10 @@ export class AuthService {
const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
const sharedLink = await this.sharedLinkRepository.getByKey(bytes);
if (sharedLink) {
if (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date()) {
const user = sharedLink.user;
if (user) {
return { user, sharedLink };
}
if (sharedLink && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) {
const user = sharedLink.user;
if (user) {
return { user, sharedLink };
}
}
throw new UnauthorizedException('Invalid share key');
@ -423,7 +422,7 @@ export class AuthService {
}
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);
await this.userTokenRepository.create({

View file

@ -50,14 +50,14 @@ export class DatabaseService {
}
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();
this.logger.fatal(`
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.
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());
});
for (const [ext, v] of Object.entries(mimeTypes.profile)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
for (const [extension, v] of Object.entries(mimeTypes.profile)) {
it(`should lookup ${extension}`, () => {
expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]);
});
}
});
@ -135,9 +135,9 @@ describe('mimeTypes', () => {
expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('image/')));
});
for (const [ext, v] of Object.entries(mimeTypes.image)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
for (const [extension, v] of Object.entries(mimeTypes.image)) {
it(`should lookup ${extension}`, () => {
expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]);
});
}
});
@ -162,9 +162,9 @@ describe('mimeTypes', () => {
expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('video/')));
});
for (const [ext, v] of Object.entries(mimeTypes.video)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
for (const [extension, v] of Object.entries(mimeTypes.video)) {
it(`should lookup ${extension}`, () => {
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']);
});
for (const [ext, v] of Object.entries(mimeTypes.sidecar)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`it.${ext}`)).toEqual(v[0]);
for (const [extension, v] of Object.entries(mimeTypes.sidecar)) {
it(`should lookup ${extension}`, () => {
expect(mimeTypes.lookup(`it.${extension}`)).toEqual(v[0]);
});
}
});

View file

@ -3,8 +3,6 @@ import { Duration } from 'luxon';
import { readFileSync } from 'node:fs';
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 ONE_HOUR = Duration.fromObject({ hours: 1 });
@ -31,7 +29,7 @@ export class Version implements IVersion {
}
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);
if (matchResult) {
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 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';
@ -129,9 +128,9 @@ const image: Record<string, string[]> = {
'.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(
Object.entries(image).filter(([key]) => profileExtensions.includes(key)),
Object.entries(image).filter(([key]) => profileExtensions.has(key)),
);
const video: Record<string, string[]> = {
@ -180,5 +179,5 @@ export const mimeTypes = {
}
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 function ValidateUUID({ optional, each }: Options = { optional: false, each: false }) {
export function ValidateUUID(options?: Options) {
const { optional, each } = { optional: false, each: false, ...options };
return applyDecorators(
IsUUID('4', { each }),
ApiProperty({ format: 'uuid' }),
@ -58,7 +59,7 @@ export function ValidateUUID({ optional, each }: Options = { optional: false, ea
export function validateCronExpression(expression: string) {
try {
new CronJob(expression, () => {});
} catch (error) {
} catch {
return false;
}
@ -96,7 +97,7 @@ export const toBoolean = ({ value }: IValue) => {
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 {
return basename(path, extname(path));
@ -173,7 +174,7 @@ export function Optional({ nullable, ...validationOptions }: OptionalOptions = {
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) {
const result = [];
let chunk = [];
for (const elem of collection) {
chunk.push(elem);
for (const element of collection) {
chunk.push(element);
if (chunk.length === size) {
result.push(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> => {
const union = new Set(sets[0]);
for (const set of sets.slice(1)) {
for (const elem of set) {
union.add(elem);
for (const element of set) {
union.add(element);
}
}
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> => {
const difference = new Set(setA);
for (const set of sets) {
for (const elem of set) {
difference.delete(elem);
for (const element of set) {
difference.delete(element);
}
}
return difference;
};
export const setIsSuperset = <T>(set: Set<T>, subset: Set<T>): boolean => {
for (const elem of subset) {
if (!set.has(elem)) {
for (const element of subset) {
if (!set.has(element)) {
return false;
}
}

View file

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

View file

@ -23,7 +23,7 @@ import { JobService } from './job.service';
const makeMockHandlers = (success: boolean) => {
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,
JobHandler
>;

View file

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

View file

@ -19,7 +19,7 @@ import {
} from '@test';
import { newFSWatcherMock } from '@test/mocks';
import { Stats } from 'fs';
import { Stats } from 'node:fs';
import { ILibraryFileJob, ILibraryRefreshJob, JobName } from '../job';
import {
IAssetRepository,
@ -116,12 +116,15 @@ describe(LibraryService.name, () => {
libraryMock.get.mockImplementation(async (id) => {
switch (id) {
case libraryStub.externalLibraryWithImportPaths1.id:
case libraryStub.externalLibraryWithImportPaths1.id: {
return libraryStub.externalLibraryWithImportPaths1;
case libraryStub.externalLibraryWithImportPaths2.id:
}
case libraryStub.externalLibraryWithImportPaths2.id: {
return libraryStub.externalLibraryWithImportPaths2;
default:
}
default: {
return null;
}
}
});
@ -532,7 +535,7 @@ describe(LibraryService.name, () => {
});
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 = {
id: assetStub.image.id,
@ -1430,12 +1433,15 @@ describe(LibraryService.name, () => {
libraryMock.get.mockImplementation(async (id) => {
switch (id) {
case libraryStub.externalLibraryWithImportPaths1.id:
case libraryStub.externalLibraryWithImportPaths1.id: {
return libraryStub.externalLibraryWithImportPaths1;
case libraryStub.externalLibraryWithImportPaths2.id:
}
case libraryStub.externalLibraryWithImportPaths2.id: {
return libraryStub.externalLibraryWithImportPaths2;
default:
}
default: {
return null;
}
}
});

View file

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

View file

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

View file

@ -15,16 +15,14 @@ class BaseConfig implements VideoCodecSWConfig {
getOptions(videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const options = {
inputOptions: this.getBaseInputOptions(),
outputOptions: this.getBaseOutputOptions(videoStream, audioStream).concat('-v verbose'),
outputOptions: [...this.getBaseOutputOptions(videoStream, audioStream), '-v verbose'],
twoPass: this.eligibleForTwoPass(),
} as TranscodeOptions;
const filters = this.getFilterOptions(videoStream);
if (filters.length > 0) {
options.outputOptions.push(`-vf ${filters.join(',')}`);
}
options.outputOptions.push(...this.getPresetOptions());
options.outputOptions.push(...this.getThreadOptions());
options.outputOptions.push(...this.getBitrateOptions());
options.outputOptions.push(...this.getPresetOptions(), ...this.getThreadOptions(), ...this.getBitrateOptions());
return options;
}
@ -129,11 +127,10 @@ class BaseConfig implements VideoCodecSWConfig {
getTargetResolution(videoStream: VideoStreamInfo) {
let target;
if (this.config.targetResolution === 'original') {
target = Math.min(videoStream.height, videoStream.width);
} else {
target = Number.parseInt(this.config.targetResolution);
}
target =
this.config.targetResolution === 'original'
? Math.min(videoStream.height, videoStream.width)
: Number.parseInt(this.config.targetResolution);
if (target % 2 !== 0) {
target -= 1;
@ -182,7 +179,7 @@ class BaseConfig implements VideoCodecSWConfig {
getBitrateUnit() {
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() {
@ -411,8 +408,7 @@ export class NVENCConfig extends BaseHWConfig {
...super.getBaseOutputOptions(videoStream, audioStream),
];
if (this.getBFrames() > 0) {
options.push('-b_ref_mode middle');
options.push('-b_qfactor 1.1');
options.push('-b_ref_mode middle', '-b_qfactor 1.1');
}
if (this.config.temporalAQ) {
options.push('-temporal-aq 1');
@ -474,8 +470,8 @@ export class NVENCConfig extends BaseHWConfig {
export class QSVConfig extends BaseHWConfig {
getBaseInputOptions() {
if (!this.devices.length) {
throw Error('No QSV device found');
if (this.devices.length === 0) {
throw new Error('No QSV device found');
}
let qsvString = '';
@ -519,8 +515,7 @@ export class QSVConfig extends BaseHWConfig {
options.push(`-${this.useCQP() ? 'q:v' : 'global_quality'} ${this.config.crf}`);
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) {
options.push(`-maxrate ${bitrates.max}${bitrates.unit}`);
options.push(`-bufsize ${bitrates.max * 2}${bitrates.unit}`);
options.push(`-maxrate ${bitrates.max}${bitrates.unit}`, `-bufsize ${bitrates.max * 2}${bitrates.unit}`);
}
return options;
}
@ -623,7 +618,7 @@ export class RKMPPConfig extends BaseHWConfig {
getBaseInputOptions() {
if (this.devices.length === 0) {
throw Error('No RKMPP device found');
throw new Error('No RKMPP device found');
}
return [];
}
@ -642,14 +637,17 @@ export class RKMPPConfig extends BaseHWConfig {
getPresetOptions() {
switch (this.config.targetVideoCodec) {
case VideoCodec.H264:
case VideoCodec.H264: {
// from ffmpeg_mpp help, commonly referred to as H264 level 5.1
return ['-level 51'];
case VideoCodec.HEVC:
}
case VideoCodec.HEVC: {
// from ffmpeg_mpp help, commonly referred to as HEVC level 5.1
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,
probeStub,
} from '@test';
import { randomBytes } from 'crypto';
import { BinaryField } from 'exiftool-vendored';
import { Stats } from 'fs';
import { constants } from 'fs/promises';
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 {
ClientEvent,
@ -234,7 +234,7 @@ describe(MetadataService.name, () => {
describe('handleMetadataExtraction', () => {
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 () => {
@ -507,7 +507,7 @@ describe(MetadataService.name, () => {
exifImageWidth: null,
exposureTime: tags.ExposureTime,
fNumber: null,
fileSizeInByte: 123456,
fileSizeInByte: 123_456,
focalLength: tags.FocalLength,
fps: null,
iso: tags.ISO,
@ -565,7 +565,7 @@ describe(MetadataService.name, () => {
it('should handle duration with scale', async () => {
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 });

View file

@ -3,9 +3,9 @@ import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { ExifDateTime, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import { constants } from 'fs/promises';
import _ from 'lodash';
import { Duration } from 'luxon';
import { constants } from 'node:fs/promises';
import { Subscription } from 'rxjs';
import { usePagination } from '../domain.util';
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;
}
if (typeof value === 'number' && (isNaN(value) || !isFinite(value))) {
if (typeof value === 'number' && (Number.isNaN(value) || !Number.isFinite(value))) {
return null;
}
@ -217,18 +217,22 @@ export class MetadataService {
if (videoStreams[0]) {
switch (videoStreams[0].rotation) {
case -90:
case -90: {
exifData.orientation = Orientation.Rotate90CW;
break;
case 0:
}
case 0: {
exifData.orientation = Orientation.Horizontal;
break;
case 90:
}
case 90: {
exifData.orientation = Orientation.Rotate270CW;
break;
case 180:
}
case 180: {
exifData.orientation = Orientation.Rotate180;
break;
}
}
}
}
@ -243,7 +247,7 @@ export class MetadataService {
const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0;
if (dateTimeOriginal && timeZoneOffset) {
localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60000);
localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
}
await this.assetRepository.save({
id: asset.id,
@ -413,7 +417,13 @@ export class MetadataService {
const checksum = this.cryptoRepository.hashSha1(video);
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
// (allowing us to delete old ones if necessary)
const motionAssetId = this.cryptoRepository.randomUUID();
@ -448,12 +458,6 @@ export class MetadataService {
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: 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})`);
@ -494,7 +498,7 @@ export class MetadataService {
fileSizeInByte: stats.size,
fNumber: validate(tags.FNumber),
focalLength: validate(tags.FocalLength),
fps: validate(parseFloat(tags.VideoFrameRate!)),
fps: validate(Number.parseFloat(tags.VideoFrameRate!)),
iso: validate(tags.ISO),
latitude: validate(tags.GPSLatitude),
lensModel: tags.LensModel ?? null,

View file

@ -24,7 +24,7 @@ export class PartnerService {
}
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> {
@ -43,7 +43,7 @@ export class PartnerService {
return partners
.filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users
.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> {
@ -51,10 +51,10 @@ export class PartnerService {
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
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"
const user = mapUser(
direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy,

View file

@ -814,7 +814,7 @@ describe(PersonService.name, () => {
}
const faces = [
{ face: faceStub.noPerson1, distance: 0.0 },
{ face: faceStub.noPerson1, distance: 0 },
{ face: faceStub.primaryFace1, distance: 0.2 },
{ face: faceStub.noPerson2, distance: 0.3 },
{ 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 () => {
const faces = [
{ face: faceStub.noPerson1, distance: 0.0 },
{ face: faceStub.noPerson1, distance: 0 },
{ face: faceStub.noPerson2, distance: 0.3 },
] as FaceSearchResult[];
@ -867,7 +867,7 @@ describe(PersonService.name, () => {
});
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([
{ 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 () => {
const faces = [{ face: faceStub.noPerson1, distance: 0.0 }] as FaceSearchResult[];
const faces = [{ face: faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 2 },

View file

@ -122,7 +122,7 @@ export class PersonService {
}
if (changeFeaturePhoto.length > 0) {
// Remove duplicates
await this.createNewFeaturePhoto(Array.from(new Set(changeFeaturePhoto)));
await this.createNewFeaturePhoto([...new Set(changeFeaturePhoto)]);
}
return result;
}
@ -332,7 +332,7 @@ export class PersonService {
this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`);
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 } });
const mappedFaces = faces.map((face) => ({
@ -417,7 +417,7 @@ export class PersonService {
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;
if (!isCore && !deferred) {

View file

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

View file

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

View file

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

View file

@ -46,7 +46,7 @@ export class SearchService {
this.assetRepository.getAssetIdByTag(auth.user.id, options),
]);
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)]));
return results.map(({ fieldName, items }) => ({
@ -75,7 +75,7 @@ export class SearchService {
let assets: AssetEntity[] = [];
switch (strategy) {
case SearchStrategy.SMART:
case SearchStrategy.SMART: {
const embedding = await this.machineLearning.encodeText(
machineLearning.url,
{ text: query },
@ -88,10 +88,13 @@ export class SearchService {
withArchived,
});
break;
case SearchStrategy.TEXT:
}
case SearchStrategy.TEXT: {
assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: 250 });
default:
}
default: {
break;
}
}
return {

View file

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

View file

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

View file

@ -21,7 +21,7 @@ export class SharedLinkService {
}
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> {
@ -30,7 +30,7 @@ export class SharedLinkService {
}
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) {
response.token = this.validateAndRefreshToken(sharedLink, dto);
}
@ -40,19 +40,20 @@ export class SharedLinkService {
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
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> {
switch (dto.type) {
case SharedLinkType.ALBUM:
case SharedLinkType.ALBUM: {
if (!dto.albumId) {
throw new BadRequestException('Invalid albumId');
}
await this.access.requirePermission(auth, Permission.ALBUM_SHARE, dto.albumId);
break;
}
case SharedLinkType.INDIVIDUAL:
case SharedLinkType.INDIVIDUAL: {
if (!dto.assetIds || dto.assetIds.length === 0) {
throw new BadRequestException('Invalid assetIds');
}
@ -60,6 +61,7 @@ export class SharedLinkService {
await this.access.requirePermission(auth, Permission.ASSET_SHARE, dto.assetIds);
break;
}
}
const sharedLink = await this.repository.create({
@ -76,7 +78,7 @@ export class SharedLinkService {
showExif: dto.showMetadata ?? true,
});
return this.map(sharedLink, { withExif: true });
return this.mapToSharedLink(sharedLink, { withExif: true });
}
async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) {
@ -91,7 +93,7 @@ export class SharedLinkService {
allowDownload: dto.allowDownload,
showExif: dto.showMetadata,
});
return this.map(sharedLink, { withExif: true });
return this.mapToSharedLink(sharedLink, { withExif: true });
}
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 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 {
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);
}

View file

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

View file

@ -269,7 +269,7 @@ describe(StorageTemplateService.name, () => {
when(storageMock.stat)
.calledWith(newPath)
.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)
.calledWith({ id: assetStub.image.id, originalPath: newPath })
@ -311,9 +311,9 @@ describe(StorageTemplateService.name, () => {
});
it.each`
failedPathChecksum | failedPathSize | reason
${assetStub.image.checksum} | ${500} | ${'file size'}
${Buffer.from('bad checksum', 'utf-8')} | ${assetStub.image.exifInfo?.fileSizeInByte} | ${'checksum'}
failedPathChecksum | failedPathSize | reason
${assetStub.image.checksum} | ${500} | ${'file size'}
${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',
async ({ failedPathChecksum, failedPathSize }) => {

View file

@ -86,7 +86,8 @@ export class StorageTemplateService {
}
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) {
return true;
}
@ -109,8 +110,9 @@ export class StorageTemplateService {
async handleMigration() {
this.logger.log('Starting storage template migration');
const storageTemplateEnabled = (await this.configCore.getConfig()).storageTemplate.enabled;
if (!storageTemplateEnabled) {
const { storageTemplate } = await this.configCore.getConfig();
const { enabled } = storageTemplate;
if (!enabled) {
this.logger.log('Storage template migration disabled, skipping');
return true;
}
@ -145,7 +147,7 @@ export class StorageTemplateService {
}
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
const { id, sidecarPath, originalPath, exifInfo } = asset;
const { id, sidecarPath, originalPath, exifInfo, checksum } = asset;
const oldPath = originalPath;
const newPath = await this.getTemplatePath(asset, metadata);
@ -160,7 +162,7 @@ export class StorageTemplateService {
pathType: AssetPathType.ORIGINAL,
oldPath,
newPath,
assetInfo: { sizeInBytes: exifInfo.fileSizeInByte, checksum: asset.checksum },
assetInfo: { sizeInBytes: exifInfo.fileSizeInByte, checksum },
});
if (sidecarPath) {
await this.storageCore.moveFile({
@ -171,7 +173,7 @@ export class StorageTemplateService {
});
}
} 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 {
const source = asset.originalPath;
const ext = path.extname(source).split('.').pop() as string;
const sanitized = sanitize(path.basename(filename, `.${ext}`));
const extension = path.extname(source).split('.').pop() as string;
const sanitized = sanitize(path.basename(filename, `.${extension}`));
const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel });
let albumName = null;
@ -194,11 +196,11 @@ export class StorageTemplateService {
const storagePath = this.render(this.template.compiled, {
asset,
filename: sanitized,
extension: ext,
extension: extension,
albumName,
});
const fullPath = path.normalize(path.join(rootPath, storagePath));
let destination = `${fullPath}.${ext}`;
let destination = `${fullPath}.${extension}`;
if (!fullPath.startsWith(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
* +7 suffix, and if so, it will be considered as already migrated.
*/
if (source.startsWith(fullPath) && source.endsWith(`.${ext}`)) {
const diff = source.replace(fullPath, '').replace(`.${ext}`, '');
if (source.startsWith(fullPath) && source.endsWith(`.${extension}`)) {
const diff = source.replace(fullPath, '').replace(`.${extension}`, '');
const hasDuplicationAnnotation = /^\+\d+$/.test(diff);
if (hasDuplicationAnnotation) {
return source;
@ -240,7 +242,7 @@ export class StorageTemplateService {
}
duplicateCount++;
destination = `${fullPath}+${duplicateCount}.${ext}`;
destination = `${fullPath}+${duplicateCount}.${extension}`;
}
return destination;
@ -264,9 +266,9 @@ export class StorageTemplateService {
extension: 'jpg',
albumName: 'album',
});
} catch (e) {
this.logger.warn(`Storage template validation failed: ${JSON.stringify(e)}`);
throw new Error(`Invalid storage template: ${e}`);
} catch (error) {
this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`);
throw new Error(`Invalid storage template: ${error}`);
}
}
@ -282,7 +284,7 @@ export class StorageTemplateService {
return {
raw: template,
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',
assetId: asset.id,
//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;

View file

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

View file

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

View file

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

View file

@ -121,7 +121,7 @@ export class SystemConfigService {
private async setLogLevel({ logging }: SystemConfig) {
const envLevel = this.getEnvLogLevel();
const configLevel = logging.enabled ? logging.level : false;
const level = envLevel ? envLevel : configLevel;
const level = envLevel ?? configLevel;
ImmichLogger.setLogLevel(level);
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) {}
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> {
@ -78,10 +78,10 @@ export class TagService {
const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId);
if (!hasAsset) {
results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
} else {
if (hasAsset) {
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 => {
const values = Object.values(UserAvatarColor);
const randomIndex = Math.floor(
user.email
.split('')
.map((letter) => letter.charCodeAt(0))
.reduce((a, b) => a + b, 0) % values.length,
[...user.email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length,
);
return values[randomIndex] as UserAvatarColor;
};

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { UserEntity } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { randomBytes } from 'crypto';
import { randomBytes } from 'node:crypto';
import { AuthDto } from '../auth';
import { CacheControl, ImmichFileResponse } from '../domain.util';
import { IEntityJob, JobName } from '../job';
@ -39,7 +39,7 @@ export class UserService {
async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: !isAll });
return users.map(mapUser);
return users.map((user) => mapUser(user));
}
async get(userId: string): Promise<UserResponseDto> {
@ -125,7 +125,7 @@ export class UserService {
}
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 });
@ -188,9 +188,10 @@ export class UserService {
return false;
}
const msInDay = 86400000;
// TODO use luxon for date calculation
const msInDay = 86_400_000;
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;
}

View file

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

View file

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

View file

@ -51,8 +51,8 @@ const _getAsset_1 = () => {
asset_1.encodedVideoPath = '';
asset_1.duration = '0:00:00.000000';
asset_1.exifInfo = new ExifEntity();
asset_1.exifInfo.latitude = 49.533547;
asset_1.exifInfo.longitude = 10.703075;
asset_1.exifInfo.latitude = 49.533_547;
asset_1.exifInfo.longitude = 10.703_075;
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 { CreateAssetDto } from './dto/create-asset.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 {
AssetBulkUploadCheckResponseDto,
@ -163,7 +162,8 @@ export class AssetService {
const possibleSearchTerm = new Set<string>();
const rows = await this.assetRepositoryV1.getSearchPropertiesByUserId(auth.user.id);
rows.forEach((row: SearchPropertiesDto) => {
for (const row of rows) {
// tags
row.tags?.map((tag: string) => possibleSearchTerm.add(tag?.toLowerCase()));
@ -187,9 +187,9 @@ export class AssetService {
possibleSearchTerm.add(row.city?.toLowerCase() || '');
possibleSearchTerm.add(row.state?.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[]> {
@ -249,18 +249,18 @@ export class AssetService {
private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) {
switch (format) {
case GetAssetThumbnailFormatEnum.WEBP:
case GetAssetThumbnailFormatEnum.WEBP: {
if (asset.webpPath) {
return asset.webpPath;
}
this.logger.warn(`WebP thumbnail requested but not found for asset ${asset.id}, falling back to JPEG`);
case GetAssetThumbnailFormatEnum.JPEG:
default:
}
case GetAssetThumbnailFormatEnum.JPEG: {
if (!asset.resizePath) {
throw new NotFoundException(`No thumbnail found for asset ${asset.id}`);
}
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 }));
export const AdminRoute = (value = true) => SetMetadata(Metadata.ADMIN_ROUTE, value);
export const Auth = createParamDecorator((data, ctx: ExecutionContext): AuthDto => {
return ctx.switchToHttp().getRequest<{ user: AuthDto }>().user;
export const Auth = createParamDecorator((data, context: ExecutionContext): AuthDto => {
return context.switchToHttp().getRequest<{ user: AuthDto }>().user;
});
export const FileResponse = () =>
@ -59,15 +59,15 @@ export const FileResponse = () =>
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
});
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => {
const req = ctx.switchToHttp().getRequest<Request>();
const userAgent = UAParser(req.headers['user-agent']);
export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
const request = context.switchToHttp().getRequest<Request>();
const userAgent = UAParser(request.headers['user-agent']);
return {
clientIp: req.ip,
isSecure: req.secure,
deviceType: userAgent.browser.name || userAgent.device.type || (req.headers.devicemodel as string) || '',
deviceOS: userAgent.os.name || (req.headers.devicetype as string) || '',
clientIp: request.ip,
isSecure: request.secure,
deviceType: userAgent.browser.name || userAgent.device.type || (request.headers.devicemodel as string) || '',
deviceOS: userAgent.os.name || (request.headers.devicetype as string) || '',
};
});
@ -95,20 +95,20 @@ export class AppGuard implements CanActivate {
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) {
this.logger.warn(`Denied access to non-shared route: ${req.path}`);
this.logger.warn(`Denied access to non-shared route: ${request.path}`);
return false;
}
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;
}
req.user = authDto;
request.user = authDto;
return true;
}

View file

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

View file

@ -18,11 +18,11 @@ import {
SwaggerModule,
} from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { writeFileSync } from 'fs';
import { access, constants } from 'fs/promises';
import _ from 'lodash';
import path, { isAbsolute } from 'path';
import { promisify } from 'util';
import { writeFileSync } from 'node:fs';
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 { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
@ -55,13 +55,15 @@ export const sendFile = async (
try {
const file = await handler();
switch (file.cacheControl) {
case CacheControl.PRIVATE_WITH_CACHE:
case CacheControl.PRIVATE_WITH_CACHE: {
res.set('Cache-Control', 'private, max-age=86400, no-transform');
break;
}
case CacheControl.PRIVATE_WITHOUT_CACHE:
case CacheControl.PRIVATE_WITHOUT_CACHE: {
res.set('Cache-Control', 'private, no-cache, no-transform');
break;
}
}
res.header('Content-Type', file.contentType);
@ -94,21 +96,21 @@ export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) =>
return new StreamableFile(stream, { type, length });
};
function sortKeys<T>(obj: T): T {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
return obj;
function sortKeys<T>(target: T): T {
if (!target || typeof target !== 'object' || Array.isArray(target)) {
return target;
}
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) {
result[key] = sortKeys(obj[key]);
result[key] = sortKeys(target[key]);
}
return result as T;
}
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) => {
document.paths = sortKeys(document.paths);
@ -152,7 +154,7 @@ const patchOpenAPI = (document: OpenAPIObject) => {
continue;
}
if ((operation.security || []).find((item) => !!item[Metadata.PUBLIC_SECURITY])) {
if ((operation.security || []).some((item) => !!item[Metadata.PUBLIC_SECURITY])) {
delete operation.security;
}
@ -177,7 +179,7 @@ const patchOpenAPI = (document: OpenAPIObject) => {
return document;
};
export const useSwagger = (app: INestApplication, isDev: boolean) => {
export const useSwagger = (app: INestApplication, isDevelopment: boolean) => {
const config = new DocumentBuilder()
.setTitle('Immich')
.setDescription('Immich API')
@ -203,7 +205,7 @@ export const useSwagger = (app: INestApplication, isDev: boolean) => {
operationIdFactory: (controllerKey: string, methodKey: string) => methodKey,
};
const doc = SwaggerModule.createDocument(app, config, options);
const specification = SwaggerModule.createDocument(app, config, options);
const customOptions: SwaggerCustomOptions = {
swaggerOptions: {
@ -212,11 +214,11 @@ export const useSwagger = (app: INestApplication, isDev: boolean) => {
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
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')
@HttpCode(HttpStatus.OK)
logout(
@Req() req: Request,
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@Auth() auth: AuthDto,
): Promise<LogoutResponseDto> {
res.clearCookie(IMMICH_ACCESS_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()
@Get('mobile-redirect')
@Redirect()
redirectOAuthToMobile(@Req() req: Request) {
redirectOAuthToMobile(@Req() request: Request) {
return {
url: this.service.getMobileRedirect(req.url),
url: this.service.getMobileRedirect(request.url),
statusCode: HttpStatus.TEMPORARY_REDIRECT,
};
}

View file

@ -33,10 +33,10 @@ export class SharedLinkController {
async getMySharedLink(
@Auth() auth: AuthDto,
@Query() dto: SharedLinkPasswordDto,
@Req() req: Request,
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
): Promise<SharedLinkResponseDto> {
const sharedLinkToken = req.cookies?.[IMMICH_SHARED_LINK_ACCESS_COOKIE];
const sharedLinkToken = request.cookies?.[IMMICH_SHARED_LINK_ACCESS_COOKIE];
if (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 { Reflector } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
import { createHash, randomUUID } from 'crypto';
import { NextFunction, RequestHandler } from 'express';
import multer, { StorageEngine, diskStorage } from 'multer';
import { createHash, randomUUID } from 'node:crypto';
import { Observable } from 'rxjs';
import { AuthRequest } from '../app.guard';
@ -40,17 +40,17 @@ interface Callback<T> {
(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 {
return callback(null, await fn());
return callback(null, await target());
} catch (error: Error | any) {
return callback(error);
}
};
const asRequest = (req: AuthRequest, file: Express.Multer.File) => {
const asRequest = (request: AuthRequest, file: Express.Multer.File) => {
return {
auth: req.user || null,
auth: request.user || null,
fieldName: file.fieldname as UploadFieldName,
file: mapToUploadFile(file as ImmichFile),
};
@ -94,14 +94,14 @@ export class FileUploadInterceptor implements NestInterceptor {
}
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 handler: RequestHandler | null = this.getHandler(route as Route);
if (handler) {
await new Promise<void>((resolve, reject) => {
const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve());
handler(ctx.getRequest(), ctx.getResponse(), next);
handler(context_.getRequest(), context_.getResponse(), next);
});
} else {
this.logger.warn(`Skipping invalid file upload route: ${route}`);
@ -110,28 +110,31 @@ export class FileUploadInterceptor implements NestInterceptor {
return next.handle();
}
private fileFilter(req: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
return callbackify(() => this.assetService.canUploadFile(asRequest(req, file)), callback);
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
return callbackify(() => this.assetService.canUploadFile(asRequest(request, file)), callback);
}
private filename(req: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(() => this.assetService.getUploadFilename(asRequest(req, file)), callback as Callback<string>);
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFilename(asRequest(request, file)),
callback as Callback<string>,
);
}
private destination(req: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(() => this.assetService.getUploadFolder(asRequest(req, file)), callback as Callback<string>);
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
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();
if (!this.isAssetUploadFile(file)) {
this.defaultStorage._handleFile(req, file, callback);
this.defaultStorage._handleFile(request, file, callback);
return;
}
const hash = createHash('sha1');
file.stream.on('data', (chunk) => hash.update(chunk));
this.defaultStorage._handleFile(req, file, (error, info) => {
this.defaultStorage._handleFile(request, file, (error, info) => {
if (error) {
hash.destroy();
callback(error);
@ -141,15 +144,16 @@ export class FileUploadInterceptor implements NestInterceptor {
});
}
private removeFile(req: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
this.defaultStorage._removeFile(req, file, callback);
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
this.defaultStorage._removeFile(request, file, callback);
}
private isAssetUploadFile(file: Express.Multer.File) {
switch (file.fieldname as UploadFieldName) {
case UploadFieldName.ASSET_DATA:
case UploadFieldName.LIVE_PHOTO_DATA:
case UploadFieldName.LIVE_PHOTO_DATA: {
return true;
}
}
return false;
@ -157,14 +161,17 @@ export class FileUploadInterceptor implements NestInterceptor {
private getHandler(route: Route) {
switch (route) {
case Route.ASSET:
case Route.ASSET: {
return this.handlers.assetUpload;
}
case Route.USER:
case Route.USER: {
return this.handlers.userProfile;
}
default:
default: {
return null;
}
}
}
}

View file

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

View file

@ -15,8 +15,8 @@ function parseRedisConfig(): RedisOptions {
}
return {
host: process.env.REDIS_HOSTNAME || 'immich_redis',
port: parseInt(process.env.REDIS_PORT || '6379'),
db: parseInt(process.env.REDIS_DBINDEX || '0'),
port: Number.parseInt(process.env.REDIS_PORT || '6379'),
db: Number.parseInt(process.env.REDIS_DBINDEX || '0'),
username: process.env.REDIS_USERNAME || undefined,
password: process.env.REDIS_PASSWORD || 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
// 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.
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 {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value;
const paramIndex = options.paramIndex ?? 0;
descriptor.value = async function (...args: any[]) {
const arg = args[paramIndex];
const parameterIndex = options.paramIndex ?? 0;
descriptor.value = async function (...arguments_: any[]) {
const argument = arguments_[parameterIndex];
// Early return if argument length is less than or equal to the chunk size.
if (
(arg instanceof Array && arg.length <= DATABASE_PARAMETER_CHUNK_SIZE) ||
(arg instanceof Set && arg.size <= DATABASE_PARAMETER_CHUNK_SIZE)
(Array.isArray(argument) && argument.length <= 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(
chunks(arg, DATABASE_PARAMETER_CHUNK_SIZE).map(async (chunk) => {
await originalMethod.apply(this, [...args.slice(0, paramIndex), chunk, ...args.slice(paramIndex + 1)]);
chunks(argument, DATABASE_PARAMETER_CHUNK_SIZE).map(async (chunk) => {
await Reflect.apply(originalMethod, this, [
...arguments_.slice(0, parameterIndex),
chunk,
...arguments_.slice(parameterIndex + 1),
]);
}),
).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
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) {
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 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(`
ALTER TABLE asset_faces

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,10 +10,10 @@ import {
import { ImmichLogger } from '@app/infra/logger';
import archiver from 'archiver';
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 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 {
private logger = new ImmichLogger(FilesystemProvider.name);
@ -60,7 +60,7 @@ export class FilesystemProvider implements IStorageRepository {
try {
await fs.access(filepath, mode);
return true;
} catch (_) {
} catch {
return false;
}
}
@ -68,11 +68,11 @@ export class FilesystemProvider implements IStorageRepository {
async unlink(file: string) {
try {
await fs.unlink(file);
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
this.logger.warn(`File ${file} does not exist.`);
} else {
throw err;
throw error;
}
}
}

View file

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

View file

@ -2,10 +2,10 @@ import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoIn
import { Colorspace } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
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 { Writable } from 'stream';
import { promisify } from 'util';
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
sharp.concurrency(0);
@ -91,7 +91,7 @@ export class MediaRepository implements IMediaRepository {
}
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
@ -124,12 +124,12 @@ export class MediaRepository implements IMediaRepository {
.inputOptions(options.inputOptions)
.outputOptions(options.outputOptions)
.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) {
const sep = existing.endsWith(':') ? '' : ':';
return `${existing}${sep}${path}`;
const separator = existing.endsWith(':') ? '' : ':';
return `${existing}${separator}${path}`;
}
async generateThumbhash(imagePath: string): Promise<Buffer> {

View file

@ -15,11 +15,11 @@ import { ImmichLogger } from '@app/infra/logger';
import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DefaultReadTaskOptions, exiftool, Tags } from 'exiftool-vendored';
import { createReadStream, existsSync } from 'fs';
import { readFile } from 'fs/promises';
import * as geotz from 'geo-tz';
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';
type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
@ -69,10 +69,10 @@ export class MetadataRepository implements IMetadataRepository {
await this.loadAdmin2(queryRunner);
await queryRunner.commitTransaction();
} catch (e) {
this.logger.fatal('Error importing geodata', e);
} catch (error) {
this.logger.fatal('Error importing geodata', error);
await queryRunner.rollbackTransaction();
throw e;
throw error;
} finally {
await queryRunner.release();
}
@ -110,10 +110,10 @@ export class MetadataRepository implements IMetadataRepository {
queryRunner,
(lineSplit: string[]) =>
this.geodataPlacesRepository.create({
id: parseInt(lineSplit[0]),
id: Number.parseInt(lineSplit[0]),
name: lineSplit[1],
latitude: parseFloat(lineSplit[4]),
longitude: parseFloat(lineSplit[5]),
latitude: Number.parseFloat(lineSplit[4]),
longitude: Number.parseFloat(lineSplit[5]),
countryCode: lineSplit[8],
admin1Code: lineSplit[10],
admin2Code: lineSplit[11],
@ -192,7 +192,8 @@ export class MetadataRepository implements IMetadataRepository {
backfillTimezones: true,
inferTimezoneFromDatestamps: 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],
})
.catch((error) => {

View file

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

View file

@ -31,11 +31,11 @@ export class SmartInfoRepository implements ISmartInfoRepository {
throw new Error(`Invalid CLIP model name: ${modelName}`);
}
const curDimSize = await this.getDimSize();
this.logger.verbose(`Current database CLIP dimension size is ${curDimSize}`);
const currentDimSize = await this.getDimSize();
this.logger.verbose(`Current database CLIP dimension size is ${currentDimSize}`);
if (dimSize != curDimSize) {
this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${curDimSize}.`);
if (dimSize != currentDimSize) {
this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${currentDimSize}.`);
await this.updateDimSize(dimSize);
}
}
@ -119,7 +119,9 @@ export class SmartInfoRepository implements ISmartInfoRepository {
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
.createQueryBuilder()
@ -157,8 +159,8 @@ export class SmartInfoRepository implements ISmartInfoRepository {
throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
}
const curDimSize = await this.getDimSize();
if (curDimSize === dimSize) {
const currentDimSize = await this.getDimSize();
if (currentDimSize === dimSize) {
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> {

View file

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

View file

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

View file

@ -1,10 +1,11 @@
#!/usr/bin/env node
import { ISystemConfigRepository } from '@app/domain';
import { INestApplication } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { mkdir, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { databaseConfig } from '../database.config';
import { databaseEntities } from '../entities';
import { GENERATE_SQL_KEY, GenerateSqlQueries } from '../infra.util';
@ -157,7 +158,7 @@ class SqlGenerator {
private async write() {
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`);
await writeFile(file, data.join('\n\n') + '\n');
}

View file

@ -1,8 +1,6 @@
import { format } from 'sql-formatter';
import { Logger } from 'typeorm';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { format } = require('sql-formatter');
export class SqlLogger implements Logger {
queries: 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 {
switch (entityName) {
case AssetEntity.name:
case AssetEntity.name: {
const asset = entity as AssetEntity;
return {
entityType: EntityType.ASSET,
entityId: asset.id,
ownerId: asset.ownerId,
};
}
case AlbumEntity.name:
case AlbumEntity.name: {
const album = entity as AlbumEntity;
return {
entityType: EntityType.ALBUM,
entityId: album.id,
ownerId: album.ownerId,
};
}
}
return null;

View file

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

View file

@ -7,7 +7,7 @@ describe('parsing latitude from string input', () => {
expect(parseLatitude('Infinity')).toBeNull();
expect(parseLatitude('-Infinity')).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();
});
@ -15,10 +15,10 @@ describe('parsing latitude from string input', () => {
it('returns the numeric coordinate for valid inputs', () => {
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(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', () => {
expect(parseLongitude('')).toBeNull();
expect(parseLongitude('NaN')).toBeNull();
expect(parseLongitude(Infinity)).toBeNull();
expect(parseLongitude(Number.POSITIVE_INFINITY)).toBeNull();
expect(parseLongitude('-Infinity')).toBeNull();
expect(parseLongitude('180.001')).toBeNull();
expect(parseLongitude('-180.000001')).toBeNull();
@ -43,10 +43,10 @@ describe('parsing longitude from string input', () => {
it('returns the numeric coordinate for valid inputs', () => {
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('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', () => {
it('returns false for non-decimal numbers', () => {
expect(isDecimalNumber(NaN)).toBe(false);
expect(isDecimalNumber(Infinity)).toBe(false);
expect(isDecimalNumber(-Infinity)).toBe(false);
expect(isDecimalNumber(Number.NaN)).toBe(false);
expect(isDecimalNumber(Number.POSITIVE_INFINITY)).toBe(false);
expect(isDecimalNumber(Number.NEGATIVE_INFINITY)).toBe(false);
});
it('returns true for decimal numbers', () => {
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.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', () => {
expect(isNumberInRange(0, 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', () => {
it('returns null for invalid inputs', () => {
expect(toNumberOrNull(null)).toBeNull();
// eslint-disable-next-line unicorn/no-useless-undefined
expect(toNumberOrNull(undefined)).toBeNull();
expect(toNumberOrNull('')).toBeNull();
expect(toNumberOrNull(NaN)).toBeNull();
expect(toNumberOrNull(Number.NaN)).toBeNull();
});
it('returns a number for valid inputs', () => {

View file

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

View file

@ -7,8 +7,8 @@ import { Test } from '@nestjs/testing';
import { DateTime } from 'luxon';
import * as fs from 'node:fs';
import path from 'node:path';
import { EventEmitter } from 'node:stream';
import { Server } from 'node:tls';
import { EventEmitter } from 'stream';
import { EntityTarget, ObjectLiteral } from 'typeorm';
import { AppService } from '../immich/app.service';
import { AppService as MicroAppService } from '../microservices/app.service';
@ -69,7 +69,7 @@ class JobMock implements IJobRepository {
return this._handler(item);
}
queueAll(items: JobItem[]) {
return Promise.all(items.map(this._handler)).then(() => Promise.resolve());
return Promise.all(items.map((arg) => this._handler(arg))).then(() => {});
}
async resume() {}
async empty() {}
@ -140,13 +140,13 @@ export const testApp = {
export function waitForEvent<T>(emitter: EventEmitter, event: string): Promise<T> {
return new Promise((resolve, reject) => {
const success = (val: T) => {
const success = (value: T) => {
emitter.off('error', fail);
resolve(val);
resolve(value);
};
const fail = (err: Error) => {
const fail = (error: Error) => {
emitter.off(event, success);
reject(err);
reject(error);
};
emitter.once(event, success);
emitter.once('error', fail);