mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-08 20:29:05 +00: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:
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }
|
||||
|
||||
15
server/src/domain/repositories/server-info.repository.ts
Normal file
15
server/src/domain/repositories/server-info.repository.ts
Normal 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>;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class SystemConfigNewVersionCheckDto {
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user