Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files

This commit is contained in:
Jonathan Jogenfors
2025-10-02 23:34:28 +02:00
888 changed files with 58184 additions and 14539 deletions
@@ -25,6 +25,7 @@ const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const uploadFile = {
nullAuth: {
auth: null,
body: {},
fieldName: UploadFieldName.ASSET_DATA,
file: {
uuid: 'random-uuid',
@@ -37,6 +38,7 @@ const uploadFile = {
filename: (fieldName: UploadFieldName, filename: string) => {
return {
auth: authStub.admin,
body: {},
fieldName,
file: {
uuid: 'random-uuid',
@@ -916,7 +918,10 @@ describe(AssetMediaService.name, () => {
describe('onUploadError', () => {
it('should queue a job to delete the uploaded file', async () => {
const request = { user: authStub.user1 } as AuthRequest;
const request = {
body: {},
user: authStub.user1,
} as AuthRequest;
const file = {
fieldname: UploadFieldName.ASSET_DATA,
+14 -16
View File
@@ -33,20 +33,14 @@ import {
} from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service';
import { UploadFile } from 'src/types';
import { UploadFile, UploadRequest } from 'src/types';
import { requireUploadAccess } from 'src/utils/access';
import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
import { asUploadRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database';
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request';
interface UploadRequest {
auth: AuthDto | null;
fieldName: UploadFieldName;
file: UploadFile;
}
export interface AssetMediaRedirectResponse {
targetSize: AssetMediaSize | 'original';
}
@@ -98,15 +92,15 @@ export class AssetMediaService extends BaseService {
throw new BadRequestException(`Unsupported file type ${filename}`);
}
getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string {
requireUploadAccess(auth);
const originalExtension = extname(file.originalName);
const extension = extname(body.filename || file.originalName);
const lookup = {
[UploadFieldName.ASSET_DATA]: originalExtension,
[UploadFieldName.ASSET_DATA]: extension,
[UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: originalExtension,
[UploadFieldName.PROFILE_DATA]: extension,
};
return sanitize(`${file.uuid}${lookup[fieldName]}`);
@@ -126,8 +120,8 @@ export class AssetMediaService extends BaseService {
}
async onUploadError(request: AuthRequest, file: Express.Multer.File) {
const uploadFilename = this.getUploadFilename(asRequest(request, file));
const uploadFolder = this.getUploadFolder(asRequest(request, file));
const uploadFilename = this.getUploadFilename(asUploadRequest(request, file));
const uploadFolder = this.getUploadFolder(asUploadRequest(request, file));
const uploadPath = `${uploadFolder}/${uploadFilename}`;
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } });
@@ -327,7 +321,7 @@ export class AssetMediaService extends BaseService {
});
// handle duplicates with a success response
if (error.constraint_name === ASSET_CHECKSUM_CONSTRAINT) {
if (isAssetChecksumConstraint(error)) {
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
if (!duplicateId) {
this.logger.error(`Error locating duplicate for checksum constraint`);
@@ -433,6 +427,10 @@ export class AssetMediaService extends BaseService {
originalFileName: dto.filename || file.originalName,
});
if (dto.metadata) {
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
}
if (sidecarFile) {
await this.assetRepository.upsertFile({
assetId: asset.id,
+1 -1
View File
@@ -420,7 +420,7 @@ describe(AssetService.name, () => {
ids: ['asset-1'],
latitude: 0,
longitude: 0,
visibility: undefined,
visibility: AssetVisibility.Archive,
isFavorite: false,
duplicateId: undefined,
rating: undefined,
+77 -41
View File
@@ -9,12 +9,14 @@ import {
AssetBulkUpdateDto,
AssetJobName,
AssetJobsDto,
AssetMetadataResponseDto,
AssetMetadataUpsertDto,
AssetStatsDto,
UpdateAssetDto,
mapStats,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { AssetMetadataKey, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
import { requireElevatedPermission } from 'src/utils/access';
@@ -93,7 +95,7 @@ export class AssetService extends BaseService {
}
}
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
await this.updateExif({ id, description, dateTimeOriginal, latitude, longitude, rating });
const asset = await this.assetRepository.update({ id, ...rest });
@@ -113,59 +115,68 @@ export class AssetService extends BaseService {
}
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, description, dateTimeOriginal, dateTimeRelative, timeZone, latitude, longitude, ...options } = dto;
const {
ids,
isFavorite,
visibility,
dateTimeOriginal,
latitude,
longitude,
rating,
description,
duplicateId,
dateTimeRelative,
timeZone,
} = dto;
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
const staticValuesChanged =
description !== undefined || dateTimeOriginal !== undefined || latitude !== undefined || longitude !== undefined;
const assetDto = { isFavorite, visibility, duplicateId };
const exifDto = { latitude, longitude, rating, description, dateTimeOriginal };
if (staticValuesChanged) {
await this.assetRepository.updateAllExif(ids, { description, dateTimeOriginal, latitude, longitude });
const isExifChanged = Object.values(exifDto).some((v) => v !== undefined);
if (isExifChanged) {
await this.assetRepository.updateAllExif(ids, exifDto);
}
const assets =
(dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined
? await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone)
: null;
: undefined;
const dateTimesWithTimezone =
assets?.map((asset) => {
const isoString = asset.dateTimeOriginal?.toISOString();
let dateTime = isoString ? DateTime.fromISO(isoString) : null;
const dateTimesWithTimezone = assets
? assets.map((asset) => {
const isoString = asset.dateTimeOriginal?.toISOString();
let dateTime = isoString ? DateTime.fromISO(isoString) : null;
if (dateTime && asset.timeZone) {
dateTime = dateTime.setZone(asset.timeZone);
}
if (dateTime && asset.timeZone) {
dateTime = dateTime.setZone(asset.timeZone);
}
return {
assetId: asset.assetId,
dateTimeOriginal: dateTime?.toISO() ?? null,
};
}) ?? null;
return {
assetId: asset.assetId,
dateTimeOriginal: dateTime?.toISO() ?? null,
};
})
: ids.map((id) => ({ assetId: id, dateTimeOriginal }));
if (staticValuesChanged || dateTimesWithTimezone) {
const entries: JobItem[] = (dateTimesWithTimezone ?? ids).map((entry: any) => ({
name: JobName.SidecarWrite,
data: {
id: entry.assetId ?? entry,
description,
dateTimeOriginal: entry.dateTimeOriginal ?? dateTimeOriginal,
latitude,
longitude,
},
}));
await this.jobRepository.queueAll(entries);
if (dateTimesWithTimezone.length > 0) {
await this.jobRepository.queueAll(
dateTimesWithTimezone.map(({ assetId: id, dateTimeOriginal }) => ({
name: JobName.SidecarWrite,
data: {
...exifDto,
id,
dateTimeOriginal: dateTimeOriginal ?? undefined,
},
})),
);
}
if (
options.visibility !== undefined ||
options.isFavorite !== undefined ||
options.duplicateId !== undefined ||
options.rating !== undefined
) {
await this.assetRepository.updateAll(ids, options);
const isAssetChanged = Object.values(assetDto).some((v) => v !== undefined);
if (isAssetChanged) {
await this.assetRepository.updateAll(ids, assetDto);
if (options.visibility === AssetVisibility.Locked) {
if (visibility === AssetVisibility.Locked) {
await this.albumRepository.removeAssetsFromAll(ids);
}
}
@@ -273,6 +284,31 @@ export class AssetService extends BaseService {
});
}
async getMetadata(auth: AuthDto, id: string): Promise<AssetMetadataResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
return this.assetRepository.getMetadata(id);
}
async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise<AssetMetadataResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
return this.assetRepository.upsertMetadata(id, dto.items);
}
async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise<AssetMetadataResponseDto> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
const item = await this.assetRepository.getMetadataByKey(id, key);
if (!item) {
throw new BadRequestException(`Metadata with key "${key}" not found for asset with id "${id}"`);
}
return item;
}
async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
return this.assetRepository.deleteMetadataByKey(id, key);
}
async run(auth: AuthDto, dto: AssetJobsDto) {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds });
@@ -313,7 +349,7 @@ export class AssetService extends BaseService {
return asset;
}
private async updateMetadata(dto: ISidecarWriteJob) {
private async updateExif(dto: ISidecarWriteJob) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
if (Object.keys(writes).length > 0) {
+1 -1
View File
@@ -344,7 +344,7 @@ export class AuthService extends BaseService {
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } });
}
} catch (error: Error | any) {
this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack);
this.logger.warn(`Unable to sync oauth profile picture: ${error}\n${error?.stack}`);
}
}
+5 -5
View File
@@ -118,7 +118,7 @@ export class BackupService extends BaseService {
{
env: {
PATH: process.env.PATH,
PGPASSWORD: isUrlConnection ? undefined : config.password,
PGPASSWORD: isUrlConnection ? new URL(config.url).password : config.password,
},
},
);
@@ -132,12 +132,12 @@ export class BackupService extends BaseService {
gzip.stdout.pipe(fileStream);
pgdump.on('error', (err) => {
this.logger.error('Backup failed with error', err);
this.logger.error(`Backup failed with error: ${err}`);
reject(err);
});
gzip.on('error', (err) => {
this.logger.error('Gzip failed with error', err);
this.logger.error(`Gzip failed with error: ${err}`);
reject(err);
});
@@ -175,10 +175,10 @@ export class BackupService extends BaseService {
});
await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
} catch (error) {
this.logger.error('Database Backup Failure', error);
this.logger.error(`Database Backup Failure: ${error}`);
await this.storageRepository
.unlink(backupFilePath)
.catch((error) => this.logger.error('Failed to delete failed backup file', error));
.catch((error) => this.logger.error(`Failed to delete failed backup file: ${error}`));
throw error;
}
+5 -5
View File
@@ -22,7 +22,7 @@ const messages = {
The ${name} extension version is ${version}, which means it is a nightly release.
Please run 'DROP EXTENSION IF EXISTS ${extension}' and switch to a release version.
See https://immich.app/docs/guides/database-queries for how to query the database.`,
See https://docs.immich.app/guides/database-queries for how to query the database.`,
outOfRange: ({ name, version, range }: OutOfRangeArgs) =>
`The ${name} extension version is ${version}, but Immich only supports ${range}.
Please change ${name} to a compatible version in the Postgres instance.`,
@@ -32,20 +32,20 @@ const messages = {
If the Postgres instance already has ${name} installed, Immich may not have the necessary permissions to activate it.
In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension} CASCADE' manually as a superuser.
See https://immich.app/docs/guides/database-queries for how to query the database.`,
See https://docs.immich.app/guides/database-queries for how to query the database.`,
updateFailed: ({ name, extension, availableVersion }: UpdateFailedArgs) =>
`The ${name} extension can be updated to ${availableVersion}.
Immich attempted to update the extension, but failed to do so.
This may be because Immich does not have the necessary permissions to update the extension.
Please run 'ALTER EXTENSION ${extension} UPDATE' manually as a superuser.
See https://immich.app/docs/guides/database-queries for how to query the database.`,
See https://docs.immich.app/guides/database-queries for how to query the database.`,
dropFailed: ({ name, extension }: DropFailedArgs) =>
`The ${name} extension is no longer needed, but could not be dropped.
This may be because Immich does not have the necessary permissions to drop the extension.
Please run 'DROP EXTENSION ${extension};' manually as a superuser.
See https://immich.app/docs/guides/database-queries for how to query the database.`,
See https://docs.immich.app/guides/database-queries for how to query the database.`,
restartRequired: ({ name, availableVersion }: RestartRequiredArgs) =>
`The ${name} extension has been updated to ${availableVersion}.
Please restart the Postgres instance to complete the update.`,
@@ -55,7 +55,7 @@ const messages = {
If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`,
deprecatedExtension: (name: string) =>
`DEPRECATION WARNING: The ${name} extension is deprecated and support for it will be removed very soon.
See https://immich.app/docs/install/upgrading#migrating-to-vectorchord in order to switch to the VectorChord extension instead.`,
See https://docs.immich.app/install/upgrading#migrating-to-vectorchord in order to switch to the VectorChord extension instead.`,
};
@Injectable()
+1
View File
@@ -42,6 +42,7 @@ describe(JobService.name, () => {
{ name: JobName.PersonCleanup },
{ name: JobName.MemoryCleanup },
{ name: JobName.SessionCleanup },
{ name: JobName.AuditTableCleanup },
{ name: JobName.AuditLogCleanup },
{ name: JobName.MemoryGenerate },
{ name: JobName.UserSyncUsage },
+1
View File
@@ -281,6 +281,7 @@ export class JobService extends BaseService {
{ name: JobName.PersonCleanup },
{ name: JobName.MemoryCleanup },
{ name: JobName.SessionCleanup },
{ name: JobName.AuditTableCleanup },
{ name: JobName.AuditLogCleanup },
);
}
+1 -1
View File
@@ -245,7 +245,7 @@ export class LibraryService extends BaseService {
job.paths.map((path) =>
this.processEntity(path, library.ownerId, job.libraryId)
.then((asset) => assetImports.push(asset))
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}`, error)),
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`)),
),
);
+1 -1
View File
@@ -40,7 +40,7 @@ export class MemoryService extends BaseService {
try {
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
} catch (error) {
this.logger.error(`Failed to create memories for ${target.toISO()}`, error);
this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`);
}
// update system metadata even when there is an error to minimize the chance of duplicates
await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, {
+47 -11
View File
@@ -23,16 +23,24 @@ import { tagStub } from 'test/fixtures/tag.stub';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
function removeNonSidecarFiles(asset: any) {
return {
...asset,
files: asset.files.filter((file: any) => file.type === AssetFileType.Sidecar),
};
}
const forSidecarJob = (
asset: {
id?: string;
originalPath?: string;
files: { id: string; type: AssetFileType; path: string }[];
files?: { id: string; type: AssetFileType; path: string }[];
} = {},
) => {
return {
id: factory.uuid(),
originalPath: '/path/to/IMG_123.jpg',
files: [],
...asset,
};
};
@@ -1501,7 +1509,7 @@ describe(MetadataService.name, () => {
});
describe('handleSidecarCheck', () => {
it("should do nothing if asset isn't found", async () => {
it('should do nothing if asset could not be found', async () => {
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(void 0);
await expect(sut.handleSidecarCheck({ id: assetStub.image.id })).resolves.toBeUndefined();
@@ -1510,18 +1518,25 @@ describe(MetadataService.name, () => {
});
it('should detect a new sidecar at .jpg.xmp', async () => {
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', files: [] });
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: `/path/to/IMG_123.jpg.xmp` });
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
assetId: asset.id,
type: AssetFileType.Sidecar,
path: '/path/to/IMG_123.jpg.xmp',
});
});
it('should detect a new sidecar at .xmp', async () => {
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg',
files: [],
});
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
@@ -1529,29 +1544,39 @@ describe(MetadataService.name, () => {
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: '/path/to/IMG_123.xmp' });
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
assetId: asset.id,
type: AssetFileType.Sidecar,
path: '/path/to/IMG_123.xmp',
});
});
it('should unset sidecar path if file does not exist anymore', async () => {
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg.xmp' });
it('should unset sidecar path if file no longer exist', async () => {
const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
});
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValue(false);
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: null });
expect(mocks.asset.deleteFile).toHaveBeenCalledWith({ assetId: asset.id, type: AssetFileType.Sidecar });
});
it('should do nothing if the sidecar file still exists', async () => {
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg' });
const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
});
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
});
});
@@ -1669,5 +1694,16 @@ describe(MetadataService.name, () => {
expect(result?.tag).toBe('GPSDateTime');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-10-10T10:00:00.000Z');
});
it('should prefer CreationDate over CreateDate', () => {
const tags = {
CreationDate: '2025:05:24 18:26:20+02:00',
CreateDate: '2025:08:27 08:45:40',
};
const result = firstDateTime(tags);
expect(result?.tag).toBe('CreationDate');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2025-05-24T16:26:20.000Z');
});
});
});
+70 -41
View File
@@ -30,6 +30,7 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { isAssetChecksumConstraint } from 'src/utils/database';
import { isFaceImportEnabled } from 'src/utils/misc';
import { upsertTags } from 'src/utils/tag';
@@ -39,9 +40,9 @@ const EXIF_DATE_TAGS: Array<keyof ImmichTags> = [
'SubSecCreateDate',
'SubSecMediaCreateDate',
'DateTimeOriginal',
'CreationDate',
'CreateDate',
'MediaCreateDate',
'CreationDate',
'DateTimeCreated',
'GPSDateTime',
'DateTimeUTC',
@@ -372,11 +373,9 @@ export class MetadataService extends BaseService {
return JobStatus.Skipped;
}
if (sidecarPath === null) {
await this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar });
} else {
await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: sidecarPath });
}
await (sidecarPath === null
? this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar })
: this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: sidecarPath }));
return JobStatus.Success;
}
@@ -431,6 +430,12 @@ export class MetadataService extends BaseService {
private getSidecarCandidates({ files, originalPath }: { files: AssetFile[] | null; originalPath: string }) {
const candidates: string[] = [];
const existingSidecar = files?.find((file) => file.type === AssetFileType.Sidecar);
if (existingSidecar) {
candidates.push(existingSidecar.path);
}
const assetPath = parse(originalPath);
candidates.push(
@@ -598,47 +603,62 @@ export class MetadataService extends BaseService {
});
}
const checksum = this.cryptoRepository.hashSha1(video);
const checksumQuery = { ownerId: asset.ownerId, libraryId: asset.libraryId ?? undefined, checksum };
let motionAsset = await this.assetRepository.getByChecksum({
ownerId: asset.ownerId,
libraryId: asset.libraryId ?? undefined,
checksum,
});
if (motionAsset) {
let motionAsset = await this.assetRepository.getByChecksum(checksumQuery);
let isNewMotionAsset = false;
if (!motionAsset) {
try {
const motionAssetId = this.cryptoRepository.randomUUID();
motionAsset = await this.assetRepository.create({
id: motionAssetId,
libraryId: asset.libraryId,
type: AssetType.Video,
fileCreatedAt: dates.dateTimeOriginal,
fileModifiedAt: stats.mtime,
localDateTime: dates.localDateTime,
checksum,
ownerId: asset.ownerId,
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
visibility: AssetVisibility.Hidden,
deviceAssetId: 'NONE',
deviceId: 'NONE',
});
isNewMotionAsset = true;
if (!asset.isExternal) {
await this.userRepository.updateUsage(asset.ownerId, video.byteLength);
}
} catch (error) {
if (!isAssetChecksumConstraint(error)) {
throw error;
}
motionAsset = await this.assetRepository.getByChecksum(checksumQuery);
if (!motionAsset) {
this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${asset.originalPath}`);
return;
}
}
}
if (!isNewMotionAsset) {
this.logger.debugFn(() => {
const base64Checksum = checksum.toString('base64');
return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${asset.originalPath}`;
});
}
// Hide the motion photo video asset if it's not already hidden to prepare for linking
if (motionAsset.visibility === AssetVisibility.Timeline) {
await this.assetRepository.update({
id: motionAsset.id,
visibility: AssetVisibility.Hidden,
});
this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`);
}
} else {
const motionAssetId = this.cryptoRepository.randomUUID();
motionAsset = await this.assetRepository.create({
id: motionAssetId,
libraryId: asset.libraryId,
type: AssetType.Video,
fileCreatedAt: dates.dateTimeOriginal,
fileModifiedAt: stats.mtime,
localDateTime: dates.localDateTime,
checksum,
ownerId: asset.ownerId,
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
// Hide the motion photo video asset if it's not already hidden to prepare for linking
if (motionAsset.visibility === AssetVisibility.Timeline) {
await this.assetRepository.update({
id: motionAsset.id,
visibility: AssetVisibility.Hidden,
deviceAssetId: 'NONE',
deviceId: 'NONE',
});
if (!asset.isExternal) {
await this.userRepository.updateUsage(asset.ownerId, video.byteLength);
}
this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`);
}
if (asset.livePhotoVideoId !== motionAsset.id) {
@@ -830,7 +850,11 @@ export class MetadataService extends BaseService {
}
}
private getDates(asset: { id: string; originalPath: string }, exifTags: ImmichTags, stats: Stats) {
private getDates(
asset: { id: string; originalPath: string; fileCreatedAt: Date },
exifTags: ImmichTags,
stats: Stats,
) {
const result = firstDateTime(exifTags);
const tag = result?.tag;
const dateTime = result?.dateTime;
@@ -859,7 +883,12 @@ export class MetadataService extends BaseService {
if (!localDateTime || !dateTimeOriginal) {
// FileCreateDate is not available on linux, likely because exiftool hasn't integrated the statx syscall yet
// birthtime is not available in Docker on macOS, so it appears as 0
const earliestDate = stats.birthtimeMs ? new Date(Math.min(stats.mtimeMs, stats.birthtimeMs)) : stats.mtime;
const earliestDate = new Date(
Math.min(
asset.fileCreatedAt.getTime(),
stats.birthtimeMs ? Math.min(stats.mtimeMs, stats.birthtimeMs) : stats.mtime.getTime(),
),
);
this.logger.debug(
`No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`,
);
@@ -147,7 +147,7 @@ describe(NotificationService.name, () => {
await sut.onUserSignup({ id: '', notify: true });
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.NotifyUserSignup,
data: { id: '', tempPassword: undefined },
data: { id: '', password: undefined },
});
});
});
+4 -66
View File
@@ -191,9 +191,9 @@ export class NotificationService extends BaseService {
}
@OnEvent({ name: 'UserSignup' })
async onUserSignup({ notify, id, tempPassword }: ArgOf<'UserSignup'>) {
async onUserSignup({ notify, id, password: password }: ArgOf<'UserSignup'>) {
if (notify) {
await this.jobRepository.queue({ name: JobName.NotifyUserSignup, data: { id, tempPassword } });
await this.jobRepository.queue({ name: JobName.NotifyUserSignup, data: { id, password } });
}
}
@@ -251,70 +251,8 @@ export class NotificationService extends BaseService {
return { messageId };
}
async getTemplate(name: EmailTemplate, customTemplate: string) {
const { server, templates } = await this.getConfig({ withCache: false });
let templateResponse = '';
switch (name) {
case EmailTemplate.WELCOME: {
const { html: _welcomeHtml } = await this.emailRepository.renderEmail({
template: EmailTemplate.WELCOME,
data: {
baseUrl: getExternalDomain(server),
displayName: 'John Doe',
username: 'john@doe.com',
password: 'thisIsAPassword123',
},
customTemplate: customTemplate || templates.email.welcomeTemplate,
});
templateResponse = _welcomeHtml;
break;
}
case EmailTemplate.ALBUM_UPDATE: {
const { html: _updateAlbumHtml } = await this.emailRepository.renderEmail({
template: EmailTemplate.ALBUM_UPDATE,
data: {
baseUrl: getExternalDomain(server),
albumId: '1',
albumName: 'Favorite Photos',
recipientName: 'Jane Doe',
cid: undefined,
},
customTemplate: customTemplate || templates.email.albumInviteTemplate,
});
templateResponse = _updateAlbumHtml;
break;
}
case EmailTemplate.ALBUM_INVITE: {
const { html } = await this.emailRepository.renderEmail({
template: EmailTemplate.ALBUM_INVITE,
data: {
baseUrl: getExternalDomain(server),
albumId: '1',
albumName: "John Doe's Favorites",
senderName: 'John Doe',
recipientName: 'Jane Doe',
cid: undefined,
},
customTemplate: customTemplate || templates.email.albumInviteTemplate,
});
templateResponse = html;
break;
}
default: {
templateResponse = '';
break;
}
}
return { name, html: templateResponse };
}
@OnJob({ name: JobName.NotifyUserSignup, queue: QueueName.Notification })
async handleUserSignup({ id, tempPassword }: JobOf<JobName.NotifyUserSignup>) {
async handleUserSignup({ id, password }: JobOf<JobName.NotifyUserSignup>) {
const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) {
return JobStatus.Skipped;
@@ -327,7 +265,7 @@ export class NotificationService extends BaseService {
baseUrl: getExternalDomain(server),
displayName: user.name,
username: user.email,
password: tempPassword,
password,
},
customTemplate: templates.email.welcomeTemplate,
});
+2 -2
View File
@@ -53,7 +53,7 @@ describe(PartnerService.name, () => {
mocks.partner.get.mockResolvedValue(void 0);
mocks.partner.create.mockResolvedValue(partner);
await expect(sut.create(auth, user2.id)).resolves.toBeDefined();
await expect(sut.create(auth, { sharedWithId: user2.id })).resolves.toBeDefined();
expect(mocks.partner.create).toHaveBeenCalledWith({
sharedById: partner.sharedById,
@@ -69,7 +69,7 @@ describe(PartnerService.name, () => {
mocks.partner.get.mockResolvedValue(partner);
await expect(sut.create(auth, user2.id)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.create(auth, { sharedWithId: user2.id })).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.partner.create).not.toHaveBeenCalled();
});
+3 -3
View File
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Partner } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
import { mapUser } from 'src/dtos/user.dto';
import { Permission } from 'src/enum';
import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository';
@@ -9,7 +9,7 @@ import { BaseService } from 'src/services/base.service';
@Injectable()
export class PartnerService extends BaseService {
async create(auth: AuthDto, sharedWithId: string): Promise<PartnerResponseDto> {
async create(auth: AuthDto, { sharedWithId }: PartnerCreateDto): Promise<PartnerResponseDto> {
const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId };
const exists = await this.partnerRepository.get(partnerId);
if (exists) {
@@ -39,7 +39,7 @@ export class PartnerService extends BaseService {
.map((partner) => this.mapPartner(partner, direction));
}
async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
async update(auth: AuthDto, sharedById: string, dto: PartnerUpdateDto): Promise<PartnerResponseDto> {
await this.requireAccess({ auth, permission: Permission.PartnerUpdate, ids: [sharedById] });
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
@@ -729,7 +729,6 @@ describe(PersonService.name, () => {
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] });
await sut.handleDetectFaces({ id: assetStub.image.id });
expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith(
['http://immich-machine-learning:3003'],
'/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }),
);
-1
View File
@@ -316,7 +316,6 @@ export class PersonService extends BaseService {
}
const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces(
machineLearning.urls,
previewFile.path,
machineLearning.facialRecognition,
);
@@ -211,7 +211,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test',
expect.objectContaining({ modelName: expect.any(String) }),
);
@@ -225,7 +224,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test', page: 2, size: 50 });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test',
expect.objectContaining({ modelName: expect.any(String) }),
);
@@ -243,7 +241,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test',
expect.objectContaining({ modelName: 'ViT-B-16-SigLIP__webli' }),
);
@@ -253,7 +250,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test', language: 'de' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test',
expect.objectContaining({ language: 'de' }),
);
+22 -9
View File
@@ -18,7 +18,7 @@ import {
SmartSearchDto,
StatisticsSearchDto,
} from 'src/dtos/search.dto';
import { AssetOrder, AssetVisibility } from 'src/enum';
import { AssetOrder, AssetVisibility, Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { requireElevatedPermission } from 'src/utils/access';
import { getMyPartnerIds } from 'src/utils/asset.util';
@@ -113,14 +113,27 @@ export class SearchService extends BaseService {
}
const userIds = this.getUserIdsToSearch(auth);
const key = machineLearning.clip.modelName + dto.query + dto.language;
let embedding = this.embeddingCache.get(key);
if (!embedding) {
embedding = await this.machineLearningRepository.encodeText(machineLearning.urls, dto.query, {
modelName: machineLearning.clip.modelName,
language: dto.language,
});
this.embeddingCache.set(key, embedding);
let embedding;
if (dto.query) {
const key = machineLearning.clip.modelName + dto.query + dto.language;
embedding = this.embeddingCache.get(key);
if (!embedding) {
embedding = await this.machineLearningRepository.encodeText(dto.query, {
modelName: machineLearning.clip.modelName,
language: dto.language,
});
this.embeddingCache.set(key, embedding);
}
} else if (dto.queryAssetId) {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.queryAssetId] });
const getEmbeddingResponse = await this.searchRepository.getEmbedding(dto.queryAssetId);
const assetEmbedding = getEmbeddingResponse?.embedding;
if (!assetEmbedding) {
throw new BadRequestException(`Asset ${dto.queryAssetId} has no embedding`);
}
embedding = assetEmbedding;
} else {
throw new BadRequestException('Either `query` or `queryAssetId` must be set');
}
const page = dto.page ?? 1;
const size = dto.size || 100;
@@ -205,7 +205,6 @@ describe(SmartInfoService.name, () => {
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success);
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
['http://immich-machine-learning:3003'],
'/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
);
@@ -242,7 +241,6 @@ describe(SmartInfoService.name, () => {
expect(mocks.database.wait).toHaveBeenCalledWith(512);
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
['http://immich-machine-learning:3003'],
'/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
);
+1 -5
View File
@@ -108,11 +108,7 @@ export class SmartInfoService extends BaseService {
return JobStatus.Skipped;
}
const embedding = await this.machineLearningRepository.encodeImage(
machineLearning.urls,
asset.files[0].path,
machineLearning.clip,
);
const embedding = await this.machineLearningRepository.encodeImage(asset.files[0].path, machineLearning.clip);
if (this.databaseRepository.isBusy(DatabaseLock.CLIPDimSize)) {
this.logger.verbose(`Waiting for CLIP dimension size to be updated`);
@@ -338,7 +338,7 @@ export class StorageTemplateService extends BaseService {
return destination;
} catch (error: any) {
this.logger.error(`Unable to get template path for ${filename}`, error);
this.logger.error(`Unable to get template path for ${filename}: ${error}`);
return asset.originalPath;
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { BaseService } from 'src/services/base.service';
import { JobOf, SystemFlags } from 'src/types';
import { ImmichStartupError } from 'src/utils/misc';
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;
const docsMessage = `Please see https://docs.immich.app/administration/system-integrity#folder-checks for more information.`;
@Injectable()
export class StorageService extends BaseService {
+86 -8
View File
@@ -1,8 +1,9 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { Insertable } from 'kysely';
import { DateTime } from 'luxon';
import { DateTime, Duration } from 'luxon';
import { Writable } from 'node:stream';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { OnJob } from 'src/decorators';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -15,7 +16,16 @@ import {
SyncItem,
SyncStreamDto,
} from 'src/dtos/sync.dto';
import { AssetVisibility, DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType } from 'src/enum';
import {
AssetVisibility,
DatabaseAction,
EntityType,
JobName,
Permission,
QueueName,
SyncEntityType,
SyncRequestType,
} from 'src/enum';
import { SyncQueryOptions } from 'src/repositories/sync.repository';
import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table';
import { BaseService } from 'src/services/base.service';
@@ -32,6 +42,8 @@ type AssetLike = Omit<SyncAssetV1, 'checksum' | 'thumbhash'> & {
};
const COMPLETE_ID = 'complete';
const MAX_DAYS = 30;
const MAX_DURATION = Duration.fromObject({ days: MAX_DAYS });
const mapSyncAssetV1 = ({ checksum, thumbhash, ...data }: AssetLike): SyncAssetV1 => ({
...data,
@@ -74,6 +86,7 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.PeopleV1,
SyncRequestType.AssetFacesV1,
SyncRequestType.UserMetadataV1,
SyncRequestType.AssetMetadataV1,
];
const throwSessionRequired = () => {
@@ -136,19 +149,24 @@ export class SyncService extends BaseService {
}
const isPendingSyncReset = await this.sessionRepository.isPendingSyncReset(session.id);
if (isPendingSyncReset) {
send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} });
response.end();
return;
}
const checkpoints = await this.syncCheckpointRepository.getAll(session.id);
const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)]));
if (this.needsFullSync(checkpointMap)) {
send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} });
response.end();
return;
}
const { nowId } = await this.syncCheckpointRepository.getNow();
const options: SyncQueryOptions = { nowId, userId: auth.user.id };
const checkpoints = await this.syncCheckpointRepository.getAll(session.id);
const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)]));
const handlers: Record<SyncRequestType, () => Promise<void>> = {
[SyncRequestType.AuthUsersV1]: () => this.syncAuthUsersV1(options, response, checkpointMap),
[SyncRequestType.UsersV1]: () => this.syncUsersV1(options, response, checkpointMap),
@@ -156,6 +174,7 @@ export class SyncService extends BaseService {
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap),
[SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap),
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth),
[SyncRequestType.PartnerAssetExifsV1]: () =>
this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap),
@@ -178,9 +197,41 @@ export class SyncService extends BaseService {
await handler();
}
send(response, { type: SyncEntityType.SyncCompleteV1, ids: [nowId], data: {} });
response.end();
}
@OnJob({ name: JobName.AuditTableCleanup, queue: QueueName.BackgroundTask })
async onAuditTableCleanup() {
const pruneThreshold = MAX_DAYS + 1;
await this.syncRepository.album.cleanupAuditTable(pruneThreshold);
await this.syncRepository.albumUser.cleanupAuditTable(pruneThreshold);
await this.syncRepository.albumToAsset.cleanupAuditTable(pruneThreshold);
await this.syncRepository.asset.cleanupAuditTable(pruneThreshold);
await this.syncRepository.assetFace.cleanupAuditTable(pruneThreshold);
await this.syncRepository.assetMetadata.cleanupAuditTable(pruneThreshold);
await this.syncRepository.memory.cleanupAuditTable(pruneThreshold);
await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold);
await this.syncRepository.partner.cleanupAuditTable(pruneThreshold);
await this.syncRepository.person.cleanupAuditTable(pruneThreshold);
await this.syncRepository.stack.cleanupAuditTable(pruneThreshold);
await this.syncRepository.user.cleanupAuditTable(pruneThreshold);
await this.syncRepository.userMetadata.cleanupAuditTable(pruneThreshold);
}
private needsFullSync(checkpointMap: CheckpointMap) {
const completeAck = checkpointMap[SyncEntityType.SyncCompleteV1];
if (!completeAck) {
return false;
}
const milliseconds = Number.parseInt(completeAck.updateId.replaceAll('-', '').slice(0, 12), 16);
return DateTime.fromMillis(milliseconds) < DateTime.now().minus(MAX_DURATION);
}
private async syncAuthUsersV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const upsertType = SyncEntityType.AuthUserV1;
const upserts = this.syncRepository.authUser.getUpserts({ ...options, ack: checkpointMap[upsertType] });
@@ -717,13 +768,13 @@ export class SyncService extends BaseService {
private async syncPeopleV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.PersonDeleteV1;
const deletes = this.syncRepository.people.getDeletes({ ...options, ack: checkpointMap[deleteType] });
const deletes = this.syncRepository.person.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.PersonV1;
const upserts = this.syncRepository.people.getUpserts({ ...options, ack: checkpointMap[upsertType] });
const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
@@ -759,6 +810,33 @@ export class SyncService extends BaseService {
}
}
private async syncAssetMetadataV1(
options: SyncQueryOptions,
response: Writable,
checkpointMap: CheckpointMap,
auth: AuthDto,
) {
const deleteType = SyncEntityType.AssetMetadataDeleteV1;
const deletes = this.syncRepository.assetMetadata.getDeletes(
{ ...options, ack: checkpointMap[deleteType] },
auth.user.id,
);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.AssetMetadataV1;
const upserts = this.syncRepository.assetMetadata.getUpserts(
{ ...options, ack: checkpointMap[upsertType] },
auth.user.id,
);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
const { type, sessionId, createId } = item;
await this.syncCheckpointRepository.upsertAll([
@@ -52,7 +52,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
threads: 0,
preset: 'ultrafast',
targetAudioCodec: AudioCodec.Aac,
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus, AudioCodec.PcmS16le],
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
targetResolution: '720',
targetVideoCodec: VideoCodec.H264,
acceptedVideoCodecs: [VideoCodec.H264],
@@ -82,6 +82,11 @@ const updatedConfig = Object.freeze<SystemConfig>({
machineLearning: {
enabled: true,
urls: ['http://immich-machine-learning:3003'],
availabilityChecks: {
enabled: true,
interval: 30_000,
timeout: 2000,
},
clip: {
enabled: true,
modelName: 'ViT-B-32__openai',
+17 -1
View File
@@ -16,6 +16,20 @@ export class SystemConfigService extends BaseService {
async onBootstrap() {
const config = await this.getConfig({ withCache: false });
await this.eventRepository.emit('ConfigInit', { newConfig: config });
if (
process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT ||
process.env.IMMICH_MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME
) {
this.logger.deprecate(
'IMMICH_MACHINE_LEARNING_PING_TIMEOUT and MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME have been moved to system config(`machineLearning.availabilityChecks`) and will be removed in a future release.',
);
}
}
@OnEvent({ name: 'AppShutdown' })
onShutdown() {
this.machineLearningRepository.teardown();
}
async getSystemConfig(): Promise<SystemConfigDto> {
@@ -28,12 +42,14 @@ export class SystemConfigService extends BaseService {
}
@OnEvent({ name: 'ConfigInit', priority: -100 })
onConfigInit({ newConfig: { logging } }: ArgOf<'ConfigInit'>) {
onConfigInit({ newConfig: { logging, machineLearning } }: ArgOf<'ConfigInit'>) {
const { logLevel: envLevel } = this.configRepository.getEnv();
const configLevel = logging.enabled ? logging.level : false;
const level = envLevel ?? configLevel;
this.logger.setLogLevel(level);
this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`);
this.machineLearningRepository.setup(machineLearning);
}
@OnEvent({ name: 'ConfigUpdate', server: true })
+1 -1
View File
@@ -38,7 +38,7 @@ export class UserAdminService extends BaseService {
await this.eventRepository.emit('UserSignup', {
notify: !!notify,
id: user.id,
tempPassword: user.shouldChangePassword ? userDto.password : undefined,
password: userDto.password,
});
return mapUserAdmin(user);
+1 -1
View File
@@ -95,7 +95,7 @@ export class VersionService extends BaseService {
this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata));
}
} catch (error: Error | any) {
this.logger.warn(`Unable to run version check: ${error}`, error?.stack);
this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`);
return JobStatus.Failed;
}