Explorar o código

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>
martin hai 1 ano
pai
achega
1aae29a0b8
Modificáronse 48 ficheiros con 656 adicións e 100 borrados
  1. 19 0
      cli/src/api/open-api/api.ts
  2. 5 9
      mobile/lib/shared/providers/websocket.provider.dart
  3. 3 0
      mobile/openapi/.openapi-generator/FILES
  4. 1 0
      mobile/openapi/README.md
  5. 1 0
      mobile/openapi/doc/SystemConfigDto.md
  6. 15 0
      mobile/openapi/doc/SystemConfigNewVersionCheckDto.md
  7. 1 0
      mobile/openapi/lib/api.dart
  8. 2 0
      mobile/openapi/lib/api_client.dart
  9. 9 1
      mobile/openapi/lib/model/system_config_dto.dart
  10. 98 0
      mobile/openapi/lib/model/system_config_new_version_check_dto.dart
  11. 5 0
      mobile/openapi/test/system_config_dto_test.dart
  12. 27 0
      mobile/openapi/test/system_config_new_version_check_dto_test.dart
  13. 15 0
      server/immich-openapi-specs.json
  14. 1 1
      server/src/domain/album/dto/get-albums.dto.ts
  15. 69 2
      server/src/domain/domain.constant.spec.ts
  16. 43 8
      server/src/domain/domain.constant.ts
  17. 1 1
      server/src/domain/job/job.constants.ts
  18. 4 0
      server/src/domain/repositories/communication.repository.ts
  19. 1 0
      server/src/domain/repositories/index.ts
  20. 1 1
      server/src/domain/repositories/job.repository.ts
  21. 15 0
      server/src/domain/repositories/server-info.repository.ts
  22. 19 3
      server/src/domain/server-info/server-info.service.spec.ts
  23. 70 3
      server/src/domain/server-info/server-info.service.ts
  24. 6 0
      server/src/domain/system-config/dto/system-config-new-version-check.dto.ts
  25. 6 0
      server/src/domain/system-config/dto/system-config.dto.ts
  26. 4 1
      server/src/domain/system-config/system-config.core.ts
  27. 5 2
      server/src/domain/system-config/system-config.service.spec.ts
  28. 8 2
      server/src/immich/app.service.ts
  29. 2 2
      server/src/immich/app.utils.ts
  30. 2 4
      server/src/immich/main.ts
  31. 5 0
      server/src/infra/entities/system-config.entity.ts
  32. 3 0
      server/src/infra/infra.module.ts
  33. 10 3
      server/src/infra/repositories/communication.repository.ts
  34. 1 0
      server/src/infra/repositories/index.ts
  35. 12 0
      server/src/infra/repositories/server-info.repository.ts
  36. 5 3
      server/src/microservices/app.service.ts
  37. 2 3
      server/src/microservices/main.ts
  38. 1 0
      server/test/repositories/communication.repository.mock.ts
  39. 1 0
      server/test/repositories/index.ts
  40. 7 0
      server/test/repositories/system-info.repository.mock.ts
  41. 19 0
      web/src/api/open-api/api.ts
  42. 92 0
      web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte
  43. 18 26
      web/src/lib/components/shared-components/version-announcement-box.svelte
  44. 14 5
      web/src/lib/stores/websocket.ts
  45. 0 15
      web/src/lib/utils/get-github-version.ts
  46. 2 4
      web/src/routes/+layout.server.ts
  47. 1 1
      web/src/routes/+layout.svelte
  48. 5 0
      web/src/routes/admin/system-settings/+page.svelte

+ 19 - 0
cli/src/api/open-api/api.ts

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

+ 5 - 9
mobile/lib/shared/providers/websocket.provider.dart

@@ -1,5 +1,3 @@
-import 'dart:convert';
-
 import 'package:flutter/foundation.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -175,9 +173,8 @@ class WebsocketNotifier extends StateNotifier<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();
   }
 

+ 3 - 0
mobile/openapi/.openapi-generator/FILES

@@ -130,6 +130,7 @@ doc/SystemConfigFFmpegDto.md
 doc/SystemConfigJobDto.md
 doc/SystemConfigMachineLearningDto.md
 doc/SystemConfigMapDto.md
