Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Tran 0b15a90618 test: fix scroll flicker 2 2026-05-27 15:43:25 -05:00
57 changed files with 306 additions and 1027 deletions
+24 -15
View File
@@ -1,46 +1,46 @@
dev: dev:
@printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n" >&2 && exit 1 @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-down: dev-down:
@printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n" >&2 && exit 1 docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
dev-update: dev-update:
@printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n" >&2 && exit 1 @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale: dev-scale:
@printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n" >&2 && exit 1 @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
dev-docs: dev-docs:
npm --prefix docs run start npm --prefix docs run start
.PHONY: e2e .PHONY: e2e
e2e: e2e:
@printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n" >&2 && exit 1 @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
e2e-dev: e2e-dev:
@printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n" >&2 && exit 1 @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
e2e-update: e2e-update:
@printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n" >&2 && exit 1 @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e-down: e2e-down:
@printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n" >&2 && exit 1 docker compose -f ./e2e/docker-compose.yml down --remove-orphans
prod: prod:
@printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n" >&2 && exit 1 @trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-down: prod-down:
@printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n" >&2 && exit 1 docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
prod-scale: prod-scale:
@printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n" >&2 && exit 1 @trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
.PHONY: open-api .PHONY: open-api
open-api: open-api:
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n" >&2 && exit 1 @printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n"\n\n >&2 && exit 1
sql: sql:
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n" >&2 && exit 1 @printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n"\n\n >&2 && exit 1
renovate: renovate:
@@ -52,7 +52,16 @@ renovate:
MODULES = e2e server web cli sdk docs .github MODULES = e2e server web cli sdk docs .github
test-e2e: test-e2e:
@printf "This command has been removed. Please use:\n\n mise //e2e:test # or mise //e2e:test-web for web tests, respectively\n\n" >&2 && exit 1 docker compose -f ./e2e/docker-compose.yml build
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
clean: clean:
@printf "This command has been removed. Please use:\n\n mise clean # or mise //:clean from another directory\n\n" >&2 && exit 1 find . -name "node_modules" -type d -prune -exec rm -rf {} +
find . -name "dist" -type d -prune -exec rm -rf '{}' +
find . -name "build" -type d -prune -exec rm -rf '{}' +
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true
+1 -16
View File
@@ -1,21 +1,11 @@
[tasks.install] [tasks.install]
run = "pnpm install --filter immich-e2e --frozen-lockfile" run = "pnpm install --filter immich-e2e --frozen-lockfile"
[tasks.build]
dir = "{{ config_root }}"
run = "docker compose build"
[tasks.test] [tasks.test]
depends = ["//e2e:build", "//e2e:ci-setup"]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
run = "vitest --run" run = "vitest --run"
[tasks.playwright-install]
env._.path = "./node_modules/.bin"
run = "playwright install"
[tasks."test-web"] [tasks."test-web"]
depends = ["//e2e:build", "//e2e:ci-setup", "//e2e:playwright-install"]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
run = "playwright test" run = "playwright test"
@@ -40,12 +30,7 @@ run = "tsc --noEmit"
[tasks.ci-setup] [tasks.ci-setup]
depends = [ depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"]
"//:sdk:install",
"//:sdk:build",
"//packages/cli:install",
"//packages/cli:build",
]
run = { task = ":install" } run = { task = ":install" }
@@ -95,7 +95,6 @@ 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, channel: 'stable' } }); .send({ ...config, newVersionCheck: { enabled: false } });
expect(response1.status).toBe(200); expect(response1.status).toBe(200);
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } }); expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false } });
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, channel: 'stable' } }); .send({ ...config, newVersionCheck: { enabled: true } });
expect(response2.status).toBe(200); expect(response2.status).toBe(200);
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } }); expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true } });
}); });
it('should reject an invalid config entry', async () => { it('should reject an invalid config entry', async () => {
@@ -55,8 +55,8 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
result.duration.push(asset.duration); result.duration.push(asset.duration);
result.projectionType.push(asset.projectionType); result.projectionType.push(asset.projectionType);
result.livePhotoVideoId.push(asset.livePhotoVideoId); result.livePhotoVideoId.push(asset.livePhotoVideoId);
result.city?.push(asset.city); result.city.push(asset.city);
result.country?.push(asset.country); result.country.push(asset.country);
result.visibility.push(asset.visibility); result.visibility.push(asset.visibility);
} }
-4
View File
@@ -305,8 +305,6 @@
"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",
@@ -444,8 +442,6 @@
"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",
-77
View File
@@ -84,72 +84,6 @@ run = [
dir = "server" dir = "server"
run = "node ./dist/bin/sync-sql.js" run = "node ./dist/bin/sync-sql.js"
# TODO dev, prod, and e2e should be de-duplicated by using env but for some reason I ran into issues
[tasks.dev]
depends = "//:plugins"
dir = "docker"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
depends_post = "//:dev-down"
[tasks.dev-update]
run = { task = "//:dev", args = ["--build", "-V"] }
[tasks.dev-scale]
run = { task = "//:dev", args = ["--build", "-V", "--scale immich-server=3"] }
[tasks.dev-down]
dir = "docker"
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
[tasks.prod]
depends = "//:plugins"
dir = "docker"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.prod.yml up --remove-orphans"
depends_post = "//:prod-down"
[tasks.prod-scale]
run = { task = "//:prod", args = [
"--build",
"-V",
"--scale immich-server=3",
"--scale immich-microservices",
] }
[tasks.prod-down]
dir = "docker"
run = "docker compose -f ./docker-compose.prod.yml down --remove-orphans"
[tasks.e2e]
depends = "//:plugins"
dir = "e2e"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.yml up --remove-orphans"
depends_post = "//:e2e-down"
[tasks.e2e-dev]
depends = "//:plugins"
dir = "e2e"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
depends_post = "//:e2e-dev-down"
[tasks.e2e-update]
run = { task = "//:e2e", args = ["--build", '-V'] }
[tasks.e2e-down]
dir = "e2e"
run = "docker compose -f ./docker-compose.yml down --remove-orphans"
[tasks.e2e-dev-down]
dir = "e2e"
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
# SDK tasks # SDK tasks
[tasks."sdk:install"] [tasks."sdk:install"]
dir = "packages/sdk" dir = "packages/sdk"
@@ -165,14 +99,3 @@ run = "pnpm format"
[tasks."i18n:format-fix"] [tasks."i18n:format-fix"]
run = "pnpm format:fix" run = "pnpm format:fix"
[tasks.clean]
run = [
"find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
"find . -name 'dist' -type d -prune -exec rm -rf '{}' +",
"find . -name 'build' -type d -prune -exec rm -rf '{}' +",
"find . -name '.svelte-kit' -type d -prune -exec rm -rf '{}' +",
"find . -name 'coverage' -type d -prune -exec rm -rf '{}' +",
"find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' +",
{ task = "//:*-down" },
]
@@ -2,12 +2,16 @@ 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, super.prerelease}); const ServerVersion({required super.major, required super.minor, required super.patch});
ServerVersion.fromDto(ServerVersionResponseDto dto) @override
: super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease); String toString() {
return 'ServerVersion(major: $major, minor: $minor, patch: $patch)';
}
bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) { ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_);
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);
} }
} }
+20 -52
View File
@@ -1,42 +1,36 @@
enum SemVerType { major, minor, patch, prerelease } enum SemVerType { major, minor, patch }
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, this.prerelease}); const SemVer({required this.major, required this.minor, required this.patch});
@override @override
String toString() { String toString() {
return '$major.$minor.$patch${prerelease == null ? '' : '-rc.$prerelease'}'; return '$major.$minor.$patch';
} }
SemVer copyWith({int? major, int? minor, int? patch, int? prerelease}) { SemVer copyWith({int? major, int? minor, int? patch}) {
return SemVer( return SemVer(major: major ?? this.major, minor: minor ?? this.minor, patch: patch ?? this.patch);
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) {
final match = _pattern.firstMatch(version); if (version.toLowerCase().startsWith("v")) {
if (match == null) { version = version.substring(1);
}
final parts = version.split("-")[0].split('.');
if (parts.length != 3) {
throw FormatException('Invalid semantic version string: $version'); throw FormatException('Invalid semantic version string: $version');
} }
final prerelease = match.group(4); try {
return SemVer( return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
major: int.parse(match.group(1)!), } catch (e) {
minor: int.parse(match.group(2)!), throw FormatException('Invalid semantic version string: $version');
patch: int.parse(match.group(3)!), }
prerelease: prerelease == null ? null : int.parse(prerelease),
);
} }
bool operator >(SemVer other) { bool operator >(SemVer other) {
@@ -46,10 +40,7 @@ class SemVer {
if (minor != other.minor) { if (minor != other.minor) {
return minor > other.minor; return minor > other.minor;
} }
if (patch != other.patch) { return patch > other.patch;
return patch > other.patch;
}
return _comparePrerelease(other) > 0;
} }
bool operator <(SemVer other) { bool operator <(SemVer other) {
@@ -59,23 +50,7 @@ class SemVer {
if (minor != other.minor) { if (minor != other.minor) {
return minor < other.minor; return minor < other.minor;
} }
if (patch != other.patch) { return 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) {
@@ -92,11 +67,7 @@ class SemVer {
return true; return true;
} }
return other is SemVer && return other is SemVer && other.major == major && other.minor == minor && other.patch == patch;
other.major == major &&
other.minor == minor &&
other.patch == patch &&
other.prerelease == prerelease;
} }
SemVerType? differenceType(SemVer other) { SemVerType? differenceType(SemVer other) {
@@ -109,13 +80,10 @@ 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 ^ prerelease.hashCode; int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode;
} }
@@ -50,7 +50,9 @@ class AppBarServerInfo extends HookConsumerWidget {
divider, divider,
_ServerInfoItem( _ServerInfoItem(
label: "server_version".tr(), label: "server_version".tr(),
text: serverInfoState.serverVersion.major > 0 ? "${serverInfoState.serverVersion}" : "--", text: serverInfoState.serverVersion.major > 0
? "${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),
@@ -58,7 +60,9 @@ class AppBarServerInfo extends HookConsumerWidget {
divider, divider,
_ServerInfoItem( _ServerInfoItem(
label: "latest_version".tr(), label: "latest_version".tr(),
text: serverInfoState.latestVersion!.major > 0 ? "${serverInfoState.latestVersion!}" : "--", text: serverInfoState.latestVersion!.major > 0
? "${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,9 +513,6 @@ 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,9 +258,6 @@ 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,12 +562,6 @@ 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,12 +157,6 @@ 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
@@ -1,85 +0,0 @@
//
// 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
@@ -1,133 +0,0 @@
//
// 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
@@ -1,103 +0,0 @@
//
// 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;
}
+6 -22
View File
@@ -16,61 +16,47 @@ 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: 0 /// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991 /// Maximum value: 9007199254740991
int major; int major;
/// Minor version number /// Minor version number
/// ///
/// Minimum value: 0 /// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991 /// Maximum value: 9007199254740991
int minor; int minor;
/// Patch version number /// Patch version number
/// ///
/// Minimum value: 0 /// Minimum value: -9007199254740991
/// 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_, prerelease=$prerelease]'; String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]';
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;
} }
@@ -86,7 +72,6 @@ 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;
@@ -137,7 +122,6 @@ class ServerVersionResponseDto {
'major', 'major',
'minor', 'minor',
'patch', 'patch',
'prerelease',
}; };
} }
@@ -13,32 +13,26 @@ 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[channel=$channel, enabled=$enabled]'; String toString() => 'SystemConfigNewVersionCheckDto[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;
} }
@@ -52,7 +46,6 @@ 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')!,
); );
} }
@@ -101,7 +94,6 @@ 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, prerelease: null)); ).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0));
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, prerelease: null)); ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
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, prerelease: null)); ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0));
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, prerelease: null)); ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
await sut.sync(); await sut.sync();
-66
View File
@@ -88,71 +88,5 @@ 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);
});
}); });
} }
+4 -74
View File
@@ -20800,58 +20800,6 @@
], ],
"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": {
@@ -21521,40 +21469,26 @@
"major": { "major": {
"description": "Major version number", "description": "Major version number",
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": 0, "minimum": -9007199254740991,
"type": "integer" "type": "integer"
}, },
"minor": { "minor": {
"description": "Minor version number", "description": "Minor version number",
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": 0, "minimum": -9007199254740991,
"type": "integer" "type": "integer"
}, },
"patch": { "patch": {
"description": "Patch version number", "description": "Patch version number",
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": 0, "minimum": -9007199254740991,
"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"
}, },
@@ -24575,16 +24509,12 @@
}, },
"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,8 +2074,6 @@ 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 */
@@ -2423,7 +2421,6 @@ export type SystemConfigMetadataDto = {
faces: SystemConfigFacesDto; faces: SystemConfigFacesDto;
}; };
export type SystemConfigNewVersionCheckDto = { export type SystemConfigNewVersionCheckDto = {
channel: ReleaseChannel;
/** Enabled */ /** Enabled */
enabled: boolean; enabled: boolean;
}; };
@@ -2769,16 +2766,6 @@ 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 */
@@ -7318,10 +7305,6 @@ 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"
@@ -7330,16 +7313,6 @@ 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",
+20 -27
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.8.1 specifier: ^7.6.2
version: 7.8.1 version: 7.8.0
sharp: sharp:
specifier: ^0.34.5 specifier: ^0.34.5
version: 0.34.5 version: 0.34.5
@@ -11243,11 +11243,6 @@ 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'}
@@ -16305,7 +16300,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.1 semver: 7.8.0
tar: 6.2.1 tar: 6.2.1
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@@ -17762,7 +17757,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.7 '@babel/runtime': 7.29.2
'@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
@@ -18466,7 +18461,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.1 semver: 7.8.0
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
@@ -19566,7 +19561,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.1 semver: 7.8.0
uint8array-extras: 1.5.0 uint8array-extras: 1.5.0
config-chain@1.1.13: config-chain@1.1.13:
@@ -19738,7 +19733,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.1 semver: 7.8.0
optionalDependencies: optionalDependencies:
webpack: 5.107.0(postcss@8.5.15) webpack: 5.107.0(postcss@8.5.15)
@@ -20606,7 +20601,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.1 semver: 7.8.0
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:
@@ -20629,7 +20624,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.1 semver: 7.8.0
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)
@@ -21107,7 +21102,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.1 semver: 7.8.0
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)
@@ -21543,7 +21538,7 @@ snapshots:
history@4.10.1: history@4.10.1:
dependencies: dependencies:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.2
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
@@ -22131,7 +22126,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.1 semver: 7.8.0
just-compare@2.3.0: {} just-compare@2.3.0: {}
@@ -22417,7 +22412,7 @@ snapshots:
make-dir@4.0.0: make-dir@4.0.0:
dependencies: dependencies:
semver: 7.8.1 semver: 7.8.0
maplibre-gl@5.24.0: maplibre-gl@5.24.0:
dependencies: dependencies:
@@ -23252,7 +23247,7 @@ snapshots:
node-abi@3.92.0: node-abi@3.92.0:
dependencies: dependencies:
semver: 7.8.1 semver: 7.8.0
optional: true optional: true
node-abort-controller@3.1.1: {} node-abort-controller@3.1.1: {}
@@ -23293,7 +23288,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.1 semver: 7.8.0
tar: 7.5.15 tar: 7.5.15
tinyglobby: 0.2.16 tinyglobby: 0.2.16
undici: 6.25.0 undici: 6.25.0
@@ -23531,7 +23526,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.1 semver: 7.8.0
package-manager-detector@1.6.0: {} package-manager-detector@1.6.0: {}
@@ -23919,7 +23914,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.1 semver: 7.8.0
webpack: 5.107.0(postcss@8.5.15) webpack: 5.107.0(postcss@8.5.15)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
@@ -24974,14 +24969,12 @@ snapshots:
semver-diff@4.0.0: semver-diff@4.0.0:
dependencies: dependencies:
semver: 7.8.1 semver: 7.8.0
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
@@ -25516,7 +25509,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.1 semver: 7.8.0
optionalDependencies: optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4) svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -26224,7 +26217,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.1 semver: 7.8.0
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.8.1", "semver": "^7.6.2",
"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,5 +1,4 @@
import { CronExpression } from '@nestjs/schedule'; import { CronExpression } from '@nestjs/schedule';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { import {
AudioCodec, AudioCodec,
Colorspace, Colorspace,
@@ -136,7 +135,6 @@ export type SystemConfig = {
}; };
newVersionCheck: { newVersionCheck: {
enabled: boolean; enabled: boolean;
channel: ReleaseChannel;
}; };
nightlyTasks: { nightlyTasks: {
startTime: string; startTime: string;
@@ -346,7 +344,6 @@ 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,13 +265,3 @@ 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);
};
};
+11 -39
View File
@@ -1,6 +1,5 @@
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';
@@ -59,15 +58,9 @@ const ServerStorageResponseSchema = z
const ServerVersionResponseSchema = z const ServerVersionResponseSchema = z
.object({ .object({
major: z.int().min(0).describe('Major version number'), major: z.int().describe('Major version number'),
minor: z.int().min(0).describe('Minor version number'), minor: z.int().describe('Minor version number'),
patch: z.int().min(0).describe('Patch version number'), patch: z.int().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' });
@@ -147,27 +140,6 @@ 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) {}
@@ -175,12 +147,7 @@ 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 { return { major: value.major, minor: value.minor, patch: value.patch };
major: value.major,
minor: value.minor,
patch: value.patch,
prerelease: (value.prerelease[1] as number) ?? null,
};
} }
} }
@@ -191,5 +158,10 @@ 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) {}
@ExtraModel() export interface ReleaseNotification {
export class ReleaseEventV1 extends createZodDto(ReleaseEventV1Schema) {} isAvailable: boolean;
/** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}
+10 -1
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,6 +17,15 @@ 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'),
+1 -8
View File
@@ -151,15 +151,8 @@ 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'), channel: ReleaseChannelSchema }) .object({ enabled: configBool.describe('Enabled') })
.meta({ id: 'SystemConfigNewVersionCheckDto' }); .meta({ id: 'SystemConfigNewVersionCheckDto' });
const SystemConfigNightlyTasksSchema = z const SystemConfigNightlyTasksSchema = z
@@ -4,7 +4,6 @@ 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';
@@ -65,12 +64,10 @@ export class ServerInfoRepository {
this.logger.setContext(ServerInfoRepository.name); this.logger.setContext(ServerInfoRepository.name);
} }
async getLatestRelease(channel: ReleaseChannel): Promise<VersionResponse> { async getLatestRelease(): Promise<VersionResponse> {
try { try {
const { versionCheck } = this.configRepository.getEnv(); const { versionCheck } = this.configRepository.getEnv();
const url = new URL(versionCheck.url); const response = await fetch(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 { ReleaseEventV1, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ReleaseNotification, 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: [ReleaseEventV1]; on_new_release: [ReleaseNotification];
on_notification: [NotificationDto]; on_notification: [NotificationDto];
on_session_delete: [string]; on_session_delete: [string];
+5 -9
View File
@@ -75,7 +75,7 @@ export class SearchService extends BaseService {
const page = dto.page ?? 1; const page = dto.page ?? 1;
const size = dto.size || 250; const size = dto.size || 250;
const userIds = await this.getUserIdsToSearch(auth, dto.visibility); const userIds = await this.getUserIdsToSearch(auth);
const { hasNextPage, items } = await this.searchRepository.searchMetadata( const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size }, { page, size },
{ {
@@ -103,7 +103,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth); requireElevatedPermission(auth);
} }
const userIds = await this.getUserIdsToSearch(auth, dto.visibility); const userIds = await this.getUserIdsToSearch(auth);
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds }); const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth })); return items.map((item) => mapAsset(item, { auth }));
} }
@@ -113,7 +113,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth); requireElevatedPermission(auth);
} }
const userIds = await this.getUserIdsToSearch(auth, dto.visibility); const userIds = await this.getUserIdsToSearch(auth);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds }); const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth })); return items.map((item) => mapAsset(item, { auth }));
} }
@@ -128,7 +128,7 @@ export class SearchService extends BaseService {
throw new BadRequestException('Smart search is not enabled'); throw new BadRequestException('Smart search is not enabled');
} }
const userIds = this.getUserIdsToSearch(auth, dto.visibility); const userIds = this.getUserIdsToSearch(auth);
let embedding; let embedding;
if (dto.query) { if (dto.query) {
const key = machineLearning.clip.modelName + dto.query + dto.language; const key = machineLearning.clip.modelName + dto.query + dto.language;
@@ -202,11 +202,7 @@ export class SearchService extends BaseService {
} }
} }
private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise<string[]> { private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> {
// Locked assets are personal. Never include partner IDs, regardless of A's elevated session.
if (visibility === AssetVisibility.Locked) {
return [auth.user.id];
}
const partnerIds = await getMyPartnerIds({ const partnerIds = await getMyPartnerIds({
userId: auth.user.id, userId: auth.user.id,
repository: this.partnerRepository, repository: this.partnerRepository,
@@ -1,6 +1,5 @@
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,
@@ -185,7 +184,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
}, },
newVersionCheck: { newVersionCheck: {
enabled: true, enabled: true,
channel: ReleaseChannel.Stable,
}, },
trash: { trash: {
enabled: true, enabled: true,
@@ -204,16 +204,5 @@ describe(TimelineService.name, () => {
}), }),
).rejects.toThrow(BadRequestException); ).rejects.toThrow(BadRequestException);
}); });
it('should throw an error if withPartners is true and visibility is locked', async () => {
await expect(
sut.getTimeBucket(authStub.adminWithElevatedPermission, {
timeBucket: 'bucket',
visibility: AssetVisibility.Locked,
withPartners: true,
userId: authStub.adminWithElevatedPermission.user.id,
}),
).rejects.toThrow(BadRequestException);
});
}); });
}); });
+2 -3
View File
@@ -71,14 +71,13 @@ export class TimelineService extends BaseService {
} }
if (dto.withPartners) { if (dto.withPartners) {
const requestedLocked = dto.visibility === AssetVisibility.Locked;
const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined; const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
const requestedTrash = dto.isTrashed === true; const requestedTrash = dto.isTrashed === true;
if (requestedLocked || requestedArchived || requestedFavorite || requestedTrash) { if (requestedArchived || requestedFavorite || requestedTrash) {
throw new BadRequestException( throw new BadRequestException(
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
); );
} }
} }
+12 -40
View File
@@ -2,7 +2,6 @@ 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';
@@ -23,17 +22,6 @@ 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();
}); });
@@ -78,10 +66,9 @@ 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: 3, major: serverVersion.major,
minor: 0, minor: serverVersion.minor,
patch: 0, patch: serverVersion.patch,
prerelease: null,
}); });
}); });
}); });
@@ -156,24 +143,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, channel: ReleaseChannel.Stable } }, oldConfig: { ...defaults, newVersionCheck: { enabled: false } },
newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } }, newConfig: { ...defaults, newVersionCheck: { enabled: true } },
}); });
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, channel: ReleaseChannel.Stable } }, oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } }, newConfig: { ...defaults, newVersionCheck: { enabled: false } },
}); });
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, channel: ReleaseChannel.Stable } }, oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } }, newConfig: { ...defaults, newVersionCheck: { enabled: true } },
}); });
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
}); });
@@ -182,36 +169,21 @@ 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(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
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(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
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(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
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));
}); });
}); });
+8 -27
View File
@@ -3,27 +3,19 @@ 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 { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ReleaseNotification, 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 = ( const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
channel: ReleaseChannel,
{ checkedAt, releaseVersion }: VersionCheckMetadata,
): ReleaseEventV1 => {
return { return {
// can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483 isAvailable: semver.gt(releaseVersion, serverVersion),
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,
}; };
}; };
@@ -106,21 +98,14 @@ 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);
// can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483 if (semver.gt(releaseVersion, serverVersion)) {
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(newVersionCheck.channel, metadata)); this.websocketRepository.clientBroadcast('on_new_release', asNotification(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}`);
@@ -132,11 +117,7 @@ export class VersionService extends BaseService {
@OnEvent({ name: 'WebsocketConnect' }) @OnEvent({ name: 'WebsocketConnect' })
async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) { async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) {
this.websocketRepository.clientSend( this.websocketRepository.clientSend('on_server_version', userId, serverVersion);
'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) {
@@ -145,7 +126,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(newVersionCheck.channel, metadata)); this.websocketRepository.clientSend('on_new_release', userId, asNotification(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 { extraModels } from 'src/decorators'; import { extraSyncModels } from 'src/dtos/sync.dto';
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, extraModels: extraSyncModels,
ignoreGlobalPrefix: true, ignoreGlobalPrefix: true,
}; };
-7
View File
@@ -41,13 +41,6 @@ export const authStub = {
id: 'token-id', id: 'token-id',
} as AuthSession, } as AuthSession,
}), }),
adminWithElevatedPermission: Object.freeze<AuthDto>({
user: authUser.admin,
session: {
id: 'token-id-elevated',
hasElevatedPermission: true,
} as AuthSession,
}),
adminSharedLink: Object.freeze({ adminSharedLink: Object.freeze({
user: authUser.admin, user: authUser.admin,
sharedLink: { sharedLink: {
@@ -51,13 +51,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive }); const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive });
await expect(response1).rejects.toBeInstanceOf(BadRequestException); await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow( await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
); );
const response2 = sut.getTimeBuckets(auth, { withPartners: true }); const response2 = sut.getTimeBuckets(auth, { withPartners: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException); await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow( await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
); );
}); });
@@ -67,13 +67,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false }); const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false });
await expect(response1).rejects.toBeInstanceOf(BadRequestException); await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow( await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
); );
const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true }); const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException); await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow( await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
); );
}); });
@@ -83,7 +83,7 @@ describe(TimelineService.name, () => {
const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true }); const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true });
await expect(response).rejects.toBeInstanceOf(BadRequestException); await expect(response).rejects.toBeInstanceOf(BadRequestException);
await expect(response).rejects.toThrow( await expect(response).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
); );
}); });
@@ -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,9 +35,11 @@
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($serverVersion ? semverToName($serverVersion) : null); let version = $derived(
$serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
);
const getReleaseInfo = (release?: ReleaseEventV1) => { const getReleaseInfo = (release?: ReleaseEvent) => {
if (!release || !release?.isAvailable || !authManager.user.isAdmin) { if (!release || !release?.isAvailable || !authManager.user.isAdmin) {
return; return;
} }
@@ -30,11 +30,14 @@
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150); const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const firstInOrNearViewport = $derived(viewerAssets.findIndex((a) => a.isInOrNearViewport));
const lastInOrNearViewport = $derived(viewerAssets.findLastIndex((a) => a.isInOrNearViewport));
</script> </script>
<!-- Image grid --> <!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}> <div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each viewerAssets as viewerAsset (viewerAsset.id)} {#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!} {@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!} {@const asset = viewerAsset.asset!}
+1 -1
View File
@@ -100,7 +100,7 @@
<AssetLayout <AssetLayout
{manager} {manager}
viewerAssets={timelineDay.activeViewerAssets} viewerAssets={timelineDay.viewerAssets}
height={timelineDay.height} height={timelineDay.height}
width={timelineDay.width} width={timelineDay.width}
{customThumbnailLayout} {customThumbnailLayout}
+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: [ReleaseEventV1]; ReleaseEvent: [ReleaseEvent];
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<ReleaseEventV1 | undefined>(); value = $state<ReleaseEvent | undefined>();
constructor() { constructor() {
eventManager.on({ eventManager.on({
@@ -53,3 +53,25 @@ export function updateTimelineMonthViewportProximity(timelineManager: TimelineMa
timelineManager.clearDeferredLayout(month); timelineManager.clearDeferredLayout(month);
} }
} }
export function calculateViewerAssetViewportProximity(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
) {
const headerHeight = timelineManager.headerHeight;
return calculateViewportProximity(
positionTop,
positionTop + positionHeight,
timelineManager.visibleWindow.top - headerHeight,
timelineManager.visibleWindow.bottom + headerHeight,
);
}
export function calculateViewerAssetIsInOrNearViewport(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
) {
return isInOrNearViewport(calculateViewerAssetViewportProximity(timelineManager, positionTop, positionHeight));
}
@@ -1,31 +1,12 @@
import { AssetOrder, AssetOrderBy } from '@immich/sdk'; import { AssetOrder, AssetOrderBy } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { CommonLayoutOptions, CommonPosition } from '$lib/utils/layout-utils'; import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util'; import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import type { TimelineMonth } from './timeline-month.svelte'; import type { TimelineMonth } from './timeline-month.svelte';
import type { Direction, MoveAsset, TimelineAsset } from './types'; import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte'; import { ViewerAsset } from './viewer-asset.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
function lowerBound(assets: ViewerAsset[], target: number, key: (pos: CommonPosition) => number): number {
let lo = 0;
let hi = assets.length;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (key(assets[mid].position!) < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
export class TimelineDay { export class TimelineDay {
readonly timelineMonth: TimelineMonth; readonly timelineMonth: TimelineMonth;
readonly index: number; readonly index: number;
@@ -37,15 +18,12 @@ export class TimelineDay {
height = $state(0); height = $state(0);
width = $state(0); width = $state(0);
// Assets in or near the viewport; active assets should be added to the DOM.
activeViewerAssets: ViewerAsset[] = $state([]);
isInOrNearViewport = $state(false);
#top: number = $state(0); #top: number = $state(0);
#start: number = $state(0); #start: number = $state(0);
#row = $state(0); #row = $state(0);
#col = $state(0); #col = $state(0);
#deferredLayout = false; #deferredLayout = false;
#lastInOrNearViewport = -1;
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) { constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
this.index = index; this.index = index;
@@ -171,32 +149,18 @@ export class TimelineDay {
for (let i = 0; i < this.viewerAssets.length; i++) { for (let i = 0; i < this.viewerAssets.length; i++) {
this.viewerAssets[i].position = geometry.getPosition(i); this.viewerAssets[i].position = geometry.getPosition(i);
} }
this.updateAssetBoundaries();
}
updateAssetBoundaries() {
const manager = this.timelineMonth.timelineManager;
const visibleWindow = manager.visibleWindow;
if (this.viewerAssets.length === 0 || !this.viewerAssets[0].position) {
this.activeViewerAssets = [];
this.isInOrNearViewport = false;
return;
}
const dayOffset = this.absoluteTimelineDayTop;
const headerHeight = manager.headerHeight;
const expandedTop = visibleWindow.top - headerHeight - INTERSECTION_EXPAND_TOP - dayOffset;
const expandedBottom = visibleWindow.bottom + headerHeight + INTERSECTION_EXPAND_BOTTOM - dayOffset;
const first = lowerBound(this.viewerAssets, expandedTop, (p) => p.top + p.height);
const last = lowerBound(this.viewerAssets, expandedBottom, (p) => p.top) - 1;
const hasActive = last >= first && first < this.viewerAssets.length;
this.activeViewerAssets = hasActive ? this.viewerAssets.slice(first, last + 1) : [];
this.isInOrNearViewport = hasActive;
} }
get absoluteTimelineDayTop() { get absoluteTimelineDayTop() {
return this.timelineMonth.top + this.#top; return this.timelineMonth.top + this.#top;
} }
get isInOrNearViewport() {
if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) {
return true;
}
this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport);
return this.#lastInOrNearViewport !== -1;
}
} }
@@ -214,11 +214,6 @@ export class TimelineManager extends VirtualScrollManager {
for (const month of this.months) { for (const month of this.months) {
updateTimelineMonthViewportProximity(this, month); updateTimelineMonthViewportProximity(this, month);
if (month.isInOrNearViewport && month.isLoaded) {
for (const day of month.timelineDays) {
day.updateAssetBoundaries();
}
}
} }
const month = this.months.find((month) => month.isInViewport); const month = this.months.find((month) => month.isInViewport);
@@ -29,7 +29,8 @@ import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './typ
import { ViewerAsset } from './viewer-asset.svelte'; import { ViewerAsset } from './viewer-asset.svelte';
export class TimelineMonth { export class TimelineMonth {
#viewportProximity: ViewportProximity = $state(ViewportProximity.FarFromViewport); #isInOrNearViewport = $state(false);
#isInViewport = $state(false);
isLoaded: boolean = $state(false); isLoaded: boolean = $state(false);
timelineDays: TimelineDay[] = $state([]); timelineDays: TimelineDay[] = $state([]);
readonly timelineManager: TimelineManager; readonly timelineManager: TimelineManager;
@@ -85,24 +86,28 @@ export class TimelineMonth {
} }
set viewportProximity(newValue: ViewportProximity) { set viewportProximity(newValue: ViewportProximity) {
const old = this.#viewportProximity; const isInOrNearViewport = isInOrNearViewportUtil(newValue);
if (old === newValue) { if (this.#isInOrNearViewport !== isInOrNearViewport) {
return; this.#isInOrNearViewport = isInOrNearViewport;
if (isInOrNearViewport) {
void this.timelineManager.loadTimelineMonth(this.yearMonth);
} else {
this.cancel();
}
} }
this.#viewportProximity = newValue;
if (isInOrNearViewportUtil(newValue)) { const isInViewport = isInViewportUtil(newValue);
void this.timelineManager.loadTimelineMonth(this.yearMonth); if (this.#isInViewport !== isInViewport) {
} else { this.#isInViewport = isInViewport;
this.cancel();
} }
} }
get isInOrNearViewport() { get isInOrNearViewport() {
return isInOrNearViewportUtil(this.#viewportProximity); return this.#isInOrNearViewport;
} }
get isInViewport() { get isInViewport() {
return isInViewportUtil(this.#viewportProximity); return this.#isInViewport;
} }
get lastTimelineDay() { get lastTimelineDay() {
@@ -254,7 +259,7 @@ export class TimelineMonth {
addContext.newTimelineDays.add(timelineDay); addContext.newTimelineDays.add(timelineDay);
} }
const viewerAsset = new ViewerAsset(timelineAsset); const viewerAsset = new ViewerAsset(timelineDay, timelineAsset);
timelineDay.viewerAssets.push(viewerAsset); timelineDay.viewerAssets.push(viewerAsset);
addContext.changedTimelineDays.add(timelineDay); addContext.changedTimelineDays.add(timelineDay);
} }
@@ -1,12 +1,32 @@
import type { CommonPosition } from '$lib/utils/layout-utils'; import type { CommonPosition } from '$lib/utils/layout-utils';
import { calculateViewerAssetIsInOrNearViewport } from './internal/intersection-support.svelte';
import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types'; import type { TimelineAsset } from './types';
export class ViewerAsset { export class ViewerAsset {
readonly #group: TimelineDay;
#isInOrNearViewport = $derived.by(() => {
if (!this.position) {
return false;
}
const store = this.#group.timelineMonth.timelineManager;
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
return calculateViewerAssetIsInOrNearViewport(store, positionTop, this.position.height);
});
get isInOrNearViewport() {
return this.#isInOrNearViewport;
}
position: CommonPosition | undefined = $state.raw(); position: CommonPosition | undefined = $state.raw();
asset: TimelineAsset = $state() as TimelineAsset; asset: TimelineAsset = $state() as TimelineAsset;
id: string = $derived(this.asset.id); id: string = $derived(this.asset.id);
constructor(asset: TimelineAsset) { constructor(group: TimelineDay, asset: TimelineAsset) {
this.#group = group;
this.asset = asset; this.asset = asset;
} }
} }
+2 -2
View File
@@ -3,7 +3,6 @@ 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,
@@ -16,6 +15,7 @@ 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: ReleaseEventV1) => void; on_new_release: (event: ReleaseEvent) => void;
on_session_delete: (sessionId: string) => void; on_session_delete: (sessionId: string) => void;
on_notification: (notification: NotificationDto) => void; on_notification: (notification: NotificationDto) => void;
+9 -1
View File
@@ -1,4 +1,4 @@
import type { QueueResponseDto } from '@immich/sdk'; import type { QueueResponseDto, ServerVersionResponseDto } 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,6 +7,14 @@ 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 } };
+23 -1
View File
@@ -1,5 +1,5 @@
import { AssetTypeEnum } from '@immich/sdk'; import { AssetTypeEnum } from '@immich/sdk';
import { getAssetUrl } from '$lib/utils'; import { getAssetUrl, getReleaseType } 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,4 +161,26 @@ 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');
});
});
}); });
+20 -2
View File
@@ -411,8 +411,26 @@ export function createDateFormatter(localeCode: string | undefined): DateFormatt
}; };
} }
export const semverToName = ({ major, minor, patch, prerelease }: ServerVersionResponseDto) => export const getReleaseType = (
`v${major}.${minor}.${patch}${prerelease ? `-rc.${prerelease}` : ''}`; 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 withoutIcons = (actions: ActionItem[]): ActionItem[] => export const withoutIcons = (actions: ActionItem[]): ActionItem[] =>
actions.map((action) => ({ ...action, icon: undefined })); actions.map((action) => ({ ...action, icon: undefined }));
+5 -9
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 { semverToName } from '$lib/utils'; import type { ReleaseEvent } from '$lib/types';
import { ReleaseType, type ReleaseEventV1 } from '@immich/sdk'; import { getReleaseType, semverToName } from '$lib/utils';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
let modal = $state<{ let modal = $state<{
@@ -11,20 +11,16 @@
close: () => Promise<void>; close: () => Promise<void>;
}>(); }>();
const onReleaseEvent = async (release: ReleaseEventV1) => { const onReleaseEvent = async (release: ReleaseEvent) => {
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 ( if (type === 'none' || type === 'patch' || localStorage.getItem('appVersion') === releaseVersion) {
!release.type ||
release.type === ReleaseType.Patch ||
release.type === ReleaseType.Prepatch ||
localStorage.getItem('appVersion') === releaseVersion
) {
return; return;
} }
@@ -5,11 +5,8 @@
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>
@@ -23,20 +20,6 @@
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>