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 656 additions and 100 deletions

View File

@ -3283,6 +3283,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto * @memberof SystemConfigDto
*/ */
'map': SystemConfigMapDto; 'map': SystemConfigMapDto;
/**
*
* @type {SystemConfigNewVersionCheckDto}
* @memberof SystemConfigDto
*/
'newVersionCheck': SystemConfigNewVersionCheckDto;
/** /**
* *
* @type {SystemConfigOAuthDto} * @type {SystemConfigOAuthDto}
@ -3572,6 +3578,19 @@ export interface SystemConfigMapDto {
*/ */
'tileUrl': string; 'tileUrl': string;
} }
/**
*
* @export
* @interface SystemConfigNewVersionCheckDto
*/
export interface SystemConfigNewVersionCheckDto {
/**
*
* @type {boolean}
* @memberof SystemConfigNewVersionCheckDto
*/
'enabled': boolean;
}
/** /**
* *
* @export * @export

View File

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.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) .where((c) => c.action == PendingAction.assetDelete)
.toList(); .toList();
if (deleteChanges.isNotEmpty) { if (deleteChanges.isNotEmpty) {
List<String> remoteIds = deleteChanges List<String> remoteIds =
.map((a) => jsonDecode(a.value.toString()).toString()) deleteChanges.map((a) => a.value.toString()).toList();
.toList();
ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds); ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
state = state.copyWith( state = state.copyWith(
pendingChanges: state.pendingChanges pendingChanges: state.pendingChanges
@ -188,21 +185,20 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
} }
_handleOnUploadSuccess(dynamic data) { _handleOnUploadSuccess(dynamic data) {
final jsonString = jsonDecode(data.toString()); final dto = AssetResponseDto.fromJson(data);
final dto = AssetResponseDto.fromJson(jsonString);
if (dto != null) { if (dto != null) {
final newAsset = Asset.remote(dto); final newAsset = Asset.remote(dto);
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset); ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
} }
} }
_handleOnConfigUpdate(dynamic data) { _handleOnConfigUpdate(dynamic _) {
ref.read(serverInfoProvider.notifier).getServerFeatures(); ref.read(serverInfoProvider.notifier).getServerFeatures();
ref.read(serverInfoProvider.notifier).getServerConfig(); ref.read(serverInfoProvider.notifier).getServerConfig();
} }
// Refresh updated assets // Refresh updated assets
_handleServerUpdates(dynamic data) { _handleServerUpdates(dynamic _) {
ref.read(assetProvider.notifier).getAllAsset(); ref.read(assetProvider.notifier).getAllAsset();
} }

View File

@ -130,6 +130,7 @@ doc/SystemConfigFFmpegDto.md
doc/SystemConfigJobDto.md doc/SystemConfigJobDto.md
doc/SystemConfigMachineLearningDto.md doc/SystemConfigMachineLearningDto.md
doc/SystemConfigMapDto.md doc/SystemConfigMapDto.md
doc/SystemConfigNewVersionCheckDto.md
doc/SystemConfigOAuthDto.md doc/SystemConfigOAuthDto.md
doc/SystemConfigPasswordLoginDto.md doc/SystemConfigPasswordLoginDto.md
doc/SystemConfigReverseGeocodingDto.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_job_dto.dart
lib/model/system_config_machine_learning_dto.dart lib/model/system_config_machine_learning_dto.dart
lib/model/system_config_map_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_o_auth_dto.dart
lib/model/system_config_password_login_dto.dart lib/model/system_config_password_login_dto.dart
lib/model/system_config_reverse_geocoding_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_job_dto_test.dart
test/system_config_machine_learning_dto_test.dart test/system_config_machine_learning_dto_test.dart
test/system_config_map_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_o_auth_dto_test.dart
test/system_config_password_login_dto_test.dart test/system_config_password_login_dto_test.dart
test/system_config_reverse_geocoding_dto_test.dart test/system_config_reverse_geocoding_dto_test.dart

View File

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

View File

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

View File

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

View File

@ -158,6 +158,7 @@ part 'model/system_config_f_fmpeg_dto.dart';
part 'model/system_config_job_dto.dart'; part 'model/system_config_job_dto.dart';
part 'model/system_config_machine_learning_dto.dart'; part 'model/system_config_machine_learning_dto.dart';
part 'model/system_config_map_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_o_auth_dto.dart';
part 'model/system_config_password_login_dto.dart'; part 'model/system_config_password_login_dto.dart';
part 'model/system_config_reverse_geocoding_dto.dart'; part 'model/system_config_reverse_geocoding_dto.dart';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8048,6 +8048,9 @@
"map": { "map": {
"$ref": "#/components/schemas/SystemConfigMapDto" "$ref": "#/components/schemas/SystemConfigMapDto"
}, },
"newVersionCheck": {
"$ref": "#/components/schemas/SystemConfigNewVersionCheckDto"
},
"oauth": { "oauth": {
"$ref": "#/components/schemas/SystemConfigOAuthDto" "$ref": "#/components/schemas/SystemConfigOAuthDto"
}, },
@ -8074,6 +8077,7 @@
"ffmpeg", "ffmpeg",
"machineLearning", "machineLearning",
"map", "map",
"newVersionCheck",
"oauth", "oauth",
"passwordLogin", "passwordLogin",
"reverseGeocoding", "reverseGeocoding",
@ -8257,6 +8261,17 @@
], ],
"type": "object" "type": "object"
}, },
"SystemConfigNewVersionCheckDto": {
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
],
"type": "object"
},
"SystemConfigOAuthDto": { "SystemConfigOAuthDto": {
"properties": { "properties": {
"autoLaunch": { "autoLaunch": {

View File

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean } from 'class-validator'; import { IsBoolean } from 'class-validator';
import { Optional, ValidateUUID, toBoolean } from '../../domain.util'; import { Optional, toBoolean, ValidateUUID } from '../../domain.util';
export class GetAlbumsDto { export class GetAlbumsDto {
@Optional() @Optional()

View File

@ -1,4 +1,4 @@
import { mimeTypes } from '@app/domain'; import { ServerVersion, mimeTypes } from './domain.constant';
describe('mimeTypes', () => { describe('mimeTypes', () => {
for (const { mimetype, extension } of [ for (const { mimetype, extension } of [
@ -188,7 +188,74 @@ describe('mimeTypes', () => {
for (const [ext, v] of Object.entries(mimeTypes.sidecar)) { for (const [ext, v] of Object.entries(mimeTypes.sidecar)) {
it(`should lookup ${ext}`, () => { 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'; import pkg from 'src/../../package.json';
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
const [major, minor, patch] = pkg.version.split('.');
export interface IServerVersion { export interface IServerVersion {
major: number; major: number;
@ -13,13 +12,49 @@ export interface IServerVersion {
patch: number; patch: number;
} }
export const serverVersion: IServerVersion = { export class ServerVersion implements IServerVersion {
major: Number(major), constructor(
minor: Number(minor), public readonly major: number,
patch: Number(patch), 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'; 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_DISCOVERY]: QueueName.SIDECAR,
[JobName.SIDECAR_SYNC]: QueueName.SIDECAR, [JobName.SIDECAR_SYNC]: QueueName.SIDECAR,
// Library managment // Library management
[JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY,
[JobName.LIBRARY_SCAN]: QueueName.LIBRARY, [JobName.LIBRARY_SCAN]: QueueName.LIBRARY,
[JobName.LIBRARY_DELETE]: QueueName.LIBRARY, [JobName.LIBRARY_DELETE]: QueueName.LIBRARY,

View File

@ -9,9 +9,13 @@ export enum CommunicationEvent {
PERSON_THUMBNAIL = 'on_person_thumbnail', PERSON_THUMBNAIL = 'on_person_thumbnail',
SERVER_VERSION = 'on_server_version', SERVER_VERSION = 'on_server_version',
CONFIG_UPDATE = 'on_config_update', CONFIG_UPDATE = 'on_config_update',
NEW_RELEASE = 'on_new_release',
} }
export type Callback = (userId: string) => Promise<void>;
export interface ICommunicationRepository { export interface ICommunicationRepository {
send(event: CommunicationEvent, userId: string, data: any): void; send(event: CommunicationEvent, userId: string, data: any): void;
broadcast(event: CommunicationEvent, 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 './partner.repository';
export * from './person.repository'; export * from './person.repository';
export * from './search.repository'; export * from './search.repository';
export * from './server-info.repository';
export * from './shared-link.repository'; export * from './shared-link.repository';
export * from './smart-info.repository'; export * from './smart-info.repository';
export * from './storage.repository'; export * from './storage.repository';

View File

@ -85,7 +85,7 @@ export type JobItem =
| { name: JobName.ASSET_DELETION; data: IAssetDeletionJob } | { name: JobName.ASSET_DELETION; data: IAssetDeletionJob }
| { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob }
// Library Managment // Library Management
| { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob } | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob }
| { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob } | { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob }
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } | { 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 { serverVersion } from '../domain.constant';
import { IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories'; import {
ICommunicationRepository,
IServerInfoRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
} from '../repositories';
import { ServerInfoService } from './server-info.service'; import { ServerInfoService } from './server-info.service';
describe(ServerInfoService.name, () => { describe(ServerInfoService.name, () => {
let sut: ServerInfoService; let sut: ServerInfoService;
let communicationMock: jest.Mocked<ICommunicationRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>; let configMock: jest.Mocked<ISystemConfigRepository>;
let serverInfoMock: jest.Mocked<IServerInfoRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>; let userMock: jest.Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
communicationMock = newCommunicationRepositoryMock();
serverInfoMock = newServerInfoRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
sut = new ServerInfoService(configMock, userMock, storageMock); sut = new ServerInfoService(communicationMock, configMock, userMock, serverInfoMock, storageMock);
}); });
it('should work', () => { it('should work', () => {

View File

@ -1,7 +1,16 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { mimeTypes, serverVersion } from '../domain.constant'; import { DateTime } from 'luxon';
import { ServerVersion, isDev, mimeTypes, serverVersion } from '../domain.constant';
import { asHumanReadable } from '../domain.util'; 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 { StorageCore, StorageFolder } from '../storage';
import { SystemConfigCore } from '../system-config'; import { SystemConfigCore } from '../system-config';
import { import {
@ -16,14 +25,20 @@ import {
@Injectable() @Injectable()
export class ServerInfoService { export class ServerInfoService {
private logger = new Logger(ServerInfoService.name);
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
private releaseVersion = serverVersion;
private releaseVersionCheckedAt: DateTime | null = null;
constructor( constructor(
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IServerInfoRepository) private repository: IServerInfoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
) { ) {
this.configCore = SystemConfigCore.create(configRepository); this.configCore = SystemConfigCore.create(configRepository);
this.communicationRepository.addEventListener('connect', (userId) => this.handleConnect(userId));
} }
async getInfo(): Promise<ServerInfoResponseDto> { async getInfo(): Promise<ServerInfoResponseDto> {
@ -101,4 +116,56 @@ export class ServerInfoService {
sidecar: Object.keys(mimeTypes.sidecar), 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 { SystemConfigJobDto } from './system-config-job.dto';
import { SystemConfigMachineLearningDto } from './system-config-machine-learning.dto'; import { SystemConfigMachineLearningDto } from './system-config-machine-learning.dto';
import { SystemConfigMapDto } from './system-config-map.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 { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto'; import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
import { SystemConfigReverseGeocodingDto } from './system-config-reverse-geocoding.dto'; import { SystemConfigReverseGeocodingDto } from './system-config-reverse-geocoding.dto';
@ -29,6 +30,11 @@ export class SystemConfigDto implements SystemConfig {
@IsObject() @IsObject()
map!: SystemConfigMapDto; map!: SystemConfigMapDto;
@Type(() => SystemConfigNewVersionCheckDto)
@ValidateNested()
@IsObject()
newVersionCheck!: SystemConfigNewVersionCheckDto;
@Type(() => SystemConfigOAuthDto) @Type(() => SystemConfigOAuthDto)
@ValidateNested() @ValidateNested()
@IsObject() @IsObject()

View File

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

View File

@ -1,8 +1,8 @@
import { import {
AudioCodec, AudioCodec,
CQMode,
CitiesFile, CitiesFile,
Colorspace, Colorspace,
CQMode,
SystemConfig, SystemConfig,
SystemConfigEntity, SystemConfigEntity,
SystemConfigKey, SystemConfigKey,
@ -15,7 +15,7 @@ import { BadRequestException } from '@nestjs/common';
import { newCommunicationRepositoryMock, newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test'; import { newCommunicationRepositoryMock, newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test';
import { JobName, QueueName } from '../job'; import { JobName, QueueName } from '../job';
import { ICommunicationRepository, IJobRepository, ISystemConfigRepository } from '../repositories'; 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'; import { SystemConfigService } from './system-config.service';
const updates: SystemConfigEntity[] = [ const updates: SystemConfigEntity[] = [
@ -111,6 +111,9 @@ const updatedConfig = Object.freeze<SystemConfig>({
quality: 80, quality: 80,
colorspace: Colorspace.P3, colorspace: Colorspace.P3,
}, },
newVersionCheck: {
enabled: true,
},
trash: { trash: {
enabled: true, enabled: true,
days: 10, 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 { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression, Interval } from '@nestjs/schedule';
@Injectable() @Injectable()
export class AppService { export class AppService {
@ -13,6 +13,11 @@ export class AppService {
private serverService: ServerInfoService, private serverService: ServerInfoService,
) {} ) {}
@Interval(ONE_HOUR.as('milliseconds'))
async onVersionCheck() {
await this.serverService.handleVersionCheck();
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async onNightlyJob() { async onNightlyJob() {
await this.jobService.handleNightlyJobs(); await this.jobService.handleNightlyJobs();
@ -21,6 +26,7 @@ export class AppService {
async init() { async init() {
this.storageService.init(); this.storageService.init();
await this.searchService.init(); await this.searchService.init();
await this.serverService.handleVersionCheck();
this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); 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_HEADER,
IMMICH_API_KEY_NAME, IMMICH_API_KEY_NAME,
ImmichReadStream, ImmichReadStream,
SERVER_VERSION, serverVersion,
} from '@app/domain'; } from '@app/domain';
import { INestApplication, StreamableFile } from '@nestjs/common'; import { INestApplication, StreamableFile } from '@nestjs/common';
import { import {
@ -91,7 +91,7 @@ export const useSwagger = (app: INestApplication, isDev: boolean) => {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('Immich') .setTitle('Immich')
.setDescription('Immich API') .setDescription('Immich API')
.setVersion(SERVER_VERSION) .setVersion(serverVersion.toString())
.addBearerAuth({ .addBearerAuth({
type: 'http', type: 'http',
scheme: 'Bearer', 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 { RedisIoAdapter } from '@app/infra';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
@ -9,9 +9,7 @@ import { AppModule } from './app.module';
import { useSwagger } from './app.utils'; import { useSwagger } from './app.utils';
const logger = new Logger('ImmichServer'); const logger = new Logger('ImmichServer');
const envName = (process.env.NODE_ENV || 'development').toUpperCase();
const port = Number(process.env.SERVER_PORT) || 3001; const port = Number(process.env.SERVER_PORT) || 3001;
const isDev = process.env.NODE_ENV === 'development';
export async function bootstrap() { export async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger: getLogLevels() }); const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger: getLogLevels() });
@ -29,5 +27,5 @@ export async function bootstrap() {
const server = await app.listen(port); const server = await app.listen(port);
server.requestTimeout = 30 * 60 * 1000; 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_ENABLED = 'reverseGeocoding.enabled',
REVERSE_GEOCODING_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride', REVERSE_GEOCODING_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride',
NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled',
OAUTH_ENABLED = 'oauth.enabled', OAUTH_ENABLED = 'oauth.enabled',
OAUTH_ISSUER_URL = 'oauth.issuerUrl', OAUTH_ISSUER_URL = 'oauth.issuerUrl',
OAUTH_CLIENT_ID = 'oauth.clientId', OAUTH_CLIENT_ID = 'oauth.clientId',
@ -219,6 +221,9 @@ export interface SystemConfig {
quality: number; quality: number;
colorspace: Colorspace; colorspace: Colorspace;
}; };
newVersionCheck: {
enabled: boolean;
};
trash: { trash: {
enabled: boolean; enabled: boolean;
days: number; days: number;

View File

@ -15,6 +15,7 @@ import {
IPartnerRepository, IPartnerRepository,
IPersonRepository, IPersonRepository,
ISearchRepository, ISearchRepository,
IServerInfoRepository,
ISharedLinkRepository, ISharedLinkRepository,
ISmartInfoRepository, ISmartInfoRepository,
IStorageRepository, IStorageRepository,
@ -48,6 +49,7 @@ import {
MoveRepository, MoveRepository,
PartnerRepository, PartnerRepository,
PersonRepository, PersonRepository,
ServerInfoRepository,
SharedLinkRepository, SharedLinkRepository,
SmartInfoRepository, SmartInfoRepository,
SystemConfigRepository, SystemConfigRepository,
@ -73,6 +75,7 @@ const providers: Provider[] = [
{ provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: IPersonRepository, useClass: PersonRepository }, { provide: IPersonRepository, useClass: PersonRepository },
{ provide: ISearchRepository, useClass: TypesenseRepository }, { provide: ISearchRepository, useClass: TypesenseRepository },
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISmartInfoRepository, useClass: SmartInfoRepository }, { provide: ISmartInfoRepository, useClass: SmartInfoRepository },
{ provide: IStorageRepository, useClass: FilesystemProvider }, { 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 { Logger } from '@nestjs/common';
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
@ -6,18 +6,25 @@ import { Server, Socket } from 'socket.io';
@WebSocketGateway({ cors: true }) @WebSocketGateway({ cors: true })
export class CommunicationRepository implements OnGatewayConnection, OnGatewayDisconnect, ICommunicationRepository { export class CommunicationRepository implements OnGatewayConnection, OnGatewayDisconnect, ICommunicationRepository {
private logger = new Logger(CommunicationRepository.name); private logger = new Logger(CommunicationRepository.name);
private onConnectCallbacks: Callback[] = [];
constructor(private authService: AuthService) {} constructor(private authService: AuthService) {}
@WebSocketServer() server!: Server; @WebSocketServer() server!: Server;
addEventListener(event: 'connect', callback: Callback) {
this.onConnectCallbacks.push(callback);
}
async handleConnection(client: Socket) { async handleConnection(client: Socket) {
try { try {
this.logger.log(`New websocket connection: ${client.id}`); this.logger.log(`New websocket connection: ${client.id}`);
const user = await this.authService.validate(client.request.headers, {}); const user = await this.authService.validate(client.request.headers, {});
if (user) { if (user) {
await client.join(user.id); await client.join(user.id);
this.send(CommunicationEvent.SERVER_VERSION, user.id, serverVersion); for (const callback of this.onConnectCallbacks) {
await callback(user.id);
}
} else { } else {
client.emit('error', 'unauthorized'); client.emit('error', 'unauthorized');
client.disconnect(); client.disconnect();
@ -34,7 +41,7 @@ export class CommunicationRepository implements OnGatewayConnection, OnGatewayDi
} }
send(event: CommunicationEvent, userId: string, data: any) { 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) { broadcast(event: CommunicationEvent, data: any) {

View File

@ -14,6 +14,7 @@ export * from './metadata.repository';
export * from './move.repository'; export * from './move.repository';
export * from './partner.repository'; export * from './partner.repository';
export * from './person.repository'; export * from './person.repository';
export * from './server-info.repository';
export * from './shared-link.repository'; export * from './shared-link.repository';
export * from './smart-info.repository'; export * from './smart-info.repository';
export * from './system-config.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, MetadataService,
PersonService, PersonService,
SearchService, SearchService,
ServerInfoService,
SmartInfoService, SmartInfoService,
StorageService, StorageService,
StorageTemplateService, StorageTemplateService,
@ -23,19 +24,20 @@ export class AppService {
private logger = new Logger(AppService.name); private logger = new Logger(AppService.name);
constructor( constructor(
private jobService: JobService, private auditService: AuditService,
private assetService: AssetService, private assetService: AssetService,
private jobService: JobService,
private libraryService: LibraryService,
private mediaService: MediaService, private mediaService: MediaService,
private metadataService: MetadataService, private metadataService: MetadataService,
private personService: PersonService, private personService: PersonService,
private searchService: SearchService, private searchService: SearchService,
private serverInfoService: ServerInfoService,
private smartInfoService: SmartInfoService, private smartInfoService: SmartInfoService,
private storageTemplateService: StorageTemplateService, private storageTemplateService: StorageTemplateService,
private storageService: StorageService, private storageService: StorageService,
private systemConfigService: SystemConfigService, private systemConfigService: SystemConfigService,
private userService: UserService, private userService: UserService,
private auditService: AuditService,
private libraryService: LibraryService,
) {} ) {}
async init() { 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 { RedisIoAdapter } from '@app/infra';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
@ -7,7 +7,6 @@ import { MicroservicesModule } from './microservices.module';
const logger = new Logger('ImmichMicroservice'); const logger = new Logger('ImmichMicroservice');
const port = Number(process.env.MICROSERVICES_PORT) || 3002; const port = Number(process.env.MICROSERVICES_PORT) || 3002;
const envName = (process.env.NODE_ENV || 'development').toUpperCase();
export async function bootstrap() { export async function bootstrap() {
const app = await NestFactory.create(MicroservicesModule, { logger: getLogLevels() }); const app = await NestFactory.create(MicroservicesModule, { logger: getLogLevels() });
@ -17,5 +16,5 @@ export async function bootstrap() {
await app.get(AppService).init(); await app.get(AppService).init();
await app.listen(port); 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 { return {
send: jest.fn(), send: jest.fn(),
broadcast: 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 './smart-info.repository.mock';
export * from './storage.repository.mock'; export * from './storage.repository.mock';
export * from './system-config.repository.mock'; export * from './system-config.repository.mock';
export * from './system-info.repository.mock';
export * from './tag.repository.mock'; export * from './tag.repository.mock';
export * from './user-token.repository.mock'; export * from './user-token.repository.mock';
export * from './user.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 * @memberof SystemConfigDto
*/ */
'map': SystemConfigMapDto; 'map': SystemConfigMapDto;
/**
*
* @type {SystemConfigNewVersionCheckDto}
* @memberof SystemConfigDto
*/
'newVersionCheck': SystemConfigNewVersionCheckDto;
/** /**
* *
* @type {SystemConfigOAuthDto} * @type {SystemConfigOAuthDto}
@ -3572,6 +3578,19 @@ export interface SystemConfigMapDto {
*/ */
'tileUrl': string; 'tileUrl': string;
} }
/**
*
* @export
* @interface SystemConfigNewVersionCheckDto
*/
export interface SystemConfigNewVersionCheckDto {
/**
*
* @type {boolean}
* @memberof SystemConfigNewVersionCheckDto
*/
'enabled': boolean;
}
/** /**
* *
* @export * @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"> <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 type { ServerVersionResponseDto } from '@api';
import { websocketStore } from '$lib/stores/websocket';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import FullScreenModal from './full-screen-modal.svelte';
export let serverVersion: ServerVersionResponseDto;
let showModal = false; let showModal = false;
let githubVersion: string;
$: serverVersionName = semverToName(serverVersion);
function semverToName({ major, minor, patch }: ServerVersionResponseDto) { const { onRelease } = websocketStore;
return `v${major}.${minor}.${patch}`;
}
function onAcknowledge() { const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`;
// Store server version to prevent the notification
// from showing again. $: releaseVersion = $onRelease && semverToName($onRelease.releaseVersion);
localStorage.setItem('appVersion', githubVersion); $: serverVersion = $onRelease && semverToName($onRelease.serverVersion);
$: $onRelease?.isAvailable && handleRelease();
const onAcknowledge = () => {
localStorage.setItem('appVersion', releaseVersion);
showModal = false; showModal = false;
} };
onMount(async () => { const handleRelease = () => {
try { try {
githubVersion = await getGithubVersion(); if (localStorage.getItem('appVersion') === releaseVersion) {
if (localStorage.getItem('appVersion') === githubVersion) {
// Updated version has already been acknowledged.
return; return;
} }
if (githubVersion !== serverVersionName) { showModal = true;
showModal = true;
}
} catch (err) { } catch (err) {
// Only log any errors that occur.
console.error('Error [VersionAnnouncementBox]:', err); console.error('Error [VersionAnnouncementBox]:', err);
} }
}); };
</script> </script>
{#if showModal} {#if showModal}
@ -63,9 +55,9 @@
<div class="mt-4 font-medium">Your friend, Alex</div> <div class="mt-4 font-medium">Your friend, Alex</div>
<div class="font-sm mt-8"> <div class="font-sm mt-8">
<code>Server Version: {serverVersionName}</code> <code>Server Version: {serverVersion}</code>
<br /> <br />
<code>Latest Version: {githubVersion}</code> <code>Latest Version: {releaseVersion}</code>
</div> </div>
<div class="mt-8 text-right"> <div class="mt-8 text-right">

View File

@ -3,6 +3,13 @@ import { io } from 'socket.io-client';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { loadConfig } from './server-config.store'; import { loadConfig } from './server-config.store';
export interface ReleaseEvent {
isAvailable: boolean;
checkedAt: Date;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}
export const websocketStore = { export const websocketStore = {
onUploadSuccess: writable<AssetResponseDto>(), onUploadSuccess: writable<AssetResponseDto>(),
onAssetDelete: writable<string>(), onAssetDelete: writable<string>(),
@ -10,6 +17,7 @@ export const websocketStore = {
onPersonThumbnail: writable<string>(), onPersonThumbnail: writable<string>(),
serverVersion: writable<ServerVersionResponseDto>(), serverVersion: writable<ServerVersionResponseDto>(),
connected: writable<boolean>(false), connected: writable<boolean>(false),
onRelease: writable<ReleaseEvent>(),
}; };
export const openWebsocketConnection = () => { export const openWebsocketConnection = () => {
@ -24,12 +32,13 @@ export const openWebsocketConnection = () => {
websocket websocket
.on('connect', () => websocketStore.connected.set(true)) .on('connect', () => websocketStore.connected.set(true))
.on('disconnect', () => websocketStore.connected.set(false)) .on('disconnect', () => websocketStore.connected.set(false))
// .on('on_upload_success', (data) => websocketStore.onUploadSuccess.set(JSON.parse(data) as AssetResponseDto)) // .on('on_upload_success', (data) => websocketStore.onUploadSuccess.set(data))
.on('on_asset_delete', (data) => websocketStore.onAssetDelete.set(JSON.parse(data) as string)) .on('on_asset_delete', (data) => websocketStore.onAssetDelete.set(data))
.on('on_asset_trash', (data) => websocketStore.onAssetTrash.set(JSON.parse(data) as string[])) .on('on_asset_trash', (data) => websocketStore.onAssetTrash.set(data))
.on('on_person_thumbnail', (data) => websocketStore.onPersonThumbnail.set(JSON.parse(data) as string)) .on('on_person_thumbnail', (data) => websocketStore.onPersonThumbnail.set(data))
.on('on_server_version', (data) => websocketStore.serverVersion.set(JSON.parse(data) as ServerVersionResponseDto)) .on('on_server_version', (data) => websocketStore.serverVersion.set(data))
.on('on_config_update', () => loadConfig()) .on('on_config_update', () => loadConfig())
.on('on_new_release', (data) => websocketStore.onRelease.set(data))
.on('error', (e) => console.log('Websocket Error', e)); .on('error', (e) => console.log('Websocket Error', e));
return () => websocket?.close(); 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'; import type { LayoutServerLoad } from './$types';
export const load = (async ({ locals: { api, user } }) => { export const load = (async ({ locals: { user } }) => {
const { data: serverVersion } = await api.serverInfoApi.getServerVersion(); return { user };
return { serverVersion, user };
}) satisfies LayoutServerLoad; }) satisfies LayoutServerLoad;

View File

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

View File

@ -21,6 +21,7 @@
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte'; import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
import Download from 'svelte-material-icons/Download.svelte'; import Download from 'svelte-material-icons/Download.svelte';
import type { PageData } from './$types'; 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; export let data: PageData;
@ -109,6 +110,10 @@
<TrashSettings disabled={$featureFlags.configFile} trashConfig={configs.trash} /> <TrashSettings disabled={$featureFlags.configFile} trashConfig={configs.trash} />
</SettingAccordion> </SettingAccordion>
<SettingAccordion title="Version Check" subtitle="Enable/disable the new version notification">
<NewVersionCheckSettings newVersionCheckConfig={configs.newVersionCheck} />
</SettingAccordion>
<SettingAccordion <SettingAccordion
title="Video Transcoding Settings" title="Video Transcoding Settings"
subtitle="Manage the resolution and encoding information of the video files" subtitle="Manage the resolution and encoding information of the video files"