+doc/SystemConfigNewVersionCheckDto.md
 doc/SystemConfigOAuthDto.md
 doc/SystemConfigPasswordLoginDto.md
 doc/SystemConfigReverseGeocodingDto.md
@@ -298,6 +299,7 @@ lib/model/system_config_f_fmpeg_dto.dart
 lib/model/system_config_job_dto.dart
 lib/model/system_config_machine_learning_dto.dart
 lib/model/system_config_map_dto.dart
+lib/model/system_config_new_version_check_dto.dart
 lib/model/system_config_o_auth_dto.dart
 lib/model/system_config_password_login_dto.dart
 lib/model/system_config_reverse_geocoding_dto.dart
@@ -453,6 +455,7 @@ test/system_config_f_fmpeg_dto_test.dart
 test/system_config_job_dto_test.dart
 test/system_config_machine_learning_dto_test.dart
 test/system_config_map_dto_test.dart
+test/system_config_new_version_check_dto_test.dart
 test/system_config_o_auth_dto_test.dart
 test/system_config_password_login_dto_test.dart
 test/system_config_reverse_geocoding_dto_test.dart

+ 1 - 0
mobile/openapi/README.md

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

+ 1 - 0
mobile/openapi/doc/SystemConfigDto.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) |  | 

+ 15 - 0
mobile/openapi/doc/SystemConfigNewVersionCheckDto.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)
+
+

+ 1 - 0
mobile/openapi/lib/api.dart

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

+ 2 - 0
mobile/openapi/lib/api_client.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':

+ 9 - 1
mobile/openapi/lib/model/system_config_dto.dart

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

+ 98 - 0
mobile/openapi/lib/model/system_config_new_version_check_dto.dart

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

+ 5 - 0
mobile/openapi/test/system_config_dto_test.dart

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

+ 27 - 0
mobile/openapi/test/system_config_new_version_check_dto_test.dart

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

+ 15 - 0
server/immich-openapi-specs.json

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

+ 1 - 1
server/src/domain/album/dto/get-albums.dto.ts

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

+ 69 - 2
server/src/domain/domain.constant.spec.ts

