1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

refactor(server, web)!: store latest immich version available on the server (#3565)

* refactor: store latest immich version available on the server

* don't store admins acknowledgement

* merge main

* fix: api

* feat: custom interval

* pr feedback

* remove unused code

* update environment-variables

* pr feedback

* ci: fix server tests

* fix: dart number

* pr feedback

* remove proxy

* pr feedback

* feat: make stringToVersion more flexible

* feat(web): disable check

* feat: working version

* remove env

* fix: check if interval exists when updating the interval

* feat: show last check

* fix: tests

* fix: remove availableVersion when updated

* fix merge

* fix: web

* fix e2e tests

* merge main

* merge main

* pr feedback

* pr feedback

* fix: tests

* pr feedback

* pr feedback

* pr feedback

* pr feedback

* pr feedback

* fix: migration

* regenerate api

* fix: typo

* fix: compare versions

* pr feedback

* fix

* pr feedback

* fix: checkIntervalTime on startup

* refactor: websockets and interval logic

* chore: open api

* chore: remove unused code

* fix: use interval instead of cron

* mobile: handle WS event data as json object

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
martin 2023-10-24 17:05:42 +02:00 committed by GitHub
parent 99c6f8fb13
commit 1aae29a0b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 497 additions and 99 deletions

View File

@ -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

View File

@ -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<WebsocketState> {
.where((c) => c.action == PendingAction.assetDelete)
.toList();
if (deleteChanges.isNotEmpty) {
List<String> remoteIds = deleteChanges
.map((a) => jsonDecode(a.value.toString()).toString())
.toList();
List<String> 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<WebsocketState> {
}
_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();
}

View File

@ -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

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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": {

View File

@ -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()

View File

@ -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);
});
}
});

View File

@ -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)?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\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';

View File

@ -169,7 +169,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[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,

View File

@ -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<void>;
export interface ICommunicationRepository {
send(event: CommunicationEvent, userId: string, data: any): void;
broadcast(event: CommunicationEvent, data: any): void;
addEventListener(event: 'connect', callback: Callback): void;
}

View File

@ -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';

View File

@ -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 }

View File

@ -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<GitHubRelease>;
}

View File

@ -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<ICommunicationRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let serverInfoMock: jest.Mocked<IServerInfoRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
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', () => {

View File

@ -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<ServerInfoResponseDto> {
@ -101,4 +116,56 @@ export class ServerInfoService {
sidecar: Object.keys(mimeTypes.sidecar),
};
}
async handleVersionCheck(): Promise<boolean> {
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);
}
}

View File

@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class SystemConfigNewVersionCheckDto {
@IsBoolean()
enabled!: boolean;
}

View File

@ -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()

View File

@ -1,8 +1,8 @@
import {
AudioCodec,
CQMode,
CitiesFile,
Colorspace,
CQMode,
SystemConfig,
SystemConfigEntity,
SystemConfigKey,
@ -110,6 +110,9 @@ export const defaults = Object.freeze<SystemConfig>({
quality: 80,
colorspace: Colorspace.P3,
},
newVersionCheck: {
enabled: true,
},
trash: {
enabled: true,
days: 30,

View File

@ -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<SystemConfig>({
quality: 80,
colorspace: Colorspace.P3,
},
newVersionCheck: {
enabled: true,
},
trash: {
enabled: true,
days: 10,

View File

@ -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)}`);
}

View File

@ -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',

View File

@ -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<NestExpressApplication>(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}] `);
}

View File

@ -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;

View File

@ -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 },

View File

@ -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) {

View File

@ -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';

View File

@ -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<GitHubRelease> {
return axios
.get<GitHubRelease>('https://api.github.com/repos/immich-app/immich/releases/latest')
.then((response) => response.data);
}
}

View File

