diff --git a/e2e/src/api/specs/system-config.e2e-spec.ts b/e2e/src/api/specs/system-config.e2e-spec.ts index 060163d7c..1bd7bdc48 100644 --- a/e2e/src/api/specs/system-config.e2e-spec.ts +++ b/e2e/src/api/specs/system-config.e2e-spec.ts @@ -15,12 +15,6 @@ describe('/system-config', () => { }); describe('PUT /system-config', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put('/system-config'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should always return the new config', async () => { const config = await getSystemConfig(admin.accessToken); diff --git a/i18n/en.json b/i18n/en.json index 06b3a5d59..f547a4e48 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -166,6 +166,20 @@ "metadata_settings_description": "Manage metadata settings", "migration_job": "Migration", "migration_job_description": "Migrate thumbnails for assets and faces to the latest folder structure", + "nightly_tasks_cluster_faces_setting_description": "Run facial recognition on newly detected faces", + "nightly_tasks_cluster_new_faces_setting": "Cluster new faces", + "nightly_tasks_database_cleanup_setting": "Database cleanup tasks", + "nightly_tasks_database_cleanup_setting_description": "Clean up old, expired data from the database", + "nightly_tasks_generate_memories_setting": "Generate memories", + "nightly_tasks_generate_memories_setting_description": "Create new memories from assets", + "nightly_tasks_missing_thumbnails_setting": "Generate missing thumbnails", + "nightly_tasks_missing_thumbnails_setting_description": "Queue assets without thumbnails for thumbnail generation", + "nightly_tasks_settings": "Nightly Tasks Settings", + "nightly_tasks_settings_description": "Manage nightly tasks", + "nightly_tasks_start_time_setting": "Start time", + "nightly_tasks_start_time_setting_description": "The time at which the server starts running the nightly tasks", + "nightly_tasks_sync_quota_usage_setting": "Sync quota usage", + "nightly_tasks_sync_quota_usage_setting_description": "Update user storage quota, based on current usage", "no_paths_added": "No paths added", "no_pattern_added": "No pattern added", "note_apply_storage_label_previous_assets": "Note: To apply the Storage Label to previously uploaded assets, run the", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5085052c0..28fa63ba8 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index f30481ecc..becafa06b 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index f1cf05f11..603163f00 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index 59d5f09fc..38dbb30f0 100644 Binary files a/mobile/openapi/lib/model/system_config_dto.dart and b/mobile/openapi/lib/model/system_config_dto.dart differ diff --git a/mobile/openapi/lib/model/system_config_nightly_tasks_dto.dart b/mobile/openapi/lib/model/system_config_nightly_tasks_dto.dart new file mode 100644 index 000000000..ab7b4b37c Binary files /dev/null and b/mobile/openapi/lib/model/system_config_nightly_tasks_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 18204c21f..492d3cfec 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -14318,6 +14318,9 @@ "newVersionCheck": { "$ref": "#/components/schemas/SystemConfigNewVersionCheckDto" }, + "nightlyTasks": { + "$ref": "#/components/schemas/SystemConfigNightlyTasksDto" + }, "notifications": { "$ref": "#/components/schemas/SystemConfigNotificationsDto" }, @@ -14360,6 +14363,7 @@ "map", "metadata", "newVersionCheck", + "nightlyTasks", "notifications", "oauth", "passwordLogin", @@ -14790,6 +14794,37 @@ ], "type": "object" }, + "SystemConfigNightlyTasksDto": { + "properties": { + "clusterNewFaces": { + "type": "boolean" + }, + "databaseCleanup": { + "type": "boolean" + }, + "generateMemories": { + "type": "boolean" + }, + "missingThumbnails": { + "type": "boolean" + }, + "startTime": { + "type": "string" + }, + "syncQuotaUsage": { + "type": "boolean" + } + }, + "required": [ + "clusterNewFaces", + "databaseCleanup", + "generateMemories", + "missingThumbnails", + "startTime", + "syncQuotaUsage" + ], + "type": "object" + }, "SystemConfigNotificationsDto": { "properties": { "smtp": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 88dee9bf0..f60fa6dfe 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1389,6 +1389,14 @@ export type SystemConfigMetadataDto = { export type SystemConfigNewVersionCheckDto = { enabled: boolean; }; +export type SystemConfigNightlyTasksDto = { + clusterNewFaces: boolean; + databaseCleanup: boolean; + generateMemories: boolean; + missingThumbnails: boolean; + startTime: string; + syncQuotaUsage: boolean; +}; export type SystemConfigNotificationsDto = { smtp: SystemConfigSmtpDto; }; @@ -1457,6 +1465,7 @@ export type SystemConfigDto = { map: SystemConfigMapDto; metadata: SystemConfigMetadataDto; newVersionCheck: SystemConfigNewVersionCheckDto; + nightlyTasks: SystemConfigNightlyTasksDto; notifications: SystemConfigNotificationsDto; oauth: SystemConfigOAuthDto; passwordLogin: SystemConfigPasswordLoginDto; diff --git a/server/src/config.ts b/server/src/config.ts index 1fcc2e978..90ca2c152 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -121,6 +121,14 @@ export interface SystemConfig { newVersionCheck: { enabled: boolean; }; + nightlyTasks: { + startTime: string; + databaseCleanup: boolean; + missingThumbnails: boolean; + clusterNewFaces: boolean; + generateMemories: boolean; + syncQuotaUsage: boolean; + }; trash: { enabled: boolean; days: number; @@ -298,6 +306,14 @@ export const defaults = Object.freeze({ newVersionCheck: { enabled: true, }, + nightlyTasks: { + startTime: '00:00', + databaseCleanup: true, + generateMemories: true, + syncQuotaUsage: true, + missingThumbnails: true, + clusterNewFaces: true, + }, trash: { enabled: true, days: 30, diff --git a/server/src/controllers/system-config.controller.spec.ts b/server/src/controllers/system-config.controller.spec.ts new file mode 100644 index 000000000..48b8c1bcf --- /dev/null +++ b/server/src/controllers/system-config.controller.spec.ts @@ -0,0 +1,74 @@ +import _ from 'lodash'; +import { defaults } from 'src/config'; +import { SystemConfigController } from 'src/controllers/system-config.controller'; +import { StorageTemplateService } from 'src/services/storage-template.service'; +import { SystemConfigService } from 'src/services/system-config.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(SystemConfigController.name, () => { + let ctx: ControllerContext; + const systemConfigService = mockBaseService(SystemConfigService); + const templateService = mockBaseService(StorageTemplateService); + + beforeAll(async () => { + ctx = await controllerSetup(SystemConfigController, [ + { provide: SystemConfigService, useValue: systemConfigService }, + { provide: StorageTemplateService, useValue: templateService }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + systemConfigService.resetAllMocks(); + templateService.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /system-config', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/system-config'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('GET /system-config/defaults', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/system-config/defaults'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('PUT /system-config', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put('/system-config'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + describe('nightlyTasks', () => { + it('should validate nightly jobs start time', async () => { + const config = _.cloneDeep(defaults); + config.nightlyTasks.startTime = 'invalid'; + const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['nightlyTasks.startTime must be in HH:mm format'])); + }); + + it('should accept a valid time', async () => { + const config = _.cloneDeep(defaults); + config.nightlyTasks.startTime = '05:05'; + const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config); + expect(status).toBe(200); + }); + + it('should validate a boolean field', async () => { + const config = _.cloneDeep(defaults); + (config.nightlyTasks.databaseCleanup as any) = 'invalid'; + const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['nightlyTasks.databaseCleanup must be a boolean value'])); + }); + }); + }); +}); diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index b0385984b..49c5e5b4e 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -34,7 +34,7 @@ import { VideoContainer, } from 'src/enum'; import { ConcurrentQueueName } from 'src/types'; -import { IsCronExpression, Optional, ValidateBoolean } from 'src/validation'; +import { IsCronExpression, IsDateStringFormat, Optional, ValidateBoolean } from 'src/validation'; const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled; const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled; @@ -329,6 +329,26 @@ class SystemConfigNewVersionCheckDto { enabled!: boolean; } +class SystemConfigNightlyTasksDto { + @IsDateStringFormat('HH:mm', { message: 'startTime must be in HH:mm format' }) + startTime!: string; + + @ValidateBoolean() + databaseCleanup!: boolean; + + @ValidateBoolean() + missingThumbnails!: boolean; + + @ValidateBoolean() + clusterNewFaces!: boolean; + + @ValidateBoolean() + generateMemories!: boolean; + + @ValidateBoolean() + syncQuotaUsage!: boolean; +} + class SystemConfigOAuthDto { @ValidateBoolean() autoLaunch!: boolean; @@ -638,6 +658,11 @@ export class SystemConfigDto implements SystemConfig { @IsObject() newVersionCheck!: SystemConfigNewVersionCheckDto; + @Type(() => SystemConfigNightlyTasksDto) + @ValidateNested() + @IsObject() + nightlyTasks!: SystemConfigNightlyTasksDto; + @Type(() => SystemConfigOAuthDto) @ValidateNested() @IsObject() diff --git a/server/src/enum.ts b/server/src/enum.ts index dca0f0955..d7c74a71c 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -567,6 +567,7 @@ export enum DatabaseLock { VersionHistory = 500, CLIPDimSize = 512, Library = 1337, + NightlyJobs = 600, GetSystemConfig = 69, BackupDatabase = 42, MemoryCreation = 777, @@ -684,3 +685,8 @@ export enum AssetVisibility { HIDDEN = 'hidden', LOCKED = 'locked', } + +export enum CronJob { + LibraryScan = 'LibraryScan', + NightlyJobs = 'NightlyJobs', +} diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index 064b2e268..27a776e86 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Cron, CronExpression, Interval } from '@nestjs/schedule'; +import { Interval } from '@nestjs/schedule'; import { NextFunction, Request, Response } from 'express'; import { readFileSync } from 'node:fs'; import sanitizeHtml from 'sanitize-html'; @@ -54,11 +54,6 @@ export class ApiService { await this.versionService.handleQueueVersionCheck(); } - @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) - async onNightlyJob() { - await this.jobService.handleNightlyJobs(); - } - ssr(excludePaths: string[]) { const { resourcePaths } = this.configRepository.getEnv(); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index c9020ed96..a18eccdd8 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -41,12 +41,12 @@ describe(JobService.name, () => { { name: JobName.USER_DELETE_CHECK }, { name: JobName.PERSON_CLEANUP }, { name: JobName.MEMORIES_CLEANUP }, - { name: JobName.MEMORIES_CREATE }, - { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, - { name: JobName.CLEAN_OLD_AUDIT_LOGS }, - { name: JobName.USER_SYNC_USAGE }, - { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false, nightly: true } }, { name: JobName.CLEAN_OLD_SESSION_TOKENS }, + { name: JobName.CLEAN_OLD_AUDIT_LOGS }, + { name: JobName.MEMORIES_CREATE }, + { name: JobName.USER_SYNC_USAGE }, + { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, + { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false, nightly: true } }, ]); }); }); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index e4b20ba37..645b22249 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ClassConstructor } from 'class-transformer'; import { snakeCase } from 'lodash'; +import { SystemConfig } from 'src/config'; import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; @@ -8,6 +9,8 @@ import { AssetType, AssetVisibility, BootstrapEventPriority, + CronJob, + DatabaseLock, ImmichWorker, JobCommand, JobName, @@ -20,6 +23,7 @@ import { ArgOf, ArgsOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { ConcurrentQueueName, JobItem } from 'src/types'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; +import { handlePromiseError } from 'src/utils/misc'; const asJobItem = (dto: JobCreateDto): JobItem => { switch (dto.name) { @@ -53,12 +57,59 @@ const asJobItem = (dto: JobCreateDto): JobItem => { } }; +const asNightlyTasksCron = (config: SystemConfig) => { + const [hours, minutes] = config.nightlyTasks.startTime.split(':').map(Number); + return `${minutes} ${hours} * * *`; +}; + @Injectable() export class JobService extends BaseService { private services: ClassConstructor[] = []; + private nightlyJobsLock = false; - @OnEvent({ name: 'config.init', workers: [ImmichWorker.MICROSERVICES] }) - onConfigInit({ newConfig: config }: ArgOf<'config.init'>) { + @OnEvent({ name: 'config.init' }) + async onConfigInit({ newConfig: config }: ArgOf<'config.init'>) { + if (this.worker === ImmichWorker.MICROSERVICES) { + this.updateQueueConcurrency(config); + return; + } + + this.nightlyJobsLock = await this.databaseRepository.tryLock(DatabaseLock.NightlyJobs); + if (this.nightlyJobsLock) { + const cronExpression = asNightlyTasksCron(config); + this.logger.debug(`Scheduling nightly jobs for ${cronExpression}`); + this.cronRepository.create({ + name: CronJob.NightlyJobs, + expression: cronExpression, + start: true, + onTick: () => handlePromiseError(this.handleNightlyJobs(), this.logger), + }); + } + } + + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: config }: ArgOf<'config.update'>) { + if (this.worker === ImmichWorker.MICROSERVICES) { + this.updateQueueConcurrency(config); + return; + } + + if (this.nightlyJobsLock) { + const cronExpression = asNightlyTasksCron(config); + this.logger.debug(`Scheduling nightly jobs for ${cronExpression}`); + this.cronRepository.update({ name: CronJob.NightlyJobs, expression: cronExpression, start: true }); + } + } + + @OnEvent({ name: 'app.bootstrap', priority: BootstrapEventPriority.JobService }) + onBootstrap() { + this.jobRepository.setup(this.services); + if (this.worker === ImmichWorker.MICROSERVICES) { + this.jobRepository.startWorkers(); + } + } + + private updateQueueConcurrency(config: SystemConfig) { this.logger.debug(`Updating queue concurrency settings`); for (const queueName of Object.values(QueueName)) { let concurrency = 1; @@ -70,19 +121,6 @@ export class JobService extends BaseService { } } - @OnEvent({ name: 'config.update', server: true, workers: [ImmichWorker.MICROSERVICES] }) - onConfigUpdate({ newConfig: config }: ArgOf<'config.update'>) { - this.onConfigInit({ newConfig: config }); - } - - @OnEvent({ name: 'app.bootstrap', priority: BootstrapEventPriority.JobService }) - onBootstrap() { - this.jobRepository.setup(this.services); - if (this.worker === ImmichWorker.MICROSERVICES) { - this.jobRepository.startWorkers(); - } - } - setServices(services: ClassConstructor[]) { this.services = services; } @@ -233,18 +271,37 @@ export class JobService extends BaseService { } async handleNightlyJobs() { - await this.jobRepository.queueAll([ - { name: JobName.ASSET_DELETION_CHECK }, - { name: JobName.USER_DELETE_CHECK }, - { name: JobName.PERSON_CLEANUP }, - { name: JobName.MEMORIES_CLEANUP }, - { name: JobName.MEMORIES_CREATE }, - { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, - { name: JobName.CLEAN_OLD_AUDIT_LOGS }, - { name: JobName.USER_SYNC_USAGE }, - { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false, nightly: true } }, - { name: JobName.CLEAN_OLD_SESSION_TOKENS }, - ]); + const config = await this.getConfig({ withCache: false }); + const jobs: JobItem[] = []; + + if (config.nightlyTasks.databaseCleanup) { + jobs.push( + { name: JobName.ASSET_DELETION_CHECK }, + { name: JobName.USER_DELETE_CHECK }, + { name: JobName.PERSON_CLEANUP }, + { name: JobName.MEMORIES_CLEANUP }, + { name: JobName.CLEAN_OLD_SESSION_TOKENS }, + { name: JobName.CLEAN_OLD_AUDIT_LOGS }, + ); + } + + if (config.nightlyTasks.generateMemories) { + jobs.push({ name: JobName.MEMORIES_CREATE }); + } + + if (config.nightlyTasks.syncQuotaUsage) { + jobs.push({ name: JobName.USER_SYNC_USAGE }); + } + + if (config.nightlyTasks.missingThumbnails) { + jobs.push({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); + } + + if (config.nightlyTasks.clusterNewFaces) { + jobs.push({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false, nightly: true } }); + } + + await this.jobRepository.queueAll(jobs); } /** diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index e92cdcf20..ab69e22b9 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -3,7 +3,7 @@ import { Stats } from 'node:fs'; import { defaults, SystemConfig } from 'src/config'; import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { mapLibrary } from 'src/dtos/library.dto'; -import { AssetType, ImmichWorker, JobName, JobStatus } from 'src/enum'; +import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { LibraryService } from 'src/services/library.service'; import { ILibraryBulkIdsJob, ILibraryFileJob } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -56,7 +56,11 @@ describe(LibraryService.name, () => { } as SystemConfig, }); - expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: '0 1 * * *', start: true }); + expect(mocks.cron.update).toHaveBeenCalledWith({ + name: CronJob.LibraryScan, + expression: '0 1 * * *', + start: true, + }); }); it('should initialize watcher for all external libraries', async () => { @@ -128,7 +132,7 @@ describe(LibraryService.name, () => { }); expect(mocks.cron.update).toHaveBeenCalledWith({ - name: 'libraryScan', + name: CronJob.LibraryScan, expression: systemConfigStub.libraryScan.library.scan.cronExpression, start: systemConfigStub.libraryScan.library.scan.enabled, }); @@ -149,7 +153,7 @@ describe(LibraryService.name, () => { }); expect(mocks.cron.update).toHaveBeenCalledWith({ - name: 'libraryScan', + name: CronJob.LibraryScan, expression: systemConfigStub.libraryScan.library.scan.cronExpression, start: systemConfigStub.libraryScan.library.scan.enabled, }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 8c7f79ee5..f6bc5b2eb 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -17,7 +17,7 @@ import { ValidateLibraryImportPathResponseDto, ValidateLibraryResponseDto, } from 'src/dtos/library.dto'; -import { AssetStatus, AssetType, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; +import { AssetStatus, AssetType, CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { AssetSyncResult } from 'src/repositories/library.repository'; import { AssetTable } from 'src/schema/tables/asset.table'; @@ -45,7 +45,7 @@ export class LibraryService extends BaseService { if (this.lock) { this.cronRepository.create({ - name: 'libraryScan', + name: CronJob.LibraryScan, expression: scan.cronExpression, onTick: () => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL }), this.logger), @@ -65,7 +65,7 @@ export class LibraryService extends BaseService { } this.cronRepository.update({ - name: 'libraryScan', + name: CronJob.LibraryScan, expression: library.scan.cronExpression, start: library.scan.enabled, }); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index c7b98cc99..43be32345 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -103,6 +103,14 @@ const updatedConfig = Object.freeze({ lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', }, + nightlyTasks: { + startTime: '00:00', + databaseCleanup: true, + clusterNewFaces: true, + missingThumbnails: true, + generateMemories: true, + syncQuotaUsage: true, + }, reverseGeocoding: { enabled: true, }, diff --git a/web/src/lib/components/admin-page/settings/nightly-tasks-settings/nightly-tasks-settings.svelte b/web/src/lib/components/admin-page/settings/nightly-tasks-settings/nightly-tasks-settings.svelte new file mode 100644 index 000000000..af653f247 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/nightly-tasks-settings/nightly-tasks-settings.svelte @@ -0,0 +1,81 @@ + + +
+
+
+
+ + + + + + +
+ + onReset({ ...options, configKeys: ['nightlyTasks'] })} + onSave={() => onSave({ nightlyTasks: config.nightlyTasks })} + showResetToDefault={!isEqual(savedConfig.nightlyTasks, defaultConfig.nightlyTasks)} + {disabled} + /> + +
+
diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index e053eea17..3b1d68a49 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -12,6 +12,7 @@ import MapSettings from '$lib/components/admin-page/settings/map-settings/map-settings.svelte'; import MetadataSettings from '$lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte'; import NewVersionCheckSettings from '$lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte'; + import NightlyTasksSettings from '$lib/components/admin-page/settings/nightly-tasks-settings/nightly-tasks-settings.svelte'; import NotificationSettings from '$lib/components/admin-page/settings/notification-settings/notification-settings.svelte'; import ServerSettings from '$lib/components/admin-page/settings/server/server-settings.svelte'; import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte'; @@ -33,6 +34,7 @@ mdiBackupRestore, mdiBellOutline, mdiBookshelf, + mdiClockOutline, mdiContentCopy, mdiDatabaseOutline, mdiDownload, @@ -136,13 +138,6 @@ key: 'job', icon: mdiSync, }, - { - component: MetadataSettings, - title: $t('admin.metadata_settings'), - subtitle: $t('admin.metadata_settings_description'), - key: 'metadata', - icon: mdiDatabaseOutline, - }, { component: LibrarySettings, title: $t('admin.library_settings'), @@ -171,6 +166,20 @@ key: 'location', icon: mdiMapMarkerOutline, }, + { + component: MetadataSettings, + title: $t('admin.metadata_settings'), + subtitle: $t('admin.metadata_settings_description'), + key: 'metadata', + icon: mdiDatabaseOutline, + }, + { + component: NightlyTasksSettings, + title: $t('admin.nightly_tasks_settings'), + subtitle: $t('admin.nightly_tasks_settings_description'), + key: 'nightly-tasks', + icon: mdiClockOutline, + }, { component: NotificationSettings, title: $t('admin.notification_settings'),