@@ -1,4 +1,4 @@
-import { mimeTypes } from '@app/domain';
+import { ServerVersion, mimeTypes } from './domain.constant';
 
 describe('mimeTypes', () => {
   for (const { mimetype, extension } of [
@@ -188,7 +188,74 @@ describe('mimeTypes', () => {
 
     for (const [ext, v] of Object.entries(mimeTypes.sidecar)) {
       it(`should lookup ${ext}`, () => {
-        expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
+        expect(mimeTypes.lookup(`it.${ext}`)).toEqual(v[0]);
+      });
+    }
+  });
+});
+
+describe('ServerVersion', () => {
+  describe('isNewerThan', () => {
+    it('should work on patch versions', () => {
+      expect(new ServerVersion(0, 0, 1).isNewerThan(new ServerVersion(0, 0, 0))).toBe(true);
+      expect(new ServerVersion(1, 72, 1).isNewerThan(new ServerVersion(1, 72, 0))).toBe(true);
+
+      expect(new ServerVersion(0, 0, 0).isNewerThan(new ServerVersion(0, 0, 1))).toBe(false);
+      expect(new ServerVersion(1, 72, 0).isNewerThan(new ServerVersion(1, 72, 1))).toBe(false);
+    });
+
+    it('should work on minor versions', () => {
+      expect(new ServerVersion(0, 1, 0).isNewerThan(new ServerVersion(0, 0, 0))).toBe(true);
+      expect(new ServerVersion(1, 72, 0).isNewerThan(new ServerVersion(1, 71, 0))).toBe(true);
+      expect(new ServerVersion(1, 72, 0).isNewerThan(new ServerVersion(1, 71, 9))).toBe(true);
+
+      expect(new ServerVersion(0, 0, 0).isNewerThan(new ServerVersion(0, 1, 0))).toBe(false);
+      expect(new ServerVersion(1, 71, 0).isNewerThan(new ServerVersion(1, 72, 0))).toBe(false);
+      expect(new ServerVersion(1, 71, 9).isNewerThan(new ServerVersion(1, 72, 0))).toBe(false);
+    });
+
+    it('should work on major versions', () => {
+      expect(new ServerVersion(1, 0, 0).isNewerThan(new ServerVersion(0, 0, 0))).toBe(true);
+      expect(new ServerVersion(2, 0, 0).isNewerThan(new ServerVersion(1, 71, 0))).toBe(true);
+
+      expect(new ServerVersion(0, 0, 0).isNewerThan(new ServerVersion(1, 0, 0))).toBe(false);
+      expect(new ServerVersion(1, 71, 0).isNewerThan(new ServerVersion(2, 0, 0))).toBe(false);
+    });
+
+    it('should work on equal', () => {
+      for (const version of [
+        new ServerVersion(0, 0, 0),
+        new ServerVersion(0, 0, 1),
+        new ServerVersion(0, 1, 1),
+        new ServerVersion(0, 1, 0),
+        new ServerVersion(1, 1, 1),
+        new ServerVersion(1, 0, 0),
+        new ServerVersion(1, 72, 1),
+        new ServerVersion(1, 72, 0),
+        new ServerVersion(1, 73, 9),
+      ]) {
+        expect(version.isNewerThan(version)).toBe(false);
+      }
+    });
+  });
+
+  describe('fromString', () => {
+    const tests = [
+      { scenario: 'leading v', value: 'v1.72.2', expected: new ServerVersion(1, 72, 2) },
+      { scenario: 'uppercase v', value: 'V1.72.2', expected: new ServerVersion(1, 72, 2) },
+      { scenario: 'missing v', value: '1.72.2', expected: new ServerVersion(1, 72, 2) },
+      { scenario: 'large patch', value: '1.72.123', expected: new ServerVersion(1, 72, 123) },
+      { scenario: 'large minor', value: '1.123.0', expected: new ServerVersion(1, 123, 0) },
+      { scenario: 'large major', value: '123.0.0', expected: new ServerVersion(123, 0, 0) },
+      { scenario: 'major bump', value: 'v2.0.0', expected: new ServerVersion(2, 0, 0) },
+    ];
+
+    for (const { scenario, value, expected } of tests) {
+      it(`should correctly parse ${scenario}`, () => {
+        const actual = ServerVersion.fromString(value);
+        expect(actual.major).toEqual(expected.major);
+        expect(actual.minor).toEqual(expected.minor);
+        expect(actual.patch).toEqual(expected.patch);
       });
     }
   });

+ 43 - 8
server/src/domain/domain.constant.ts

@@ -4,8 +4,7 @@ import { extname } from 'node:path';
 import pkg from 'src/../../package.json';
 
 export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
-
-const [major, minor, patch] = pkg.version.split('.');
+export const ONE_HOUR = Duration.fromObject({ hours: 1 });
 
 export interface IServerVersion {
   major: number;
@@ -13,13 +12,49 @@ export interface IServerVersion {
   patch: number;
 }
 
-export const serverVersion: IServerVersion = {
-  major: Number(major),
-  minor: Number(minor),
-  patch: Number(patch),
-};
+export class ServerVersion implements IServerVersion {
+  constructor(
+    public readonly major: number,
+    public readonly minor: number,
+    public readonly patch: number,
+  ) {}
+
+  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';
 

+ 1 - 1
server/src/domain/job/job.constants.ts

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

+ 4 - 0
server/src/domain/repositories/communication.repository.ts

@@ -9,9 +9,13 @@ export enum CommunicationEvent {
   PERSON_THUMBNAIL = 'on_person_thumbnail',
   SERVER_VERSION = 'on_server_version',
   CONFIG_UPDATE = 'on_config_update',
+  NEW_RELEASE = 'on_new_release',
 }
 
+export type Callback = (userId: string) => Promise<void>;
+
 export interface ICommunicationRepository {
   send(event: CommunicationEvent, userId: string, data: any): void;
   broadcast(event: CommunicationEvent, data: any): void;
+  addEventListener(event: 'connect', callback: Callback): void;
 }

+ 1 - 0
server/src/domain/repositories/index.ts

@@ -14,6 +14,7 @@ export * from './move.repository';
 export * from './partner.repository';
 export * from './person.repository';
 export * from './search.repository';
+export * from './server-info.repository';
 export * from './shared-link.repository';
 export * from './smart-info.repository';
 export * from './storage.repository';

+ 1 - 1
server/src/domain/repositories/job.repository.ts

@@ -85,7 +85,7 @@ export type JobItem =
   | { name: JobName.ASSET_DELETION; data: IAssetDeletionJob }
   | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob }
 
-  // Library Managment
+  // Library Management
   | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob }
   | { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob }
   | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }

+ 15 - 0
server/src/domain/repositories/server-info.repository.ts

@@ -0,0 +1,15 @@
+export interface GitHubRelease {
+  id: number;
+  url: string;
+  tag_name: string;
+  name: string;
+  created_at: string;
+  published_at: string;
+  body: string;
+}
+
+export const IServerInfoRepository = 'IServerInfoRepository';
+
+export interface IServerInfoRepository {
+  getGitHubRelease(): Promise<GitHubRelease>;
+}

+ 19 - 3
server/src/domain/server-info/server-info.service.spec.ts

@@ -1,20 +1,36 @@
-import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock } from '@test';
+import {
+  newCommunicationRepositoryMock,
+  newServerInfoRepositoryMock,
+  newStorageRepositoryMock,
+  newSystemConfigRepositoryMock,
+  newUserRepositoryMock,
+} from '@test';
 import { serverVersion } from '../domain.constant';
-import { IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories';
+import {
+  ICommunicationRepository,
+  IServerInfoRepository,
+  IStorageRepository,
+  ISystemConfigRepository,
+  IUserRepository,
+} from '../repositories';
 import { ServerInfoService } from './server-info.service';
 
 describe(ServerInfoService.name, () => {
   let sut: ServerInfoService;
+  let communicationMock: jest.Mocked<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', () => {

+ 70 - 3
server/src/domain/server-info/server-info.service.ts

@@ -1,7 +1,16 @@
-import { Inject, Injectable } from '@nestjs/common';
-import { mimeTypes, serverVersion } from '../domain.constant';
+import { Inject, Injectable, Logger } from '@nestjs/common';
+import { DateTime } from 'luxon';
+import { ServerVersion, isDev, mimeTypes, serverVersion } from '../domain.constant';
 import { asHumanReadable } from '../domain.util';
-import { IStorageRepository, ISystemConfigRepository, IUserRepository, UserStatsQueryResponse } from '../repositories';
+import {
+  CommunicationEvent,
+  ICommunicationRepository,
+  IServerInfoRepository,
+  IStorageRepository,
+  ISystemConfigRepository,
+  IUserRepository,
+  UserStatsQueryResponse,
+} from '../repositories';
 import { StorageCore, StorageFolder } from '../storage';
 import { SystemConfigCore } from '../system-config';
 import {
@@ -16,14 +25,20 @@ import {
 
 @Injectable()
 export class ServerInfoService {
+  private logger = new Logger(ServerInfoService.name);
   private configCore: SystemConfigCore;
+  private releaseVersion = serverVersion;
+  private releaseVersionCheckedAt: DateTime | null = null;
 
   constructor(
+    @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
     @Inject(IUserRepository) private userRepository: IUserRepository,
+    @Inject(IServerInfoRepository) private repository: IServerInfoRepository,
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
   ) {
     this.configCore = SystemConfigCore.create(configRepository);
+    this.communicationRepository.addEventListener('connect', (userId) => this.handleConnect(userId));
   }
 
   async getInfo(): Promise<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);
+  }
 }

+ 6 - 0
server/src/domain/system-config/dto/system-config-new-version-check.dto.ts

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

+ 6 - 0
server/src/domain/system-config/dto/system-config.dto.ts

@@ -5,6 +5,7 @@ import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
 import { SystemConfigJobDto } from './system-config-job.dto';
 import { SystemConfigMachineLearningDto } from './system-config-machine-learning.dto';
 import { SystemConfigMapDto } from './system-config-map.dto';
+import { SystemConfigNewVersionCheckDto } from './system-config-new-version-check.dto';
 import { SystemConfigOAuthDto } from './system-config-oauth.dto';
 import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
 import { SystemConfigReverseGeocodingDto } from './system-config-reverse-geocoding.dto';
@@ -29,6 +30,11 @@ export class SystemConfigDto implements SystemConfig {
   @IsObject()
   map!: SystemConfigMapDto;
 
+  @Type(() => SystemConfigNewVersionCheckDto)
+  @ValidateNested()
+  @IsObject()
+  newVersionCheck!: SystemConfigNewVersionCheckDto;
+
   @Type(() => SystemConfigOAuthDto)
   @ValidateNested()
   @IsObject()

+ 4 - 1
server/src/domain/system-config/system-config.core.ts

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

+ 5 - 2
server/src/domain/system-config/system-config.service.spec.ts

@@ -1,8 +1,8 @@
 import {
   AudioCodec,
-  CQMode,
   CitiesFile,
   Colorspace,
+  CQMode,
   SystemConfig,
   SystemConfigEntity,
   SystemConfigKey,
@@ -15,7 +15,7 @@ import { BadRequestException } from '@nestjs/common';
 import { newCommunicationRepositoryMock, newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test';
 import { JobName, QueueName } from '../job';
 import { ICommunicationRepository, IJobRepository, ISystemConfigRepository } from '../repositories';
-import { SystemConfigValidator, defaults } from './system-config.core';
+import { defaults, SystemConfigValidator } from './system-config.core';
 import { SystemConfigService } from './system-config.service';
 
 const updates: SystemConfigEntity[] = [
@@ -111,6 +111,9 @@ const updatedConfig = Object.freeze<SystemConfig>({
     quality: 80,
     colorspace: Colorspace.P3,
   },
+  newVersionCheck: {
+    enabled: true,
+  },
   trash: {
     enabled: true,
     days: 10,

+ 8 - 2
server/src/immich/app.service.ts

@@ -1,6 +1,6 @@
-import { JobService, SearchService, ServerInfoService, StorageService } from '@app/domain';
+import { JobService, ONE_HOUR, SearchService, ServerInfoService, StorageService } from '@app/domain';
 import { Injectable, Logger } from '@nestjs/common';
-import { Cron, CronExpression } from '@nestjs/schedule';
+import { Cron, CronExpression, Interval } from '@nestjs/schedule';
 
 @Injectable()
 export class AppService {
@@ -13,6 +13,11 @@ export class AppService {
     private serverService: ServerInfoService,
   ) {}
 
+  @Interval(ONE_HOUR.as('milliseconds'))
+  async onVersionCheck() {
+    await this.serverService.handleVersionCheck();
+  }
+
   @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
   async onNightlyJob() {
     await this.jobService.handleNightlyJobs();
@@ -21,6 +26,7 @@ export class AppService {
   async init() {
     this.storageService.init();
     await this.searchService.init();
+    await this.serverService.handleVersionCheck();
     this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`);
   }
 

+ 2 - 2
server/src/immich/app.utils.ts

@@ -3,7 +3,7 @@ import {
   IMMICH_API_KEY_HEADER,
   IMMICH_API_KEY_NAME,
   ImmichReadStream,
-  SERVER_VERSION,
+  serverVersion,
 } from '@app/domain';
 import { INestApplication, StreamableFile } from '@nestjs/common';
 import {
@@ -91,7 +91,7 @@ export const useSwagger = (app: INestApplication, isDev: boolean) => {
   const config = new DocumentBuilder()
     .setTitle('Immich')
     .setDescription('Immich API')
-    .setVersion(SERVER_VERSION)
+    .setVersion(serverVersion.toString())
     .addBearerAuth({
       type: 'http',
       scheme: 'Bearer',

+ 2 - 4
server/src/immich/main.ts

@@ -1,4 +1,4 @@
-import { getLogLevels, SERVER_VERSION } from '@app/domain';
+import { envName, getLogLevels, isDev, serverVersion } from '@app/domain';
 import { RedisIoAdapter } from '@app/infra';
 import { Logger } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
@@ -9,9 +9,7 @@ import { AppModule } from './app.module';
 import { useSwagger } from './app.utils';
 
 const logger = new Logger('ImmichServer');
-const envName = (process.env.NODE_ENV || 'development').toUpperCase();
 const port = Number(process.env.SERVER_PORT) || 3001;
-const isDev = process.env.NODE_ENV === 'development';
 
 export async function bootstrap() {
   const app = await NestFactory.create<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}] `);
 }

+ 5 - 0
server/src/infra/entities/system-config.entity.ts

@@ -67,6 +67,8 @@ export enum SystemConfigKey {
   REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled',
   REVERSE_GEOCODING_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride',
 
+  NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled',
+
   OAUTH_ENABLED = 'oauth.enabled',
   OAUTH_ISSUER_URL = 'oauth.issuerUrl',
   OAUTH_CLIENT_ID = 'oauth.clientId',
@@ -219,6 +221,9 @@ export interface SystemConfig {
     quality: number;
     colorspace: Colorspace;
   };
+  newVersionCheck: {
+    enabled: boolean;
+  };
   trash: {
     enabled: boolean;
     days: number;

+ 3 - 0
server/src/infra/infra.module.ts

@@ -15,6 +15,7 @@ import {
   IPartnerRepository,
   IPersonRepository,
   ISearchRepository,
+  IServerInfoRepository,
   ISharedLinkRepository,
   ISmartInfoRepository,
   IStorageRepository,
@@ -48,6 +49,7 @@ import {
   MoveRepository,
   PartnerRepository,
   PersonRepository,
+  ServerInfoRepository,
   SharedLinkRepository,
   SmartInfoRepository,
   SystemConfigRepository,
@@ -73,6 +75,7 @@ const providers: Provider[] = [
   { provide: IPartnerRepository, useClass: PartnerRepository },
   { provide: IPersonRepository, useClass: PersonRepository },
   { provide: ISearchRepository, useClass: TypesenseRepository },
+  { provide: IServerInfoRepository, useClass: ServerInfoRepository },
   { provide: ISharedLinkRepository, useClass: SharedLinkRepository },
   { provide: ISmartInfoRepository, useClass: SmartInfoRepository },
   { provide: IStorageRepository, useClass: FilesystemProvider },

+ 10 - 3
server/src/infra/repositories/communication.repository.ts

@@ -1,4 +1,4 @@
-import { AuthService, CommunicationEvent, ICommunicationRepository, serverVersion } from '@app/domain';
+import { AuthService, Callback, CommunicationEvent, ICommunicationRepository } from '@app/domain';
 import { Logger } from '@nestjs/common';
 import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
 import { Server, Socket } from 'socket.io';
@@ -6,18 +6,25 @@ import { Server, Socket } from 'socket.io';
 @WebSocketGateway({ cors: true })
 export class CommunicationRepository implements OnGatewayConnection, OnGatewayDisconnect, ICommunicationRepository {
   private logger = new Logger(CommunicationRepository.name);
+  private onConnectCallbacks: Callback[] = [];
 
   constructor(private authService: AuthService) {}
 
   @WebSocketServer() server!: Server;
 
+  addEventListener(event: 'connect', callback: Callback) {
+    this.onConnectCallbacks.push(callback);
+  }
+
   async handleConnection(client: Socket) {
     try {
       this.logger.log(`New websocket connection: ${client.id}`);
       const user = await this.authService.validate(client.request.headers, {});
       if (user) {
         await client.join(user.id);
-        this.send(CommunicationEvent.SERVER_VERSION, user.id, serverVersion);
+        for (const callback of this.onConnectCallbacks) {
+          await callback(user.id);
+        }
       } else {
         client.emit('error', 'unauthorized');
         client.disconnect();
@@ -34,7 +41,7 @@ export class CommunicationRepository implements OnGatewayConnection, OnGatewayDi
   }
 
   send(event: CommunicationEvent, userId: string, data: any) {
-    this.server.to(userId).emit(event, JSON.stringify(data));
+    this.server.to(userId).emit(event, data);
   }
 
   broadcast(event: CommunicationEvent, data: any) {

+ 1 - 0
server/src/infra/repositories/index.ts

@@ -14,6 +14,7 @@ export * from './metadata.repository';
 export * from './move.repository';
 export * from './partner.repository';
 export * from './person.repository';
+export * from './server-info.repository';
 export * from './shared-link.repository';
 export * from './smart-info.repository';
 export * from './system-config.repository';

+ 12 - 0
server/src/infra/repositories/server-info.repository.ts

@@ -0,0 +1,12 @@
+import { GitHubRelease, IServerInfoRepository } from '@app/domain';
+import { Injectable } from '@nestjs/common';
+import axios from 'axios';
+
+@Injectable()
+export class ServerInfoRepository implements IServerInfoRepository {
+  getGitHubRelease(): Promise<GitHubRelease> {
+    return axios
+      .get<GitHubRelease>('https://api.github.com/repos/immich-app/immich/releases/latest')
+      .then((response) => response.data);
+  }
+}

+ 5 - 3
server/src/microservices/app.service.ts

@@ -9,6 +9,7 @@ import {
   MetadataService,
   PersonService,
   SearchService,
+  ServerInfoService,
   SmartInfoService,
   StorageService,
   StorageTemplateService,
@@ -23,19 +24,20 @@ export class AppService {
   private logger = new Logger(AppService.name);
 
   constructor(
-    private jobService: JobService,
+    private auditService: AuditService,
     private assetService: AssetService,
+    private jobService: JobService,
+    private libraryService: LibraryService,
     private mediaService: MediaService,
     private metadataService: MetadataService,
     private personService: PersonService,
     private searchService: SearchService,
+    private serverInfoService: ServerInfoService,
     private smartInfoService: SmartInfoService,
     private storageTemplateService: StorageTemplateService,
     private storageService: StorageService,
     private systemConfigService: SystemConfigService,
     private userService: UserService,
-    private auditService: AuditService,
-    private libraryService: LibraryService,
   ) {}
 
   async init() {

+ 2 - 3
server/src/microservices/main.ts

@@ -1,4 +1,4 @@
-import { getLogLevels, SERVER_VERSION } from '@app/domain';
+import { envName, getLogLevels, serverVersion } from '@app/domain';
 import { RedisIoAdapter } from '@app/infra';
 import { Logger } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
@@ -7,7 +7,6 @@ import { MicroservicesModule } from './microservices.module';
 
 const logger = new Logger('ImmichMicroservice');
 const port = Number(process.env.MICROSERVICES_PORT) || 3002;
-const envName = (process.env.NODE_ENV || 'development').toUpperCase();
 
 export async function bootstrap() {
   const app = await NestFactory.create(MicroservicesModule, { logger: getLogLevels() });
@@ -17,5 +16,5 @@ export async function bootstrap() {
   await app.get(AppService).init();
   await app.listen(port);
 
-  logger.log(`Immich Microservices is listening on ${await app.getUrl()} [v${SERVER_VERSION}] [${envName}] `);
+  logger.log(`Immich Microservices is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `);
 }

+ 1 - 0
server/test/repositories/communication.repository.mock.ts

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

+ 1 - 0
server/test/repositories/index.ts

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

+ 7 - 0
server/test/repositories/system-info.repository.mock.ts

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

+ 19 - 0
web/src/api/open-api/api.ts

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

+ 92 - 0
web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte

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

+ 18 - 26
web/src/lib/components/shared-components/version-announcement-box.svelte

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

+ 14 - 5
web/src/lib/stores/websocket.ts

@@ -3,6 +3,13 @@ import { io } from 'socket.io-client';
 import { writable } from 'svelte/store';
 import { loadConfig } from './server-config.store';
 
+export interface ReleaseEvent {
+  isAvailable: boolean;
+  checkedAt: Date;
+  serverVersion: ServerVersionResponseDto;
+  releaseVersion: ServerVersionResponseDto;
+}
+
 export const websocketStore = {
   onUploadSuccess: writable<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();

+ 0 - 15
web/src/lib/utils/get-github-version.ts

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

+ 2 - 4
web/src/routes/+layout.server.ts

@@ -1,7 +1,5 @@
 import type { LayoutServerLoad } from './$types';
 
-export const load = (async ({ locals: { api, user } }) => {
-  const { data: serverVersion } = await api.serverInfoApi.getServerVersion();
-
-  return { serverVersion, user };
+export const load = (async ({ locals: { user } }) => {
+  return { user };
 }) satisfies LayoutServerLoad;

+ 1 - 1
web/src/routes/+layout.svelte

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

+ 5 - 0
web/src/routes/admin/system-settings/+page.svelte

@@ -21,6 +21,7 @@
   import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
   import Download from 'svelte-material-icons/Download.svelte';
   import type { PageData } from './$types';
+  import NewVersionCheckSettings from '$lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte';
 
   export let data: PageData;
 
@@ -109,6 +110,10 @@
         <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"