@ -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() {

View File

@ -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}] `);
}

View File

@ -4,5 +4,6 @@ export const newCommunicationRepositoryMock = (): jest.Mocked<ICommunicationRepo
return {
send: jest.fn(),
broadcast: jest.fn(),
addEventListener: jest.fn(),
};
};

View File

@ -18,6 +18,7 @@ export * from './shared-link.repository.mock';
export * from './smart-info.repository.mock';
export * from './storage.repository.mock';
export * from './system-config.repository.mock';
export * from './system-info.repository.mock';
export * from './tag.repository.mock';
export * from './user-token.repository.mock';
export * from './user.repository.mock';

View File

@ -0,0 +1,7 @@
import { IServerInfoRepository } from '@app/domain';
export const newServerInfoRepositoryMock = (): jest.Mocked<IServerInfoRepository> => {
return {
getGitHubRelease: jest.fn(),
};
};

View File

@ -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

View File

@ -0,0 +1,92 @@
<script lang="ts">
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api, SystemConfigNewVersionCheckDto } from '@api';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingSwitch from '../setting-switch.svelte';
export let newVersionCheckConfig: SystemConfigNewVersionCheckDto; // this is the config that is being edited
let savedConfig: SystemConfigNewVersionCheckDto;
let defaultConfig: SystemConfigNewVersionCheckDto;
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.newVersionCheck),
api.systemConfigApi.getDefaults().then((res) => res.data.newVersionCheck),
]);
}
async function saveSetting() {
try {
const { data: configs } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...configs,
newVersionCheck: newVersionCheckConfig,
},
});
newVersionCheckConfig = { ...result.data.newVersionCheck };
savedConfig = { ...result.data.newVersionCheck };
notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save settings');
}
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
newVersionCheckConfig = { ...resetConfig.newVersionCheck };
savedConfig = { ...resetConfig.newVersionCheck };
notificationController.show({
message: 'Reset settings to the recent saved settings',
type: NotificationType.Info,
});
}
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
newVersionCheckConfig = { ...configs.newVersionCheck };
defaultConfig = { ...configs.newVersionCheck };
notificationController.show({
message: 'Reset settings to default',
type: NotificationType.Info,
});
}
</script>
<div>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4">
<SettingSwitch
title="ENABLED"
subtitle="Enable period requests to GitHub to check for new releases"
bind:checked={newVersionCheckConfig.enabled}
/>
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</div>
</div>
</form>
</div>
{/await}
</div>

View File

@ -1,43 +1,35 @@
<script lang="ts">
import { getGithubVersion } from '$lib/utils/get-github-version';
import { onMount } from 'svelte';
import FullScreenModal from './full-screen-modal.svelte';
import type { ServerVersionResponseDto } from '@api';
import { websocketStore } from '$lib/stores/websocket';
import Button from '../elements/buttons/button.svelte';
export let serverVersion: ServerVersionResponseDto;
import FullScreenModal from './full-screen-modal.svelte';
let showModal = false;
let githubVersion: string;
$: serverVersionName = semverToName(serverVersion);
function semverToName({ major, minor, patch }: ServerVersionResponseDto) {
return `v${major}.${minor}.${patch}`;
}
const { onRelease } = websocketStore;
function onAcknowledge() {
// Store server version to prevent the notification
// from showing again.
localStorage.setItem('appVersion', githubVersion);
const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`;
$: releaseVersion = $onRelease && semverToName($onRelease.releaseVersion);
$: serverVersion = $onRelease && semverToName($onRelease.serverVersion);
$: $onRelease?.isAvailable && handleRelease();
const onAcknowledge = () => {
localStorage.setItem('appVersion', releaseVersion);
showModal = false;
}
};
onMount(async () => {
const handleRelease = () => {
try {
githubVersion = await getGithubVersion();
if (localStorage.getItem('appVersion') === githubVersion) {
// Updated version has already been acknowledged.
if (localStorage.getItem('appVersion') === releaseVersion) {
return;
}
if (githubVersion !== serverVersionName) {
showModal = true;
}
} catch (err) {
// Only log any errors that occur.
console.error('Error [VersionAnnouncementBox]:', err);
}
});
};
</script>
{#if showModal}
@ -63,9 +55,9 @@
<div class="mt-4 font-medium">Your friend, Alex</div>
<div class="font-sm mt-8">
<code>Server Version: {serverVersionName}</code>
<code>Server Version: {serverVersion}</code>
<br />
<code>Latest Version: {githubVersion}</code>
<code>Latest Version: {releaseVersion}</code>
</div>
<div class="mt-8 text-right">

View File

@ -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<AssetResponseDto>(),
onAssetDelete: writable<string>(),
@ -10,6 +17,7 @@ export const websocketStore = {
onPersonThumbnail: writable<string>(),
serverVersion: writable<ServerVersionResponseDto>(),
connected: writable<boolean>(false),
onRelease: writable<ReleaseEvent>(),
};
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();

View File

@ -1,15 +0,0 @@
import axios from 'axios';
type GithubRelease = {
tag_name: string;
};
export const getGithubVersion = async (): Promise<string> => {
const { data } = await axios.get<GithubRelease>('https://api.github.com/repos/immich-app/immich/releases/latest', {
headers: {
Accept: 'application/vnd.github.v3+json',
},
});
return data.tag_name;
};

View File

@ -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;

View File

@ -108,7 +108,7 @@
<NotificationList />
{#if data.user?.isAdmin}
<VersionAnnouncementBox serverVersion={data.serverVersion} />
<VersionAnnouncementBox />
{/if}
{#if $page.route.id?.includes('(user)')}

View File

@ -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 @@
<TrashSettings disabled={$featureFlags.configFile} trashConfig={configs.trash} />
</SettingAccordion>
<SettingAccordion title="Version Check" subtitle="Enable/disable the new version notification">
<NewVersionCheckSettings newVersionCheckConfig={configs.newVersionCheck} />
</SettingAccordion>
<SettingAccordion
title="Video Transcoding Settings"
subtitle="Manage the resolution and encoding information of the video files"