diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 2133d04113..fffb9914a5 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -3283,6 +3283,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'map': SystemConfigMapDto; + /** + * + * @type {SystemConfigNewVersionCheckDto} + * @memberof SystemConfigDto + */ + 'newVersionCheck': SystemConfigNewVersionCheckDto; /** * * @type {SystemConfigOAuthDto} @@ -3572,6 +3578,19 @@ export interface SystemConfigMapDto { */ 'tileUrl': string; } +/** + * + * @export + * @interface SystemConfigNewVersionCheckDto + */ +export interface SystemConfigNewVersionCheckDto { + /** + * + * @type {boolean} + * @memberof SystemConfigNewVersionCheckDto + */ + 'enabled': boolean; +} /** * * @export diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index 1dda262f50..83fcb000fe 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/mobile/lib/shared/providers/websocket.provider.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; @@ -175,9 +173,8 @@ class WebsocketNotifier extends StateNotifier { .where((c) => c.action == PendingAction.assetDelete) .toList(); if (deleteChanges.isNotEmpty) { - List remoteIds = deleteChanges - .map((a) => jsonDecode(a.value.toString()).toString()) - .toList(); + List remoteIds = + deleteChanges.map((a) => a.value.toString()).toList(); ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds); state = state.copyWith( pendingChanges: state.pendingChanges @@ -188,21 +185,20 @@ class WebsocketNotifier extends StateNotifier { } _handleOnUploadSuccess(dynamic data) { - final jsonString = jsonDecode(data.toString()); - final dto = AssetResponseDto.fromJson(jsonString); + final dto = AssetResponseDto.fromJson(data); if (dto != null) { final newAsset = Asset.remote(dto); ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset); } } - _handleOnConfigUpdate(dynamic data) { + _handleOnConfigUpdate(dynamic _) { ref.read(serverInfoProvider.notifier).getServerFeatures(); ref.read(serverInfoProvider.notifier).getServerConfig(); } // Refresh updated assets - _handleServerUpdates(dynamic data) { + _handleServerUpdates(dynamic _) { ref.read(assetProvider.notifier).getAllAsset(); } diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 747c435dd3..3bb00bb242 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -130,6 +130,7 @@ doc/SystemConfigFFmpegDto.md doc/SystemConfigJobDto.md doc/SystemConfigMachineLearningDto.md doc/SystemConfigMapDto.md +doc/SystemConfigNewVersionCheckDto.md doc/SystemConfigOAuthDto.md doc/SystemConfigPasswordLoginDto.md doc/SystemConfigReverseGeocodingDto.md @@ -298,6 +299,7 @@ lib/model/system_config_f_fmpeg_dto.dart lib/model/system_config_job_dto.dart lib/model/system_config_machine_learning_dto.dart lib/model/system_config_map_dto.dart +lib/model/system_config_new_version_check_dto.dart lib/model/system_config_o_auth_dto.dart lib/model/system_config_password_login_dto.dart lib/model/system_config_reverse_geocoding_dto.dart @@ -453,6 +455,7 @@ test/system_config_f_fmpeg_dto_test.dart test/system_config_job_dto_test.dart test/system_config_machine_learning_dto_test.dart test/system_config_map_dto_test.dart +test/system_config_new_version_check_dto_test.dart test/system_config_o_auth_dto_test.dart test/system_config_password_login_dto_test.dart test/system_config_reverse_geocoding_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 07d2cf402f..80735dcd01 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/SystemConfigDto.md b/mobile/openapi/doc/SystemConfigDto.md index d426bef345..98a6266402 100644 Binary files a/mobile/openapi/doc/SystemConfigDto.md and b/mobile/openapi/doc/SystemConfigDto.md differ diff --git a/mobile/openapi/doc/SystemConfigNewVersionCheckDto.md b/mobile/openapi/doc/SystemConfigNewVersionCheckDto.md new file mode 100644 index 0000000000..18b922de20 Binary files /dev/null and b/mobile/openapi/doc/SystemConfigNewVersionCheckDto.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 091a38e36d..b030be56f4 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 33586a7e13..c391658e66 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 8e15d7e219..89c7e5f7d2 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_new_version_check_dto.dart b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart new file mode 100644 index 0000000000..e428442cce Binary files /dev/null and b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart differ diff --git a/mobile/openapi/test/system_config_dto_test.dart b/mobile/openapi/test/system_config_dto_test.dart index 30dbe6860b..75e6045397 100644 Binary files a/mobile/openapi/test/system_config_dto_test.dart and b/mobile/openapi/test/system_config_dto_test.dart differ diff --git a/mobile/openapi/test/system_config_new_version_check_dto_test.dart b/mobile/openapi/test/system_config_new_version_check_dto_test.dart new file mode 100644 index 0000000000..8711a52456 Binary files /dev/null and b/mobile/openapi/test/system_config_new_version_check_dto_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 09ff41e24f..7230951384 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -8048,6 +8048,9 @@ "map": { "$ref": "#/components/schemas/SystemConfigMapDto" }, + "newVersionCheck": { + "$ref": "#/components/schemas/SystemConfigNewVersionCheckDto" + }, "oauth": { "$ref": "#/components/schemas/SystemConfigOAuthDto" }, @@ -8074,6 +8077,7 @@ "ffmpeg", "machineLearning", "map", + "newVersionCheck", "oauth", "passwordLogin", "reverseGeocoding", @@ -8257,6 +8261,17 @@ ], "type": "object" }, + "SystemConfigNewVersionCheckDto": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "SystemConfigOAuthDto": { "properties": { "autoLaunch": { diff --git a/server/src/domain/album/dto/get-albums.dto.ts b/server/src/domain/album/dto/get-albums.dto.ts index 680bae5fc6..ce037e1899 100644 --- a/server/src/domain/album/dto/get-albums.dto.ts +++ b/server/src/domain/album/dto/get-albums.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean } from 'class-validator'; -import { Optional, ValidateUUID, toBoolean } from '../../domain.util'; +import { Optional, toBoolean, ValidateUUID } from '../../domain.util'; export class GetAlbumsDto { @Optional() diff --git a/server/src/domain/domain.constant.spec.ts b/server/src/domain/domain.constant.spec.ts index 9936e4854a..0db2b51bfb 100644 --- a/server/src/domain/domain.constant.spec.ts +++ b/server/src/domain/domain.constant.spec.ts @@ -1,4 +1,4 @@ -import { mimeTypes } from '@app/domain'; +import { ServerVersion, mimeTypes } from './domain.constant'; describe('mimeTypes', () => { for (const { mimetype, extension } of [ @@ -188,7 +188,74 @@ describe('mimeTypes', () => { for (const [ext, v] of Object.entries(mimeTypes.sidecar)) { it(`should lookup ${ext}`, () => { - expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]); + expect(mimeTypes.lookup(`it.${ext}`)).toEqual(v[0]); + }); + } + }); +}); + +describe('ServerVersion', () => { + describe('isNewerThan', () => { + it('should work on patch versions', () => { + expect(new ServerVersion(0, 0, 1).isNewerThan(new ServerVersion(0, 0, 0))).toBe(true); + expect(new ServerVersion(1, 72, 1).isNewerThan(new ServerVersion(1, 72, 0))).toBe(true); + + expect(new ServerVersion(0, 0, 0).isNewerThan(new ServerVersion(0, 0, 1))).toBe(false); + expect(new ServerVersion(1, 72, 0).isNewerThan(new ServerVersion(1, 72, 1))).toBe(false); + }); + + it('should work on minor versions', () => { + expect(new ServerVersion(0, 1, 0).isNewerThan(new ServerVersion(0, 0, 0))).toBe(true); + expect(new ServerVersion(1, 72, 0).isNewerThan(new ServerVersion(1, 71, 0))).toBe(true); + expect(new ServerVersion(1, 72, 0).isNewerThan(new ServerVersion(1, 71, 9))).toBe(true); + + expect(new ServerVersion(0, 0, 0).isNewerThan(new ServerVersion(0, 1, 0))).toBe(false); + expect(new ServerVersion(1, 71, 0).isNewerThan(new ServerVersion(1, 72, 0))).toBe(false); + expect(new ServerVersion(1, 71, 9).isNewerThan(new ServerVersion(1, 72, 0))).toBe(false); + }); + + it('should work on major versions', () => { + expect(new ServerVersion(1, 0, 0).isNewerThan(new ServerVersion(0, 0, 0))).toBe(true); + expect(new ServerVersion(2, 0, 0).isNewerThan(new ServerVersion(1, 71, 0))).toBe(true); + + expect(new ServerVersion(0, 0, 0).isNewerThan(new ServerVersion(1, 0, 0))).toBe(false); + expect(new ServerVersion(1, 71, 0).isNewerThan(new ServerVersion(2, 0, 0))).toBe(false); + }); + + it('should work on equal', () => { + for (const version of [ + new ServerVersion(0, 0, 0), + new ServerVersion(0, 0, 1), + new ServerVersion(0, 1, 1), + new ServerVersion(0, 1, 0), + new ServerVersion(1, 1, 1), + new ServerVersion(1, 0, 0), + new ServerVersion(1, 72, 1), + new ServerVersion(1, 72, 0), + new ServerVersion(1, 73, 9), + ]) { + expect(version.isNewerThan(version)).toBe(false); + } + }); + }); + + describe('fromString', () => { + const tests = [ + { scenario: 'leading v', value: 'v1.72.2', expected: new ServerVersion(1, 72, 2) }, + { scenario: 'uppercase v', value: 'V1.72.2', expected: new ServerVersion(1, 72, 2) }, + { scenario: 'missing v', value: '1.72.2', expected: new ServerVersion(1, 72, 2) }, + { scenario: 'large patch', value: '1.72.123', expected: new ServerVersion(1, 72, 123) }, + { scenario: 'large minor', value: '1.123.0', expected: new ServerVersion(1, 123, 0) }, + { scenario: 'large major', value: '123.0.0', expected: new ServerVersion(123, 0, 0) }, + { scenario: 'major bump', value: 'v2.0.0', expected: new ServerVersion(2, 0, 0) }, + ]; + + for (const { scenario, value, expected } of tests) { + it(`should correctly parse ${scenario}`, () => { + const actual = ServerVersion.fromString(value); + expect(actual.major).toEqual(expected.major); + expect(actual.minor).toEqual(expected.minor); + expect(actual.patch).toEqual(expected.patch); }); } }); diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index e89b90fb28..1b301be565 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -4,8 +4,7 @@ import { extname } from 'node:path'; import pkg from 'src/../../package.json'; export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); - -const [major, minor, patch] = pkg.version.split('.'); +export const ONE_HOUR = Duration.fromObject({ hours: 1 }); export interface IServerVersion { major: number; @@ -13,13 +12,49 @@ export interface IServerVersion { patch: number; } -export const serverVersion: IServerVersion = { - major: Number(major), - minor: Number(minor), - patch: Number(patch), -}; +export class ServerVersion implements IServerVersion { + constructor( + public readonly major: number, + public readonly minor: number, + public readonly patch: number, + ) {} -export const SERVER_VERSION = `${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`; + toString() { + return `${this.major}.${this.minor}.${this.patch}`; + } + + toJSON() { + const { major, minor, patch } = this; + return { major, minor, patch }; + } + + static fromString(version: string): ServerVersion { + const regex = /(?:v)?(?\d+)\.(?\d+)\.(?\d+)/i; + const matchResult = version.match(regex); + if (matchResult) { + const [, major, minor, patch] = matchResult.map(Number); + return new ServerVersion(major, minor, patch); + } else { + throw new Error(`Invalid version format: ${version}`); + } + } + + isNewerThan(version: ServerVersion): boolean { + const equalMajor = this.major === version.major; + const equalMinor = this.minor === version.minor; + + return ( + this.major > version.major || + (equalMajor && this.minor > version.minor) || + (equalMajor && equalMinor && this.patch > version.patch) + ); + } +} + +export const envName = (process.env.NODE_ENV || 'development').toUpperCase(); +export const isDev = process.env.NODE_ENV === 'development'; + +export const serverVersion = ServerVersion.fromString(pkg.version); export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; diff --git a/server/src/domain/job/job.constants.ts b/server/src/domain/job/job.constants.ts index ec1307cba4..7782167b56 100644 --- a/server/src/domain/job/job.constants.ts +++ b/server/src/domain/job/job.constants.ts @@ -169,7 +169,7 @@ export const JOBS_TO_QUEUE: Record = { [JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR, [JobName.SIDECAR_SYNC]: QueueName.SIDECAR, - // Library managment + // Library management [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, [JobName.LIBRARY_SCAN]: QueueName.LIBRARY, [JobName.LIBRARY_DELETE]: QueueName.LIBRARY, diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index f4c06a1e9a..958adb8033 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/server/src/domain/repositories/communication.repository.ts @@ -9,9 +9,13 @@ export enum CommunicationEvent { PERSON_THUMBNAIL = 'on_person_thumbnail', SERVER_VERSION = 'on_server_version', CONFIG_UPDATE = 'on_config_update', + NEW_RELEASE = 'on_new_release', } +export type Callback = (userId: string) => Promise; + export interface ICommunicationRepository { send(event: CommunicationEvent, userId: string, data: any): void; broadcast(event: CommunicationEvent, data: any): void; + addEventListener(event: 'connect', callback: Callback): void; } diff --git a/server/src/domain/repositories/index.ts b/server/src/domain/repositories/index.ts index 28f1cf6a57..2c4a10cc24 100644 --- a/server/src/domain/repositories/index.ts +++ b/server/src/domain/repositories/index.ts @@ -14,6 +14,7 @@ export * from './move.repository'; export * from './partner.repository'; export * from './person.repository'; export * from './search.repository'; +export * from './server-info.repository'; export * from './shared-link.repository'; export * from './smart-info.repository'; export * from './storage.repository'; diff --git a/server/src/domain/repositories/job.repository.ts b/server/src/domain/repositories/job.repository.ts index 0f571dc991..3527c9ea62 100644 --- a/server/src/domain/repositories/job.repository.ts +++ b/server/src/domain/repositories/job.repository.ts @@ -85,7 +85,7 @@ export type JobItem = | { name: JobName.ASSET_DELETION; data: IAssetDeletionJob } | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } - // Library Managment + // Library Management | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob } | { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob } | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } diff --git a/server/src/domain/repositories/server-info.repository.ts b/server/src/domain/repositories/server-info.repository.ts new file mode 100644 index 0000000000..a4168d4c3e --- /dev/null +++ b/server/src/domain/repositories/server-info.repository.ts @@ -0,0 +1,15 @@ +export interface GitHubRelease { + id: number; + url: string; + tag_name: string; + name: string; + created_at: string; + published_at: string; + body: string; +} + +export const IServerInfoRepository = 'IServerInfoRepository'; + +export interface IServerInfoRepository { + getGitHubRelease(): Promise; +} diff --git a/server/src/domain/server-info/server-info.service.spec.ts b/server/src/domain/server-info/server-info.service.spec.ts index 53115594c9..4f6b24680d 100644 --- a/server/src/domain/server-info/server-info.service.spec.ts +++ b/server/src/domain/server-info/server-info.service.spec.ts @@ -1,20 +1,36 @@ -import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock } from '@test'; +import { + newCommunicationRepositoryMock, + newServerInfoRepositoryMock, + newStorageRepositoryMock, + newSystemConfigRepositoryMock, + newUserRepositoryMock, +} from '@test'; import { serverVersion } from '../domain.constant'; -import { IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories'; +import { + ICommunicationRepository, + IServerInfoRepository, + IStorageRepository, + ISystemConfigRepository, + IUserRepository, +} from '../repositories'; import { ServerInfoService } from './server-info.service'; describe(ServerInfoService.name, () => { let sut: ServerInfoService; + let communicationMock: jest.Mocked; let configMock: jest.Mocked; + let serverInfoMock: jest.Mocked; let storageMock: jest.Mocked; let userMock: jest.Mocked; beforeEach(() => { configMock = newSystemConfigRepositoryMock(); + communicationMock = newCommunicationRepositoryMock(); + serverInfoMock = newServerInfoRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new ServerInfoService(configMock, userMock, storageMock); + sut = new ServerInfoService(communicationMock, configMock, userMock, serverInfoMock, storageMock); }); it('should work', () => { diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index 1406423abe..e386fa17c0 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/server/src/domain/server-info/server-info.service.ts @@ -1,7 +1,16 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { mimeTypes, serverVersion } from '../domain.constant'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { DateTime } from 'luxon'; +import { ServerVersion, isDev, mimeTypes, serverVersion } from '../domain.constant'; import { asHumanReadable } from '../domain.util'; -import { IStorageRepository, ISystemConfigRepository, IUserRepository, UserStatsQueryResponse } from '../repositories'; +import { + CommunicationEvent, + ICommunicationRepository, + IServerInfoRepository, + IStorageRepository, + ISystemConfigRepository, + IUserRepository, + UserStatsQueryResponse, +} from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; import { SystemConfigCore } from '../system-config'; import { @@ -16,14 +25,20 @@ import { @Injectable() export class ServerInfoService { + private logger = new Logger(ServerInfoService.name); private configCore: SystemConfigCore; + private releaseVersion = serverVersion; + private releaseVersionCheckedAt: DateTime | null = null; constructor( + @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IUserRepository) private userRepository: IUserRepository, + @Inject(IServerInfoRepository) private repository: IServerInfoRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { this.configCore = SystemConfigCore.create(configRepository); + this.communicationRepository.addEventListener('connect', (userId) => this.handleConnect(userId)); } async getInfo(): Promise { @@ -101,4 +116,56 @@ export class ServerInfoService { sidecar: Object.keys(mimeTypes.sidecar), }; } + + async handleVersionCheck(): Promise { + try { + if (isDev) { + return true; + } + + const { newVersionCheck } = await this.configCore.getConfig(); + if (!newVersionCheck.enabled) { + return true; + } + + // check once per hour (max) + if (this.releaseVersionCheckedAt && this.releaseVersionCheckedAt.diffNow().as('minutes') < 60) { + return true; + } + + const githubRelease = await this.repository.getGitHubRelease(); + const githubVersion = ServerVersion.fromString(githubRelease.tag_name); + const publishedAt = new Date(githubRelease.published_at); + this.releaseVersion = githubVersion; + this.releaseVersionCheckedAt = DateTime.now(); + + if (githubVersion.isNewerThan(serverVersion)) { + this.logger.log(`Found ${githubVersion.toString()}, released at ${publishedAt.toLocaleString()}`); + this.newReleaseNotification(); + } + } catch (error: Error | any) { + this.logger.warn(`Unable to run version check: ${error}`, error?.stack); + } + + return true; + } + + private async handleConnect(userId: string) { + this.communicationRepository.send(CommunicationEvent.SERVER_VERSION, userId, serverVersion); + this.newReleaseNotification(userId); + } + + private newReleaseNotification(userId?: string) { + const event = CommunicationEvent.NEW_RELEASE; + const payload = { + isAvailable: this.releaseVersion.isNewerThan(serverVersion), + checkedAt: this.releaseVersionCheckedAt, + serverVersion, + releaseVersion: this.releaseVersion, + }; + + userId + ? this.communicationRepository.send(event, userId, payload) + : this.communicationRepository.broadcast(event, payload); + } } diff --git a/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts b/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts new file mode 100644 index 0000000000..c276739243 --- /dev/null +++ b/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from 'class-validator'; + +export class SystemConfigNewVersionCheckDto { + @IsBoolean() + enabled!: boolean; +} diff --git a/server/src/domain/system-config/dto/system-config.dto.ts b/server/src/domain/system-config/dto/system-config.dto.ts index 6a88e758c6..975f5df893 100644 --- a/server/src/domain/system-config/dto/system-config.dto.ts +++ b/server/src/domain/system-config/dto/system-config.dto.ts @@ -5,6 +5,7 @@ import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto'; import { SystemConfigJobDto } from './system-config-job.dto'; import { SystemConfigMachineLearningDto } from './system-config-machine-learning.dto'; import { SystemConfigMapDto } from './system-config-map.dto'; +import { SystemConfigNewVersionCheckDto } from './system-config-new-version-check.dto'; import { SystemConfigOAuthDto } from './system-config-oauth.dto'; import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto'; import { SystemConfigReverseGeocodingDto } from './system-config-reverse-geocoding.dto'; @@ -29,6 +30,11 @@ export class SystemConfigDto implements SystemConfig { @IsObject() map!: SystemConfigMapDto; + @Type(() => SystemConfigNewVersionCheckDto) + @ValidateNested() + @IsObject() + newVersionCheck!: SystemConfigNewVersionCheckDto; + @Type(() => SystemConfigOAuthDto) @ValidateNested() @IsObject() diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index 4fd2faa295..29f9c363d3 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -1,8 +1,8 @@ import { AudioCodec, - CQMode, CitiesFile, Colorspace, + CQMode, SystemConfig, SystemConfigEntity, SystemConfigKey, @@ -110,6 +110,9 @@ export const defaults = Object.freeze({ quality: 80, colorspace: Colorspace.P3, }, + newVersionCheck: { + enabled: true, + }, trash: { enabled: true, days: 30, diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index 36cdb6543e..1dcbd013dc 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -1,8 +1,8 @@ import { AudioCodec, - CQMode, CitiesFile, Colorspace, + CQMode, SystemConfig, SystemConfigEntity, SystemConfigKey, @@ -15,7 +15,7 @@ import { BadRequestException } from '@nestjs/common'; import { newCommunicationRepositoryMock, newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test'; import { JobName, QueueName } from '../job'; import { ICommunicationRepository, IJobRepository, ISystemConfigRepository } from '../repositories'; -import { SystemConfigValidator, defaults } from './system-config.core'; +import { defaults, SystemConfigValidator } from './system-config.core'; import { SystemConfigService } from './system-config.service'; const updates: SystemConfigEntity[] = [ @@ -111,6 +111,9 @@ const updatedConfig = Object.freeze({ quality: 80, colorspace: Colorspace.P3, }, + newVersionCheck: { + enabled: true, + }, trash: { enabled: true, days: 10, diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts index 6f386eb126..110380753e 100644 --- a/server/src/immich/app.service.ts +++ b/server/src/immich/app.service.ts @@ -1,6 +1,6 @@ -import { JobService, SearchService, ServerInfoService, StorageService } from '@app/domain'; +import { JobService, ONE_HOUR, SearchService, ServerInfoService, StorageService } from '@app/domain'; import { Injectable, Logger } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; +import { Cron, CronExpression, Interval } from '@nestjs/schedule'; @Injectable() export class AppService { @@ -13,6 +13,11 @@ export class AppService { private serverService: ServerInfoService, ) {} + @Interval(ONE_HOUR.as('milliseconds')) + async onVersionCheck() { + await this.serverService.handleVersionCheck(); + } + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) async onNightlyJob() { await this.jobService.handleNightlyJobs(); @@ -21,6 +26,7 @@ export class AppService { async init() { this.storageService.init(); await this.searchService.init(); + await this.serverService.handleVersionCheck(); this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); } diff --git a/server/src/immich/app.utils.ts b/server/src/immich/app.utils.ts index 61ee81c624..1a27b6f30b 100644 --- a/server/src/immich/app.utils.ts +++ b/server/src/immich/app.utils.ts @@ -3,7 +3,7 @@ import { IMMICH_API_KEY_HEADER, IMMICH_API_KEY_NAME, ImmichReadStream, - SERVER_VERSION, + serverVersion, } from '@app/domain'; import { INestApplication, StreamableFile } from '@nestjs/common'; import { @@ -91,7 +91,7 @@ export const useSwagger = (app: INestApplication, isDev: boolean) => { const config = new DocumentBuilder() .setTitle('Immich') .setDescription('Immich API') - .setVersion(SERVER_VERSION) + .setVersion(serverVersion.toString()) .addBearerAuth({ type: 'http', scheme: 'Bearer', diff --git a/server/src/immich/main.ts b/server/src/immich/main.ts index f262a173cd..e3ac816317 100644 --- a/server/src/immich/main.ts +++ b/server/src/immich/main.ts @@ -1,4 +1,4 @@ -import { getLogLevels, SERVER_VERSION } from '@app/domain'; +import { envName, getLogLevels, isDev, serverVersion } from '@app/domain'; import { RedisIoAdapter } from '@app/infra'; import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; @@ -9,9 +9,7 @@ import { AppModule } from './app.module'; import { useSwagger } from './app.utils'; const logger = new Logger('ImmichServer'); -const envName = (process.env.NODE_ENV || 'development').toUpperCase(); const port = Number(process.env.SERVER_PORT) || 3001; -const isDev = process.env.NODE_ENV === 'development'; export async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: getLogLevels() }); @@ -29,5 +27,5 @@ export async function bootstrap() { const server = await app.listen(port); server.requestTimeout = 30 * 60 * 1000; - logger.log(`Immich Server is listening on ${await app.getUrl()} [v${SERVER_VERSION}] [${envName}] `); + logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `); } diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index 6bd552111a..2e0a7473c0 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -67,6 +67,8 @@ export enum SystemConfigKey { REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled', REVERSE_GEOCODING_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride', + NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled', + OAUTH_ENABLED = 'oauth.enabled', OAUTH_ISSUER_URL = 'oauth.issuerUrl', OAUTH_CLIENT_ID = 'oauth.clientId', @@ -219,6 +221,9 @@ export interface SystemConfig { quality: number; colorspace: Colorspace; }; + newVersionCheck: { + enabled: boolean; + }; trash: { enabled: boolean; days: number; diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index ac9cb8484e..367458169f 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -15,6 +15,7 @@ import { IPartnerRepository, IPersonRepository, ISearchRepository, + IServerInfoRepository, ISharedLinkRepository, ISmartInfoRepository, IStorageRepository, @@ -48,6 +49,7 @@ import { MoveRepository, PartnerRepository, PersonRepository, + ServerInfoRepository, SharedLinkRepository, SmartInfoRepository, SystemConfigRepository, @@ -73,6 +75,7 @@ const providers: Provider[] = [ { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPersonRepository, useClass: PersonRepository }, { provide: ISearchRepository, useClass: TypesenseRepository }, + { provide: IServerInfoRepository, useClass: ServerInfoRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISmartInfoRepository, useClass: SmartInfoRepository }, { provide: IStorageRepository, useClass: FilesystemProvider }, diff --git a/server/src/infra/repositories/communication.repository.ts b/server/src/infra/repositories/communication.repository.ts index 01239eb52b..8cc5ce0e07 100644 --- a/server/src/infra/repositories/communication.repository.ts +++ b/server/src/infra/repositories/communication.repository.ts @@ -1,4 +1,4 @@ -import { AuthService, CommunicationEvent, ICommunicationRepository, serverVersion } from '@app/domain'; +import { AuthService, Callback, CommunicationEvent, ICommunicationRepository } from '@app/domain'; import { Logger } from '@nestjs/common'; import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; @@ -6,18 +6,25 @@ import { Server, Socket } from 'socket.io'; @WebSocketGateway({ cors: true }) export class CommunicationRepository implements OnGatewayConnection, OnGatewayDisconnect, ICommunicationRepository { private logger = new Logger(CommunicationRepository.name); + private onConnectCallbacks: Callback[] = []; constructor(private authService: AuthService) {} @WebSocketServer() server!: Server; + addEventListener(event: 'connect', callback: Callback) { + this.onConnectCallbacks.push(callback); + } + async handleConnection(client: Socket) { try { this.logger.log(`New websocket connection: ${client.id}`); const user = await this.authService.validate(client.request.headers, {}); if (user) { await client.join(user.id); - this.send(CommunicationEvent.SERVER_VERSION, user.id, serverVersion); + for (const callback of this.onConnectCallbacks) { + await callback(user.id); + } } else { client.emit('error', 'unauthorized'); client.disconnect(); @@ -34,7 +41,7 @@ export class CommunicationRepository implements OnGatewayConnection, OnGatewayDi } send(event: CommunicationEvent, userId: string, data: any) { - this.server.to(userId).emit(event, JSON.stringify(data)); + this.server.to(userId).emit(event, data); } broadcast(event: CommunicationEvent, data: any) { diff --git a/server/src/infra/repositories/index.ts b/server/src/infra/repositories/index.ts index 5229e610ed..bc2fba7666 100644 --- a/server/src/infra/repositories/index.ts +++ b/server/src/infra/repositories/index.ts @@ -14,6 +14,7 @@ export * from './metadata.repository'; export * from './move.repository'; export * from './partner.repository'; export * from './person.repository'; +export * from './server-info.repository'; export * from './shared-link.repository'; export * from './smart-info.repository'; export * from './system-config.repository'; diff --git a/server/src/infra/repositories/server-info.repository.ts b/server/src/infra/repositories/server-info.repository.ts new file mode 100644 index 0000000000..61fa448f97 --- /dev/null +++ b/server/src/infra/repositories/server-info.repository.ts @@ -0,0 +1,12 @@ +import { GitHubRelease, IServerInfoRepository } from '@app/domain'; +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; + +@Injectable() +export class ServerInfoRepository implements IServerInfoRepository { + getGitHubRelease(): Promise { + return axios + .get('https://api.github.com/repos/immich-app/immich/releases/latest') + .then((response) => response.data); + } +} diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 365b073299..67d995e331 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -9,6 +9,7 @@ import { MetadataService, PersonService, SearchService, + ServerInfoService, SmartInfoService, StorageService, StorageTemplateService, @@ -23,19 +24,20 @@ export class AppService { private logger = new Logger(AppService.name); constructor( - private jobService: JobService, + private auditService: AuditService, private assetService: AssetService, + private jobService: JobService, + private libraryService: LibraryService, private mediaService: MediaService, private metadataService: MetadataService, private personService: PersonService, private searchService: SearchService, + private serverInfoService: ServerInfoService, private smartInfoService: SmartInfoService, private storageTemplateService: StorageTemplateService, private storageService: StorageService, private systemConfigService: SystemConfigService, private userService: UserService, - private auditService: AuditService, - private libraryService: LibraryService, ) {} async init() { diff --git a/server/src/microservices/main.ts b/server/src/microservices/main.ts index 2937e6213b..bdb2893638 100644 --- a/server/src/microservices/main.ts +++ b/server/src/microservices/main.ts @@ -1,4 +1,4 @@ -import { getLogLevels, SERVER_VERSION } from '@app/domain'; +import { envName, getLogLevels, serverVersion } from '@app/domain'; import { RedisIoAdapter } from '@app/infra'; import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; @@ -7,7 +7,6 @@ import { MicroservicesModule } from './microservices.module'; const logger = new Logger('ImmichMicroservice'); const port = Number(process.env.MICROSERVICES_PORT) || 3002; -const envName = (process.env.NODE_ENV || 'development').toUpperCase(); export async function bootstrap() { const app = await NestFactory.create(MicroservicesModule, { logger: getLogLevels() }); @@ -17,5 +16,5 @@ export async function bootstrap() { await app.get(AppService).init(); await app.listen(port); - logger.log(`Immich Microservices is listening on ${await app.getUrl()} [v${SERVER_VERSION}] [${envName}] `); + logger.log(`Immich Microservices is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `); } diff --git a/server/test/repositories/communication.repository.mock.ts b/server/test/repositories/communication.repository.mock.ts index d8374e8b27..2db02e5277 100644 --- a/server/test/repositories/communication.repository.mock.ts +++ b/server/test/repositories/communication.repository.mock.ts @@ -4,5 +4,6 @@ export const newCommunicationRepositoryMock = (): jest.Mocked => { + return { + getGitHubRelease: jest.fn(), + }; +}; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 2133d04113..fffb9914a5 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -3283,6 +3283,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'map': SystemConfigMapDto; + /** + * + * @type {SystemConfigNewVersionCheckDto} + * @memberof SystemConfigDto + */ + 'newVersionCheck': SystemConfigNewVersionCheckDto; /** * * @type {SystemConfigOAuthDto} @@ -3572,6 +3578,19 @@ export interface SystemConfigMapDto { */ 'tileUrl': string; } +/** + * + * @export + * @interface SystemConfigNewVersionCheckDto + */ +export interface SystemConfigNewVersionCheckDto { + /** + * + * @type {boolean} + * @memberof SystemConfigNewVersionCheckDto + */ + 'enabled': boolean; +} /** * * @export diff --git a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte new file mode 100644 index 0000000000..acb83f0ca7 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte @@ -0,0 +1,92 @@ + + +
+ {#await getConfigs() then} +
+
+
+
+ + +
+
+
+
+ {/await} +
diff --git a/web/src/lib/components/shared-components/version-announcement-box.svelte b/web/src/lib/components/shared-components/version-announcement-box.svelte index af98568283..b2c535cd65 100644 --- a/web/src/lib/components/shared-components/version-announcement-box.svelte +++ b/web/src/lib/components/shared-components/version-announcement-box.svelte @@ -1,43 +1,35 @@ {#if showModal} @@ -63,9 +55,9 @@
Your friend, Alex
- Server Version: {serverVersionName} + Server Version: {serverVersion}
- Latest Version: {githubVersion} + Latest Version: {releaseVersion}
diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index af250984a3..213dadfda7 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -3,6 +3,13 @@ import { io } from 'socket.io-client'; import { writable } from 'svelte/store'; import { loadConfig } from './server-config.store'; +export interface ReleaseEvent { + isAvailable: boolean; + checkedAt: Date; + serverVersion: ServerVersionResponseDto; + releaseVersion: ServerVersionResponseDto; +} + export const websocketStore = { onUploadSuccess: writable(), onAssetDelete: writable(), @@ -10,6 +17,7 @@ export const websocketStore = { onPersonThumbnail: writable(), serverVersion: writable(), connected: writable(false), + onRelease: writable(), }; export const openWebsocketConnection = () => { @@ -24,12 +32,13 @@ export const openWebsocketConnection = () => { websocket .on('connect', () => websocketStore.connected.set(true)) .on('disconnect', () => websocketStore.connected.set(false)) - // .on('on_upload_success', (data) => websocketStore.onUploadSuccess.set(JSON.parse(data) as AssetResponseDto)) - .on('on_asset_delete', (data) => websocketStore.onAssetDelete.set(JSON.parse(data) as string)) - .on('on_asset_trash', (data) => websocketStore.onAssetTrash.set(JSON.parse(data) as string[])) - .on('on_person_thumbnail', (data) => websocketStore.onPersonThumbnail.set(JSON.parse(data) as string)) - .on('on_server_version', (data) => websocketStore.serverVersion.set(JSON.parse(data) as ServerVersionResponseDto)) + // .on('on_upload_success', (data) => websocketStore.onUploadSuccess.set(data)) + .on('on_asset_delete', (data) => websocketStore.onAssetDelete.set(data)) + .on('on_asset_trash', (data) => websocketStore.onAssetTrash.set(data)) + .on('on_person_thumbnail', (data) => websocketStore.onPersonThumbnail.set(data)) + .on('on_server_version', (data) => websocketStore.serverVersion.set(data)) .on('on_config_update', () => loadConfig()) + .on('on_new_release', (data) => websocketStore.onRelease.set(data)) .on('error', (e) => console.log('Websocket Error', e)); return () => websocket?.close(); diff --git a/web/src/lib/utils/get-github-version.ts b/web/src/lib/utils/get-github-version.ts deleted file mode 100644 index 5748585050..0000000000 --- a/web/src/lib/utils/get-github-version.ts +++ /dev/null @@ -1,15 +0,0 @@ -import axios from 'axios'; - -type GithubRelease = { - tag_name: string; -}; - -export const getGithubVersion = async (): Promise => { - const { data } = await axios.get('https://api.github.com/repos/immich-app/immich/releases/latest', { - headers: { - Accept: 'application/vnd.github.v3+json', - }, - }); - - return data.tag_name; -}; diff --git a/web/src/routes/+layout.server.ts b/web/src/routes/+layout.server.ts index 6ee3890737..804071cd71 100644 --- a/web/src/routes/+layout.server.ts +++ b/web/src/routes/+layout.server.ts @@ -1,7 +1,5 @@ import type { LayoutServerLoad } from './$types'; -export const load = (async ({ locals: { api, user } }) => { - const { data: serverVersion } = await api.serverInfoApi.getServerVersion(); - - return { serverVersion, user }; +export const load = (async ({ locals: { user } }) => { + return { user }; }) satisfies LayoutServerLoad; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index a0cc96aba8..b658f26e27 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -108,7 +108,7 @@ {#if data.user?.isAdmin} - + {/if} {#if $page.route.id?.includes('(user)')} diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 04a46bbed0..6d81c6093c 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -21,6 +21,7 @@ import ContentCopy from 'svelte-material-icons/ContentCopy.svelte'; import Download from 'svelte-material-icons/Download.svelte'; import type { PageData } from './$types'; + import NewVersionCheckSettings from '$lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte'; export let data: PageData; @@ -109,6 +110,10 @@ + + + +