mirror of
https://github.com/immich-app/immich.git
synced 2026-05-29 19:12:32 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fe48d21c2 | |||
| e35610d0a7 | |||
| bcff1d42b0 | |||
| 1bd367bd51 | |||
| 725f266b81 | |||
| d08e3de207 |
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Generated
+3
@@ -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)
|
||||||
|
|||||||
Generated
+3
@@ -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';
|
||||||
|
|||||||
Generated
+6
@@ -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':
|
||||||
|
|||||||
Generated
+6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+27
-20
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,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
@@ -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 }));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user