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>
pull/4625/head
martin 2023-10-24 17:05:42 +07: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 656 additions and 100 deletions

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

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

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

@ -313,6 +313,7 @@ Class | Method | HTTP request | Description
- [SystemConfigJobDto](doc//SystemConfigJobDto.md)
- [SystemConfigMachineLearningDto](doc//SystemConfigMachineLearningDto.md)
- [SystemConfigMapDto](doc//SystemConfigMapDto.md)
- [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md)
- [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md)
- [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md)
- [SystemConfigReverseGeocodingDto](doc//SystemConfigReverseGeocodingDto.md)

@ -12,6 +12,7 @@ Name | Type | Description | Notes
**job** | [**SystemConfigJobDto**](SystemConfigJobDto.md) | |
**machineLearning** | [**SystemConfigMachineLearningDto**](SystemConfigMachineLearningDto.md) | |
**map** | [**SystemConfigMapDto**](SystemConfigMapDto.md) | |
**newVersionCheck** | [**SystemConfigNewVersionCheckDto**](SystemConfigNewVersionCheckDto.md) | |
**oauth** | [**SystemConfigOAuthDto**](SystemConfigOAuthDto.md) | |
**passwordLogin** | [**SystemConfigPasswordLoginDto**](SystemConfigPasswordLoginDto.md) | |
**reverseGeocoding** | [**SystemConfigReverseGeocodingDto**](SystemConfigReverseGeocodingDto.md) | |

@ -0,0 +1,15 @@
# openapi.model.SystemConfigNewVersionCheckDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**enabled** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

@ -158,6 +158,7 @@ part 'model/system_config_f_fmpeg_dto.dart';
part 'model/system_config_job_dto.dart';
part 'model/system_config_machine_learning_dto.dart';
part 'model/system_config_map_dto.dart';
part 'model/system_config_new_version_check_dto.dart';
part 'model/system_config_o_auth_dto.dart';
part 'model/system_config_password_login_dto.dart';
part 'model/system_config_reverse_geocoding_dto.dart';

@ -407,6 +407,8 @@ class ApiClient {
return SystemConfigMachineLearningDto.fromJson(value);
case 'SystemConfigMapDto':
return SystemConfigMapDto.fromJson(value);
case 'SystemConfigNewVersionCheckDto':
return SystemConfigNewVersionCheckDto.fromJson(value);
case 'SystemConfigOAuthDto':
return SystemConfigOAuthDto.fromJson(value);
case 'SystemConfigPasswordLoginDto':

@ -17,6 +17,7 @@ class SystemConfigDto {
required this.job,
required this.machineLearning,
required this.map,
required this.newVersionCheck,
required this.oauth,
required this.passwordLogin,
required this.reverseGeocoding,
@ -34,6 +35,8 @@ class SystemConfigDto {
SystemConfigMapDto map;
SystemConfigNewVersionCheckDto newVersionCheck;
SystemConfigOAuthDto oauth;
SystemConfigPasswordLoginDto passwordLogin;
@ -54,6 +57,7 @@ class SystemConfigDto {
other.job == job &&
other.machineLearning == machineLearning &&
other.map == map &&
other.newVersionCheck == newVersionCheck &&
other.oauth == oauth &&
other.passwordLogin == passwordLogin &&
other.reverseGeocoding == reverseGeocoding &&
@ -69,6 +73,7 @@ class SystemConfigDto {
(job.hashCode) +
(machineLearning.hashCode) +
(map.hashCode) +
(newVersionCheck.hashCode) +
(oauth.hashCode) +
(passwordLogin.hashCode) +
(reverseGeocoding.hashCode) +
@ -78,7 +83,7 @@ class SystemConfigDto {
(trash.hashCode);
@override
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, map=$map, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]';
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -86,6 +91,7 @@ class SystemConfigDto {
json[r'job'] = this.job;
json[r'machineLearning'] = this.machineLearning;
json[r'map'] = this.map;
json[r'newVersionCheck'] = this.newVersionCheck;
json[r'oauth'] = this.oauth;
json[r'passwordLogin'] = this.passwordLogin;
json[r'reverseGeocoding'] = this.reverseGeocoding;
@ -108,6 +114,7 @@ class SystemConfigDto {
job: SystemConfigJobDto.fromJson(json[r'job'])!,
machineLearning: SystemConfigMachineLearningDto.fromJson(json[r'machineLearning'])!,
map: SystemConfigMapDto.fromJson(json[r'map'])!,
newVersionCheck: SystemConfigNewVersionCheckDto.fromJson(json[r'newVersionCheck'])!,
oauth: SystemConfigOAuthDto.fromJson(json[r'oauth'])!,
passwordLogin: SystemConfigPasswordLoginDto.fromJson(json[r'passwordLogin'])!,
reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!,
@ -166,6 +173,7 @@ class SystemConfigDto {
'job',
'machineLearning',
'map',
'newVersionCheck',
'oauth',
'passwordLogin',
'reverseGeocoding',

@ -0,0 +1,98 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SystemConfigNewVersionCheckDto {
/// Returns a new [SystemConfigNewVersionCheckDto] instance.
SystemConfigNewVersionCheckDto({
required this.enabled,
});
bool enabled;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigNewVersionCheckDto &&
other.enabled == enabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled.hashCode);
@override
String toString() => 'SystemConfigNewVersionCheckDto[enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
return json;
}
/// Returns a new [SystemConfigNewVersionCheckDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SystemConfigNewVersionCheckDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return SystemConfigNewVersionCheckDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
);
}
return null;
}
static List<SystemConfigNewVersionCheckDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigNewVersionCheckDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigNewVersionCheckDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SystemConfigNewVersionCheckDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigNewVersionCheckDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigNewVersionCheckDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SystemConfigNewVersionCheckDto-objects as value to a dart map
static Map<String, List<SystemConfigNewVersionCheckDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigNewVersionCheckDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SystemConfigNewVersionCheckDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'enabled',
};
}

@ -36,6 +36,11 @@ void main() {
// TODO
});
// SystemConfigNewVersionCheckDto newVersionCheck
test('to test the property `newVersionCheck`', () async {
// TODO
});
// SystemConfigOAuthDto oauth
test('to test the property `oauth`', () async {
// TODO

@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for SystemConfigNewVersionCheckDto
void main() {
// final instance = SystemConfigNewVersionCheckDto();
group('test SystemConfigNewVersionCheckDto', () {
// bool enabled
test('to test the property `enabled`', () async {
// TODO
});
});
}

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

@ -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,
) {}
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 SERVER_VERSION = `${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`;
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 }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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