Compare commits

..

6 Commits

Author SHA1 Message Date
shenlong-tanwen 9fe48d21c2 feat(mobile): release candidate support 2026-05-30 00:55:43 +05:30
Daniel Dietzler e35610d0a7 feat: release candidate support 2026-05-29 16:06:16 +02:00
Daniel Dietzler bcff1d42b0 chore: migrate more make targets (#28663) 2026-05-28 08:33:57 -04:00
Min Idzelis 1bd367bd51 refactor(web): replace per-asset viewport proximity with day-tier active indices (#28597) 2026-05-28 11:44:18 +02:00
Daniel Dietzler 725f266b81 chore: migrate more make targets to mise (#28651) 2026-05-28 11:31:02 +02:00
Daniel Dietzler d08e3de207 fix: e2e linting (#28659) 2026-05-28 11:12:26 +02:00
61 changed files with 1299 additions and 446 deletions
+15 -24
View File
@@ -1,46 +1,46 @@
dev: dev:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans @printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n" >&2 && exit 1
dev-down: dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans @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
dev-update: dev-update:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans @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
dev-scale: dev-scale:
@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 @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
dev-docs: dev-docs:
npm --prefix docs run start npm --prefix docs run start
.PHONY: e2e .PHONY: e2e
e2e: e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans @printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n" >&2 && exit 1
e2e-dev: e2e-dev:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans @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
e2e-update: e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans @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
e2e-down: e2e-down:
docker compose -f ./e2e/docker-compose.yml down --remove-orphans @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
prod: prod:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans @printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n" >&2 && exit 1
prod-down: prod-down:
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans @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
prod-scale: prod-scale:
@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 @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
.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"\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" >&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"\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" >&2 && exit 1
renovate: renovate:
@@ -52,16 +52,7 @@ renovate:
MODULES = e2e server web cli sdk docs .github MODULES = e2e server web cli sdk docs .github
test-e2e: test-e2e:
docker compose -f ./e2e/docker-compose.yml build @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
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
clean: clean:
find . -name "node_modules" -type d -prune -exec rm -rf {} + @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 "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
+16 -1
View File
@@ -1,11 +1,21 @@
[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"
@@ -30,7 +40,12 @@ run = "tsc --noEmit"
[tasks.ci-setup] [tasks.ci-setup]
depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"] depends = [
"//:sdk:install",
"//:sdk:build",
"//packages/cli:install",
"//packages/cli:build",
]
run = { task = ":install" } run = { task = ":install" }
@@ -95,6 +95,7 @@ describe('/server', () => {
major: expect.any(Number), major: expect.any(Number),
minor: expect.any(Number), minor: expect.any(Number),
patch: expect.any(Number), patch: expect.any(Number),
prerelease: null,
}); });
}); });
}); });
@@ -21,18 +21,18 @@ describe('/system-config', () => {
const response1 = await request(app) const response1 = await request(app)
.put('/system-config') .put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: false } }); .send({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
expect(response1.status).toBe(200); expect(response1.status).toBe(200);
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false } }); expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
const response2 = await request(app) const response2 = await request(app)
.put('/system-config') .put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: true } }); .send({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
expect(response2.status).toBe(200); expect(response2.status).toBe(200);
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true } }); expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
}); });
it('should reject an invalid config entry', async () => { it('should reject an invalid config entry', async () => {
@@ -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,6 +305,8 @@
"refreshing_all_libraries": "Refreshing all libraries", "refreshing_all_libraries": "Refreshing all libraries",
"registration": "Admin Registration", "registration": "Admin Registration",
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.", "registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
"release_channel_release_candidate": "Release candidate",
"release_channel_stable": "Stable",
"remove_failed_jobs": "Remove failed jobs", "remove_failed_jobs": "Remove failed jobs",
"require_password_change_on_login": "Require user to change password on first login", "require_password_change_on_login": "Require user to change password on first login",
"reset_settings_to_default": "Reset settings to default", "reset_settings_to_default": "Reset settings to default",
@@ -442,6 +444,8 @@
"user_settings_description": "Manage user settings", "user_settings_description": "Manage user settings",
"user_successfully_removed": "User {email} has been successfully removed.", "user_successfully_removed": "User {email} has been successfully removed.",
"users_page_description": "Admin users page", "users_page_description": "Admin users page",
"version_check_channel": "Release channel",
"version_check_channel_description": "Pick the release channel you want to get version announcements for",
"version_check_enabled_description": "Enable version check", "version_check_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with {server}", "version_check_implications": "The version check feature relies on periodic communication with {server}",
"version_check_settings": "Version Check", "version_check_settings": "Version Check",
+77
View File
@@ -84,6 +84,72 @@ 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"
@@ -99,3 +165,14 @@ 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" },
]
@@ -234,13 +234,24 @@ class RemoteAlbumService {
final pendingAdds = <Future<void>>[]; final pendingAdds = <Future<void>>[];
final localById = {for (final a in localAssets) a.id: a}; final localById = {for (final a in localAssets) a.id: a};
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = userCallbacks;
final wrappedCallbacks = UploadCallbacks( final wrappedCallbacks = UploadCallbacks(
onProgress: onProgress, onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback(
onICloudProgress: onICloudProgress, 'Upload progress callback failed for $localId',
onError: onError, () => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes),
),
onICloudProgress: (localId, progress) => _runUploadCallback(
'iCloud progress callback failed for $localId',
() => userCallbacks.onICloudProgress?.call(localId, progress),
),
onError: (localId, errorMessage) => _runUploadCallback(
'Upload error callback failed for $localId',
() => userCallbacks.onError?.call(localId, errorMessage),
),
onSuccess: (localId, remoteId) { onSuccess: (localId, remoteId) {
onSuccess?.call(localId, remoteId); _runUploadCallback(
'Upload success callback failed for $localId',
() => userCallbacks.onSuccess?.call(localId, remoteId),
);
final source = localById[localId]; final source = localById[localId];
if (source == null) { if (source == null) {
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link'); _logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
@@ -248,22 +259,29 @@ class RemoteAlbumService {
} }
pendingAdds.add( pendingAdds.add(
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source) _linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
.then<void>((added) => addedCount += added) .then<void>((added) {
.onError( addedCount += added;
(error, stack) => })
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack), .catchError((Object error, StackTrace stack) {
), _logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack);
}),
); );
}, },
); );
await _uploadService.uploadManual(localAssets, cancelToken: null, callbacks: wrappedCallbacks); await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
await Future.wait(pendingAdds); await Future.wait(pendingAdds);
return addedCount; return addedCount;
} }
// TODO: this is a poorly designed flow; adding a "stub" just to satisfy FK constraints is hacky, void _runUploadCallback(String message, void Function() callback) {
// it goes out of its way to insert one at a time, and it swallows errors that should be surfaced to the user. try {
callback();
} catch (error, stack) {
_logger.warning(message, error, stack);
}
}
/// Links a freshly-uploaded asset to an album, ensuring the local DB /// Links a freshly-uploaded asset to an album, ensuring the local DB
/// reflects the change without waiting for the next sync. We call the API /// reflects the change without waiting for the next sync. We call the API
/// (server is the source of truth), then upsert a placeholder /// (server is the source of truth), then upsert a placeholder
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -6,54 +5,66 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
typedef OnProgress = void Function(String id, double progress);
class StorageRepository { class StorageRepository {
static final log = Logger('StorageRepository'); final log = Logger('StorageRepository');
const StorageRepository(); StorageRepository();
Future<File?> getAssetFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) { Future<File?> getFileForAsset(String assetId) async {
return _getFileForAsset(assetId, isMotion: false, onProgress: onProgress, cancelToken: cancelToken); File? file;
} final log = Logger('StorageRepository');
Future<File?> getMotionFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
return _getFileForAsset(assetId, isMotion: true, onProgress: onProgress, cancelToken: cancelToken);
}
Future<File?> _getFileForAsset(
String assetId, {
bool isMotion = false,
OnProgress? onProgress,
Completer<void>? cancelToken,
}) async {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
PMProgressHandler? progressHandler;
StreamSubscription<PMProgressState>? progressSubscription;
PMCancelToken? pmCancelToken;
if (cancelToken != null) {
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) => onProgress?.call(assetId, event.progress));
pmCancelToken = PMCancelToken();
unawaited(cancelToken.future.then((_) => pmCancelToken!.cancelRequest()));
}
try { try {
return await entity.loadFile(withSubtype: isMotion, progressHandler: progressHandler, cancelToken: pmCancelToken); final entity = await AssetEntity.fromId(assetId);
file = await entity?.originFile;
if (file == null) {
log.warning("Cannot get file for asset $assetId");
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("File for asset $assetId does not exist");
return null;
}
} catch (error, stackTrace) { } catch (error, stackTrace) {
log.warning("Error loading file for asset $assetId", error, stackTrace); log.warning("Error getting file for asset $assetId", error, stackTrace);
return null;
} finally {
unawaited(progressSubscription?.cancel());
} }
return file;
}
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
File? file;
final log = Logger('StorageRepository');
try {
final entity = await AssetEntity.fromId(asset.id);
file = await entity?.originFileWithSubtype;
if (file == null) {
log.warning(
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("Motion file for asset ${asset.id} does not exist");
return null;
}
} catch (error, stackTrace) {
log.warning(
"Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
error,
stackTrace,
);
}
return file;
} }
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async { Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
final log = Logger('StorageRepository');
AssetEntity? entity; AssetEntity? entity;
try { try {
@@ -88,7 +99,39 @@ class StorageRepository {
} }
} }
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(withSubtype: true, progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<void> clearCache() async { Future<void> clearCache() async {
final log = Logger('StorageRepository');
try { try {
await PhotoManager.clearFileCache(); await PhotoManager.clearFileCache();
} catch (error, stackTrace) { } catch (error, stackTrace) {
@@ -2,16 +2,12 @@ import 'package:immich_mobile/utils/semver.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class ServerVersion extends SemVer { class ServerVersion extends SemVer {
const ServerVersion({required super.major, required super.minor, required super.patch}); const ServerVersion({required super.major, required super.minor, required super.patch, super.prerelease});
@override ServerVersion.fromDto(ServerVersionResponseDto dto)
String toString() { : super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease);
return 'ServerVersion(major: $major, minor: $minor, patch: $patch)';
}
ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_); bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) {
return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease);
bool isAtLeast({int major = 0, int minor = 0, int patch = 0}) {
return this >= SemVer(major: major, minor: minor, patch: patch);
} }
} }
@@ -6,13 +6,13 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart'; import 'package:native_video_player/native_video_player.dart';
@@ -108,7 +108,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
try { try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await ref.read(storageRepositoryProvider).getAssetFile(id); final file = await StorageRepository().getFileForAsset(id);
if (!mounted) { if (!mounted) {
return null; return null;
} }
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
@@ -274,7 +273,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
onProgress: _handleForegroundBackupProgress, onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess, onSuccess: _handleForegroundBackupSuccess,
onError: _handleForegroundBackupError, onError: _handleForegroundBackupError,
onICloudProgress: CurrentPlatform.isIOS ? _handleICloudProgress : null, onICloudProgress: _handleICloudProgress,
), ),
); );
} }
@@ -283,7 +282,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
_cancelToken?.complete(); _cancelToken?.complete();
_cancelToken = null; _cancelToken = null;
_uploadSpeedManager.clear(); _uploadSpeedManager.clear();
state = state.copyWith(uploadItems: const {}, iCloudDownloadProgress: const {}); state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {});
} }
void _handleICloudProgress(String localAssetId, double progress) { void _handleICloudProgress(String localAssetId, double progress) {
@@ -1,4 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
final storageRepositoryProvider = Provider<StorageRepository>((ref) => const StorageRepository()); final storageRepositoryProvider = Provider<StorageRepository>((ref) => StorageRepository());
+91 -43
View File
@@ -10,6 +10,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final uploadRepositoryProvider = Provider((ref) => UploadRepository()); final uploadRepositoryProvider = Provider((ref) => UploadRepository());
@@ -19,14 +20,21 @@ class UploadRepository {
void Function(TaskProgressUpdate)? onTaskProgress; void Function(TaskProgressUpdate)? onTaskProgress;
UploadRepository() { UploadRepository() {
final downloader = FileDownloader(); FileDownloader().registerCallbacks(
for (final group in const [kBackupGroup, kBackupLivePhotoGroup, kManualUploadGroup]) { group: kBackupGroup,
downloader.registerCallbacks( taskStatusCallback: (update) => onUploadStatus?.call(update),
group: group, taskProgressCallback: (update) => onTaskProgress?.call(update),
taskStatusCallback: onUploadStatus, );
taskProgressCallback: onTaskProgress, FileDownloader().registerCallbacks(
); group: kBackupLivePhotoGroup,
} taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
} }
Future<void> enqueueBackground(UploadTask task) { Future<void> enqueueBackground(UploadTask task) {
@@ -58,6 +66,28 @@ class UploadRepository {
return FileDownloader().start(); return FileDownloader().start();
} }
Future<void> getUploadInfo() async {
final [enqueuedTasks, runningTasks, canceledTasks, waitingTasks, pausedTasks] = await Future.wait([
FileDownloader().database.allRecordsWithStatus(TaskStatus.enqueued, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.running, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.canceled, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.waitingToRetry, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup),
]);
dPrint(
() =>
"""
Upload Info:
Enqueued: ${enqueuedTasks.length}
Running: ${runningTasks.length}
Canceled: ${canceledTasks.length}
Waiting: ${waitingTasks.length}
Paused: ${pausedTasks.length}
""",
);
}
Future<UploadResult> uploadFile({ Future<UploadResult> uploadFile({
required File file, required File file,
required String originalFileName, required String originalFileName,
@@ -81,30 +111,41 @@ class UploadRepository {
baseRequest.fields.addAll(fields); baseRequest.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData); baseRequest.files.add(assetRawUploadData);
final StreamedResponse(:statusCode, :stream) = await NetworkRepository.client.send(baseRequest); final response = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await stream.bytesToString(); final responseBodyString = await response.stream.bytesToString();
return switch ((statusCode, _tryJsonDecode(responseBodyString))) { if (![200, 201].contains(response.statusCode)) {
(200 || 201, {'id': String id}) => UploadSuccess(remoteAssetId: id), String? errorMessage;
(413, _) => const UploadError(statusCode: 413, message: 'File is too large to upload'),
(_, {'message': String message}) => UploadError(statusCode: statusCode, message: message), if (response.statusCode == 413) {
_ => UploadError(statusCode: statusCode, message: 'Upload failed with status $statusCode'), errorMessage = 'Error(413) File is too large to upload';
}; return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final error = jsonDecode(responseBodyString);
errorMessage = error['message'] ?? error['error'];
} catch (_) {
errorMessage = responseBodyString.isNotEmpty
? responseBodyString
: 'Upload failed with status ${response.statusCode}';
}
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final responseBody = jsonDecode(responseBodyString);
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
} catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response');
}
} on RequestAbortedException { } on RequestAbortedException {
logger.warning("Upload $logContext was cancelled"); logger.warning("Upload $logContext was cancelled");
return const UploadCancelled(); return UploadResult.cancelled();
} catch (error, stackTrace) { } catch (error, stackTrace) {
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace"); logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
return UploadError(message: error.toString()); return UploadResult.error(errorMessage: error.toString());
}
}
@pragma('vm:prefer-inline')
Map? _tryJsonDecode(String s) {
try {
return (jsonDecode(s) as Map);
} catch (_) {
return null;
} }
} }
} }
@@ -139,23 +180,30 @@ class ProgressMultipartRequest extends MultipartRequest with Abortable {
} }
} }
sealed class UploadResult { class UploadResult {
const UploadResult(); final bool isSuccess;
} final bool isCancelled;
final String? remoteAssetId;
final class UploadSuccess extends UploadResult { final String? errorMessage;
final String remoteAssetId;
const UploadSuccess({required this.remoteAssetId});
}
final class UploadError extends UploadResult {
final String message;
final int? statusCode; final int? statusCode;
const UploadError({required this.message, this.statusCode}); const UploadResult({
} required this.isSuccess,
required this.isCancelled,
this.remoteAssetId,
this.errorMessage,
this.statusCode,
});
final class UploadCancelled extends UploadResult { factory UploadResult.success({required String remoteAssetId}) {
const UploadCancelled(); return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
}
factory UploadResult.error({String? errorMessage, int? statusCode}) {
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode);
}
factory UploadResult.cancelled() {
return const UploadResult(isSuccess: false, isCancelled: true);
}
} }
@@ -266,6 +266,8 @@ class BackgroundUploadService {
return null; return null;
} }
File? file;
/// iOS LivePhoto has two files: a photo and a video. /// iOS LivePhoto has two files: a photo and a video.
/// They are uploaded separately, with video file being upload first, then returned with the assetId /// They are uploaded separately, with video file being upload first, then returned with the assetId
/// The assetId is then used as a metadata for the photo file upload task. /// The assetId is then used as a metadata for the photo file upload task.
@@ -276,9 +278,11 @@ class BackgroundUploadService {
/// The cancel operation will only cancel the video group (normal group), the photo group will not /// The cancel operation will only cancel the video group (normal group), the photo group will not
/// be touched, as the video file is already uploaded. /// be touched, as the video file is already uploaded.
final file = await (entity.isLivePhoto if (entity.isLivePhoto) {
? _storageRepository.getMotionFile(asset.id) file = await _storageRepository.getMotionFileForAsset(asset);
: _storageRepository.getAssetFile(asset.id)); } else {
file = await _storageRepository.getFileForAsset(asset.id);
}
if (file == null) { if (file == null) {
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}"); _logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
@@ -326,7 +330,7 @@ class BackgroundUploadService {
return null; return null;
} }
final file = await _storageRepository.getAssetFile(asset.id); final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) { if (file == null) {
return null; return null;
} }
@@ -20,6 +20,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
/// Callbacks for upload progress and status updates /// Callbacks for upload progress and status updates
class UploadCallbacks { class UploadCallbacks {
@@ -98,7 +99,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset); final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi; return requireWifi && !hasWifi;
}, },
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks), processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
); );
} }
} }
@@ -124,14 +125,14 @@ class ForegroundUploadService {
continue; continue;
} }
await _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks); await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
} }
} }
/// Manually upload picked local assets /// Manually upload picked local assets
Future<void> uploadManual( Future<void> uploadManual(
List<LocalAsset> localAssets, { List<LocalAsset> localAssets, {
required Completer<void>? cancelToken, Completer<void>? cancelToken,
UploadCallbacks callbacks = const UploadCallbacks(), UploadCallbacks callbacks = const UploadCallbacks(),
}) async { }) async {
if (localAssets.isEmpty) { if (localAssets.isEmpty) {
@@ -141,7 +142,7 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>( await _executeWithWorkerPool<LocalAsset>(
items: localAssets, items: localAssets,
cancelToken: cancelToken, cancelToken: cancelToken,
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks), processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
); );
} }
@@ -169,11 +170,11 @@ class ForegroundUploadService {
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
); );
return switch (result) { if (result.isSuccess) {
UploadSuccess() => onSuccess?.call(fileId), onSuccess?.call(fileId);
UploadError(:final message) => onError?.call(fileId, message), } else if (!result.isCancelled && result.errorMessage != null) {
UploadCancelled() => null, onError?.call(fileId, result.errorMessage!);
}; }
}, },
); );
} }
@@ -215,7 +216,7 @@ class ForegroundUploadService {
final item = items[index]; final item = items[index];
if (shouldSkip != null && shouldSkip(item)) { if (shouldSkip?.call(item) ?? false) {
continue; continue;
} }
@@ -232,48 +233,78 @@ class ForegroundUploadService {
} }
Future<void> _uploadSingleAsset( Future<void> _uploadSingleAsset(
LocalAsset asset, { LocalAsset asset,
required Completer<void>? cancelToken, Completer<void>? cancelToken, {
required UploadCallbacks callbacks, required UploadCallbacks callbacks,
}) async { }) async {
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = callbacks; File? file;
File? assetFile;
File? livePhotoFile; File? livePhotoFile;
try { try {
final entity = await _storageRepository.getAssetEntityForAsset(asset); final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) { if (entity == null) {
onError?.call( callbacks.onError?.call(
asset.localId!, asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(), CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
); );
return; return;
} }
File? file; final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
if (entity.isLivePhoto) {
file = await _storageRepository.getMotionFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress); if (!isAvailableLocally && CurrentPlatform.isIOS) {
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
// Create progress handler for iCloud download
PMProgressHandler? progressHandler;
StreamSubscription? progressSubscription;
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) {
callbacks.onICloudProgress?.call(asset.localId!, event.progress);
});
try {
file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler);
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.loadMotionFileFromCloud(
asset.id,
progressHandler: progressHandler,
);
}
} finally {
await progressSubscription.cancel();
}
} else {
// Get files locally
file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) { if (file == null) {
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}"); _logger.warning("Failed to get file ${asset.id} - ${asset.name}");
onError?.call( callbacks.onError?.call(
asset.localId!, asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(), CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
); );
return; return;
} }
livePhotoFile = file;
// For live photos, get the motion video file
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
if (livePhotoFile == null) {
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
}
}
} }
file = await _storageRepository.getAssetFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
if (file == null) { if (file == null) {
_logger.warning("Failed to get file ${asset.id} - ${asset.name}"); _logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}");
onError?.call( callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t());
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return; return;
} }
assetFile = file;
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
@@ -299,9 +330,11 @@ class ForegroundUploadService {
}; };
// Upload live photo video first if available // Upload live photo video first if available
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) { if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path)); final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
final onProgress = callbacks.onProgress;
final livePhotoResult = await _uploadRepository.uploadFile( final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile, file: livePhotoFile,
originalFileName: livePhotoTitle, originalFileName: livePhotoTitle,
@@ -313,16 +346,15 @@ class ForegroundUploadService {
logContext: 'livePhotoVideo[${asset.localId}]', logContext: 'livePhotoVideo[${asset.localId}]',
); );
switch (livePhotoResult) { if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
case UploadSuccess(:final remoteAssetId): livePhotoVideoId = livePhotoResult.remoteAssetId;
fields['livePhotoVideoId'] = remoteAssetId;
case UploadError(:final message):
onError?.call(asset.localId!, "Failed to upload live photo video: $message");
return;
case UploadCancelled():
} }
} }
if (livePhotoVideoId != null) {
fields['livePhotoVideoId'] = livePhotoVideoId;
}
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image. // Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
if (CurrentPlatform.isIOS && asset.cloudId != null) { if (CurrentPlatform.isIOS && asset.cloudId != null) {
fields['metadata'] = jsonEncode([ fields['metadata'] = jsonEncode([
@@ -339,6 +371,7 @@ class ForegroundUploadService {
]); ]);
} }
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile( final result = await _uploadRepository.uploadFile(
file: file, file: file,
originalFileName: originalFileName, originalFileName: originalFileName,
@@ -350,33 +383,34 @@ class ForegroundUploadService {
logContext: 'asset[${asset.localId}]', logContext: 'asset[${asset.localId}]',
); );
switch (result) { if (result.isSuccess && result.remoteAssetId != null) {
case UploadSuccess(:final remoteAssetId): callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
onSuccess?.call(asset.localId!, remoteAssetId); } else if (result.isCancelled) {
case UploadCancelled(): _logger.warning(() => "Backup was cancelled by the user");
shouldAbortUpload = true;
} else if (result.errorMessage != null) {
_logger.severe(
() =>
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
);
callbacks.onError?.call(asset.localId!, result.errorMessage!);
if (result.errorMessage == "Quota has been exceeded!") {
shouldAbortUpload = true; shouldAbortUpload = true;
_logger.warning("Upload was cancelled by the user for asset ${asset.localId}"); }
case UploadError(:final message, :final statusCode):
shouldAbortUpload |= message == "Quota has been exceeded!";
_logger.severe(
"Error(${statusCode ?? 'unknown'}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | $message",
);
onError?.call(asset.localId!, message);
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
_logger.severe("Asset backup failed", error, stackTrace); _logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
onError?.call(asset.localId!, error.toString()); callbacks.onError?.call(asset.localId!, error.toString());
} finally { } finally {
if (Platform.isIOS) { if (Platform.isIOS) {
unawaited( try {
Future.wait([ await file?.delete();
if (assetFile != null) assetFile.delete(), await livePhotoFile?.delete();
if (livePhotoFile != null) livePhotoFile.delete(), } catch (error, stackTrace) {
]).onError((error, stackTrace) { _logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace);
_logger.severe("Post-upload file cleanup failed", error, stackTrace); }
return const [];
}),
);
} }
} }
} }
@@ -412,7 +446,7 @@ class ForegroundUploadService {
logContext: 'shareIntent[$deviceAssetId]', logContext: 'shareIntent[$deviceAssetId]',
); );
} catch (e) { } catch (e) {
return UploadError(message: e.toString()); return UploadResult.error(errorMessage: e.toString());
} }
} }
+52 -20
View File
@@ -1,36 +1,42 @@
enum SemVerType { major, minor, patch } enum SemVerType { major, minor, patch, prerelease }
class SemVer { class SemVer {
final int major; final int major;
final int minor; final int minor;
final int patch; final int patch;
final int? prerelease;
const SemVer({required this.major, required this.minor, required this.patch}); const SemVer({required this.major, required this.minor, required this.patch, this.prerelease});
@override @override
String toString() { String toString() {
return '$major.$minor.$patch'; return '$major.$minor.$patch${prerelease == null ? '' : '-rc.$prerelease'}';
} }
SemVer copyWith({int? major, int? minor, int? patch}) { SemVer copyWith({int? major, int? minor, int? patch, int? prerelease}) {
return SemVer(major: major ?? this.major, minor: minor ?? this.minor, patch: patch ?? this.patch); return SemVer(
major: major ?? this.major,
minor: minor ?? this.minor,
patch: patch ?? this.patch,
prerelease: prerelease ?? this.prerelease,
);
} }
static final _pattern = RegExp(r'^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?(?:[-+].*)?$', caseSensitive: false);
factory SemVer.fromString(String version) { factory SemVer.fromString(String version) {
if (version.toLowerCase().startsWith("v")) { final match = _pattern.firstMatch(version);
version = version.substring(1); if (match == null) {
}
final parts = version.split("-")[0].split('.');
if (parts.length != 3) {
throw FormatException('Invalid semantic version string: $version'); throw FormatException('Invalid semantic version string: $version');
} }
try { final prerelease = match.group(4);
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2])); return SemVer(
} catch (e) { major: int.parse(match.group(1)!),
throw FormatException('Invalid semantic version string: $version'); minor: int.parse(match.group(2)!),
} patch: int.parse(match.group(3)!),
prerelease: prerelease == null ? null : int.parse(prerelease),
);
} }
bool operator >(SemVer other) { bool operator >(SemVer other) {
@@ -40,7 +46,10 @@ class SemVer {
if (minor != other.minor) { if (minor != other.minor) {
return minor > other.minor; return minor > other.minor;
} }
return patch > other.patch; if (patch != other.patch) {
return patch > other.patch;
}
return _comparePrerelease(other) > 0;
} }
bool operator <(SemVer other) { bool operator <(SemVer other) {
@@ -50,7 +59,23 @@ class SemVer {
if (minor != other.minor) { if (minor != other.minor) {
return minor < other.minor; return minor < other.minor;
} }
return patch < other.patch; if (patch != other.patch) {
return patch < other.patch;
}
return _comparePrerelease(other) < 0;
}
int _comparePrerelease(SemVer other) {
if (prerelease == other.prerelease) {
return 0;
}
if (prerelease == null) {
return 1;
}
if (other.prerelease == null) {
return -1;
}
return prerelease!.compareTo(other.prerelease!);
} }
bool operator >=(SemVer other) { bool operator >=(SemVer other) {
@@ -67,7 +92,11 @@ class SemVer {
return true; return true;
} }
return other is SemVer && other.major == major && other.minor == minor && other.patch == patch; return other is SemVer &&
other.major == major &&
other.minor == minor &&
other.patch == patch &&
other.prerelease == prerelease;
} }
SemVerType? differenceType(SemVer other) { SemVerType? differenceType(SemVer other) {
@@ -80,10 +109,13 @@ class SemVer {
if (patch != other.patch) { if (patch != other.patch) {
return SemVerType.patch; return SemVerType.patch;
} }
if (prerelease != other.prerelease) {
return SemVerType.prerelease;
}
return null; return null;
} }
@override @override
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode; int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode ^ prerelease.hashCode;
} }
@@ -50,9 +50,7 @@ class AppBarServerInfo extends HookConsumerWidget {
divider, divider,
_ServerInfoItem( _ServerInfoItem(
label: "server_version".tr(), label: "server_version".tr(),
text: serverInfoState.serverVersion.major > 0 text: serverInfoState.serverVersion.major > 0 ? "${serverInfoState.serverVersion}" : "--",
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
: "--",
), ),
divider, divider,
_ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true), _ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true),
@@ -60,9 +58,7 @@ class AppBarServerInfo extends HookConsumerWidget {
divider, divider,
_ServerInfoItem( _ServerInfoItem(
label: "latest_version".tr(), label: "latest_version".tr(),
text: serverInfoState.latestVersion!.major > 0 text: serverInfoState.latestVersion!.major > 0 ? "${serverInfoState.latestVersion!}" : "--",
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
: "--",
tooltip: true, tooltip: true,
icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate
? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12) ? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12)
+3
View File
@@ -513,6 +513,9 @@ Class | Method | HTTP request | Description
- [RatingsUpdate](doc//RatingsUpdate.md) - [RatingsUpdate](doc//RatingsUpdate.md)
- [ReactionLevel](doc//ReactionLevel.md) - [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md) - [ReactionType](doc//ReactionType.md)
- [ReleaseChannel](doc//ReleaseChannel.md)
- [ReleaseEventV1](doc//ReleaseEventV1.md)
- [ReleaseType](doc//ReleaseType.md)
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
- [RotateParameters](doc//RotateParameters.md) - [RotateParameters](doc//RotateParameters.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
+3
View File
@@ -258,6 +258,9 @@ part 'model/ratings_response.dart';
part 'model/ratings_update.dart'; part 'model/ratings_update.dart';
part 'model/reaction_level.dart'; part 'model/reaction_level.dart';
part 'model/reaction_type.dart'; part 'model/reaction_type.dart';
part 'model/release_channel.dart';
part 'model/release_event_v1.dart';
part 'model/release_type.dart';
part 'model/reverse_geocoding_state_response_dto.dart'; part 'model/reverse_geocoding_state_response_dto.dart';
part 'model/rotate_parameters.dart'; part 'model/rotate_parameters.dart';
part 'model/search_album_response_dto.dart'; part 'model/search_album_response_dto.dart';
+6
View File
@@ -562,6 +562,12 @@ class ApiClient {
return ReactionLevelTypeTransformer().decode(value); return ReactionLevelTypeTransformer().decode(value);
case 'ReactionType': case 'ReactionType':
return ReactionTypeTypeTransformer().decode(value); return ReactionTypeTypeTransformer().decode(value);
case 'ReleaseChannel':
return ReleaseChannelTypeTransformer().decode(value);
case 'ReleaseEventV1':
return ReleaseEventV1.fromJson(value);
case 'ReleaseType':
return ReleaseTypeTypeTransformer().decode(value);
case 'ReverseGeocodingStateResponseDto': case 'ReverseGeocodingStateResponseDto':
return ReverseGeocodingStateResponseDto.fromJson(value); return ReverseGeocodingStateResponseDto.fromJson(value);
case 'RotateParameters': case 'RotateParameters':
+6
View File
@@ -157,6 +157,12 @@ String parameterToString(dynamic value) {
if (value is ReactionType) { if (value is ReactionType) {
return ReactionTypeTypeTransformer().encode(value).toString(); return ReactionTypeTypeTransformer().encode(value).toString();
} }
if (value is ReleaseChannel) {
return ReleaseChannelTypeTransformer().encode(value).toString();
}
if (value is ReleaseType) {
return ReleaseTypeTypeTransformer().encode(value).toString();
}
if (value is SearchSuggestionType) { if (value is SearchSuggestionType) {
return SearchSuggestionTypeTypeTransformer().encode(value).toString(); return SearchSuggestionTypeTypeTransformer().encode(value).toString();
} }
+85
View File
@@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Release channel
class ReleaseChannel {
/// Instantiate a new enum with the provided [value].
const ReleaseChannel._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const stable = ReleaseChannel._(r'stable');
static const releaseCandidate = ReleaseChannel._(r'releaseCandidate');
/// List of all possible values in this [enum][ReleaseChannel].
static const values = <ReleaseChannel>[
stable,
releaseCandidate,
];
static ReleaseChannel? fromJson(dynamic value) => ReleaseChannelTypeTransformer().decode(value);
static List<ReleaseChannel> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseChannel>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseChannel.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseChannel] to String,
/// and [decode] dynamic data back to [ReleaseChannel].
class ReleaseChannelTypeTransformer {
factory ReleaseChannelTypeTransformer() => _instance ??= const ReleaseChannelTypeTransformer._();
const ReleaseChannelTypeTransformer._();
String encode(ReleaseChannel data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseChannel.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ReleaseChannel? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'stable': return ReleaseChannel.stable;
case r'releaseCandidate': return ReleaseChannel.releaseCandidate;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseChannelTypeTransformer] instance.
static ReleaseChannelTypeTransformer? _instance;
}
+133
View File
@@ -0,0 +1,133 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ReleaseEventV1 {
/// Returns a new [ReleaseEventV1] instance.
ReleaseEventV1({
required this.checkedAt,
required this.isAvailable,
required this.releaseVersion,
required this.serverVersion,
required this.type,
});
/// When the server last checked for a latest version. As an ISO timestamp
String checkedAt;
/// Whether a new version is available
bool isAvailable;
ServerVersionResponseDto releaseVersion;
ServerVersionResponseDto serverVersion;
ReleaseType type;
@override
bool operator ==(Object other) => identical(this, other) || other is ReleaseEventV1 &&
other.checkedAt == checkedAt &&
other.isAvailable == isAvailable &&
other.releaseVersion == releaseVersion &&
other.serverVersion == serverVersion &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(checkedAt.hashCode) +
(isAvailable.hashCode) +
(releaseVersion.hashCode) +
(serverVersion.hashCode) +
(type.hashCode);
@override
String toString() => 'ReleaseEventV1[checkedAt=$checkedAt, isAvailable=$isAvailable, releaseVersion=$releaseVersion, serverVersion=$serverVersion, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'checkedAt'] = this.checkedAt;
json[r'isAvailable'] = this.isAvailable;
json[r'releaseVersion'] = this.releaseVersion;
json[r'serverVersion'] = this.serverVersion;
json[r'type'] = this.type;
return json;
}
/// Returns a new [ReleaseEventV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ReleaseEventV1? fromJson(dynamic value) {
upgradeDto(value, "ReleaseEventV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return ReleaseEventV1(
checkedAt: mapValueOfType<String>(json, r'checkedAt')!,
isAvailable: mapValueOfType<bool>(json, r'isAvailable')!,
releaseVersion: ServerVersionResponseDto.fromJson(json[r'releaseVersion'])!,
serverVersion: ServerVersionResponseDto.fromJson(json[r'serverVersion'])!,
type: ReleaseType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<ReleaseEventV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseEventV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseEventV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ReleaseEventV1> mapFromJson(dynamic json) {
final map = <String, ReleaseEventV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ReleaseEventV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ReleaseEventV1-objects as value to a dart map
static Map<String, List<ReleaseEventV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ReleaseEventV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ReleaseEventV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'checkedAt',
'isAvailable',
'releaseVersion',
'serverVersion',
'type',
};
}
+103
View File
@@ -0,0 +1,103 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ReleaseType {
/// Instantiate a new enum with the provided [value].
const ReleaseType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const major = ReleaseType._(r'major');
static const premajor = ReleaseType._(r'premajor');
static const minor = ReleaseType._(r'minor');
static const preminor = ReleaseType._(r'preminor');
static const patch_ = ReleaseType._(r'patch');
static const prepatch = ReleaseType._(r'prepatch');
static const prerelease = ReleaseType._(r'prerelease');
static const release = ReleaseType._(r'release');
/// List of all possible values in this [enum][ReleaseType].
static const values = <ReleaseType>[
major,
premajor,
minor,
preminor,
patch_,
prepatch,
prerelease,
release,
];
static ReleaseType? fromJson(dynamic value) => ReleaseTypeTypeTransformer().decode(value);
static List<ReleaseType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseType] to String,
/// and [decode] dynamic data back to [ReleaseType].
class ReleaseTypeTypeTransformer {
factory ReleaseTypeTypeTransformer() => _instance ??= const ReleaseTypeTypeTransformer._();
const ReleaseTypeTypeTransformer._();
String encode(ReleaseType data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ReleaseType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'major': return ReleaseType.major;
case r'premajor': return ReleaseType.premajor;
case r'minor': return ReleaseType.minor;
case r'preminor': return ReleaseType.preminor;
case r'patch': return ReleaseType.patch_;
case r'prepatch': return ReleaseType.prepatch;
case r'prerelease': return ReleaseType.prerelease;
case r'release': return ReleaseType.release;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseTypeTypeTransformer] instance.
static ReleaseTypeTypeTransformer? _instance;
}
+22 -6
View File
@@ -16,47 +16,61 @@ class ServerVersionResponseDto {
required this.major, required this.major,
required this.minor, required this.minor,
required this.patch_, required this.patch_,
required this.prerelease,
}); });
/// Major version number /// Major version number
/// ///
/// Minimum value: -9007199254740991 /// Minimum value: 0
/// Maximum value: 9007199254740991 /// Maximum value: 9007199254740991
int major; int major;
/// Minor version number /// Minor version number
/// ///
/// Minimum value: -9007199254740991 /// Minimum value: 0
/// Maximum value: 9007199254740991 /// Maximum value: 9007199254740991
int minor; int minor;
/// Patch version number /// Patch version number
/// ///
/// Minimum value: -9007199254740991 /// Minimum value: 0
/// Maximum value: 9007199254740991 /// Maximum value: 9007199254740991
int patch_; int patch_;
/// Pre-release version number
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? prerelease;
@override @override
bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto && bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto &&
other.major == major && other.major == major &&
other.minor == minor && other.minor == minor &&
other.patch_ == patch_; other.patch_ == patch_ &&
other.prerelease == prerelease;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(major.hashCode) + (major.hashCode) +
(minor.hashCode) + (minor.hashCode) +
(patch_.hashCode); (patch_.hashCode) +
(prerelease == null ? 0 : prerelease!.hashCode);
@override @override
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]'; String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_, prerelease=$prerelease]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'major'] = this.major; json[r'major'] = this.major;
json[r'minor'] = this.minor; json[r'minor'] = this.minor;
json[r'patch'] = this.patch_; json[r'patch'] = this.patch_;
if (this.prerelease != null) {
json[r'prerelease'] = this.prerelease;
} else {
// json[r'prerelease'] = null;
}
return json; return json;
} }
@@ -72,6 +86,7 @@ class ServerVersionResponseDto {
major: mapValueOfType<int>(json, r'major')!, major: mapValueOfType<int>(json, r'major')!,
minor: mapValueOfType<int>(json, r'minor')!, minor: mapValueOfType<int>(json, r'minor')!,
patch_: mapValueOfType<int>(json, r'patch')!, patch_: mapValueOfType<int>(json, r'patch')!,
prerelease: mapValueOfType<int>(json, r'prerelease'),
); );
} }
return null; return null;
@@ -122,6 +137,7 @@ class ServerVersionResponseDto {
'major', 'major',
'minor', 'minor',
'patch', 'patch',
'prerelease',
}; };
} }
@@ -13,26 +13,32 @@ part of openapi.api;
class SystemConfigNewVersionCheckDto { class SystemConfigNewVersionCheckDto {
/// Returns a new [SystemConfigNewVersionCheckDto] instance. /// Returns a new [SystemConfigNewVersionCheckDto] instance.
SystemConfigNewVersionCheckDto({ SystemConfigNewVersionCheckDto({
required this.channel,
required this.enabled, required this.enabled,
}); });
ReleaseChannel channel;
/// Enabled /// Enabled
bool enabled; bool enabled;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigNewVersionCheckDto && bool operator ==(Object other) => identical(this, other) || other is SystemConfigNewVersionCheckDto &&
other.channel == channel &&
other.enabled == enabled; other.enabled == enabled;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(channel.hashCode) +
(enabled.hashCode); (enabled.hashCode);
@override @override
String toString() => 'SystemConfigNewVersionCheckDto[enabled=$enabled]'; String toString() => 'SystemConfigNewVersionCheckDto[channel=$channel, enabled=$enabled]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'channel'] = this.channel;
json[r'enabled'] = this.enabled; json[r'enabled'] = this.enabled;
return json; return json;
} }
@@ -46,6 +52,7 @@ class SystemConfigNewVersionCheckDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return SystemConfigNewVersionCheckDto( return SystemConfigNewVersionCheckDto(
channel: ReleaseChannel.fromJson(json[r'channel'])!,
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
); );
} }
@@ -94,6 +101,7 @@ class SystemConfigNewVersionCheckDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'channel',
'enabled', 'enabled',
}; };
} }
@@ -116,7 +116,7 @@ void main() {
when(() => mockApi.serverInfoApi).thenReturn(mockServerApi); when(() => mockApi.serverInfoApi).thenReturn(mockServerApi);
when( when(
() => mockServerApi.getServerVersion(), () => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0)); ).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0, prerelease: null));
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler);
@@ -559,7 +559,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]"); await Store.put(StoreKey.syncMigrationStatus, "[]");
when( when(
() => mockServerApi.getServerVersion(), () => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1)); ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null));
await sut.sync(); await sut.sync();
@@ -587,7 +587,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]"); await Store.put(StoreKey.syncMigrationStatus, "[]");
when( when(
() => mockServerApi.getServerVersion(), () => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0)); ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0, prerelease: null));
await sut.sync(); await sut.sync();
verifyInOrder([ verifyInOrder([
@@ -617,7 +617,7 @@ void main() {
when( when(
() => mockServerApi.getServerVersion(), () => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1)); ).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null));
await sut.sync(); await sut.sync();
@@ -75,7 +75,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg'); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg');
final task = await sut.getUploadTask(asset); final task = await sut.getUploadTask(asset);
@@ -92,7 +92,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getUploadTask(asset); final task = await sut.getUploadTask(asset);
@@ -109,7 +109,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getMotionFile(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id), () => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC'); ).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -130,7 +130,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id), () => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC'); ).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -150,7 +150,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456'); final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
@@ -194,7 +194,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithCloudId); final task = await sutWithV24.getUploadTask(assetWithCloudId);
@@ -243,7 +243,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutAndroid.getUploadTask(assetWithCloudId); final task = await sutAndroid.getUploadTask(assetWithCloudId);
@@ -281,7 +281,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id), () => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
).thenAnswer((_) async => 'test.jpg'); ).thenAnswer((_) async => 'test.jpg');
@@ -323,7 +323,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id), () => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
).thenAnswer((_) async => 'livephoto.heic'); ).thenAnswer((_) async => 'livephoto.heic');
+66
View File
@@ -88,5 +88,71 @@ void main() {
expect(version2.minor, 2); expect(version2.minor, 2);
expect(version2.patch, 3); expect(version2.patch, 3);
}); });
test('Orders later prerelease above earlier prerelease', () {
const rc1 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const rc2 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc2 > rc1, isTrue);
expect(rc1 < rc2, isTrue);
expect(rc1 == rc2, isFalse);
});
test('Final release outranks its prerelease of the same version', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const release = SemVer(major: 1, minor: 151, patch: 0);
expect(release > rc, isTrue);
expect(rc < release, isTrue);
});
test('Higher major outranks a prerelease regardless of ordinal', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 9);
const next = SemVer(major: 2, minor: 0, patch: 0);
expect(next > rc, isTrue);
});
test('Equal prerelease versions compare as equal', () {
const a = SemVer(major: 1, minor: 151, patch: 0, prerelease: 3);
const b = SemVer(major: 1, minor: 151, patch: 0, prerelease: 3);
expect(a == b, isTrue);
expect(a > b, isFalse);
expect(a < b, isFalse);
});
test('Reports prerelease difference type', () {
const rc1 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const rc2 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc1.differenceType(rc2), SemVerType.prerelease);
});
test('toString includes prerelease suffix when present', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc.toString(), '1.151.0-rc.2');
});
test('Parses prerelease ordinal from -rc strings', () {
final dotted = SemVer.fromString('1.151.0-rc.2');
expect(dotted.major, 1);
expect(dotted.minor, 151);
expect(dotted.patch, 0);
expect(dotted.prerelease, 2);
expect(SemVer.fromString('v1.151.0-rc.3').prerelease, 3);
expect(SemVer.fromString('1.2.3-rc.2+build.5').prerelease, 2);
});
test('Plain version string has null prerelease', () {
expect(SemVer.fromString('3.0.0').prerelease, isNull);
});
test('Invalid rc suffixes parse without error and have null prerelease', () {
final debug = SemVer.fromString('1.2.3-debug');
expect(debug.major, 1);
expect(debug.minor, 2);
expect(debug.patch, 3);
expect(debug.prerelease, isNull);
expect(SemVer.fromString('1.2.3+build.5').prerelease, isNull);
expect(SemVer.fromString('1.151.0-rc4').prerelease, isNull);
});
}); });
} }
+74 -4
View File
@@ -20800,6 +20800,58 @@
], ],
"type": "string" "type": "string"
}, },
"ReleaseChannel": {
"description": "Release channel",
"enum": [
"stable",
"releaseCandidate"
],
"type": "string"
},
"ReleaseEventV1": {
"properties": {
"checkedAt": {
"description": "When the server last checked for a latest version. As an ISO timestamp",
"type": "string"
},
"isAvailable": {
"description": "Whether a new version is available",
"type": "boolean"
},
"releaseVersion": {
"$ref": "#/components/schemas/ServerVersionResponseDto"
},
"serverVersion": {
"$ref": "#/components/schemas/ServerVersionResponseDto"
},
"type": {
"$ref": "#/components/schemas/ReleaseType",
"description": "Release type",
"nullable": true
}
},
"required": [
"checkedAt",
"isAvailable",
"releaseVersion",
"serverVersion",
"type"
],
"type": "object"
},
"ReleaseType": {
"enum": [
"major",
"premajor",
"minor",
"preminor",
"patch",
"prepatch",
"prerelease",
"release"
],
"type": "string"
},
"ReverseGeocodingStateResponseDto": { "ReverseGeocodingStateResponseDto": {
"properties": { "properties": {
"lastImportFileName": { "lastImportFileName": {
@@ -21469,26 +21521,40 @@
"major": { "major": {
"description": "Major version number", "description": "Major version number",
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": -9007199254740991, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"minor": { "minor": {
"description": "Minor version number", "description": "Minor version number",
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": -9007199254740991, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"patch": { "patch": {
"description": "Patch version number", "description": "Patch version number",
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": -9007199254740991, "minimum": 0,
"type": "integer" "type": "integer"
},
"prerelease": {
"description": "Pre-release version number",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer",
"x-immich-history": [
{
"version": "v3.0.0",
"state": "Added"
}
]
} }
}, },
"required": [ "required": [
"major", "major",
"minor", "minor",
"patch" "patch",
"prerelease"
], ],
"type": "object" "type": "object"
}, },
@@ -24509,12 +24575,16 @@
}, },
"SystemConfigNewVersionCheckDto": { "SystemConfigNewVersionCheckDto": {
"properties": { "properties": {
"channel": {
"$ref": "#/components/schemas/ReleaseChannel"
},
"enabled": { "enabled": {
"description": "Enabled", "description": "Enabled",
"type": "boolean" "type": "boolean"
} }
}, },
"required": [ "required": [
"channel",
"enabled" "enabled"
], ],
"type": "object" "type": "object"
+27
View File
@@ -2074,6 +2074,8 @@ export type ServerVersionResponseDto = {
minor: number; minor: number;
/** Patch version number */ /** Patch version number */
patch: number; patch: number;
/** Pre-release version number */
prerelease: number | null;
}; };
export type VersionCheckStateResponseDto = { export type VersionCheckStateResponseDto = {
/** Last check timestamp */ /** Last check timestamp */
@@ -2421,6 +2423,7 @@ export type SystemConfigMetadataDto = {
faces: SystemConfigFacesDto; faces: SystemConfigFacesDto;
}; };
export type SystemConfigNewVersionCheckDto = { export type SystemConfigNewVersionCheckDto = {
channel: ReleaseChannel;
/** Enabled */ /** Enabled */
enabled: boolean; enabled: boolean;
}; };
@@ -2766,6 +2769,16 @@ export type WorkflowShareResponseDto = {
trigger: WorkflowTrigger; trigger: WorkflowTrigger;
}; };
export type LicenseResponseDto = UserLicense; export type LicenseResponseDto = UserLicense;
export type ReleaseEventV1 = {
/** When the server last checked for a latest version. As an ISO timestamp */
checkedAt: string;
/** Whether a new version is available */
isAvailable: boolean;
releaseVersion: ServerVersionResponseDto;
serverVersion: ServerVersionResponseDto;
/** Release type */
"type": ReleaseType;
};
export type SyncAckV1 = {}; export type SyncAckV1 = {};
export type SyncAlbumDeleteV1 = { export type SyncAlbumDeleteV1 = {
/** Album ID */ /** Album ID */
@@ -7305,6 +7318,10 @@ export enum LogLevel {
Error = "error", Error = "error",
Fatal = "fatal" Fatal = "fatal"
} }
export enum ReleaseChannel {
Stable = "stable",
ReleaseCandidate = "releaseCandidate"
}
export enum OAuthTokenEndpointAuthMethod { export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post", ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic" ClientSecretBasic = "client_secret_basic"
@@ -7313,6 +7330,16 @@ export enum AssetOrderBy {
TakenAt = "takenAt", TakenAt = "takenAt",
CreatedAt = "createdAt" CreatedAt = "createdAt"
} }
export enum ReleaseType {
Major = "major",
Premajor = "premajor",
Minor = "minor",
Preminor = "preminor",
Patch = "patch",
Prepatch = "prepatch",
Prerelease = "prerelease",
Release = "release"
}
export enum UserMetadataKey { export enum UserMetadataKey {
Preferences = "preferences", Preferences = "preferences",
License = "license", License = "license",
+27 -20
View File
@@ -571,8 +571,8 @@ importers:
specifier: ^1.6.3 specifier: ^1.6.3
version: 1.6.4 version: 1.6.4
semver: semver:
specifier: ^7.6.2 specifier: ^7.8.1
version: 7.8.0 version: 7.8.1
sharp: sharp:
specifier: ^0.34.5 specifier: ^0.34.5
version: 0.34.5 version: 0.34.5
@@ -11243,6 +11243,11 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
semver@7.8.1:
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
engines: {node: '>=10'}
hasBin: true
send@0.19.2: send@0.19.2:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -16300,7 +16305,7 @@ snapshots:
nopt: 5.0.0 nopt: 5.0.0
npmlog: 5.0.1 npmlog: 5.0.1
rimraf: 3.0.2 rimraf: 3.0.2
semver: 7.8.0 semver: 7.8.1
tar: 6.2.1 tar: 6.2.1
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@@ -17757,7 +17762,7 @@ snapshots:
'@testing-library/dom@10.4.1': '@testing-library/dom@10.4.1':
dependencies: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4 '@types/aria-query': 5.0.4
aria-query: 5.3.0 aria-query: 5.3.0
dom-accessibility-api: 0.5.16 dom-accessibility-api: 0.5.16
@@ -18461,7 +18466,7 @@ snapshots:
'@typescript-eslint/visitor-keys': 8.59.4 '@typescript-eslint/visitor-keys': 8.59.4
debug: 4.4.3 debug: 4.4.3
minimatch: 10.2.5 minimatch: 10.2.5
semver: 7.8.0 semver: 7.8.1
tinyglobby: 0.2.16 tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@6.0.3) ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3 typescript: 6.0.3
@@ -19561,7 +19566,7 @@ snapshots:
dot-prop: 10.1.0 dot-prop: 10.1.0
env-paths: 3.0.0 env-paths: 3.0.0
json-schema-typed: 8.0.2 json-schema-typed: 8.0.2
semver: 7.8.0 semver: 7.8.1
uint8array-extras: 1.5.0 uint8array-extras: 1.5.0
config-chain@1.1.13: config-chain@1.1.13:
@@ -19733,7 +19738,7 @@ snapshots:
postcss-modules-scope: 3.2.1(postcss@8.5.15) postcss-modules-scope: 3.2.1(postcss@8.5.15)
postcss-modules-values: 4.0.0(postcss@8.5.15) postcss-modules-values: 4.0.0(postcss@8.5.15)
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
semver: 7.8.0 semver: 7.8.1
optionalDependencies: optionalDependencies:
webpack: 5.107.0(postcss@8.5.15) webpack: 5.107.0(postcss@8.5.15)
@@ -20601,7 +20606,7 @@ snapshots:
find-up: 5.0.0 find-up: 5.0.0
globals: 15.15.0 globals: 15.15.0
lodash.memoize: 4.1.2 lodash.memoize: 4.1.2
semver: 7.8.0 semver: 7.8.1
eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(prettier@3.8.3): eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(prettier@3.8.3):
dependencies: dependencies:
@@ -20624,7 +20629,7 @@ snapshots:
postcss: 8.5.15 postcss: 8.5.15
postcss-load-config: 3.1.4(postcss@8.5.15) postcss-load-config: 3.1.4(postcss@8.5.15)
postcss-safe-parser: 7.0.1(postcss@8.5.15) postcss-safe-parser: 7.0.1(postcss@8.5.15)
semver: 7.8.0 semver: 7.8.1
svelte-eslint-parser: 1.6.1(svelte@5.55.8(@typescript-eslint/types@8.59.4)) svelte-eslint-parser: 1.6.1(svelte@5.55.8(@typescript-eslint/types@8.59.4))
optionalDependencies: optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4) svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -21102,7 +21107,7 @@ snapshots:
minimatch: 3.1.5 minimatch: 3.1.5
node-abort-controller: 3.1.1 node-abort-controller: 3.1.1
schema-utils: 3.3.0 schema-utils: 3.3.0
semver: 7.8.0 semver: 7.8.1
tapable: 2.3.3 tapable: 2.3.3
typescript: 5.9.3 typescript: 5.9.3
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0) webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
@@ -21538,7 +21543,7 @@ snapshots:
history@4.10.1: history@4.10.1:
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.7
loose-envify: 1.4.0 loose-envify: 1.4.0
resolve-pathname: 3.0.0 resolve-pathname: 3.0.0
tiny-invariant: 1.3.3 tiny-invariant: 1.3.3
@@ -22126,7 +22131,7 @@ snapshots:
lodash.isstring: 4.0.1 lodash.isstring: 4.0.1
lodash.once: 4.1.1 lodash.once: 4.1.1
ms: 2.1.3 ms: 2.1.3
semver: 7.8.0 semver: 7.8.1
just-compare@2.3.0: {} just-compare@2.3.0: {}
@@ -22412,7 +22417,7 @@ snapshots:
make-dir@4.0.0: make-dir@4.0.0:
dependencies: dependencies:
semver: 7.8.0 semver: 7.8.1
maplibre-gl@5.24.0: maplibre-gl@5.24.0:
dependencies: dependencies:
@@ -23247,7 +23252,7 @@ snapshots:
node-abi@3.92.0: node-abi@3.92.0:
dependencies: dependencies:
semver: 7.8.0 semver: 7.8.1
optional: true optional: true
node-abort-controller@3.1.1: {} node-abort-controller@3.1.1: {}
@@ -23288,7 +23293,7 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
nopt: 9.0.0 nopt: 9.0.0
proc-log: 6.1.0 proc-log: 6.1.0
semver: 7.8.0 semver: 7.8.1
tar: 7.5.15 tar: 7.5.15
tinyglobby: 0.2.16 tinyglobby: 0.2.16
undici: 6.25.0 undici: 6.25.0
@@ -23526,7 +23531,7 @@ snapshots:
got: 12.6.1 got: 12.6.1
registry-auth-token: 5.1.1 registry-auth-token: 5.1.1
registry-url: 6.0.1 registry-url: 6.0.1
semver: 7.8.0 semver: 7.8.1
package-manager-detector@1.6.0: {} package-manager-detector@1.6.0: {}
@@ -23914,7 +23919,7 @@ snapshots:
cosmiconfig: 8.3.6(typescript@6.0.3) cosmiconfig: 8.3.6(typescript@6.0.3)
jiti: 1.21.7 jiti: 1.21.7
postcss: 8.5.15 postcss: 8.5.15
semver: 7.8.0 semver: 7.8.1
webpack: 5.107.0(postcss@8.5.15) webpack: 5.107.0(postcss@8.5.15)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
@@ -24969,12 +24974,14 @@ snapshots:
semver-diff@4.0.0: semver-diff@4.0.0:
dependencies: dependencies:
semver: 7.8.0 semver: 7.8.1
semver@6.3.1: {} semver@6.3.1: {}
semver@7.8.0: {} semver@7.8.0: {}
semver@7.8.1: {}
send@0.19.2: send@0.19.2:
dependencies: dependencies:
debug: 2.6.9 debug: 2.6.9
@@ -25509,7 +25516,7 @@ snapshots:
postcss: 8.5.15 postcss: 8.5.15
postcss-scss: 4.0.9(postcss@8.5.15) postcss-scss: 4.0.9(postcss@8.5.15)
postcss-selector-parser: 7.1.1 postcss-selector-parser: 7.1.1
semver: 7.8.0 semver: 7.8.1
optionalDependencies: optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4) svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -26217,7 +26224,7 @@ snapshots:
is-yarn-global: 0.4.1 is-yarn-global: 0.4.1
latest-version: 7.0.0 latest-version: 7.0.0
pupa: 3.3.0 pupa: 3.3.0
semver: 7.8.0 semver: 7.8.1
semver-diff: 4.0.0 semver-diff: 4.0.0
xdg-basedir: 5.1.0 xdg-basedir: 5.1.0
+1 -1
View File
@@ -106,7 +106,7 @@
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"semver": "^7.6.2", "semver": "^7.8.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sirv": "^3.0.0", "sirv": "^3.0.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
+3
View File
@@ -1,4 +1,5 @@
import { CronExpression } from '@nestjs/schedule'; import { CronExpression } from '@nestjs/schedule';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { import {
AudioCodec, AudioCodec,
Colorspace, Colorspace,
@@ -135,6 +136,7 @@ export type SystemConfig = {
}; };
newVersionCheck: { newVersionCheck: {
enabled: boolean; enabled: boolean;
channel: ReleaseChannel;
}; };
nightlyTasks: { nightlyTasks: {
startTime: string; startTime: string;
@@ -344,6 +346,7 @@ export const defaults = Object.freeze<SystemConfig>({
}, },
newVersionCheck: { newVersionCheck: {
enabled: true, enabled: true,
channel: ReleaseChannel.Stable,
}, },
nightlyTasks: { nightlyTasks: {
startTime: '00:00', startTime: '00:00',
+10
View File
@@ -265,3 +265,13 @@ export class HistoryBuilder {
return this; return this;
} }
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export const extraModels: Function[] = [];
export const ExtraModel = (): ClassDecorator => {
// eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-unsafe-function-type
return (object: Function) => {
extraModels.push(object);
};
};
+39 -11
View File
@@ -1,5 +1,6 @@
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import type { SemVer } from 'semver'; import type { SemVer } from 'semver';
import { ExtraModel, HistoryBuilder } from 'src/decorators';
import { isoDatetimeToDate } from 'src/validation'; import { isoDatetimeToDate } from 'src/validation';
import z from 'zod'; import z from 'zod';
@@ -58,9 +59,15 @@ const ServerStorageResponseSchema = z
const ServerVersionResponseSchema = z const ServerVersionResponseSchema = z
.object({ .object({
major: z.int().describe('Major version number'), major: z.int().min(0).describe('Major version number'),
minor: z.int().describe('Minor version number'), minor: z.int().min(0).describe('Minor version number'),
patch: z.int().describe('Patch version number'), patch: z.int().min(0).describe('Patch version number'),
prerelease: z
.int()
.min(0)
.nullable()
.meta(HistoryBuilder.v3().getExtensions())
.describe('Pre-release version number'),
}) })
.meta({ id: 'ServerVersionResponseDto' }); .meta({ id: 'ServerVersionResponseDto' });
@@ -140,6 +147,27 @@ const ServerFeaturesSchema = z
}) })
.meta({ id: 'ServerFeaturesDto' }); .meta({ id: 'ServerFeaturesDto' });
export enum ReleaseType {
Major = 'major',
Premajor = 'premajor',
Minor = 'minor',
Preminor = 'preminor',
Patch = 'patch',
Prepatch = 'prepatch',
Prerelease = 'prerelease',
Release = 'release',
}
const ReleaseTypeSchema = z.enum(ReleaseType).meta({ id: 'ReleaseType' }).describe('Release type');
const ReleaseEventV1Schema = z.object({
isAvailable: z.boolean().describe('Whether a new version is available'),
checkedAt: z.string().describe('When the server last checked for a latest version. As an ISO timestamp'),
serverVersion: ServerVersionResponseSchema,
releaseVersion: ServerVersionResponseSchema,
type: ReleaseTypeSchema.nullable(),
});
export class ServerPingResponse extends createZodDto(ServerPingResponseSchema) {} export class ServerPingResponse extends createZodDto(ServerPingResponseSchema) {}
export class ServerAboutResponseDto extends createZodDto(ServerAboutResponseSchema) {} export class ServerAboutResponseDto extends createZodDto(ServerAboutResponseSchema) {}
export class ServerApkLinksDto extends createZodDto(ServerApkLinksSchema) {} export class ServerApkLinksDto extends createZodDto(ServerApkLinksSchema) {}
@@ -147,7 +175,12 @@ export class ServerStorageResponseDto extends createZodDto(ServerStorageResponse
export class ServerVersionResponseDto extends createZodDto(ServerVersionResponseSchema) { export class ServerVersionResponseDto extends createZodDto(ServerVersionResponseSchema) {
static fromSemVer(value: SemVer): z.infer<typeof ServerVersionResponseSchema> { static fromSemVer(value: SemVer): z.infer<typeof ServerVersionResponseSchema> {
return { major: value.major, minor: value.minor, patch: value.patch }; return {
major: value.major,
minor: value.minor,
patch: value.patch,
prerelease: (value.prerelease[1] as number) ?? null,
};
} }
} }
@@ -158,10 +191,5 @@ export class ServerMediaTypesResponseDto extends createZodDto(ServerMediaTypesRe
export class ServerConfigDto extends createZodDto(ServerConfigSchema) {} export class ServerConfigDto extends createZodDto(ServerConfigSchema) {}
export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {} export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {}
export interface ReleaseNotification { @ExtraModel()
isAvailable: boolean; export class ReleaseEventV1 extends createZodDto(ReleaseEventV1Schema) {}
/** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}
+1 -10
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { ExtraModel } from 'src/decorators';
import { AssetEditActionSchema } from 'src/dtos/editing.dto'; import { AssetEditActionSchema } from 'src/dtos/editing.dto';
import { import {
AlbumUserRole, AlbumUserRole,
@@ -17,15 +17,6 @@ import {
import { isoDatetimeToDate } from 'src/validation'; import { isoDatetimeToDate } from 'src/validation';
import z from 'zod'; import z from 'zod';
export const extraSyncModels: Function[] = [];
const ExtraModel = (): ClassDecorator => {
// eslint-disable-next-line unicorn/consistent-function-scoping
return (object: Function) => {
extraSyncModels.push(object);
};
};
const SyncUserV1Schema = z const SyncUserV1Schema = z
.object({ .object({
id: z.string().describe('User ID'), id: z.string().describe('User ID'),
+8 -1
View File
@@ -151,8 +151,15 @@ const SystemConfigMapSchema = z
}) })
.meta({ id: 'SystemConfigMapDto' }); .meta({ id: 'SystemConfigMapDto' });
export enum ReleaseChannel {
Stable = 'stable',
ReleaseCandidate = 'releaseCandidate',
}
const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' });
const SystemConfigNewVersionCheckSchema = z const SystemConfigNewVersionCheckSchema = z
.object({ enabled: configBool.describe('Enabled') }) .object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema })
.meta({ id: 'SystemConfigNewVersionCheckDto' }); .meta({ id: 'SystemConfigNewVersionCheckDto' });
const SystemConfigNightlyTasksSchema = z const SystemConfigNightlyTasksSchema = z
@@ -4,6 +4,7 @@ import { exec as execCallback } from 'node:child_process';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import sharp from 'sharp'; import sharp from 'sharp';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -64,10 +65,12 @@ export class ServerInfoRepository {
this.logger.setContext(ServerInfoRepository.name); this.logger.setContext(ServerInfoRepository.name);
} }
async getLatestRelease(): Promise<VersionResponse> { async getLatestRelease(channel: ReleaseChannel): Promise<VersionResponse> {
try { try {
const { versionCheck } = this.configRepository.getEnv(); const { versionCheck } = this.configRepository.getEnv();
const response = await fetch(versionCheck.url); const url = new URL(versionCheck.url);
url.searchParams.append('channel', channel);
const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`); throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`);
@@ -10,7 +10,7 @@ import { Server, Socket } from 'socket.io';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto'; import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ReleaseEventV1, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV2 } from 'src/dtos/sync.dto'; import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV2 } from 'src/dtos/sync.dto';
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository'; import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -31,7 +31,7 @@ export interface ClientEventMap {
on_person_thumbnail: [string]; on_person_thumbnail: [string];
on_server_version: [ServerVersionResponseDto]; on_server_version: [ServerVersionResponseDto];
on_config_update: []; on_config_update: [];
on_new_release: [ReleaseNotification]; on_new_release: [ReleaseEventV1];
on_notification: [NotificationDto]; on_notification: [NotificationDto];
on_session_delete: [string]; on_session_delete: [string];
@@ -1,5 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/config';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { import {
AudioCodec, AudioCodec,
Colorspace, Colorspace,
@@ -184,6 +185,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
}, },
newVersionCheck: { newVersionCheck: {
enabled: true, enabled: true,
channel: ReleaseChannel.Stable,
}, },
trash: { trash: {
enabled: true, enabled: true,
+40 -12
View File
@@ -2,6 +2,7 @@ import { DateTime } from 'luxon';
import { SemVer } from 'semver'; import { SemVer } from 'semver';
import { defaults } from 'src/config'; import { defaults } from 'src/config';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service'; import { VersionService } from 'src/services/version.service';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
@@ -22,6 +23,17 @@ describe(VersionService.name, () => {
mocks.cron.update.mockResolvedValue(); mocks.cron.update.mockResolvedValue();
}); });
beforeAll(() => {
vitest.mock(import('src/constants.js'), async () => ({
...(await vitest.importActual<typeof import('src/constants.js')>('src/constants.js')),
serverVersion: new SemVer('v3.0.0'),
}));
});
afterAll(() => {
vitest.unmock(import('src/constants.js'));
});
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
@@ -66,9 +78,10 @@ describe(VersionService.name, () => {
describe('getVersion', () => { describe('getVersion', () => {
it('should respond the server version', () => { it('should respond the server version', () => {
expect(sut.getVersion()).toEqual({ expect(sut.getVersion()).toEqual({
major: serverVersion.major, major: 3,
minor: serverVersion.minor, minor: 0,
patch: serverVersion.patch, patch: 0,
prerelease: null,
}); });
}); });
}); });
@@ -143,24 +156,24 @@ describe(VersionService.name, () => {
describe('onConfigUpdate', () => { describe('onConfigUpdate', () => {
it('should queue a version check job when newVersionCheck is enabled', async () => { it('should queue a version check job when newVersionCheck is enabled', async () => {
await sut.onConfigUpdate({ await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: false } }, oldConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } }, newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
}); });
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} });
}); });
it('should not queue a version check job when newVersionCheck is disabled', async () => { it('should not queue a version check job when newVersionCheck is disabled', async () => {
await sut.onConfigUpdate({ await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } }, oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: false } }, newConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } },
}); });
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
}); });
it('should not queue a version check job when newVersionCheck was already enabled', async () => { it('should not queue a version check job when newVersionCheck was already enabled', async () => {
await sut.onConfigUpdate({ await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } }, oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } }, newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
}); });
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
}); });
@@ -169,21 +182,36 @@ describe(VersionService.name, () => {
describe('onWebsocketConnection', () => { describe('onWebsocketConnection', () => {
it('should send on_server_version client event', async () => { it('should send on_server_version client event', async () => {
await sut.onWebsocketConnection({ userId: '42' }); await sut.onWebsocketConnection({ userId: '42' });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
major: 3,
minor: 0,
patch: 0,
prerelease: null,
});
expect(mocks.websocket.clientSend).toHaveBeenCalledTimes(1); expect(mocks.websocket.clientSend).toHaveBeenCalledTimes(1);
}); });
it('should also send a new release notification', async () => { it('should also send a new release notification', async () => {
mocks.systemMetadata.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' }); mocks.systemMetadata.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' });
await sut.onWebsocketConnection({ userId: '42' }); await sut.onWebsocketConnection({ userId: '42' });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
major: 3,
minor: 0,
patch: 0,
prerelease: null,
});
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
}); });
it('should not send a release notification when the version check is disabled', async () => { it('should not send a release notification when the version check is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } }); mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } });
await sut.onWebsocketConnection({ userId: '42' }); await sut.onWebsocketConnection({ userId: '42' });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
major: 3,
minor: 0,
patch: 0,
prerelease: null,
});
expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
}); });
}); });
+27 -8
View File
@@ -3,19 +3,27 @@ import { DateTime } from 'luxon';
import semver, { SemVer } from 'semver'; import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum'; import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { VersionCheckMetadata } from 'src/types'; import { VersionCheckMetadata } from 'src/types';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { const asNotification = (
channel: ReleaseChannel,
{ checkedAt, releaseVersion }: VersionCheckMetadata,
): ReleaseEventV1 => {
return { return {
isAvailable: semver.gt(releaseVersion, serverVersion), // can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483
isAvailable: semver.intersects(`>${serverVersion}`, releaseVersion.toString(), {
includePrerelease: channel === ReleaseChannel.ReleaseCandidate,
}),
checkedAt, checkedAt,
serverVersion: ServerVersionResponseDto.fromSemVer(serverVersion), serverVersion: ServerVersionResponseDto.fromSemVer(serverVersion),
releaseVersion: ServerVersionResponseDto.fromSemVer(new SemVer(releaseVersion)), releaseVersion: ServerVersionResponseDto.fromSemVer(new SemVer(releaseVersion)),
type: semver.diff(serverVersion, releaseVersion) as ReleaseType,
}; };
}; };
@@ -98,14 +106,21 @@ export class VersionService extends BaseService {
} }
} }
const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease(); const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease(
newVersionCheck.channel,
);
const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion }; const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion };
await this.systemMetadataRepository.set(SystemMetadataKey.VersionCheckState, metadata); await this.systemMetadataRepository.set(SystemMetadataKey.VersionCheckState, metadata);
if (semver.gt(releaseVersion, serverVersion)) { // can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483
if (
semver.intersects(`>${serverVersion}`, releaseVersion.toString(), {
includePrerelease: newVersionCheck.channel === ReleaseChannel.ReleaseCandidate,
})
) {
this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`); this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`);
this.websocketRepository.clientBroadcast('on_new_release', asNotification(metadata)); this.websocketRepository.clientBroadcast('on_new_release', asNotification(newVersionCheck.channel, metadata));
} }
} catch (error: Error | any) { } catch (error: Error | any) {
this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`); this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`);
@@ -117,7 +132,11 @@ export class VersionService extends BaseService {
@OnEvent({ name: 'WebsocketConnect' }) @OnEvent({ name: 'WebsocketConnect' })
async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) { async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) {
this.websocketRepository.clientSend('on_server_version', userId, serverVersion); this.websocketRepository.clientSend(
'on_server_version',
userId,
ServerVersionResponseDto.fromSemVer(serverVersion),
);
const { newVersionCheck } = await this.getConfig({ withCache: true }); const { newVersionCheck } = await this.getConfig({ withCache: true });
if (!newVersionCheck.enabled) { if (!newVersionCheck.enabled) {
@@ -126,7 +145,7 @@ export class VersionService extends BaseService {
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState); const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState);
if (metadata) { if (metadata) {
this.websocketRepository.clientSend('on_new_release', userId, asNotification(metadata)); this.websocketRepository.clientSend('on_new_release', userId, asNotification(newVersionCheck.channel, metadata));
} }
} }
} }
+2 -2
View File
@@ -15,7 +15,7 @@ import picomatch from 'picomatch';
import parse from 'picomatch/lib/parse'; import parse from 'picomatch/lib/parse';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { CLIP_MODEL_INFO, endpointTags, serverVersion } from 'src/constants'; import { CLIP_MODEL_INFO, endpointTags, serverVersion } from 'src/constants';
import { extraSyncModels } from 'src/dtos/sync.dto'; import { extraModels } from 'src/decorators';
import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -289,7 +289,7 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean })
const options: SwaggerDocumentOptions = { const options: SwaggerDocumentOptions = {
operationIdFactory: (controllerKey: string, methodKey: string) => methodKey, operationIdFactory: (controllerKey: string, methodKey: string) => methodKey,
extraModels: extraSyncModels, extraModels,
ignoreGlobalPrefix: true, ignoreGlobalPrefix: true,
}; };
@@ -4,12 +4,12 @@
import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte'; import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte';
import { userInteraction } from '$lib/stores/user.svelte'; import { userInteraction } from '$lib/stores/user.svelte';
import { websocketStore } from '$lib/stores/websocket'; import { websocketStore } from '$lib/stores/websocket';
import type { ReleaseEvent } from '$lib/types';
import { semverToName } from '$lib/utils'; import { semverToName } from '$lib/utils';
import { requestServerInfo } from '$lib/utils/auth'; import { requestServerInfo } from '$lib/utils/auth';
import { import {
getAboutInfo, getAboutInfo,
getVersionHistory, getVersionHistory,
type ReleaseEventV1,
type ServerAboutResponseDto, type ServerAboutResponseDto,
type ServerVersionHistoryResponseDto, type ServerVersionHistoryResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
@@ -35,11 +35,9 @@
userInteraction.versions = versions; userInteraction.versions = versions;
}); });
let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich'); let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich');
let version = $derived( let version = $derived($serverVersion ? semverToName($serverVersion) : null);
$serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
);
const getReleaseInfo = (release?: ReleaseEvent) => { const getReleaseInfo = (release?: ReleaseEventV1) => {
if (!release || !release?.isAvailable || !authManager.user.isAdmin) { if (!release || !release?.isAvailable || !authManager.user.isAdmin) {
return; return;
} }
@@ -30,14 +30,11 @@
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.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)} {#each viewerAssets 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.viewerAssets} viewerAssets={timelineDay.activeViewerAssets}
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: [ReleaseEvent]; ReleaseEvent: [ReleaseEventV1];
WebsocketConnect: []; WebsocketConnect: [];
}; };
@@ -1,8 +1,8 @@
import type { ReleaseEventV1 } from '@immich/sdk';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { type ReleaseEvent } from '$lib/types';
class ReleaseManager { class ReleaseManager {
value = $state<ReleaseEvent | undefined>(); value = $state<ReleaseEventV1 | undefined>();
constructor() { constructor() {
eventManager.on({ eventManager.on({
@@ -53,17 +53,3 @@ 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,
);
}
@@ -1,12 +1,31 @@
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 } from '$lib/utils/layout-utils'; import type { CommonLayoutOptions, CommonPosition } 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;
@@ -18,12 +37,15 @@ 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;
@@ -149,18 +171,32 @@ 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,6 +214,11 @@ 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);
@@ -254,7 +254,7 @@ export class TimelineMonth {
addContext.newTimelineDays.add(timelineDay); addContext.newTimelineDays.add(timelineDay);
} }
const viewerAsset = new ViewerAsset(timelineDay, timelineAsset); const viewerAsset = new ViewerAsset(timelineAsset);
timelineDay.viewerAssets.push(viewerAsset); timelineDay.viewerAssets.push(viewerAsset);
addContext.changedTimelineDays.add(timelineDay); addContext.changedTimelineDays.add(timelineDay);
} }
@@ -1,36 +1,12 @@
import type { CommonPosition } from '$lib/utils/layout-utils'; import type { CommonPosition } from '$lib/utils/layout-utils';
import {
ViewportProximity,
calculateViewerAssetViewportProximity,
isInOrNearViewport,
} 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;
#viewportProximity = $derived.by(() => {
if (!this.position) {
return ViewportProximity.FarFromViewport;
}
const store = this.#group.timelineMonth.timelineManager;
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
return calculateViewerAssetViewportProximity(store, positionTop, this.position.height);
});
get isInOrNearViewport() {
return isInOrNearViewport(this.#viewportProximity);
}
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(group: TimelineDay, asset: TimelineAsset) { constructor(asset: TimelineAsset) {
this.#group = group;
this.asset = asset; this.asset = asset;
} }
} }
+2 -2
View File
@@ -3,6 +3,7 @@ import {
type AssetResponseDto, type AssetResponseDto,
type MaintenanceStatusResponseDto, type MaintenanceStatusResponseDto,
type NotificationDto, type NotificationDto,
type ReleaseEventV1,
type ServerVersionResponseDto, type ServerVersionResponseDto,
type SyncAssetEditV1, type SyncAssetEditV1,
type SyncAssetV2, type SyncAssetV2,
@@ -15,7 +16,6 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { maintenanceStore } from '$lib/stores/maintenance.store'; import { maintenanceStore } from '$lib/stores/maintenance.store';
import { notificationManager } from '$lib/stores/notification-manager.svelte'; import { notificationManager } from '$lib/stores/notification-manager.svelte';
import type { ReleaseEvent } from '$lib/types';
import { createEventEmitter } from '$lib/utils/eventemitter'; import { createEventEmitter } from '$lib/utils/eventemitter';
interface AppRestartEvent { interface AppRestartEvent {
@@ -34,7 +34,7 @@ export interface Events {
on_person_thumbnail: (personId: string) => void; on_person_thumbnail: (personId: string) => void;
on_server_version: (serverVersion: ServerVersionResponseDto) => void; on_server_version: (serverVersion: ServerVersionResponseDto) => void;
on_config_update: () => void; on_config_update: () => void;
on_new_release: (event: ReleaseEvent) => void; on_new_release: (event: ReleaseEventV1) => void;
on_session_delete: (sessionId: string) => void; on_session_delete: (sessionId: string) => void;
on_notification: (notification: NotificationDto) => void; on_notification: (notification: NotificationDto) => void;
+1 -9
View File
@@ -1,4 +1,4 @@
import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk'; import type { QueueResponseDto } from '@immich/sdk';
import type { ActionItem } from '@immich/ui'; import type { ActionItem } from '@immich/ui';
import type { DateTime } from 'luxon'; import type { DateTime } from 'luxon';
import type { SvelteSet } from 'svelte/reactivity'; import type { SvelteSet } from 'svelte/reactivity';
@@ -7,14 +7,6 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
export type LatLng = { lng: number; lat: number }; export type LatLng = { lng: number; lat: number };
export interface ReleaseEvent {
isAvailable: boolean;
/** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}
export type QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] }; export type QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] };
export type HeaderButtonActionItem = ActionItem & { data?: { title?: string } }; export type HeaderButtonActionItem = ActionItem & { data?: { title?: string } };
+1 -23
View File
@@ -1,5 +1,5 @@
import { AssetTypeEnum } from '@immich/sdk'; import { AssetTypeEnum } from '@immich/sdk';
import { getAssetUrl, getReleaseType } from '$lib/utils'; import { getAssetUrl } from '$lib/utils';
import { assetFactory } from '@test-data/factories/asset-factory'; import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
@@ -161,26 +161,4 @@ describe('utils', () => {
expect(url).toContain(asset.id); expect(url).toContain(asset.id);
}); });
}); });
describe(getReleaseType.name, () => {
it('should return "major" for major version changes', () => {
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major');
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 3, minor: 2, patch: 1 })).toBe('major');
});
it('should return "minor" for minor version changes', () => {
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 1, patch: 0 })).toBe('minor');
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 2, patch: 1 })).toBe('minor');
});
it('should return "patch" for patch version changes', () => {
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 1 })).toBe('patch');
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 5 })).toBe('patch');
});
it('should return "none" for matching versions', () => {
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 0 })).toBe('none');
expect(getReleaseType({ major: 1, minor: 2, patch: 3 }, { major: 1, minor: 2, patch: 3 })).toBe('none');
});
});
}); });
+2 -20
View File
@@ -411,26 +411,8 @@ export function createDateFormatter(localeCode: string | undefined): DateFormatt
}; };
} }
export const getReleaseType = ( export const semverToName = ({ major, minor, patch, prerelease }: ServerVersionResponseDto) =>
current: ServerVersionResponseDto, `v${major}.${minor}.${patch}${prerelease ? `-rc.${prerelease}` : ''}`;
newVersion: ServerVersionResponseDto,
): 'major' | 'minor' | 'patch' | 'none' => {
if (current.major !== newVersion.major) {
return 'major';
}
if (current.minor !== newVersion.minor) {
return 'minor';
}
if (current.patch !== newVersion.patch) {
return 'patch';
}
return 'none';
};
export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`;
export const withoutIcons = (actions: ActionItem[]): ActionItem[] => export const withoutIcons = (actions: ActionItem[]): ActionItem[] =>
actions.map((action) => ({ ...action, icon: undefined })); actions.map((action) => ({ ...action, icon: undefined }));
+9 -5
View File
@@ -2,8 +2,8 @@
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte'; import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
import type { ReleaseEvent } from '$lib/types'; import { semverToName } from '$lib/utils';
import { getReleaseType, semverToName } from '$lib/utils'; import { ReleaseType, type ReleaseEventV1 } from '@immich/sdk';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
let modal = $state<{ let modal = $state<{
@@ -11,16 +11,20 @@
close: () => Promise<void>; close: () => Promise<void>;
}>(); }>();
const onReleaseEvent = async (release: ReleaseEvent) => { const onReleaseEvent = async (release: ReleaseEventV1) => {
if (!release.isAvailable || !authManager.user.isAdmin) { if (!release.isAvailable || !authManager.user.isAdmin) {
return; return;
} }
const releaseVersion = semverToName(release.releaseVersion); const releaseVersion = semverToName(release.releaseVersion);
const serverVersion = semverToName(release.serverVersion); const serverVersion = semverToName(release.serverVersion);
const type = getReleaseType(release.serverVersion, release.releaseVersion);
if (type === 'none' || type === 'patch' || localStorage.getItem('appVersion') === releaseVersion) { if (
!release.type ||
release.type === ReleaseType.Patch ||
release.type === ReleaseType.Prepatch ||
localStorage.getItem('appVersion') === releaseVersion
) {
return; return;
} }
@@ -5,8 +5,11 @@
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import SettingSelect from './SettingSelect.svelte';
import { ReleaseChannel } from '@immich/sdk';
const disabled = $derived(featureFlagsManager.value.configFile); const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
let configToEdit = $state(systemConfigManager.cloneValue()); let configToEdit = $state(systemConfigManager.cloneValue());
</script> </script>
@@ -20,6 +23,20 @@
bind:checked={configToEdit.newVersionCheck.enabled} bind:checked={configToEdit.newVersionCheck.enabled}
{disabled} {disabled}
/> />
<SettingSelect
label={$t('admin.version_check_channel')}
desc={$t('admin.version_check_channel_description')}
bind:value={configToEdit.newVersionCheck.channel}
options={[
{
value: ReleaseChannel.Stable,
text: $t('admin.release_channel_stable'),
},
{ value: ReleaseChannel.ReleaseCandidate, text: $t('admin.release_channel_release_candidate') },
]}
isEdited={configToEdit.newVersionCheck.channel !== config.newVersionCheck.channel}
{disabled}
/>
<SettingButtonsRow bind:configToEdit keys={['newVersionCheck']} {disabled} /> <SettingButtonsRow bind:configToEdit keys={['newVersionCheck']} {disabled} />
</div> </div>
</form> </form>