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:
committed by
GitHub
parent
e4d0560d49
commit
f44fa45aa0
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { VideoCodec } from '@app/infra/entities';
|
||||
import { Writable } from 'stream';
|
||||
import { Writable } from 'node:stream';
|
||||
|
||||
export const IMediaRepository = 'IMediaRepository';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -136,7 +136,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
watch: {
|
||||
enabled: false,
|
||||
usePolling: false,
|
||||
interval: 10000,
|
||||
interval: 10_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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)'}`);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user