From c8a1d0e400ca9ecd5ba3b309c1c8918f20290aa5 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:10:07 +0200 Subject: [PATCH] feat: release candidate support (#28665) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- e2e/src/specs/server/api/server.e2e-spec.ts | 1 + .../server/api/system-config.e2e-spec.ts | 8 +- i18n/en.json | 4 + .../server_info/server_version.model.dart | 14 +- mobile/lib/utils/semver.dart | 72 +++++++--- .../app_bar_dialog/app_bar_server_info.dart | 8 +- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 3 + mobile/openapi/lib/api_client.dart | 6 + mobile/openapi/lib/api_helper.dart | 6 + mobile/openapi/lib/model/release_channel.dart | 85 +++++++++++ .../openapi/lib/model/release_event_v1.dart | 133 ++++++++++++++++++ mobile/openapi/lib/model/release_type.dart | 100 +++++++++++++ .../model/server_version_response_dto.dart | 28 +++- .../system_config_new_version_check_dto.dart | 10 +- .../services/sync_stream_service_test.dart | 8 +- mobile/test/unit/utils/semver_test.dart | 66 +++++++++ open-api/immich-openapi-specs.json | 77 +++++++++- packages/sdk/src/fetch-client.ts | 26 ++++ pnpm-lock.yaml | 47 ++++--- server/package.json | 2 +- server/src/config.ts | 3 + server/src/decorators.ts | 10 ++ server/src/dtos/server.dto.ts | 49 +++++-- server/src/dtos/sync.dto.ts | 11 +- server/src/dtos/system-config.dto.ts | 9 +- .../repositories/server-info.repository.ts | 7 +- .../src/repositories/websocket.repository.ts | 4 +- .../services/system-config.service.spec.ts | 2 + server/src/services/version.service.spec.ts | 52 +++++-- server/src/services/version.service.ts | 35 +++-- server/src/utils/misc.ts | 4 +- .../side-bar/ServerStatus.svelte | 8 +- web/src/lib/managers/event-manager.svelte.ts | 4 +- .../lib/managers/release-manager.svelte.ts | 4 +- web/src/lib/stores/websocket.ts | 4 +- web/src/lib/types.ts | 10 +- web/src/lib/utils.spec.ts | 24 +--- web/src/lib/utils.ts | 22 +-- web/src/routes/VersionAnnouncement.svelte | 14 +- .../NewVersionCheckSettings.svelte | 17 +++ 41 files changed, 809 insertions(+), 191 deletions(-) create mode 100644 mobile/openapi/lib/model/release_channel.dart create mode 100644 mobile/openapi/lib/model/release_event_v1.dart create mode 100644 mobile/openapi/lib/model/release_type.dart diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index 1220e6cab5..f15d450213 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -95,6 +95,7 @@ describe('/server', () => { major: expect.any(Number), minor: expect.any(Number), patch: expect.any(Number), + prerelease: null, }); }); }); diff --git a/e2e/src/specs/server/api/system-config.e2e-spec.ts b/e2e/src/specs/server/api/system-config.e2e-spec.ts index 1bd7bdc489..91b747cf28 100644 --- a/e2e/src/specs/server/api/system-config.e2e-spec.ts +++ b/e2e/src/specs/server/api/system-config.e2e-spec.ts @@ -21,18 +21,18 @@ describe('/system-config', () => { const response1 = await request(app) .put('/system-config') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ...config, newVersionCheck: { enabled: false } }); + .send({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } }); expect(response1.status).toBe(200); - expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false } }); + expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } }); const response2 = await request(app) .put('/system-config') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ...config, newVersionCheck: { enabled: true } }); + .send({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } }); expect(response2.status).toBe(200); - expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true } }); + expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } }); }); it('should reject an invalid config entry', async () => { diff --git a/i18n/en.json b/i18n/en.json index dba0caf393..0c3c1aa5e0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -305,6 +305,8 @@ "refreshing_all_libraries": "Refreshing all libraries", "registration": "Admin Registration", "registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.", + "release_channel_release_candidate": "Release candidate", + "release_channel_stable": "Stable", "remove_failed_jobs": "Remove failed jobs", "require_password_change_on_login": "Require user to change password on first login", "reset_settings_to_default": "Reset settings to default", @@ -442,6 +444,8 @@ "user_settings_description": "Manage user settings", "user_successfully_removed": "User {email} has been successfully removed.", "users_page_description": "Admin users page", + "version_check_channel": "Release channel", + "version_check_channel_description": "Pick the release channel you want to get version announcements for", "version_check_enabled_description": "Enable version check", "version_check_implications": "The version check feature relies on periodic communication with {server}", "version_check_settings": "Version Check", diff --git a/mobile/lib/models/server_info/server_version.model.dart b/mobile/lib/models/server_info/server_version.model.dart index c8bf73db81..40f35a3cd0 100644 --- a/mobile/lib/models/server_info/server_version.model.dart +++ b/mobile/lib/models/server_info/server_version.model.dart @@ -2,16 +2,12 @@ import 'package:immich_mobile/utils/semver.dart'; import 'package:openapi/api.dart'; class ServerVersion extends SemVer { - const ServerVersion({required super.major, required super.minor, required super.patch}); + const ServerVersion({required super.major, required super.minor, required super.patch, super.prerelease}); - @override - String toString() { - return 'ServerVersion(major: $major, minor: $minor, patch: $patch)'; - } + ServerVersion.fromDto(ServerVersionResponseDto dto) + : super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease); - ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_); - - bool isAtLeast({int major = 0, int minor = 0, int patch = 0}) { - return this >= SemVer(major: major, minor: minor, patch: patch); + bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) { + return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease); } } diff --git a/mobile/lib/utils/semver.dart b/mobile/lib/utils/semver.dart index 06b186daa3..8080af00b0 100644 --- a/mobile/lib/utils/semver.dart +++ b/mobile/lib/utils/semver.dart @@ -1,36 +1,42 @@ -enum SemVerType { major, minor, patch } +enum SemVerType { major, minor, patch, prerelease } class SemVer { final int major; final int minor; final int patch; + final int? prerelease; - const SemVer({required this.major, required this.minor, required this.patch}); + const SemVer({required this.major, required this.minor, required this.patch, this.prerelease}); @override String toString() { - return '$major.$minor.$patch'; + return '$major.$minor.$patch${prerelease == null ? '' : '-rc.$prerelease'}'; } - SemVer copyWith({int? major, int? minor, int? patch}) { - return SemVer(major: major ?? this.major, minor: minor ?? this.minor, patch: patch ?? this.patch); + SemVer copyWith({int? major, int? minor, int? patch, int? prerelease}) { + return SemVer( + major: major ?? this.major, + minor: minor ?? this.minor, + patch: patch ?? this.patch, + prerelease: prerelease ?? this.prerelease, + ); } + static final _pattern = RegExp(r'^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?(?:[-+].*)?$', caseSensitive: false); + factory SemVer.fromString(String version) { - if (version.toLowerCase().startsWith("v")) { - version = version.substring(1); - } - - final parts = version.split("-")[0].split('.'); - if (parts.length != 3) { + final match = _pattern.firstMatch(version); + if (match == null) { throw FormatException('Invalid semantic version string: $version'); } - try { - return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2])); - } catch (e) { - throw FormatException('Invalid semantic version string: $version'); - } + final prerelease = match.group(4); + return SemVer( + major: int.parse(match.group(1)!), + minor: int.parse(match.group(2)!), + patch: int.parse(match.group(3)!), + prerelease: prerelease == null ? null : int.parse(prerelease), + ); } bool operator >(SemVer other) { @@ -40,7 +46,10 @@ class SemVer { if (minor != other.minor) { return minor > other.minor; } - return patch > other.patch; + if (patch != other.patch) { + return patch > other.patch; + } + return _comparePrerelease(other) > 0; } bool operator <(SemVer other) { @@ -50,7 +59,23 @@ class SemVer { if (minor != other.minor) { return minor < other.minor; } - return patch < other.patch; + if (patch != other.patch) { + return patch < other.patch; + } + return _comparePrerelease(other) < 0; + } + + int _comparePrerelease(SemVer other) { + if (prerelease == other.prerelease) { + return 0; + } + if (prerelease == null) { + return 1; + } + if (other.prerelease == null) { + return -1; + } + return prerelease!.compareTo(other.prerelease!); } bool operator >=(SemVer other) { @@ -67,7 +92,11 @@ class SemVer { return true; } - return other is SemVer && other.major == major && other.minor == minor && other.patch == patch; + return other is SemVer && + other.major == major && + other.minor == minor && + other.patch == patch && + other.prerelease == prerelease; } SemVerType? differenceType(SemVer other) { @@ -80,10 +109,13 @@ class SemVer { if (patch != other.patch) { return SemVerType.patch; } + if (prerelease != other.prerelease) { + return SemVerType.prerelease; + } return null; } @override - int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode; + int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode ^ prerelease.hashCode; } diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart index 2809505c58..a209d280c3 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart @@ -50,9 +50,7 @@ class AppBarServerInfo extends HookConsumerWidget { divider, _ServerInfoItem( label: "server_version".tr(), - text: serverInfoState.serverVersion.major > 0 - ? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}" - : "--", + text: serverInfoState.serverVersion.major > 0 ? "${serverInfoState.serverVersion}" : "--", ), divider, _ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true), @@ -60,9 +58,7 @@ class AppBarServerInfo extends HookConsumerWidget { divider, _ServerInfoItem( label: "latest_version".tr(), - text: serverInfoState.latestVersion!.major > 0 - ? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}" - : "--", + text: serverInfoState.latestVersion!.major > 0 ? "${serverInfoState.latestVersion!}" : "--", tooltip: true, icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate ? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 23987073dd..2727825df7 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -513,6 +513,9 @@ Class | Method | HTTP request | Description - [RatingsUpdate](doc//RatingsUpdate.md) - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) + - [ReleaseChannel](doc//ReleaseChannel.md) + - [ReleaseEventV1](doc//ReleaseEventV1.md) + - [ReleaseType](doc//ReleaseType.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) - [RotateParameters](doc//RotateParameters.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d5a6f483dc..cdba19bcff 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -258,6 +258,9 @@ part 'model/ratings_response.dart'; part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; +part 'model/release_channel.dart'; +part 'model/release_event_v1.dart'; +part 'model/release_type.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; part 'model/rotate_parameters.dart'; part 'model/search_album_response_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3145b7faf4..14a29e9681 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -563,6 +563,12 @@ class ApiClient { return ReactionLevelTypeTransformer().decode(value); case 'ReactionType': return ReactionTypeTypeTransformer().decode(value); + case 'ReleaseChannel': + return ReleaseChannelTypeTransformer().decode(value); + case 'ReleaseEventV1': + return ReleaseEventV1.fromJson(value); + case 'ReleaseType': + return ReleaseTypeTypeTransformer().decode(value); case 'ReverseGeocodingStateResponseDto': return ReverseGeocodingStateResponseDto.fromJson(value); case 'RotateParameters': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index b5d348edd6..6cf11022c3 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -157,6 +157,12 @@ String parameterToString(dynamic value) { if (value is ReactionType) { return ReactionTypeTypeTransformer().encode(value).toString(); } + if (value is ReleaseChannel) { + return ReleaseChannelTypeTransformer().encode(value).toString(); + } + if (value is ReleaseType) { + return ReleaseTypeTypeTransformer().encode(value).toString(); + } if (value is SearchSuggestionType) { return SearchSuggestionTypeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/release_channel.dart b/mobile/openapi/lib/model/release_channel.dart new file mode 100644 index 0000000000..48b082af07 --- /dev/null +++ b/mobile/openapi/lib/model/release_channel.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// 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; + +/// Release channel +class ReleaseChannel { + /// Instantiate a new enum with the provided [value]. + const ReleaseChannel._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const stable = ReleaseChannel._(r'stable'); + static const releaseCandidate = ReleaseChannel._(r'releaseCandidate'); + + /// List of all possible values in this [enum][ReleaseChannel]. + static const values = [ + stable, + releaseCandidate, + ]; + + static ReleaseChannel? fromJson(dynamic value) => ReleaseChannelTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ReleaseChannel.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ReleaseChannel] to String, +/// and [decode] dynamic data back to [ReleaseChannel]. +class ReleaseChannelTypeTransformer { + factory ReleaseChannelTypeTransformer() => _instance ??= const ReleaseChannelTypeTransformer._(); + + const ReleaseChannelTypeTransformer._(); + + String encode(ReleaseChannel data) => data.value; + + /// Decodes a [dynamic value][data] to a ReleaseChannel. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ReleaseChannel? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'stable': return ReleaseChannel.stable; + case r'releaseCandidate': return ReleaseChannel.releaseCandidate; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ReleaseChannelTypeTransformer] instance. + static ReleaseChannelTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/release_event_v1.dart b/mobile/openapi/lib/model/release_event_v1.dart new file mode 100644 index 0000000000..f26ae3e96e --- /dev/null +++ b/mobile/openapi/lib/model/release_event_v1.dart @@ -0,0 +1,133 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// 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 ReleaseEventV1 { + /// Returns a new [ReleaseEventV1] instance. + ReleaseEventV1({ + required this.checkedAt, + required this.isAvailable, + required this.releaseVersion, + required this.serverVersion, + required this.type, + }); + + /// When the server last checked for a latest version. As an ISO timestamp + String checkedAt; + + /// Whether a new version is available + bool isAvailable; + + ServerVersionResponseDto releaseVersion; + + ServerVersionResponseDto serverVersion; + + ReleaseType type; + + @override + bool operator ==(Object other) => identical(this, other) || other is ReleaseEventV1 && + other.checkedAt == checkedAt && + other.isAvailable == isAvailable && + other.releaseVersion == releaseVersion && + other.serverVersion == serverVersion && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (checkedAt.hashCode) + + (isAvailable.hashCode) + + (releaseVersion.hashCode) + + (serverVersion.hashCode) + + (type.hashCode); + + @override + String toString() => 'ReleaseEventV1[checkedAt=$checkedAt, isAvailable=$isAvailable, releaseVersion=$releaseVersion, serverVersion=$serverVersion, type=$type]'; + + Map toJson() { + final json = {}; + json[r'checkedAt'] = this.checkedAt; + json[r'isAvailable'] = this.isAvailable; + json[r'releaseVersion'] = this.releaseVersion; + json[r'serverVersion'] = this.serverVersion; + json[r'type'] = this.type; + return json; + } + + /// Returns a new [ReleaseEventV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ReleaseEventV1? fromJson(dynamic value) { + upgradeDto(value, "ReleaseEventV1"); + if (value is Map) { + final json = value.cast(); + + return ReleaseEventV1( + checkedAt: mapValueOfType(json, r'checkedAt')!, + isAvailable: mapValueOfType(json, r'isAvailable')!, + releaseVersion: ServerVersionResponseDto.fromJson(json[r'releaseVersion'])!, + serverVersion: ServerVersionResponseDto.fromJson(json[r'serverVersion'])!, + type: ReleaseType.fromJson(json[r'type'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ReleaseEventV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ReleaseEventV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ReleaseEventV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ReleaseEventV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'checkedAt', + 'isAvailable', + 'releaseVersion', + 'serverVersion', + 'type', + }; +} + diff --git a/mobile/openapi/lib/model/release_type.dart b/mobile/openapi/lib/model/release_type.dart new file mode 100644 index 0000000000..2d61072286 --- /dev/null +++ b/mobile/openapi/lib/model/release_type.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// 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 ReleaseType { + /// Instantiate a new enum with the provided [value]. + const ReleaseType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const major = ReleaseType._(r'major'); + static const premajor = ReleaseType._(r'premajor'); + static const minor = ReleaseType._(r'minor'); + static const preminor = ReleaseType._(r'preminor'); + static const patch_ = ReleaseType._(r'patch'); + static const prepatch = ReleaseType._(r'prepatch'); + static const prerelease = ReleaseType._(r'prerelease'); + + /// List of all possible values in this [enum][ReleaseType]. + static const values = [ + major, + premajor, + minor, + preminor, + patch_, + prepatch, + prerelease, + ]; + + static ReleaseType? fromJson(dynamic value) => ReleaseTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ReleaseType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ReleaseType] to String, +/// and [decode] dynamic data back to [ReleaseType]. +class ReleaseTypeTypeTransformer { + factory ReleaseTypeTypeTransformer() => _instance ??= const ReleaseTypeTypeTransformer._(); + + const ReleaseTypeTypeTransformer._(); + + String encode(ReleaseType data) => data.value; + + /// Decodes a [dynamic value][data] to a ReleaseType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ReleaseType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'major': return ReleaseType.major; + case r'premajor': return ReleaseType.premajor; + case r'minor': return ReleaseType.minor; + case r'preminor': return ReleaseType.preminor; + case r'patch': return ReleaseType.patch_; + case r'prepatch': return ReleaseType.prepatch; + case r'prerelease': return ReleaseType.prerelease; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ReleaseTypeTypeTransformer] instance. + static ReleaseTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/server_version_response_dto.dart b/mobile/openapi/lib/model/server_version_response_dto.dart index 60161a7458..eae574f335 100644 --- a/mobile/openapi/lib/model/server_version_response_dto.dart +++ b/mobile/openapi/lib/model/server_version_response_dto.dart @@ -16,47 +16,61 @@ class ServerVersionResponseDto { required this.major, required this.minor, required this.patch_, + required this.prerelease, }); /// Major version number /// - /// Minimum value: -9007199254740991 + /// Minimum value: 0 /// Maximum value: 9007199254740991 int major; /// Minor version number /// - /// Minimum value: -9007199254740991 + /// Minimum value: 0 /// Maximum value: 9007199254740991 int minor; /// Patch version number /// - /// Minimum value: -9007199254740991 + /// Minimum value: 0 /// Maximum value: 9007199254740991 int patch_; + /// Pre-release version number + /// + /// Minimum value: 0 + /// Maximum value: 9007199254740991 + int? prerelease; + @override bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto && other.major == major && other.minor == minor && - other.patch_ == patch_; + other.patch_ == patch_ && + other.prerelease == prerelease; @override int get hashCode => // ignore: unnecessary_parenthesis (major.hashCode) + (minor.hashCode) + - (patch_.hashCode); + (patch_.hashCode) + + (prerelease == null ? 0 : prerelease!.hashCode); @override - String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]'; + String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_, prerelease=$prerelease]'; Map toJson() { final json = {}; json[r'major'] = this.major; json[r'minor'] = this.minor; json[r'patch'] = this.patch_; + if (this.prerelease != null) { + json[r'prerelease'] = this.prerelease; + } else { + // json[r'prerelease'] = null; + } return json; } @@ -72,6 +86,7 @@ class ServerVersionResponseDto { major: mapValueOfType(json, r'major')!, minor: mapValueOfType(json, r'minor')!, patch_: mapValueOfType(json, r'patch')!, + prerelease: mapValueOfType(json, r'prerelease'), ); } return null; @@ -122,6 +137,7 @@ class ServerVersionResponseDto { 'major', 'minor', 'patch', + 'prerelease', }; } diff --git a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart index ec2b400dfd..17ae9577e8 100644 --- a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart +++ b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart @@ -13,26 +13,32 @@ part of openapi.api; class SystemConfigNewVersionCheckDto { /// Returns a new [SystemConfigNewVersionCheckDto] instance. SystemConfigNewVersionCheckDto({ + required this.channel, required this.enabled, }); + ReleaseChannel channel; + /// Enabled bool enabled; @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigNewVersionCheckDto && + other.channel == channel && other.enabled == enabled; @override int get hashCode => // ignore: unnecessary_parenthesis + (channel.hashCode) + (enabled.hashCode); @override - String toString() => 'SystemConfigNewVersionCheckDto[enabled=$enabled]'; + String toString() => 'SystemConfigNewVersionCheckDto[channel=$channel, enabled=$enabled]'; Map toJson() { final json = {}; + json[r'channel'] = this.channel; json[r'enabled'] = this.enabled; return json; } @@ -46,6 +52,7 @@ class SystemConfigNewVersionCheckDto { final json = value.cast(); return SystemConfigNewVersionCheckDto( + channel: ReleaseChannel.fromJson(json[r'channel'])!, enabled: mapValueOfType(json, r'enabled')!, ); } @@ -94,6 +101,7 @@ class SystemConfigNewVersionCheckDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'channel', 'enabled', }; } diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index ef29997e0b..80272d9310 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -116,7 +116,7 @@ void main() { when(() => mockApi.serverInfoApi).thenReturn(mockServerApi); when( () => mockServerApi.getServerVersion(), - ).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0)); + ).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0, prerelease: null)); when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler); @@ -559,7 +559,7 @@ void main() { await Store.put(StoreKey.syncMigrationStatus, "[]"); when( () => mockServerApi.getServerVersion(), - ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1)); + ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null)); await sut.sync(); @@ -587,7 +587,7 @@ void main() { await Store.put(StoreKey.syncMigrationStatus, "[]"); when( () => mockServerApi.getServerVersion(), - ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0)); + ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0, prerelease: null)); await sut.sync(); verifyInOrder([ @@ -617,7 +617,7 @@ void main() { when( () => mockServerApi.getServerVersion(), - ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1)); + ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null)); await sut.sync(); diff --git a/mobile/test/unit/utils/semver_test.dart b/mobile/test/unit/utils/semver_test.dart index 8f1958a879..1e534af593 100644 --- a/mobile/test/unit/utils/semver_test.dart +++ b/mobile/test/unit/utils/semver_test.dart @@ -88,5 +88,71 @@ void main() { expect(version2.minor, 2); expect(version2.patch, 3); }); + + test('Orders later prerelease above earlier prerelease', () { + const rc1 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1); + const rc2 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2); + expect(rc2 > rc1, isTrue); + expect(rc1 < rc2, isTrue); + expect(rc1 == rc2, isFalse); + }); + + test('Final release outranks its prerelease of the same version', () { + const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1); + const release = SemVer(major: 1, minor: 151, patch: 0); + expect(release > rc, isTrue); + expect(rc < release, isTrue); + }); + + test('Higher major outranks a prerelease regardless of ordinal', () { + const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 9); + const next = SemVer(major: 2, minor: 0, patch: 0); + expect(next > rc, isTrue); + }); + + test('Equal prerelease versions compare as equal', () { + const a = SemVer(major: 1, minor: 151, patch: 0, prerelease: 3); + const b = SemVer(major: 1, minor: 151, patch: 0, prerelease: 3); + expect(a == b, isTrue); + expect(a > b, isFalse); + expect(a < b, isFalse); + }); + + test('Reports prerelease difference type', () { + const rc1 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1); + const rc2 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2); + expect(rc1.differenceType(rc2), SemVerType.prerelease); + }); + + test('toString includes prerelease suffix when present', () { + const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2); + expect(rc.toString(), '1.151.0-rc.2'); + }); + + test('Parses prerelease ordinal from -rc strings', () { + final dotted = SemVer.fromString('1.151.0-rc.2'); + expect(dotted.major, 1); + expect(dotted.minor, 151); + expect(dotted.patch, 0); + expect(dotted.prerelease, 2); + + expect(SemVer.fromString('v1.151.0-rc.3').prerelease, 3); + expect(SemVer.fromString('1.2.3-rc.2+build.5').prerelease, 2); + }); + + test('Plain version string has null prerelease', () { + expect(SemVer.fromString('3.0.0').prerelease, isNull); + }); + + test('Invalid rc suffixes parse without error and have null prerelease', () { + final debug = SemVer.fromString('1.2.3-debug'); + expect(debug.major, 1); + expect(debug.minor, 2); + expect(debug.patch, 3); + expect(debug.prerelease, isNull); + + expect(SemVer.fromString('1.2.3+build.5').prerelease, isNull); + expect(SemVer.fromString('1.151.0-rc4').prerelease, isNull); + }); }); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5e9b460ada..be83f75546 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -20828,6 +20828,57 @@ ], "type": "string" }, + "ReleaseChannel": { + "description": "Release channel", + "enum": [ + "stable", + "releaseCandidate" + ], + "type": "string" + }, + "ReleaseEventV1": { + "properties": { + "checkedAt": { + "description": "When the server last checked for a latest version. As an ISO timestamp", + "type": "string" + }, + "isAvailable": { + "description": "Whether a new version is available", + "type": "boolean" + }, + "releaseVersion": { + "$ref": "#/components/schemas/ServerVersionResponseDto" + }, + "serverVersion": { + "$ref": "#/components/schemas/ServerVersionResponseDto" + }, + "type": { + "$ref": "#/components/schemas/ReleaseType", + "description": "Release type", + "nullable": true + } + }, + "required": [ + "checkedAt", + "isAvailable", + "releaseVersion", + "serverVersion", + "type" + ], + "type": "object" + }, + "ReleaseType": { + "enum": [ + "major", + "premajor", + "minor", + "preminor", + "patch", + "prepatch", + "prerelease" + ], + "type": "string" + }, "ReverseGeocodingStateResponseDto": { "properties": { "lastImportFileName": { @@ -21497,26 +21548,40 @@ "major": { "description": "Major version number", "maximum": 9007199254740991, - "minimum": -9007199254740991, + "minimum": 0, "type": "integer" }, "minor": { "description": "Minor version number", "maximum": 9007199254740991, - "minimum": -9007199254740991, + "minimum": 0, "type": "integer" }, "patch": { "description": "Patch version number", "maximum": 9007199254740991, - "minimum": -9007199254740991, + "minimum": 0, "type": "integer" + }, + "prerelease": { + "description": "Pre-release version number", + "maximum": 9007199254740991, + "minimum": 0, + "nullable": true, + "type": "integer", + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ] } }, "required": [ "major", "minor", - "patch" + "patch", + "prerelease" ], "type": "object" }, @@ -24537,12 +24602,16 @@ }, "SystemConfigNewVersionCheckDto": { "properties": { + "channel": { + "$ref": "#/components/schemas/ReleaseChannel" + }, "enabled": { "description": "Enabled", "type": "boolean" } }, "required": [ + "channel", "enabled" ], "type": "object" diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index e9388eb9de..9438968436 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2076,6 +2076,8 @@ export type ServerVersionResponseDto = { minor: number; /** Patch version number */ patch: number; + /** Pre-release version number */ + prerelease: number | null; }; export type VersionCheckStateResponseDto = { /** Last check timestamp */ @@ -2423,6 +2425,7 @@ export type SystemConfigMetadataDto = { faces: SystemConfigFacesDto; }; export type SystemConfigNewVersionCheckDto = { + channel: ReleaseChannel; /** Enabled */ enabled: boolean; }; @@ -2768,6 +2771,16 @@ export type WorkflowShareResponseDto = { trigger: WorkflowTrigger; }; export type LicenseResponseDto = UserLicense; +export type ReleaseEventV1 = { + /** When the server last checked for a latest version. As an ISO timestamp */ + checkedAt: string; + /** Whether a new version is available */ + isAvailable: boolean; + releaseVersion: ServerVersionResponseDto; + serverVersion: ServerVersionResponseDto; + /** Release type */ + "type": ReleaseType; +}; export type SyncAckV1 = {}; export type SyncAlbumDeleteV1 = { /** Album ID */ @@ -7312,6 +7325,10 @@ export enum LogLevel { Error = "error", Fatal = "fatal" } +export enum ReleaseChannel { + Stable = "stable", + ReleaseCandidate = "releaseCandidate" +} export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" @@ -7320,6 +7337,15 @@ export enum AssetOrderBy { TakenAt = "takenAt", CreatedAt = "createdAt" } +export enum ReleaseType { + Major = "major", + Premajor = "premajor", + Minor = "minor", + Preminor = "preminor", + Patch = "patch", + Prepatch = "prepatch", + Prerelease = "prerelease" +} export enum UserMetadataKey { Preferences = "preferences", License = "license", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a76031206..329203c36b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -577,8 +577,8 @@ importers: specifier: ^1.6.3 version: 1.6.4 semver: - specifier: ^7.6.2 - version: 7.8.0 + specifier: ^7.8.1 + version: 7.8.1 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -11250,6 +11250,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -16307,7 +16312,7 @@ snapshots: nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.8.0 + semver: 7.8.1 tar: 6.2.1 transitivePeerDependencies: - encoding @@ -17765,7 +17770,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -18469,7 +18474,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.59.4 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.8.0 + semver: 7.8.1 tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 @@ -19569,7 +19574,7 @@ snapshots: dot-prop: 10.1.0 env-paths: 3.0.0 json-schema-typed: 8.0.2 - semver: 7.8.0 + semver: 7.8.1 uint8array-extras: 1.5.0 config-chain@1.1.13: @@ -19741,7 +19746,7 @@ snapshots: postcss-modules-scope: 3.2.1(postcss@8.5.15) postcss-modules-values: 4.0.0(postcss@8.5.15) postcss-value-parser: 4.2.0 - semver: 7.8.0 + semver: 7.8.1 optionalDependencies: webpack: 5.107.0(postcss@8.5.15) @@ -20609,7 +20614,7 @@ snapshots: find-up: 5.0.0 globals: 15.15.0 lodash.memoize: 4.1.2 - semver: 7.8.0 + semver: 7.8.1 eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(prettier@3.8.3): dependencies: @@ -20632,7 +20637,7 @@ snapshots: postcss: 8.5.15 postcss-load-config: 3.1.4(postcss@8.5.15) postcss-safe-parser: 7.0.1(postcss@8.5.15) - semver: 7.8.0 + semver: 7.8.1 svelte-eslint-parser: 1.6.1(svelte@5.55.8(@typescript-eslint/types@8.59.4)) optionalDependencies: svelte: 5.55.8(@typescript-eslint/types@8.59.4) @@ -21110,7 +21115,7 @@ snapshots: minimatch: 3.1.5 node-abort-controller: 3.1.1 schema-utils: 3.3.0 - semver: 7.8.0 + semver: 7.8.1 tapable: 2.3.3 typescript: 5.9.3 webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0) @@ -21546,7 +21551,7 @@ snapshots: history@4.10.1: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 loose-envify: 1.4.0 resolve-pathname: 3.0.0 tiny-invariant: 1.3.3 @@ -22134,7 +22139,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.8.0 + semver: 7.8.1 just-compare@2.3.0: {} @@ -22420,7 +22425,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.0 + semver: 7.8.1 maplibre-gl@5.24.0: dependencies: @@ -23255,7 +23260,7 @@ snapshots: node-abi@3.92.0: dependencies: - semver: 7.8.0 + semver: 7.8.1 optional: true node-abort-controller@3.1.1: {} @@ -23296,7 +23301,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.8.0 + semver: 7.8.1 tar: 7.5.15 tinyglobby: 0.2.16 undici: 6.25.0 @@ -23534,7 +23539,7 @@ snapshots: got: 12.6.1 registry-auth-token: 5.1.1 registry-url: 6.0.1 - semver: 7.8.0 + semver: 7.8.1 package-manager-detector@1.6.0: {} @@ -23922,7 +23927,7 @@ snapshots: cosmiconfig: 8.3.6(typescript@6.0.3) jiti: 1.21.7 postcss: 8.5.15 - semver: 7.8.0 + semver: 7.8.1 webpack: 5.107.0(postcss@8.5.15) transitivePeerDependencies: - typescript @@ -24977,12 +24982,14 @@ snapshots: semver-diff@4.0.0: dependencies: - semver: 7.8.0 + semver: 7.8.1 semver@6.3.1: {} semver@7.8.0: {} + semver@7.8.1: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -25517,7 +25524,7 @@ snapshots: postcss: 8.5.15 postcss-scss: 4.0.9(postcss@8.5.15) postcss-selector-parser: 7.1.1 - semver: 7.8.0 + semver: 7.8.1 optionalDependencies: svelte: 5.55.8(@typescript-eslint/types@8.59.4) @@ -26225,7 +26232,7 @@ snapshots: is-yarn-global: 0.4.1 latest-version: 7.0.0 pupa: 3.3.0 - semver: 7.8.0 + semver: 7.8.1 semver-diff: 4.0.0 xdg-basedir: 5.1.0 diff --git a/server/package.json b/server/package.json index 119a1ea603..957aa548d3 100644 --- a/server/package.json +++ b/server/package.json @@ -106,7 +106,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", - "semver": "^7.6.2", + "semver": "^7.8.1", "sharp": "^0.34.5", "sirv": "^3.0.0", "socket.io": "^4.8.1", diff --git a/server/src/config.ts b/server/src/config.ts index 999e1e45bc..8f5c9b2579 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,4 +1,5 @@ import { CronExpression } from '@nestjs/schedule'; +import { ReleaseChannel } from 'src/dtos/system-config.dto'; import { AudioCodec, Colorspace, @@ -135,6 +136,7 @@ export type SystemConfig = { }; newVersionCheck: { enabled: boolean; + channel: ReleaseChannel; }; nightlyTasks: { startTime: string; @@ -344,6 +346,7 @@ export const defaults = Object.freeze({ }, newVersionCheck: { enabled: true, + channel: ReleaseChannel.Stable, }, nightlyTasks: { startTime: '00:00', diff --git a/server/src/decorators.ts b/server/src/decorators.ts index c8cf1f9221..513ae36c9f 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -265,3 +265,13 @@ export class HistoryBuilder { return this; } } + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export const extraModels: Function[] = []; + +export const ExtraModel = (): ClassDecorator => { + // eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-unsafe-function-type + return (object: Function) => { + extraModels.push(object); + }; +}; diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 57a58e1dd7..c770ec12ca 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -1,5 +1,6 @@ import { createZodDto } from 'nestjs-zod'; import type { SemVer } from 'semver'; +import { ExtraModel, HistoryBuilder } from 'src/decorators'; import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; @@ -58,9 +59,15 @@ const ServerStorageResponseSchema = z const ServerVersionResponseSchema = z .object({ - major: z.int().describe('Major version number'), - minor: z.int().describe('Minor version number'), - patch: z.int().describe('Patch version number'), + major: z.int().min(0).describe('Major version number'), + minor: z.int().min(0).describe('Minor version number'), + patch: z.int().min(0).describe('Patch version number'), + prerelease: z + .int() + .min(0) + .nullable() + .meta(HistoryBuilder.v3().getExtensions()) + .describe('Pre-release version number'), }) .meta({ id: 'ServerVersionResponseDto' }); @@ -140,6 +147,26 @@ const ServerFeaturesSchema = z }) .meta({ id: 'ServerFeaturesDto' }); +export enum ReleaseType { + Major = 'major', + Premajor = 'premajor', + Minor = 'minor', + Preminor = 'preminor', + Patch = 'patch', + Prepatch = 'prepatch', + Prerelease = 'prerelease', +} + +const ReleaseTypeSchema = z.enum(ReleaseType).meta({ id: 'ReleaseType' }).describe('Release type'); + +const ReleaseEventV1Schema = z.object({ + isAvailable: z.boolean().describe('Whether a new version is available'), + checkedAt: z.string().describe('When the server last checked for a latest version. As an ISO timestamp'), + serverVersion: ServerVersionResponseSchema, + releaseVersion: ServerVersionResponseSchema, + type: ReleaseTypeSchema.nullable(), +}); + export class ServerPingResponse extends createZodDto(ServerPingResponseSchema) {} export class ServerAboutResponseDto extends createZodDto(ServerAboutResponseSchema) {} export class ServerApkLinksDto extends createZodDto(ServerApkLinksSchema) {} @@ -147,7 +174,12 @@ export class ServerStorageResponseDto extends createZodDto(ServerStorageResponse export class ServerVersionResponseDto extends createZodDto(ServerVersionResponseSchema) { static fromSemVer(value: SemVer): z.infer { - return { major: value.major, minor: value.minor, patch: value.patch }; + return { + major: value.major, + minor: value.minor, + patch: value.patch, + prerelease: (value.prerelease[1] as number) ?? null, + }; } } @@ -158,10 +190,5 @@ export class ServerMediaTypesResponseDto extends createZodDto(ServerMediaTypesRe export class ServerConfigDto extends createZodDto(ServerConfigSchema) {} export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {} -export interface ReleaseNotification { - isAvailable: boolean; - /** ISO8601 */ - checkedAt: string; - serverVersion: ServerVersionResponseDto; - releaseVersion: ServerVersionResponseDto; -} +@ExtraModel() +export class ReleaseEventV1 extends createZodDto(ReleaseEventV1Schema) {} diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 35ef874dfa..698939e627 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -1,5 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ import { createZodDto } from 'nestjs-zod'; +import { ExtraModel } from 'src/decorators'; import { AssetEditActionSchema } from 'src/dtos/editing.dto'; import { AlbumUserRole, @@ -17,15 +17,6 @@ import { import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; -export const extraSyncModels: Function[] = []; - -const ExtraModel = (): ClassDecorator => { - // eslint-disable-next-line unicorn/consistent-function-scoping - return (object: Function) => { - extraSyncModels.push(object); - }; -}; - const SyncUserV1Schema = z .object({ id: z.string().describe('User ID'), diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 94c1aa36b0..d21a0f9dcf 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -151,8 +151,15 @@ const SystemConfigMapSchema = z }) .meta({ id: 'SystemConfigMapDto' }); +export enum ReleaseChannel { + Stable = 'stable', + ReleaseCandidate = 'releaseCandidate', +} + +const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' }); + const SystemConfigNewVersionCheckSchema = z - .object({ enabled: configBool.describe('Enabled') }) + .object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema }) .meta({ id: 'SystemConfigNewVersionCheckDto' }); const SystemConfigNightlyTasksSchema = z diff --git a/server/src/repositories/server-info.repository.ts b/server/src/repositories/server-info.repository.ts index 85d26d6cfa..c8fb896281 100644 --- a/server/src/repositories/server-info.repository.ts +++ b/server/src/repositories/server-info.repository.ts @@ -4,6 +4,7 @@ import { exec as execCallback } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import sharp from 'sharp'; +import { ReleaseChannel } from 'src/dtos/system-config.dto'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -64,10 +65,12 @@ export class ServerInfoRepository { this.logger.setContext(ServerInfoRepository.name); } - async getLatestRelease(): Promise { + async getLatestRelease(channel: ReleaseChannel): Promise { try { const { versionCheck } = this.configRepository.getEnv(); - const response = await fetch(versionCheck.url); + const url = new URL(versionCheck.url); + url.searchParams.append('channel', channel); + const response = await fetch(url); if (!response.ok) { throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`); diff --git a/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index b4a0fcc00a..d87d38c40b 100644 --- a/server/src/repositories/websocket.repository.ts +++ b/server/src/repositories/websocket.repository.ts @@ -10,7 +10,7 @@ import { Server, Socket } from 'socket.io'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { NotificationDto } from 'src/dtos/notification.dto'; -import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; +import { ReleaseEventV1, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV2 } from 'src/dtos/sync.dto'; import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -31,7 +31,7 @@ export interface ClientEventMap { on_person_thumbnail: [string]; on_server_version: [ServerVersionResponseDto]; on_config_update: []; - on_new_release: [ReleaseNotification]; + on_new_release: [ReleaseEventV1]; on_notification: [NotificationDto]; on_session_delete: [string]; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index c9a8492b5d..f0d880f50b 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { defaults, SystemConfig } from 'src/config'; +import { ReleaseChannel } from 'src/dtos/system-config.dto'; import { AudioCodec, Colorspace, @@ -184,6 +185,7 @@ const updatedConfig = Object.freeze({ }, newVersionCheck: { enabled: true, + channel: ReleaseChannel.Stable, }, trash: { enabled: true, diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 2fbe7292fa..d73edb9850 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -2,6 +2,7 @@ import { DateTime } from 'luxon'; import { SemVer } from 'semver'; import { defaults } from 'src/config'; import { serverVersion } from 'src/constants'; +import { ReleaseChannel } from 'src/dtos/system-config.dto'; import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; import { VersionService } from 'src/services/version.service'; import { factory } from 'test/small.factory'; @@ -22,6 +23,17 @@ describe(VersionService.name, () => { mocks.cron.update.mockResolvedValue(); }); + beforeAll(() => { + vitest.mock(import('src/constants.js'), async () => ({ + ...(await vitest.importActual('src/constants.js')), + serverVersion: new SemVer('v3.0.0'), + })); + }); + + afterAll(() => { + vitest.unmock(import('src/constants.js')); + }); + it('should work', () => { expect(sut).toBeDefined(); }); @@ -66,9 +78,10 @@ describe(VersionService.name, () => { describe('getVersion', () => { it('should respond the server version', () => { expect(sut.getVersion()).toEqual({ - major: serverVersion.major, - minor: serverVersion.minor, - patch: serverVersion.patch, + major: 3, + minor: 0, + patch: 0, + prerelease: null, }); }); }); @@ -143,24 +156,24 @@ describe(VersionService.name, () => { describe('onConfigUpdate', () => { it('should queue a version check job when newVersionCheck is enabled', async () => { await sut.onConfigUpdate({ - oldConfig: { ...defaults, newVersionCheck: { enabled: false } }, - newConfig: { ...defaults, newVersionCheck: { enabled: true } }, + oldConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } }, + newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } }, }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} }); }); it('should not queue a version check job when newVersionCheck is disabled', async () => { await sut.onConfigUpdate({ - oldConfig: { ...defaults, newVersionCheck: { enabled: true } }, - newConfig: { ...defaults, newVersionCheck: { enabled: false } }, + oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } }, + newConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } }, }); expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should not queue a version check job when newVersionCheck was already enabled', async () => { await sut.onConfigUpdate({ - oldConfig: { ...defaults, newVersionCheck: { enabled: true } }, - newConfig: { ...defaults, newVersionCheck: { enabled: true } }, + oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } }, + newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } }, }); expect(mocks.job.queue).not.toHaveBeenCalled(); }); @@ -169,21 +182,36 @@ describe(VersionService.name, () => { describe('onWebsocketConnection', () => { it('should send on_server_version client event', async () => { await sut.onWebsocketConnection({ userId: '42' }); - expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', { + major: 3, + minor: 0, + patch: 0, + prerelease: null, + }); expect(mocks.websocket.clientSend).toHaveBeenCalledTimes(1); }); it('should also send a new release notification', async () => { mocks.systemMetadata.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' }); await sut.onWebsocketConnection({ userId: '42' }); - expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', { + major: 3, + minor: 0, + patch: 0, + prerelease: null, + }); expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); }); it('should not send a release notification when the version check is disabled', async () => { mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } }); await sut.onWebsocketConnection({ userId: '42' }); - expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', { + major: 3, + minor: 0, + patch: 0, + prerelease: null, + }); expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); }); }); diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index ce6d6d7a6f..37010db5e7 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -3,19 +3,27 @@ import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; -import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; +import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto'; +import { ReleaseChannel } from 'src/dtos/system-config.dto'; import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { VersionCheckMetadata } from 'src/types'; import { handlePromiseError } from 'src/utils/misc'; -const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { +const asNotification = ( + channel: ReleaseChannel, + { checkedAt, releaseVersion }: VersionCheckMetadata, +): ReleaseEventV1 => { return { - isAvailable: semver.gt(releaseVersion, serverVersion), + // can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483 + isAvailable: semver.intersects(`>${serverVersion}`, releaseVersion.toString(), { + includePrerelease: channel === ReleaseChannel.ReleaseCandidate, + }), checkedAt, serverVersion: ServerVersionResponseDto.fromSemVer(serverVersion), releaseVersion: ServerVersionResponseDto.fromSemVer(new SemVer(releaseVersion)), + type: semver.diff(serverVersion, releaseVersion) as ReleaseType, }; }; @@ -98,14 +106,21 @@ export class VersionService extends BaseService { } } - const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease(); + const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease( + newVersionCheck.channel, + ); const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion }; await this.systemMetadataRepository.set(SystemMetadataKey.VersionCheckState, metadata); - if (semver.gt(releaseVersion, serverVersion)) { + // can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483 + if ( + semver.intersects(`>${serverVersion}`, releaseVersion.toString(), { + includePrerelease: newVersionCheck.channel === ReleaseChannel.ReleaseCandidate, + }) + ) { this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`); - this.websocketRepository.clientBroadcast('on_new_release', asNotification(metadata)); + this.websocketRepository.clientBroadcast('on_new_release', asNotification(newVersionCheck.channel, metadata)); } } catch (error: Error | any) { this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`); @@ -117,7 +132,11 @@ export class VersionService extends BaseService { @OnEvent({ name: 'WebsocketConnect' }) async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) { - this.websocketRepository.clientSend('on_server_version', userId, serverVersion); + this.websocketRepository.clientSend( + 'on_server_version', + userId, + ServerVersionResponseDto.fromSemVer(serverVersion), + ); const { newVersionCheck } = await this.getConfig({ withCache: true }); if (!newVersionCheck.enabled) { @@ -126,7 +145,7 @@ export class VersionService extends BaseService { const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState); if (metadata) { - this.websocketRepository.clientSend('on_new_release', userId, asNotification(metadata)); + this.websocketRepository.clientSend('on_new_release', userId, asNotification(newVersionCheck.channel, metadata)); } } } diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 37fff07fd9..efcb509941 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -15,7 +15,7 @@ import picomatch from 'picomatch'; import parse from 'picomatch/lib/parse'; import { SystemConfig } from 'src/config'; import { CLIP_MODEL_INFO, endpointTags, serverVersion } from 'src/constants'; -import { extraSyncModels } from 'src/dtos/sync.dto'; +import { extraModels } from 'src/decorators'; import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -289,7 +289,7 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean }) const options: SwaggerDocumentOptions = { operationIdFactory: (controllerKey: string, methodKey: string) => methodKey, - extraModels: extraSyncModels, + extraModels, ignoreGlobalPrefix: true, }; diff --git a/web/src/lib/components/shared-components/side-bar/ServerStatus.svelte b/web/src/lib/components/shared-components/side-bar/ServerStatus.svelte index 47b30e8454..3b77048af9 100644 --- a/web/src/lib/components/shared-components/side-bar/ServerStatus.svelte +++ b/web/src/lib/components/shared-components/side-bar/ServerStatus.svelte @@ -4,12 +4,12 @@ import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte'; import { userInteraction } from '$lib/stores/user.svelte'; import { websocketStore } from '$lib/stores/websocket'; - import type { ReleaseEvent } from '$lib/types'; import { semverToName } from '$lib/utils'; import { requestServerInfo } from '$lib/utils/auth'; import { getAboutInfo, getVersionHistory, + type ReleaseEventV1, type ServerAboutResponseDto, type ServerVersionHistoryResponseDto, } from '@immich/sdk'; @@ -35,11 +35,9 @@ userInteraction.versions = versions; }); let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich'); - let version = $derived( - $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null, - ); + let version = $derived($serverVersion ? semverToName($serverVersion) : null); - const getReleaseInfo = (release?: ReleaseEvent) => { + const getReleaseInfo = (release?: ReleaseEventV1) => { if (!release || !release?.isAvailable || !authManager.user.isAdmin) { return; } diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 8df3dcfb0e..4a3a560bf9 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -7,13 +7,13 @@ import type { LoginResponseDto, PersonResponseDto, QueueResponseDto, + ReleaseEventV1, SharedLinkResponseDto, SystemConfigDto, TagResponseDto, UserAdminResponseDto, WorkflowResponseDto, } from '@immich/sdk'; -import type { ReleaseEvent } from '$lib/types'; import { BaseEventManager } from '$lib/utils/base-event-manager.svelte'; import type { TreeNode } from '$lib/utils/tree-utils'; @@ -86,7 +86,7 @@ export type Events = { WorkflowUpdate: [WorkflowResponseDto]; WorkflowDelete: [WorkflowResponseDto]; - ReleaseEvent: [ReleaseEvent]; + ReleaseEvent: [ReleaseEventV1]; WebsocketConnect: []; }; diff --git a/web/src/lib/managers/release-manager.svelte.ts b/web/src/lib/managers/release-manager.svelte.ts index 15baa6de8f..fed57dd3d8 100644 --- a/web/src/lib/managers/release-manager.svelte.ts +++ b/web/src/lib/managers/release-manager.svelte.ts @@ -1,8 +1,8 @@ +import type { ReleaseEventV1 } from '@immich/sdk'; import { eventManager } from '$lib/managers/event-manager.svelte'; -import { type ReleaseEvent } from '$lib/types'; class ReleaseManager { - value = $state(); + value = $state(); constructor() { eventManager.on({ diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 5765f85a16..fc33812973 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -3,6 +3,7 @@ import { type AssetResponseDto, type MaintenanceStatusResponseDto, type NotificationDto, + type ReleaseEventV1, type ServerVersionResponseDto, type SyncAssetEditV1, type SyncAssetV2, @@ -15,7 +16,6 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; import { Route } from '$lib/route'; import { maintenanceStore } from '$lib/stores/maintenance.store'; import { notificationManager } from '$lib/stores/notification-manager.svelte'; -import type { ReleaseEvent } from '$lib/types'; import { createEventEmitter } from '$lib/utils/eventemitter'; interface AppRestartEvent { @@ -34,7 +34,7 @@ export interface Events { on_person_thumbnail: (personId: string) => void; on_server_version: (serverVersion: ServerVersionResponseDto) => void; on_config_update: () => void; - on_new_release: (event: ReleaseEvent) => void; + on_new_release: (event: ReleaseEventV1) => void; on_session_delete: (sessionId: string) => void; on_notification: (notification: NotificationDto) => void; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index ef69a6b08e..41d98df097 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1,4 +1,4 @@ -import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk'; +import type { QueueResponseDto } from '@immich/sdk'; import type { ActionItem } from '@immich/ui'; import type { DateTime } from 'luxon'; import type { SvelteSet } from 'svelte/reactivity'; @@ -7,14 +7,6 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; export type LatLng = { lng: number; lat: number }; -export interface ReleaseEvent { - isAvailable: boolean; - /** ISO8601 */ - checkedAt: string; - serverVersion: ServerVersionResponseDto; - releaseVersion: ServerVersionResponseDto; -} - export type QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] }; export type HeaderButtonActionItem = ActionItem & { data?: { title?: string } }; diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 9ecc7f548e..9fde998dcd 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -1,5 +1,5 @@ import { AssetTypeEnum } from '@immich/sdk'; -import { getAssetUrl, getReleaseType } from '$lib/utils'; +import { getAssetUrl } from '$lib/utils'; import { assetFactory } from '@test-data/factories/asset-factory'; import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; @@ -161,26 +161,4 @@ describe('utils', () => { expect(url).toContain(asset.id); }); }); - - describe(getReleaseType.name, () => { - it('should return "major" for major version changes', () => { - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major'); - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 3, minor: 2, patch: 1 })).toBe('major'); - }); - - it('should return "minor" for minor version changes', () => { - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 1, patch: 0 })).toBe('minor'); - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 2, patch: 1 })).toBe('minor'); - }); - - it('should return "patch" for patch version changes', () => { - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 1 })).toBe('patch'); - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 5 })).toBe('patch'); - }); - - it('should return "none" for matching versions', () => { - expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 0 })).toBe('none'); - expect(getReleaseType({ major: 1, minor: 2, patch: 3 }, { major: 1, minor: 2, patch: 3 })).toBe('none'); - }); - }); }); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 397e32e136..4bc3ce76b3 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -411,26 +411,8 @@ export function createDateFormatter(localeCode: string | undefined): DateFormatt }; } -export const getReleaseType = ( - current: ServerVersionResponseDto, - newVersion: ServerVersionResponseDto, -): 'major' | 'minor' | 'patch' | 'none' => { - if (current.major !== newVersion.major) { - return 'major'; - } - - if (current.minor !== newVersion.minor) { - return 'minor'; - } - - if (current.patch !== newVersion.patch) { - return 'patch'; - } - - return 'none'; -}; - -export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`; +export const semverToName = ({ major, minor, patch, prerelease }: ServerVersionResponseDto) => + `v${major}.${minor}.${patch}${prerelease ? `-rc.${prerelease}` : ''}`; export const withoutIcons = (actions: ActionItem[]): ActionItem[] => actions.map((action) => ({ ...action, icon: undefined })); diff --git a/web/src/routes/VersionAnnouncement.svelte b/web/src/routes/VersionAnnouncement.svelte index 2123f2f523..2f793e4d25 100644 --- a/web/src/routes/VersionAnnouncement.svelte +++ b/web/src/routes/VersionAnnouncement.svelte @@ -2,8 +2,8 @@ import OnEvents from '$lib/components/OnEvents.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte'; - import type { ReleaseEvent } from '$lib/types'; - import { getReleaseType, semverToName } from '$lib/utils'; + import { semverToName } from '$lib/utils'; + import { ReleaseType, type ReleaseEventV1 } from '@immich/sdk'; import { modalManager } from '@immich/ui'; let modal = $state<{ @@ -11,16 +11,20 @@ close: () => Promise; }>(); - const onReleaseEvent = async (release: ReleaseEvent) => { + const onReleaseEvent = async (release: ReleaseEventV1) => { if (!release.isAvailable || !authManager.user.isAdmin) { return; } const releaseVersion = semverToName(release.releaseVersion); const serverVersion = semverToName(release.serverVersion); - const type = getReleaseType(release.serverVersion, release.releaseVersion); - if (type === 'none' || type === 'patch' || localStorage.getItem('appVersion') === releaseVersion) { + if ( + !release.type || + release.type === ReleaseType.Patch || + release.type === ReleaseType.Prepatch || + localStorage.getItem('appVersion') === releaseVersion + ) { return; } diff --git a/web/src/routes/admin/system-settings/NewVersionCheckSettings.svelte b/web/src/routes/admin/system-settings/NewVersionCheckSettings.svelte index 71d8424e2a..c646601718 100644 --- a/web/src/routes/admin/system-settings/NewVersionCheckSettings.svelte +++ b/web/src/routes/admin/system-settings/NewVersionCheckSettings.svelte @@ -5,8 +5,11 @@ import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; + import SettingSelect from './SettingSelect.svelte'; + import { ReleaseChannel } from '@immich/sdk'; const disabled = $derived(featureFlagsManager.value.configFile); + const config = $derived(systemConfigManager.value); let configToEdit = $state(systemConfigManager.cloneValue()); @@ -20,6 +23,20 @@ bind:checked={configToEdit.newVersionCheck.enabled} {disabled} /> +