Compare commits

...

2 Commits

Author SHA1 Message Date
shenlong-tanwen 9fe48d21c2 feat(mobile): release candidate support 2026-05-30 00:55:43 +05:30
Daniel Dietzler e35610d0a7 feat: release candidate support 2026-05-29 16:06:16 +02:00
41 changed files with 815 additions and 191 deletions
@@ -95,6 +95,7 @@ describe('/server', () => {
major: expect.any(Number), major: expect.any(Number),
minor: expect.any(Number), minor: expect.any(Number),
patch: expect.any(Number), patch: expect.any(Number),
prerelease: null,
}); });
}); });
}); });
@@ -21,18 +21,18 @@ describe('/system-config', () => {
const response1 = await request(app) const response1 = await request(app)
.put('/system-config') .put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: false } }); .send({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
expect(response1.status).toBe(200); 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) const response2 = await request(app)
.put('/system-config') .put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: true } }); .send({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
expect(response2.status).toBe(200); 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 () => { it('should reject an invalid config entry', async () => {
+4
View File
@@ -305,6 +305,8 @@
"refreshing_all_libraries": "Refreshing all libraries", "refreshing_all_libraries": "Refreshing all libraries",
"registration": "Admin Registration", "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.", "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", "remove_failed_jobs": "Remove failed jobs",
"require_password_change_on_login": "Require user to change password on first login", "require_password_change_on_login": "Require user to change password on first login",
"reset_settings_to_default": "Reset settings to default", "reset_settings_to_default": "Reset settings to default",
@@ -442,6 +444,8 @@
"user_settings_description": "Manage user settings", "user_settings_description": "Manage user settings",
"user_successfully_removed": "User {email} has been successfully removed.", "user_successfully_removed": "User {email} has been successfully removed.",
"users_page_description": "Admin users page", "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_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with {server}", "version_check_implications": "The version check feature relies on periodic communication with {server}",
"version_check_settings": "Version Check", "version_check_settings": "Version Check",
@@ -2,16 +2,12 @@ import 'package:immich_mobile/utils/semver.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class ServerVersion extends SemVer { 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 ServerVersion.fromDto(ServerVersionResponseDto dto)
String toString() { : super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease);
return 'ServerVersion(major: $major, minor: $minor, patch: $patch)';
}
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, int? prerelease}) {
return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease);
bool isAtLeast({int major = 0, int minor = 0, int patch = 0}) {
return this >= SemVer(major: major, minor: minor, patch: patch);
} }
} }
+52 -20
View File
@@ -1,36 +1,42 @@
enum SemVerType { major, minor, patch } enum SemVerType { major, minor, patch, prerelease }
class SemVer { class SemVer {
final int major; final int major;
final int minor; final int minor;
final int patch; 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 @override
String toString() { String toString() {
return '$major.$minor.$patch'; return '$major.$minor.$patch${prerelease == null ? '' : '-rc.$prerelease'}';
} }
SemVer copyWith({int? major, int? minor, int? 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); 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) { factory SemVer.fromString(String version) {
if (version.toLowerCase().startsWith("v")) { final match = _pattern.firstMatch(version);
version = version.substring(1); if (match == null) {
}
final parts = version.split("-")[0].split('.');
if (parts.length != 3) {
throw FormatException('Invalid semantic version string: $version'); throw FormatException('Invalid semantic version string: $version');
} }
try { final prerelease = match.group(4);
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2])); return SemVer(
} catch (e) { major: int.parse(match.group(1)!),
throw FormatException('Invalid semantic version string: $version'); minor: int.parse(match.group(2)!),
} patch: int.parse(match.group(3)!),
prerelease: prerelease == null ? null : int.parse(prerelease),
);
} }
bool operator >(SemVer other) { bool operator >(SemVer other) {
@@ -40,7 +46,10 @@ class SemVer {
if (minor != other.minor) { if (minor != other.minor) {
return 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) { bool operator <(SemVer other) {
@@ -50,7 +59,23 @@ class SemVer {
if (minor != other.minor) { if (minor != other.minor) {
return 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) { bool operator >=(SemVer other) {
@@ -67,7 +92,11 @@ class SemVer {
return true; 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) { SemVerType? differenceType(SemVer other) {
@@ -80,10 +109,13 @@ class SemVer {
if (patch != other.patch) { if (patch != other.patch) {
return SemVerType.patch; return SemVerType.patch;
} }
if (prerelease != other.prerelease) {
return SemVerType.prerelease;
}
return null; return null;
} }
@override @override
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode; int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode ^ prerelease.hashCode;
} }
@@ -50,9 +50,7 @@ class AppBarServerInfo extends HookConsumerWidget {
divider, divider,
_ServerInfoItem( _ServerInfoItem(
label: "server_version".tr(), label: "server_version".tr(),
text: serverInfoState.serverVersion.major > 0 text: serverInfoState.serverVersion.major > 0 ? "${serverInfoState.serverVersion}" : "--",
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
: "--",
), ),
divider, divider,
_ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true), _ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true),
@@ -60,9 +58,7 @@ class AppBarServerInfo extends HookConsumerWidget {
divider, divider,
_ServerInfoItem( _ServerInfoItem(
label: "latest_version".tr(), label: "latest_version".tr(),
text: serverInfoState.latestVersion!.major > 0 text: serverInfoState.latestVersion!.major > 0 ? "${serverInfoState.latestVersion!}" : "--",
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
: "--",
tooltip: true, tooltip: true,
icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate
? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12) ? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12)
+3
View File
@@ -513,6 +513,9 @@ Class | Method | HTTP request | Description
- [RatingsUpdate](doc//RatingsUpdate.md) - [RatingsUpdate](doc//RatingsUpdate.md)
- [ReactionLevel](doc//ReactionLevel.md) - [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md) - [ReactionType](doc//ReactionType.md)
- [ReleaseChannel](doc//ReleaseChannel.md)
- [ReleaseEventV1](doc//ReleaseEventV1.md)
- [ReleaseType](doc//ReleaseType.md)
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
- [RotateParameters](doc//RotateParameters.md) - [RotateParameters](doc//RotateParameters.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
+3
View File
@@ -258,6 +258,9 @@ part 'model/ratings_response.dart';
part 'model/ratings_update.dart'; part 'model/ratings_update.dart';
part 'model/reaction_level.dart'; part 'model/reaction_level.dart';
part 'model/reaction_type.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/reverse_geocoding_state_response_dto.dart';
part 'model/rotate_parameters.dart'; part 'model/rotate_parameters.dart';
part 'model/search_album_response_dto.dart'; part 'model/search_album_response_dto.dart';
+6
View File
@@ -562,6 +562,12 @@ class ApiClient {
return ReactionLevelTypeTransformer().decode(value); return ReactionLevelTypeTransformer().decode(value);
case 'ReactionType': case 'ReactionType':
return ReactionTypeTypeTransformer().decode(value); return ReactionTypeTypeTransformer().decode(value);
case 'ReleaseChannel':
return ReleaseChannelTypeTransformer().decode(value);
case 'ReleaseEventV1':
return ReleaseEventV1.fromJson(value);
case 'ReleaseType':
return ReleaseTypeTypeTransformer().decode(value);
case 'ReverseGeocodingStateResponseDto': case 'ReverseGeocodingStateResponseDto':
return ReverseGeocodingStateResponseDto.fromJson(value); return ReverseGeocodingStateResponseDto.fromJson(value);
case 'RotateParameters': case 'RotateParameters':
+6
View File
@@ -157,6 +157,12 @@ String parameterToString(dynamic value) {
if (value is ReactionType) { if (value is ReactionType) {
return ReactionTypeTypeTransformer().encode(value).toString(); 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) { if (value is SearchSuggestionType) {
return SearchSuggestionTypeTypeTransformer().encode(value).toString(); return SearchSuggestionTypeTypeTransformer().encode(value).toString();
} }
+85
View File
@@ -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 = <ReleaseChannel>[
stable,
releaseCandidate,
];
static ReleaseChannel? fromJson(dynamic value) => ReleaseChannelTypeTransformer().decode(value);
static List<ReleaseChannel> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseChannel>[];
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;
}
+133
View File
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return ReleaseEventV1(
checkedAt: mapValueOfType<String>(json, r'checkedAt')!,
isAvailable: mapValueOfType<bool>(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<ReleaseEventV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseEventV1>[];
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<String, ReleaseEventV1> mapFromJson(dynamic json) {
final map = <String, ReleaseEventV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<ReleaseEventV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ReleaseEventV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'checkedAt',
'isAvailable',
'releaseVersion',
'serverVersion',
'type',
};
}
+103
View File
@@ -0,0 +1,103 @@
//
// 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');
static const release = ReleaseType._(r'release');
/// List of all possible values in this [enum][ReleaseType].
static const values = <ReleaseType>[
major,
premajor,
minor,
preminor,
patch_,
prepatch,
prerelease,
release,
];
static ReleaseType? fromJson(dynamic value) => ReleaseTypeTypeTransformer().decode(value);
static List<ReleaseType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseType>[];
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;
case r'release': return ReleaseType.release;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseTypeTypeTransformer] instance.
static ReleaseTypeTypeTransformer? _instance;
}
+22 -6
View File
@@ -16,47 +16,61 @@ class ServerVersionResponseDto {
required this.major, required this.major,
required this.minor, required this.minor,
required this.patch_, required this.patch_,
required this.prerelease,
}); });
/// Major version number /// Major version number
/// ///
/// Minimum value: -9007199254740991 /// Minimum value: 0
/// Maximum value: 9007199254740991 /// Maximum value: 9007199254740991
int major; int major;
/// Minor version number /// Minor version number
/// ///
/// Minimum value: -9007199254740991 /// Minimum value: 0
/// Maximum value: 9007199254740991 /// Maximum value: 9007199254740991
int minor; int minor;
/// Patch version number /// Patch version number
/// ///
/// Minimum value: -9007199254740991 /// Minimum value: 0
/// Maximum value: 9007199254740991 /// Maximum value: 9007199254740991
int patch_; int patch_;
/// Pre-release version number
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? prerelease;
@override @override
bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto && bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto &&
other.major == major && other.major == major &&
other.minor == minor && other.minor == minor &&
other.patch_ == patch_; other.patch_ == patch_ &&
other.prerelease == prerelease;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(major.hashCode) + (major.hashCode) +
(minor.hashCode) + (minor.hashCode) +
(patch_.hashCode); (patch_.hashCode) +
(prerelease == null ? 0 : prerelease!.hashCode);
@override @override
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]'; String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_, prerelease=$prerelease]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'major'] = this.major; json[r'major'] = this.major;
json[r'minor'] = this.minor; json[r'minor'] = this.minor;
json[r'patch'] = this.patch_; json[r'patch'] = this.patch_;
if (this.prerelease != null) {
json[r'prerelease'] = this.prerelease;
} else {
// json[r'prerelease'] = null;
}
return json; return json;
} }
@@ -72,6 +86,7 @@ class ServerVersionResponseDto {
major: mapValueOfType<int>(json, r'major')!, major: mapValueOfType<int>(json, r'major')!,
minor: mapValueOfType<int>(json, r'minor')!, minor: mapValueOfType<int>(json, r'minor')!,
patch_: mapValueOfType<int>(json, r'patch')!, patch_: mapValueOfType<int>(json, r'patch')!,
prerelease: mapValueOfType<int>(json, r'prerelease'),
); );
} }
return null; return null;
@@ -122,6 +137,7 @@ class ServerVersionResponseDto {
'major', 'major',
'minor', 'minor',
'patch', 'patch',
'prerelease',
}; };
} }
@@ -13,26 +13,32 @@ part of openapi.api;
class SystemConfigNewVersionCheckDto { class SystemConfigNewVersionCheckDto {
/// Returns a new [SystemConfigNewVersionCheckDto] instance. /// Returns a new [SystemConfigNewVersionCheckDto] instance.
SystemConfigNewVersionCheckDto({ SystemConfigNewVersionCheckDto({
required this.channel,
required this.enabled, required this.enabled,
}); });
ReleaseChannel channel;
/// Enabled /// Enabled
bool enabled; bool enabled;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigNewVersionCheckDto && bool operator ==(Object other) => identical(this, other) || other is SystemConfigNewVersionCheckDto &&
other.channel == channel &&
other.enabled == enabled; other.enabled == enabled;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(channel.hashCode) +
(enabled.hashCode); (enabled.hashCode);
@override @override
String toString() => 'SystemConfigNewVersionCheckDto[enabled=$enabled]'; String toString() => 'SystemConfigNewVersionCheckDto[channel=$channel, enabled=$enabled]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'channel'] = this.channel;
json[r'enabled'] = this.enabled; json[r'enabled'] = this.enabled;
return json; return json;
} }
@@ -46,6 +52,7 @@ class SystemConfigNewVersionCheckDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return SystemConfigNewVersionCheckDto( return SystemConfigNewVersionCheckDto(
channel: ReleaseChannel.fromJson(json[r'channel'])!,
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
); );
} }
@@ -94,6 +101,7 @@ class SystemConfigNewVersionCheckDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'channel',
'enabled', 'enabled',
}; };
} }
@@ -116,7 +116,7 @@ void main() {
when(() => mockApi.serverInfoApi).thenReturn(mockServerApi); when(() => mockApi.serverInfoApi).thenReturn(mockServerApi);
when( when(
() => mockServerApi.getServerVersion(), () => 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.updateUsersV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler);
@@ -559,7 +559,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]"); await Store.put(StoreKey.syncMigrationStatus, "[]");
when( when(
() => mockServerApi.getServerVersion(), () => 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(); await sut.sync();
@@ -587,7 +587,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]"); await Store.put(StoreKey.syncMigrationStatus, "[]");
when( when(
() => mockServerApi.getServerVersion(), () => 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(); await sut.sync();
verifyInOrder([ verifyInOrder([
@@ -617,7 +617,7 @@ void main() {
when( when(
() => mockServerApi.getServerVersion(), () => 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(); await sut.sync();
+66
View File
@@ -88,5 +88,71 @@ void main() {
expect(version2.minor, 2); expect(version2.minor, 2);
expect(version2.patch, 3); 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);
});
}); });
} }
+74 -4
View File
@@ -20800,6 +20800,58 @@
], ],
"type": "string" "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",
"release"
],
"type": "string"
},
"ReverseGeocodingStateResponseDto": { "ReverseGeocodingStateResponseDto": {
"properties": { "properties": {
"lastImportFileName": { "lastImportFileName": {
@@ -21469,26 +21521,40 @@
"major": { "major": {
"description": "Major version number", "description": "Major version number",
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": -9007199254740991, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"minor": { "minor": {
"description": "Minor version number", "description": "Minor version number",
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": -9007199254740991, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"patch": { "patch": {
"description": "Patch version number", "description": "Patch version number",
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": -9007199254740991, "minimum": 0,
"type": "integer" "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": [ "required": [
"major", "major",
"minor", "minor",
"patch" "patch",
"prerelease"
], ],
"type": "object" "type": "object"
}, },
@@ -24509,12 +24575,16 @@
}, },
"SystemConfigNewVersionCheckDto": { "SystemConfigNewVersionCheckDto": {
"properties": { "properties": {
"channel": {
"$ref": "#/components/schemas/ReleaseChannel"
},
"enabled": { "enabled": {
"description": "Enabled", "description": "Enabled",
"type": "boolean" "type": "boolean"
} }
}, },
"required": [ "required": [
"channel",
"enabled" "enabled"
], ],
"type": "object" "type": "object"
+27
View File
@@ -2074,6 +2074,8 @@ export type ServerVersionResponseDto = {
minor: number; minor: number;
/** Patch version number */ /** Patch version number */
patch: number; patch: number;
/** Pre-release version number */
prerelease: number | null;
}; };
export type VersionCheckStateResponseDto = { export type VersionCheckStateResponseDto = {
/** Last check timestamp */ /** Last check timestamp */
@@ -2421,6 +2423,7 @@ export type SystemConfigMetadataDto = {
faces: SystemConfigFacesDto; faces: SystemConfigFacesDto;
}; };
export type SystemConfigNewVersionCheckDto = { export type SystemConfigNewVersionCheckDto = {
channel: ReleaseChannel;
/** Enabled */ /** Enabled */
enabled: boolean; enabled: boolean;
}; };
@@ -2766,6 +2769,16 @@ export type WorkflowShareResponseDto = {
trigger: WorkflowTrigger; trigger: WorkflowTrigger;
}; };
export type LicenseResponseDto = UserLicense; 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 SyncAckV1 = {};
export type SyncAlbumDeleteV1 = { export type SyncAlbumDeleteV1 = {
/** Album ID */ /** Album ID */
@@ -7305,6 +7318,10 @@ export enum LogLevel {
Error = "error", Error = "error",
Fatal = "fatal" Fatal = "fatal"
} }
export enum ReleaseChannel {
Stable = "stable",
ReleaseCandidate = "releaseCandidate"
}
export enum OAuthTokenEndpointAuthMethod { export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post", ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic" ClientSecretBasic = "client_secret_basic"
@@ -7313,6 +7330,16 @@ export enum AssetOrderBy {
TakenAt = "takenAt", TakenAt = "takenAt",
CreatedAt = "createdAt" CreatedAt = "createdAt"
} }
export enum ReleaseType {
Major = "major",
Premajor = "premajor",
Minor = "minor",
Preminor = "preminor",
Patch = "patch",
Prepatch = "prepatch",
Prerelease = "prerelease",
Release = "release"
}
export enum UserMetadataKey { export enum UserMetadataKey {
Preferences = "preferences", Preferences = "preferences",
License = "license", License = "license",
+27 -20
View File
@@ -571,8 +571,8 @@ importers:
specifier: ^1.6.3 specifier: ^1.6.3
version: 1.6.4 version: 1.6.4
semver: semver:
specifier: ^7.6.2 specifier: ^7.8.1
version: 7.8.0 version: 7.8.1
sharp: sharp:
specifier: ^0.34.5 specifier: ^0.34.5
version: 0.34.5 version: 0.34.5
@@ -11243,6 +11243,11 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
semver@7.8.1:
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
engines: {node: '>=10'}
hasBin: true
send@0.19.2: send@0.19.2:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -16300,7 +16305,7 @@ snapshots:
nopt: 5.0.0 nopt: 5.0.0
npmlog: 5.0.1 npmlog: 5.0.1
rimraf: 3.0.2 rimraf: 3.0.2
semver: 7.8.0 semver: 7.8.1
tar: 6.2.1 tar: 6.2.1
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@@ -17757,7 +17762,7 @@ snapshots:
'@testing-library/dom@10.4.1': '@testing-library/dom@10.4.1':
dependencies: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4 '@types/aria-query': 5.0.4
aria-query: 5.3.0 aria-query: 5.3.0
dom-accessibility-api: 0.5.16 dom-accessibility-api: 0.5.16
@@ -18461,7 +18466,7 @@ snapshots:
'@typescript-eslint/visitor-keys': 8.59.4 '@typescript-eslint/visitor-keys': 8.59.4
debug: 4.4.3 debug: 4.4.3
minimatch: 10.2.5 minimatch: 10.2.5
semver: 7.8.0 semver: 7.8.1
tinyglobby: 0.2.16 tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@6.0.3) ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3 typescript: 6.0.3
@@ -19561,7 +19566,7 @@ snapshots:
dot-prop: 10.1.0 dot-prop: 10.1.0
env-paths: 3.0.0 env-paths: 3.0.0
json-schema-typed: 8.0.2 json-schema-typed: 8.0.2
semver: 7.8.0 semver: 7.8.1
uint8array-extras: 1.5.0 uint8array-extras: 1.5.0
config-chain@1.1.13: config-chain@1.1.13:
@@ -19733,7 +19738,7 @@ snapshots:
postcss-modules-scope: 3.2.1(postcss@8.5.15) postcss-modules-scope: 3.2.1(postcss@8.5.15)
postcss-modules-values: 4.0.0(postcss@8.5.15) postcss-modules-values: 4.0.0(postcss@8.5.15)
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
semver: 7.8.0 semver: 7.8.1
optionalDependencies: optionalDependencies:
webpack: 5.107.0(postcss@8.5.15) webpack: 5.107.0(postcss@8.5.15)
@@ -20601,7 +20606,7 @@ snapshots:
find-up: 5.0.0 find-up: 5.0.0
globals: 15.15.0 globals: 15.15.0
lodash.memoize: 4.1.2 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): 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: dependencies:
@@ -20624,7 +20629,7 @@ snapshots:
postcss: 8.5.15 postcss: 8.5.15
postcss-load-config: 3.1.4(postcss@8.5.15) postcss-load-config: 3.1.4(postcss@8.5.15)
postcss-safe-parser: 7.0.1(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)) svelte-eslint-parser: 1.6.1(svelte@5.55.8(@typescript-eslint/types@8.59.4))
optionalDependencies: optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4) svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -21102,7 +21107,7 @@ snapshots:
minimatch: 3.1.5 minimatch: 3.1.5
node-abort-controller: 3.1.1 node-abort-controller: 3.1.1
schema-utils: 3.3.0 schema-utils: 3.3.0
semver: 7.8.0 semver: 7.8.1
tapable: 2.3.3 tapable: 2.3.3
typescript: 5.9.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) webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
@@ -21538,7 +21543,7 @@ snapshots:
history@4.10.1: history@4.10.1:
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.7
loose-envify: 1.4.0 loose-envify: 1.4.0
resolve-pathname: 3.0.0 resolve-pathname: 3.0.0
tiny-invariant: 1.3.3 tiny-invariant: 1.3.3
@@ -22126,7 +22131,7 @@ snapshots:
lodash.isstring: 4.0.1 lodash.isstring: 4.0.1
lodash.once: 4.1.1 lodash.once: 4.1.1
ms: 2.1.3 ms: 2.1.3
semver: 7.8.0 semver: 7.8.1
just-compare@2.3.0: {} just-compare@2.3.0: {}
@@ -22412,7 +22417,7 @@ snapshots:
make-dir@4.0.0: make-dir@4.0.0:
dependencies: dependencies:
semver: 7.8.0 semver: 7.8.1
maplibre-gl@5.24.0: maplibre-gl@5.24.0:
dependencies: dependencies:
@@ -23247,7 +23252,7 @@ snapshots:
node-abi@3.92.0: node-abi@3.92.0:
dependencies: dependencies:
semver: 7.8.0 semver: 7.8.1
optional: true optional: true
node-abort-controller@3.1.1: {} node-abort-controller@3.1.1: {}
@@ -23288,7 +23293,7 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
nopt: 9.0.0 nopt: 9.0.0
proc-log: 6.1.0 proc-log: 6.1.0
semver: 7.8.0 semver: 7.8.1
tar: 7.5.15 tar: 7.5.15
tinyglobby: 0.2.16 tinyglobby: 0.2.16
undici: 6.25.0 undici: 6.25.0
@@ -23526,7 +23531,7 @@ snapshots:
got: 12.6.1 got: 12.6.1
registry-auth-token: 5.1.1 registry-auth-token: 5.1.1
registry-url: 6.0.1 registry-url: 6.0.1
semver: 7.8.0 semver: 7.8.1
package-manager-detector@1.6.0: {} package-manager-detector@1.6.0: {}
@@ -23914,7 +23919,7 @@ snapshots:
cosmiconfig: 8.3.6(typescript@6.0.3) cosmiconfig: 8.3.6(typescript@6.0.3)
jiti: 1.21.7 jiti: 1.21.7
postcss: 8.5.15 postcss: 8.5.15
semver: 7.8.0 semver: 7.8.1
webpack: 5.107.0(postcss@8.5.15) webpack: 5.107.0(postcss@8.5.15)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
@@ -24969,12 +24974,14 @@ snapshots:
semver-diff@4.0.0: semver-diff@4.0.0:
dependencies: dependencies:
semver: 7.8.0 semver: 7.8.1
semver@6.3.1: {} semver@6.3.1: {}
semver@7.8.0: {} semver@7.8.0: {}
semver@7.8.1: {}
send@0.19.2: send@0.19.2:
dependencies: dependencies:
debug: 2.6.9 debug: 2.6.9
@@ -25509,7 +25516,7 @@ snapshots:
postcss: 8.5.15 postcss: 8.5.15
postcss-scss: 4.0.9(postcss@8.5.15) postcss-scss: 4.0.9(postcss@8.5.15)
postcss-selector-parser: 7.1.1 postcss-selector-parser: 7.1.1
semver: 7.8.0 semver: 7.8.1
optionalDependencies: optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4) svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -26217,7 +26224,7 @@ snapshots:
is-yarn-global: 0.4.1 is-yarn-global: 0.4.1
latest-version: 7.0.0 latest-version: 7.0.0
pupa: 3.3.0 pupa: 3.3.0
semver: 7.8.0 semver: 7.8.1
semver-diff: 4.0.0 semver-diff: 4.0.0
xdg-basedir: 5.1.0 xdg-basedir: 5.1.0
+1 -1
View File
@@ -106,7 +106,7 @@
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"semver": "^7.6.2", "semver": "^7.8.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sirv": "^3.0.0", "sirv": "^3.0.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
+3
View File
@@ -1,4 +1,5 @@
import { CronExpression } from '@nestjs/schedule'; import { CronExpression } from '@nestjs/schedule';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { import {
AudioCodec, AudioCodec,
Colorspace, Colorspace,
@@ -135,6 +136,7 @@ export type SystemConfig = {
}; };
newVersionCheck: { newVersionCheck: {
enabled: boolean; enabled: boolean;
channel: ReleaseChannel;
}; };
nightlyTasks: { nightlyTasks: {
startTime: string; startTime: string;
@@ -344,6 +346,7 @@ export const defaults = Object.freeze<SystemConfig>({
}, },
newVersionCheck: { newVersionCheck: {
enabled: true, enabled: true,
channel: ReleaseChannel.Stable,
}, },
nightlyTasks: { nightlyTasks: {
startTime: '00:00', startTime: '00:00',
+10
View File
@@ -265,3 +265,13 @@ export class HistoryBuilder {
return this; 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);
};
};
+39 -11
View File
@@ -1,5 +1,6 @@
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import type { SemVer } from 'semver'; import type { SemVer } from 'semver';
import { ExtraModel, HistoryBuilder } from 'src/decorators';
import { isoDatetimeToDate } from 'src/validation'; import { isoDatetimeToDate } from 'src/validation';
import z from 'zod'; import z from 'zod';
@@ -58,9 +59,15 @@ const ServerStorageResponseSchema = z
const ServerVersionResponseSchema = z const ServerVersionResponseSchema = z
.object({ .object({
major: z.int().describe('Major version number'), major: z.int().min(0).describe('Major version number'),
minor: z.int().describe('Minor version number'), minor: z.int().min(0).describe('Minor version number'),
patch: z.int().describe('Patch 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' }); .meta({ id: 'ServerVersionResponseDto' });
@@ -140,6 +147,27 @@ const ServerFeaturesSchema = z
}) })
.meta({ id: 'ServerFeaturesDto' }); .meta({ id: 'ServerFeaturesDto' });
export enum ReleaseType {
Major = 'major',
Premajor = 'premajor',
Minor = 'minor',
Preminor = 'preminor',
Patch = 'patch',
Prepatch = 'prepatch',
Prerelease = 'prerelease',
Release = 'release',
}
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 ServerPingResponse extends createZodDto(ServerPingResponseSchema) {}
export class ServerAboutResponseDto extends createZodDto(ServerAboutResponseSchema) {} export class ServerAboutResponseDto extends createZodDto(ServerAboutResponseSchema) {}
export class ServerApkLinksDto extends createZodDto(ServerApkLinksSchema) {} export class ServerApkLinksDto extends createZodDto(ServerApkLinksSchema) {}
@@ -147,7 +175,12 @@ export class ServerStorageResponseDto extends createZodDto(ServerStorageResponse
export class ServerVersionResponseDto extends createZodDto(ServerVersionResponseSchema) { export class ServerVersionResponseDto extends createZodDto(ServerVersionResponseSchema) {
static fromSemVer(value: SemVer): z.infer<typeof ServerVersionResponseSchema> { static fromSemVer(value: SemVer): z.infer<typeof ServerVersionResponseSchema> {
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 +191,5 @@ export class ServerMediaTypesResponseDto extends createZodDto(ServerMediaTypesRe
export class ServerConfigDto extends createZodDto(ServerConfigSchema) {} export class ServerConfigDto extends createZodDto(ServerConfigSchema) {}
export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {} export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {}
export interface ReleaseNotification { @ExtraModel()
isAvailable: boolean; export class ReleaseEventV1 extends createZodDto(ReleaseEventV1Schema) {}
/** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}
+1 -10
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { ExtraModel } from 'src/decorators';
import { AssetEditActionSchema } from 'src/dtos/editing.dto'; import { AssetEditActionSchema } from 'src/dtos/editing.dto';
import { import {
AlbumUserRole, AlbumUserRole,
@@ -17,15 +17,6 @@ import {
import { isoDatetimeToDate } from 'src/validation'; import { isoDatetimeToDate } from 'src/validation';
import z from 'zod'; 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 const SyncUserV1Schema = z
.object({ .object({
id: z.string().describe('User ID'), id: z.string().describe('User ID'),
+8 -1
View File
@@ -151,8 +151,15 @@ const SystemConfigMapSchema = z
}) })
.meta({ id: 'SystemConfigMapDto' }); .meta({ id: 'SystemConfigMapDto' });
export enum ReleaseChannel {
Stable = 'stable',
ReleaseCandidate = 'releaseCandidate',
}
const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' });
const SystemConfigNewVersionCheckSchema = z const SystemConfigNewVersionCheckSchema = z
.object({ enabled: configBool.describe('Enabled') }) .object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema })
.meta({ id: 'SystemConfigNewVersionCheckDto' }); .meta({ id: 'SystemConfigNewVersionCheckDto' });
const SystemConfigNightlyTasksSchema = z const SystemConfigNightlyTasksSchema = z
@@ -4,6 +4,7 @@ import { exec as execCallback } from 'node:child_process';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import sharp from 'sharp'; import sharp from 'sharp';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -64,10 +65,12 @@ export class ServerInfoRepository {
this.logger.setContext(ServerInfoRepository.name); this.logger.setContext(ServerInfoRepository.name);
} }
async getLatestRelease(): Promise<VersionResponse> { async getLatestRelease(channel: ReleaseChannel): Promise<VersionResponse> {
try { try {
const { versionCheck } = this.configRepository.getEnv(); 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) { if (!response.ok) {
throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`); throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`);
@@ -10,7 +10,7 @@ import { Server, Socket } from 'socket.io';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.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 { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV2 } from 'src/dtos/sync.dto';
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository'; import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -31,7 +31,7 @@ export interface ClientEventMap {
on_person_thumbnail: [string]; on_person_thumbnail: [string];
on_server_version: [ServerVersionResponseDto]; on_server_version: [ServerVersionResponseDto];
on_config_update: []; on_config_update: [];
on_new_release: [ReleaseNotification]; on_new_release: [ReleaseEventV1];
on_notification: [NotificationDto]; on_notification: [NotificationDto];
on_session_delete: [string]; on_session_delete: [string];
@@ -1,5 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/config';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { import {
AudioCodec, AudioCodec,
Colorspace, Colorspace,
@@ -184,6 +185,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
}, },
newVersionCheck: { newVersionCheck: {
enabled: true, enabled: true,
channel: ReleaseChannel.Stable,
}, },
trash: { trash: {
enabled: true, enabled: true,
+40 -12
View File
@@ -2,6 +2,7 @@ import { DateTime } from 'luxon';
import { SemVer } from 'semver'; import { SemVer } from 'semver';
import { defaults } from 'src/config'; import { defaults } from 'src/config';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service'; import { VersionService } from 'src/services/version.service';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
@@ -22,6 +23,17 @@ describe(VersionService.name, () => {
mocks.cron.update.mockResolvedValue(); mocks.cron.update.mockResolvedValue();
}); });
beforeAll(() => {
vitest.mock(import('src/constants.js'), async () => ({
...(await vitest.importActual<typeof import('src/constants.js')>('src/constants.js')),
serverVersion: new SemVer('v3.0.0'),
}));
});
afterAll(() => {
vitest.unmock(import('src/constants.js'));
});
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
@@ -66,9 +78,10 @@ describe(VersionService.name, () => {
describe('getVersion', () => { describe('getVersion', () => {
it('should respond the server version', () => { it('should respond the server version', () => {
expect(sut.getVersion()).toEqual({ expect(sut.getVersion()).toEqual({
major: serverVersion.major, major: 3,
minor: serverVersion.minor, minor: 0,
patch: serverVersion.patch, patch: 0,
prerelease: null,
}); });
}); });
}); });
@@ -143,24 +156,24 @@ describe(VersionService.name, () => {
describe('onConfigUpdate', () => { describe('onConfigUpdate', () => {
it('should queue a version check job when newVersionCheck is enabled', async () => { it('should queue a version check job when newVersionCheck is enabled', async () => {
await sut.onConfigUpdate({ await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: false } }, oldConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } }, newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
}); });
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} });
}); });
it('should not queue a version check job when newVersionCheck is disabled', async () => { it('should not queue a version check job when newVersionCheck is disabled', async () => {
await sut.onConfigUpdate({ await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } }, oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: false } }, newConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } },
}); });
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
}); });
it('should not queue a version check job when newVersionCheck was already enabled', async () => { it('should not queue a version check job when newVersionCheck was already enabled', async () => {
await sut.onConfigUpdate({ await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } }, oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } }, newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
}); });
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
}); });
@@ -169,21 +182,36 @@ describe(VersionService.name, () => {
describe('onWebsocketConnection', () => { describe('onWebsocketConnection', () => {
it('should send on_server_version client event', async () => { it('should send on_server_version client event', async () => {
await sut.onWebsocketConnection({ userId: '42' }); 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); expect(mocks.websocket.clientSend).toHaveBeenCalledTimes(1);
}); });
it('should also send a new release notification', async () => { it('should also send a new release notification', async () => {
mocks.systemMetadata.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' }); mocks.systemMetadata.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' });
await sut.onWebsocketConnection({ userId: '42' }); 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)); 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 () => { it('should not send a release notification when the version check is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } }); mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } });
await sut.onWebsocketConnection({ userId: '42' }); 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)); expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
}); });
}); });
+27 -8
View File
@@ -3,19 +3,27 @@ import { DateTime } from 'luxon';
import semver, { SemVer } from 'semver'; import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators'; 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 { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { VersionCheckMetadata } from 'src/types'; import { VersionCheckMetadata } from 'src/types';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { const asNotification = (
channel: ReleaseChannel,
{ checkedAt, releaseVersion }: VersionCheckMetadata,
): ReleaseEventV1 => {
return { 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, checkedAt,
serverVersion: ServerVersionResponseDto.fromSemVer(serverVersion), serverVersion: ServerVersionResponseDto.fromSemVer(serverVersion),
releaseVersion: ServerVersionResponseDto.fromSemVer(new SemVer(releaseVersion)), 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 }; const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion };
await this.systemMetadataRepository.set(SystemMetadataKey.VersionCheckState, metadata); 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.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) { } catch (error: Error | any) {
this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`); this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`);
@@ -117,7 +132,11 @@ export class VersionService extends BaseService {
@OnEvent({ name: 'WebsocketConnect' }) @OnEvent({ name: 'WebsocketConnect' })
async onWebsocketConnection({ userId }: ArgOf<'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 }); const { newVersionCheck } = await this.getConfig({ withCache: true });
if (!newVersionCheck.enabled) { if (!newVersionCheck.enabled) {
@@ -126,7 +145,7 @@ export class VersionService extends BaseService {
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState); const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState);
if (metadata) { if (metadata) {
this.websocketRepository.clientSend('on_new_release', userId, asNotification(metadata)); this.websocketRepository.clientSend('on_new_release', userId, asNotification(newVersionCheck.channel, metadata));
} }
} }
} }
+2 -2
View File
@@ -15,7 +15,7 @@ import picomatch from 'picomatch';
import parse from 'picomatch/lib/parse'; import parse from 'picomatch/lib/parse';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { CLIP_MODEL_INFO, endpointTags, serverVersion } from 'src/constants'; 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 { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -289,7 +289,7 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean })
const options: SwaggerDocumentOptions = { const options: SwaggerDocumentOptions = {
operationIdFactory: (controllerKey: string, methodKey: string) => methodKey, operationIdFactory: (controllerKey: string, methodKey: string) => methodKey,
extraModels: extraSyncModels, extraModels,
ignoreGlobalPrefix: true, ignoreGlobalPrefix: true,
}; };
@@ -4,12 +4,12 @@
import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte'; import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte';
import { userInteraction } from '$lib/stores/user.svelte'; import { userInteraction } from '$lib/stores/user.svelte';
import { websocketStore } from '$lib/stores/websocket'; import { websocketStore } from '$lib/stores/websocket';
import type { ReleaseEvent } from '$lib/types';
import { semverToName } from '$lib/utils'; import { semverToName } from '$lib/utils';
import { requestServerInfo } from '$lib/utils/auth'; import { requestServerInfo } from '$lib/utils/auth';
import { import {
getAboutInfo, getAboutInfo,
getVersionHistory, getVersionHistory,
type ReleaseEventV1,
type ServerAboutResponseDto, type ServerAboutResponseDto,
type ServerVersionHistoryResponseDto, type ServerVersionHistoryResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
@@ -35,11 +35,9 @@
userInteraction.versions = versions; userInteraction.versions = versions;
}); });
let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich'); let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich');
let version = $derived( let version = $derived($serverVersion ? semverToName($serverVersion) : null);
$serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
);
const getReleaseInfo = (release?: ReleaseEvent) => { const getReleaseInfo = (release?: ReleaseEventV1) => {
if (!release || !release?.isAvailable || !authManager.user.isAdmin) { if (!release || !release?.isAvailable || !authManager.user.isAdmin) {
return; return;
} }
+2 -2
View File
@@ -7,13 +7,13 @@ import type {
LoginResponseDto, LoginResponseDto,
PersonResponseDto, PersonResponseDto,
QueueResponseDto, QueueResponseDto,
ReleaseEventV1,
SharedLinkResponseDto, SharedLinkResponseDto,
SystemConfigDto, SystemConfigDto,
TagResponseDto, TagResponseDto,
UserAdminResponseDto, UserAdminResponseDto,
WorkflowResponseDto, WorkflowResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import type { ReleaseEvent } from '$lib/types';
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte'; import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import type { TreeNode } from '$lib/utils/tree-utils'; import type { TreeNode } from '$lib/utils/tree-utils';
@@ -86,7 +86,7 @@ export type Events = {
WorkflowUpdate: [WorkflowResponseDto]; WorkflowUpdate: [WorkflowResponseDto];
WorkflowDelete: [WorkflowResponseDto]; WorkflowDelete: [WorkflowResponseDto];
ReleaseEvent: [ReleaseEvent]; ReleaseEvent: [ReleaseEventV1];
WebsocketConnect: []; WebsocketConnect: [];
}; };
@@ -1,8 +1,8 @@
import type { ReleaseEventV1 } from '@immich/sdk';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { type ReleaseEvent } from '$lib/types';
class ReleaseManager { class ReleaseManager {
value = $state<ReleaseEvent | undefined>(); value = $state<ReleaseEventV1 | undefined>();
constructor() { constructor() {
eventManager.on({ eventManager.on({
+2 -2
View File
@@ -3,6 +3,7 @@ import {
type AssetResponseDto, type AssetResponseDto,
type MaintenanceStatusResponseDto, type MaintenanceStatusResponseDto,
type NotificationDto, type NotificationDto,
type ReleaseEventV1,
type ServerVersionResponseDto, type ServerVersionResponseDto,
type SyncAssetEditV1, type SyncAssetEditV1,
type SyncAssetV2, type SyncAssetV2,
@@ -15,7 +16,6 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { maintenanceStore } from '$lib/stores/maintenance.store'; import { maintenanceStore } from '$lib/stores/maintenance.store';
import { notificationManager } from '$lib/stores/notification-manager.svelte'; import { notificationManager } from '$lib/stores/notification-manager.svelte';
import type { ReleaseEvent } from '$lib/types';
import { createEventEmitter } from '$lib/utils/eventemitter'; import { createEventEmitter } from '$lib/utils/eventemitter';
interface AppRestartEvent { interface AppRestartEvent {
@@ -34,7 +34,7 @@ export interface Events {
on_person_thumbnail: (personId: string) => void; on_person_thumbnail: (personId: string) => void;
on_server_version: (serverVersion: ServerVersionResponseDto) => void; on_server_version: (serverVersion: ServerVersionResponseDto) => void;
on_config_update: () => void; on_config_update: () => void;
on_new_release: (event: ReleaseEvent) => void; on_new_release: (event: ReleaseEventV1) => void;
on_session_delete: (sessionId: string) => void; on_session_delete: (sessionId: string) => void;
on_notification: (notification: NotificationDto) => void; on_notification: (notification: NotificationDto) => void;
+1 -9
View File
@@ -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 { ActionItem } from '@immich/ui';
import type { DateTime } from 'luxon'; import type { DateTime } from 'luxon';
import type { SvelteSet } from 'svelte/reactivity'; 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 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 QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] };
export type HeaderButtonActionItem = ActionItem & { data?: { title?: string } }; export type HeaderButtonActionItem = ActionItem & { data?: { title?: string } };
+1 -23
View File
@@ -1,5 +1,5 @@
import { AssetTypeEnum } from '@immich/sdk'; 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 { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
@@ -161,26 +161,4 @@ describe('utils', () => {
expect(url).toContain(asset.id); 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');
});
});
}); });
+2 -20
View File
@@ -411,26 +411,8 @@ export function createDateFormatter(localeCode: string | undefined): DateFormatt
}; };
} }
export const getReleaseType = ( export const semverToName = ({ major, minor, patch, prerelease }: ServerVersionResponseDto) =>
current: ServerVersionResponseDto, `v${major}.${minor}.${patch}${prerelease ? `-rc.${prerelease}` : ''}`;
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 withoutIcons = (actions: ActionItem[]): ActionItem[] => export const withoutIcons = (actions: ActionItem[]): ActionItem[] =>
actions.map((action) => ({ ...action, icon: undefined })); actions.map((action) => ({ ...action, icon: undefined }));
+9 -5
View File
@@ -2,8 +2,8 @@
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte'; import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
import type { ReleaseEvent } from '$lib/types'; import { semverToName } from '$lib/utils';
import { getReleaseType, semverToName } from '$lib/utils'; import { ReleaseType, type ReleaseEventV1 } from '@immich/sdk';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
let modal = $state<{ let modal = $state<{
@@ -11,16 +11,20 @@
close: () => Promise<void>; close: () => Promise<void>;
}>(); }>();
const onReleaseEvent = async (release: ReleaseEvent) => { const onReleaseEvent = async (release: ReleaseEventV1) => {
if (!release.isAvailable || !authManager.user.isAdmin) { if (!release.isAvailable || !authManager.user.isAdmin) {
return; return;
} }
const releaseVersion = semverToName(release.releaseVersion); const releaseVersion = semverToName(release.releaseVersion);
const serverVersion = semverToName(release.serverVersion); 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; return;
} }
@@ -5,8 +5,11 @@
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import SettingSelect from './SettingSelect.svelte';
import { ReleaseChannel } from '@immich/sdk';
const disabled = $derived(featureFlagsManager.value.configFile); const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue()); let configToEdit = $state(systemConfigManager.cloneValue());
</script> </script>
@@ -20,6 +23,20 @@
bind:checked={configToEdit.newVersionCheck.enabled} bind:checked={configToEdit.newVersionCheck.enabled}
{disabled} {disabled}
/> />
<SettingSelect
label={$t('admin.version_check_channel')}
desc={$t('admin.version_check_channel_description')}
bind:value={configToEdit.newVersionCheck.channel}
options={[
{
value: ReleaseChannel.Stable,
text: $t('admin.release_channel_stable'),
},
{ value: ReleaseChannel.ReleaseCandidate, text: $t('admin.release_channel_release_candidate') },
]}
isEdited={configToEdit.newVersionCheck.channel !== config.newVersionCheck.channel}
{disabled}
/>
<SettingButtonsRow bind:configToEdit keys={['newVersionCheck']} {disabled} /> <SettingButtonsRow bind:configToEdit keys={['newVersionCheck']} {disabled} />
</div> </div>
</form> </form>