mirror of
https://github.com/immich-app/immich.git
synced 2026-05-29 11:02:38 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45224fb94d |
@@ -8,8 +8,6 @@ log "Preparing Immich Web Frontend"
|
||||
log ""
|
||||
run_cmd pnpm --filter @immich/sdk install
|
||||
run_cmd pnpm --filter @immich/sdk build
|
||||
run_cmd pnpm --filter @immich/plugin-sdk install
|
||||
run_cmd pnpm --filter @immich/plugin-sdk build
|
||||
run_cmd pnpm --filter immich-web install
|
||||
|
||||
log "Starting Immich Web Frontend"
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
dev:
|
||||
@printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n" >&2 && exit 1
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
dev-down:
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n" >&2 && exit 1
|
||||
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
||||
|
||||
dev-update:
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n" >&2 && exit 1
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
|
||||
dev-scale:
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n" >&2 && exit 1
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||
|
||||
dev-docs:
|
||||
npm --prefix docs run start
|
||||
|
||||
.PHONY: e2e
|
||||
e2e:
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n" >&2 && exit 1
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||
|
||||
e2e-dev:
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n" >&2 && exit 1
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
e2e-update:
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n" >&2 && exit 1
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||
|
||||
e2e-down:
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n" >&2 && exit 1
|
||||
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
|
||||
|
||||
prod:
|
||||
@printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n" >&2 && exit 1
|
||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||
|
||||
prod-down:
|
||||
@printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n" >&2 && exit 1
|
||||
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
|
||||
|
||||
prod-scale:
|
||||
@printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n" >&2 && exit 1
|
||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
||||
|
||||
.PHONY: open-api
|
||||
open-api:
|
||||
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n" >&2 && exit 1
|
||||
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
sql:
|
||||
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n" >&2 && exit 1
|
||||
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
|
||||
renovate:
|
||||
@@ -52,7 +52,16 @@ renovate:
|
||||
MODULES = e2e server web cli sdk docs .github
|
||||
|
||||
test-e2e:
|
||||
@printf "This command has been removed. Please use:\n\n mise //e2e:test # or mise //e2e:test-web for web tests, respectively\n\n" >&2 && exit 1
|
||||
docker compose -f ./e2e/docker-compose.yml build
|
||||
pnpm --filter immich-e2e run test
|
||||
pnpm --filter immich-e2e run test:web
|
||||
|
||||
clean:
|
||||
@printf "This command has been removed. Please use:\n\n mise clean # or mise //:clean from another directory\n\n" >&2 && exit 1
|
||||
find . -name "node_modules" -type d -prune -exec rm -rf {} +
|
||||
find . -name "dist" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name "build" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
|
||||
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true
|
||||
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true
|
||||
|
||||
+1
-16
@@ -1,21 +1,11 @@
|
||||
[tasks.install]
|
||||
run = "pnpm install --filter immich-e2e --frozen-lockfile"
|
||||
|
||||
[tasks.build]
|
||||
dir = "{{ config_root }}"
|
||||
run = "docker compose build"
|
||||
|
||||
[tasks.test]
|
||||
depends = ["//e2e:build", "//e2e:ci-setup"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "vitest --run"
|
||||
|
||||
[tasks.playwright-install]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "playwright install"
|
||||
|
||||
[tasks."test-web"]
|
||||
depends = ["//e2e:build", "//e2e:ci-setup", "//e2e:playwright-install"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "playwright test"
|
||||
|
||||
@@ -40,12 +30,7 @@ run = "tsc --noEmit"
|
||||
|
||||
|
||||
[tasks.ci-setup]
|
||||
depends = [
|
||||
"//:sdk:install",
|
||||
"//:sdk:build",
|
||||
"//packages/cli:install",
|
||||
"//packages/cli:build",
|
||||
]
|
||||
depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"]
|
||||
run = { task = ":install" }
|
||||
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ describe('/server', () => {
|
||||
major: expect.any(Number),
|
||||
minor: expect.any(Number),
|
||||
patch: expect.any(Number),
|
||||
prerelease: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,18 +21,18 @@ describe('/system-config', () => {
|
||||
const response1 = await request(app)
|
||||
.put('/system-config')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
|
||||
.send({ ...config, newVersionCheck: { enabled: false } });
|
||||
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
|
||||
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false } });
|
||||
|
||||
const response2 = await request(app)
|
||||
.put('/system-config')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
|
||||
.send({ ...config, newVersionCheck: { enabled: true } });
|
||||
|
||||
expect(response2.status).toBe(200);
|
||||
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
|
||||
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true } });
|
||||
});
|
||||
|
||||
it('should reject an invalid config entry', async () => {
|
||||
|
||||
@@ -55,8 +55,8 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
|
||||
result.duration.push(asset.duration);
|
||||
result.projectionType.push(asset.projectionType);
|
||||
result.livePhotoVideoId.push(asset.livePhotoVideoId);
|
||||
result.city?.push(asset.city);
|
||||
result.country?.push(asset.country);
|
||||
result.city.push(asset.city);
|
||||
result.country.push(asset.country);
|
||||
result.visibility.push(asset.visibility);
|
||||
}
|
||||
|
||||
|
||||
@@ -305,8 +305,6 @@
|
||||
"refreshing_all_libraries": "Refreshing all libraries",
|
||||
"registration": "Admin Registration",
|
||||
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
|
||||
"release_channel_release_candidate": "Release candidate",
|
||||
"release_channel_stable": "Stable",
|
||||
"remove_failed_jobs": "Remove failed jobs",
|
||||
"require_password_change_on_login": "Require user to change password on first login",
|
||||
"reset_settings_to_default": "Reset settings to default",
|
||||
@@ -444,8 +442,6 @@
|
||||
"user_settings_description": "Manage user settings",
|
||||
"user_successfully_removed": "User {email} has been successfully removed.",
|
||||
"users_page_description": "Admin users page",
|
||||
"version_check_channel": "Release channel",
|
||||
"version_check_channel_description": "Pick the release channel you want to get version announcements for",
|
||||
"version_check_enabled_description": "Enable version check",
|
||||
"version_check_implications": "The version check feature relies on periodic communication with {server}",
|
||||
"version_check_settings": "Version Check",
|
||||
|
||||
@@ -84,72 +84,6 @@ run = [
|
||||
dir = "server"
|
||||
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
|
||||
[tasks."sdk:install"]
|
||||
dir = "packages/sdk"
|
||||
@@ -165,14 +99,3 @@ run = "pnpm format"
|
||||
|
||||
[tasks."i18n: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" },
|
||||
]
|
||||
|
||||
@@ -23,7 +23,6 @@ const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
||||
const String kDownloadGroupImage = 'group_image';
|
||||
const String kDownloadGroupVideo = 'group_video';
|
||||
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||
const String kShareDownloadGroup = 'group_share';
|
||||
|
||||
// Timeline constants
|
||||
const int kTimelineNoneSegmentSize = 120;
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/config/album_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/backup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/image_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/map_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/theme_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
const defaultConfig = AppConfig();
|
||||
|
||||
class AppConfig {
|
||||
final LogLevel logLevel;
|
||||
final ThemeConfig theme;
|
||||
final CleanupConfig cleanup;
|
||||
final MapConfig map;
|
||||
@@ -29,10 +18,8 @@ class AppConfig {
|
||||
final SlideshowConfig slideshow;
|
||||
final AlbumConfig album;
|
||||
final BackupConfig backup;
|
||||
final NetworkConfig network;
|
||||
|
||||
const AppConfig({
|
||||
this.logLevel = .info,
|
||||
this.theme = const .new(),
|
||||
this.cleanup = const .new(),
|
||||
this.map = const .new(),
|
||||
@@ -42,11 +29,9 @@ class AppConfig {
|
||||
this.slideshow = const .new(),
|
||||
this.album = const .new(),
|
||||
this.backup = const .new(),
|
||||
this.network = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
LogLevel? logLevel,
|
||||
ThemeConfig? theme,
|
||||
CleanupConfig? cleanup,
|
||||
MapConfig? map,
|
||||
@@ -56,9 +41,7 @@ class AppConfig {
|
||||
SlideshowConfig? slideshow,
|
||||
AlbumConfig? album,
|
||||
BackupConfig? backup,
|
||||
NetworkConfig? network,
|
||||
}) => .new(
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
theme: theme ?? this.theme,
|
||||
cleanup: cleanup ?? this.cleanup,
|
||||
map: map ?? this.map,
|
||||
@@ -68,14 +51,12 @@ class AppConfig {
|
||||
slideshow: slideshow ?? this.slideshow,
|
||||
album: album ?? this.album,
|
||||
backup: backup ?? this.backup,
|
||||
network: network ?? this.network,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is AppConfig &&
|
||||
other.logLevel == logLevel &&
|
||||
other.theme == theme &&
|
||||
other.cleanup == cleanup &&
|
||||
other.map == map &&
|
||||
@@ -84,118 +65,12 @@ class AppConfig {
|
||||
other.viewer == viewer &&
|
||||
other.slideshow == slideshow &&
|
||||
other.album == album &&
|
||||
other.backup == backup &&
|
||||
other.network == network);
|
||||
other.backup == backup);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network);
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network)';
|
||||
|
||||
T read<T extends Object>(MetadataKey<T> key) =>
|
||||
(switch (key) {
|
||||
.logLevel => logLevel,
|
||||
.themePrimaryColor => theme.primaryColor,
|
||||
.themeMode => theme.mode,
|
||||
.themeDynamic => theme.dynamicTheme,
|
||||
.themeColorfulInterface => theme.colorfulInterface,
|
||||
.imagePreferRemote => image.preferRemote,
|
||||
.imageLoadOriginal => image.loadOriginal,
|
||||
.viewerLoopVideo => viewer.loopVideo,
|
||||
.viewerLoadOriginalVideo => viewer.loadOriginalVideo,
|
||||
.viewerAutoPlayVideo => viewer.autoPlayVideo,
|
||||
.viewerTapToNavigate => viewer.tapToNavigate,
|
||||
.networkAutoEndpointSwitching => network.autoEndpointSwitching,
|
||||
.networkPreferredWifiName => network.preferredWifiName,
|
||||
.networkLocalEndpoint => network.localEndpoint,
|
||||
.networkExternalEndpointList => network.externalEndpointList,
|
||||
.networkCustomHeaders => network.customHeaders,
|
||||
.albumSortMode => album.sortMode,
|
||||
.albumIsReverse => album.isReverse,
|
||||
.albumIsGrid => album.isGrid,
|
||||
.backupEnabled => backup.enabled,
|
||||
.backupUseCellularForVideos => backup.useCellularForVideos,
|
||||
.backupUseCellularForPhotos => backup.useCellularForPhotos,
|
||||
.backupRequireCharging => backup.requireCharging,
|
||||
.backupTriggerDelay => backup.triggerDelay,
|
||||
.backupSyncAlbums => backup.syncAlbums,
|
||||
.timelineTilesPerRow => timeline.tilesPerRow,
|
||||
.timelineGroupAssetsBy => timeline.groupAssetsBy,
|
||||
.timelineStorageIndicator => timeline.storageIndicator,
|
||||
.mapShowFavoriteOnly => map.favoritesOnly,
|
||||
.mapRelativeDate => map.relativeDays,
|
||||
.mapIncludeArchived => map.includeArchived,
|
||||
.mapThemeMode => map.themeMode,
|
||||
.mapWithPartners => map.withPartners,
|
||||
.cleanupKeepFavorites => cleanup.keepFavorites,
|
||||
.cleanupKeepMediaType => cleanup.keepMediaType,
|
||||
.cleanupKeepAlbumIds => cleanup.keepAlbumIds,
|
||||
.cleanupCutoffDaysAgo => cleanup.cutoffDaysAgo,
|
||||
.cleanupDefaultsInitialized => cleanup.defaultsInitialized,
|
||||
.slideshowTransition => slideshow.transition,
|
||||
.slideshowRepeat => slideshow.repeat,
|
||||
.slideshowDuration => slideshow.duration,
|
||||
.slideshowLook => slideshow.look,
|
||||
.slideshowDirection => slideshow.direction,
|
||||
})
|
||||
as T;
|
||||
|
||||
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> entries) {
|
||||
var config = const AppConfig();
|
||||
for (final MapEntry(key: key, value: value) in entries.entries) {
|
||||
config = config.write(key, value);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
AppConfig write<T extends Object>(MetadataKey<T> key, T value) {
|
||||
return switch (key) {
|
||||
.logLevel => copyWith(logLevel: value as LogLevel),
|
||||
.themePrimaryColor => copyWith(theme: theme.copyWith(primaryColor: value as ImmichColorPreset)),
|
||||
.themeMode => copyWith(theme: theme.copyWith(mode: value as ThemeMode)),
|
||||
.themeDynamic => copyWith(theme: theme.copyWith(dynamicTheme: value as bool)),
|
||||
.themeColorfulInterface => copyWith(theme: theme.copyWith(colorfulInterface: value as bool)),
|
||||
.imagePreferRemote => copyWith(image: image.copyWith(preferRemote: value as bool)),
|
||||
.imageLoadOriginal => copyWith(image: image.copyWith(loadOriginal: value as bool)),
|
||||
.viewerLoopVideo => copyWith(viewer: viewer.copyWith(loopVideo: value as bool)),
|
||||
.viewerLoadOriginalVideo => copyWith(viewer: viewer.copyWith(loadOriginalVideo: value as bool)),
|
||||
.viewerAutoPlayVideo => copyWith(viewer: viewer.copyWith(autoPlayVideo: value as bool)),
|
||||
.viewerTapToNavigate => copyWith(viewer: viewer.copyWith(tapToNavigate: value as bool)),
|
||||
.networkAutoEndpointSwitching => copyWith(network: network.copyWith(autoEndpointSwitching: value as bool)),
|
||||
.networkPreferredWifiName => copyWith(network: network.copyWith(preferredWifiName: (value as String))),
|
||||
.networkLocalEndpoint => copyWith(network: network.copyWith(localEndpoint: (value as String))),
|
||||
.networkExternalEndpointList => copyWith(network: network.copyWith(externalEndpointList: value as List<String>)),
|
||||
.networkCustomHeaders => copyWith(network: network.copyWith(customHeaders: value as Map<String, String>)),
|
||||
.albumSortMode => copyWith(album: album.copyWith(sortMode: value as AlbumSortMode)),
|
||||
.albumIsReverse => copyWith(album: album.copyWith(isReverse: value as bool)),
|
||||
.albumIsGrid => copyWith(album: album.copyWith(isGrid: value as bool)),
|
||||
.backupEnabled => copyWith(backup: backup.copyWith(enabled: value as bool)),
|
||||
.backupUseCellularForVideos => copyWith(backup: backup.copyWith(useCellularForVideos: value as bool)),
|
||||
.backupUseCellularForPhotos => copyWith(backup: backup.copyWith(useCellularForPhotos: value as bool)),
|
||||
.backupRequireCharging => copyWith(backup: backup.copyWith(requireCharging: value as bool)),
|
||||
.backupTriggerDelay => copyWith(backup: backup.copyWith(triggerDelay: value as int)),
|
||||
.backupSyncAlbums => copyWith(backup: backup.copyWith(syncAlbums: value as bool)),
|
||||
.timelineTilesPerRow => copyWith(timeline: timeline.copyWith(tilesPerRow: value as int)),
|
||||
.timelineGroupAssetsBy => copyWith(timeline: timeline.copyWith(groupAssetsBy: value as GroupAssetsBy)),
|
||||
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
|
||||
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
|
||||
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
|
||||
.mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)),
|
||||
.mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)),
|
||||
.mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)),
|
||||
.cleanupKeepFavorites => copyWith(cleanup: cleanup.copyWith(keepFavorites: value as bool)),
|
||||
.cleanupKeepMediaType => copyWith(cleanup: cleanup.copyWith(keepMediaType: value as AssetKeepType)),
|
||||
.cleanupKeepAlbumIds => copyWith(cleanup: cleanup.copyWith(keepAlbumIds: value as List<String>)),
|
||||
.cleanupCutoffDaysAgo => copyWith(cleanup: cleanup.copyWith(cutoffDaysAgo: value as int)),
|
||||
.cleanupDefaultsInitialized => copyWith(cleanup: cleanup.copyWith(defaultsInitialized: value as bool)),
|
||||
.slideshowTransition => copyWith(slideshow: slideshow.copyWith(transition: value as bool)),
|
||||
.slideshowRepeat => copyWith(slideshow: slideshow.copyWith(repeat: value as bool)),
|
||||
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
|
||||
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
|
||||
.slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)),
|
||||
};
|
||||
}
|
||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)';
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
class NetworkConfig {
|
||||
final bool autoEndpointSwitching;
|
||||
final String preferredWifiName;
|
||||
final String localEndpoint;
|
||||
final String? preferredWifiName;
|
||||
final String? localEndpoint;
|
||||
final List<String> externalEndpointList;
|
||||
final Map<String, String> customHeaders;
|
||||
|
||||
const NetworkConfig({
|
||||
this.autoEndpointSwitching = false,
|
||||
this.preferredWifiName = '',
|
||||
this.localEndpoint = '',
|
||||
this.preferredWifiName,
|
||||
this.localEndpoint,
|
||||
this.externalEndpointList = const [],
|
||||
this.customHeaders = const {},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
|
||||
class SystemConfig {
|
||||
final LogLevel logLevel;
|
||||
final NetworkConfig network;
|
||||
|
||||
const SystemConfig({this.logLevel = .info, this.network = const .new()});
|
||||
|
||||
SystemConfig copyWith({LogLevel? logLevel, NetworkConfig? network}) =>
|
||||
SystemConfig(logLevel: logLevel ?? this.logLevel, network: network ?? this.network);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || (other is SystemConfig && other.logLevel == logLevel && other.network == network);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(logLevel, network);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfig(logLevel: $logLevel, network: $network)';
|
||||
}
|
||||
@@ -1,105 +1,142 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
enum MetadataScope {
|
||||
user, // keys with this scope are deleted on logout
|
||||
system;
|
||||
enum MetadataDomain<T extends Object> {
|
||||
appConfig<AppConfig>('config.app'),
|
||||
systemConfig<SystemConfig>('config.system');
|
||||
|
||||
const MetadataScope();
|
||||
final String prefix;
|
||||
const MetadataDomain(this.prefix);
|
||||
}
|
||||
|
||||
enum MetadataKey<T extends Object> {
|
||||
// Theme
|
||||
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
themeDynamic<bool>(),
|
||||
themeColorfulInterface<bool>(),
|
||||
themePrimaryColor<ImmichColorPreset>(.appConfig, 'theme.primaryColor', .indigo, _EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)),
|
||||
themeDynamic<bool>(.appConfig, 'theme.dynamic', false),
|
||||
themeColorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true),
|
||||
|
||||
// Image
|
||||
imagePreferRemote<bool>(),
|
||||
imageLoadOriginal<bool>(),
|
||||
imagePreferRemote<bool>(.appConfig, 'image.preferRemote', false),
|
||||
imageLoadOriginal<bool>(.appConfig, 'image.loadOriginal', false),
|
||||
|
||||
// Viewer
|
||||
viewerLoopVideo<bool>(),
|
||||
viewerLoadOriginalVideo<bool>(),
|
||||
viewerAutoPlayVideo<bool>(),
|
||||
viewerTapToNavigate<bool>(),
|
||||
viewerLoopVideo<bool>(.appConfig, 'viewer.loopVideo', true),
|
||||
viewerLoadOriginalVideo<bool>(.appConfig, 'viewer.loadOriginalVideo', false),
|
||||
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
|
||||
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
|
||||
|
||||
// Network
|
||||
networkAutoEndpointSwitching<bool>(scope: .system),
|
||||
networkPreferredWifiName<String>(scope: .system),
|
||||
networkLocalEndpoint<String>(scope: .system),
|
||||
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
networkAutoEndpointSwitching<bool>(.systemConfig, 'network.autoEndpointSwitching', false),
|
||||
networkPreferredWifiName<String>(.systemConfig, 'network.preferredWifiName', ''),
|
||||
networkLocalEndpoint<String>(.systemConfig, 'network.localEndpoint', ''),
|
||||
networkExternalEndpointList<List<String>>(
|
||||
.systemConfig,
|
||||
'network.externalEndpointList',
|
||||
[],
|
||||
_ListCodec(_PrimitiveCodec.string),
|
||||
),
|
||||
networkCustomHeaders<Map<String, String>>(
|
||||
scope: .system,
|
||||
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
||||
.systemConfig,
|
||||
'network.customHeaders',
|
||||
{},
|
||||
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
||||
),
|
||||
|
||||
// Album
|
||||
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
|
||||
albumIsReverse<bool>(),
|
||||
albumIsGrid<bool>(),
|
||||
albumSortMode<AlbumSortMode>(
|
||||
.appConfig,
|
||||
'album.sortMode',
|
||||
AlbumSortMode.mostRecent,
|
||||
_EnumCodec(AlbumSortMode.values),
|
||||
),
|
||||
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
|
||||
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
|
||||
|
||||
// Backup
|
||||
backupEnabled<bool>(),
|
||||
backupUseCellularForVideos<bool>(),
|
||||
backupUseCellularForPhotos<bool>(),
|
||||
backupRequireCharging<bool>(),
|
||||
backupTriggerDelay<int>(),
|
||||
backupSyncAlbums<bool>(),
|
||||
backupEnabled<bool>(.appConfig, 'backup.enabled', false),
|
||||
backupUseCellularForVideos<bool>(.appConfig, 'backup.useCellularForVideos', false),
|
||||
backupUseCellularForPhotos<bool>(.appConfig, 'backup.useCellularForPhotos', false),
|
||||
backupRequireCharging<bool>(.appConfig, 'backup.requireCharging', false),
|
||||
backupTriggerDelay<int>(.appConfig, 'backup.triggerDelay', 30),
|
||||
backupSyncAlbums<bool>(.appConfig, 'backup.syncAlbums', false),
|
||||
|
||||
// Timeline
|
||||
timelineTilesPerRow<int>(),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
|
||||
timelineStorageIndicator<bool>(),
|
||||
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(
|
||||
.appConfig,
|
||||
'timeline.groupAssetsBy',
|
||||
GroupAssetsBy.day,
|
||||
_EnumCodec(GroupAssetsBy.values),
|
||||
),
|
||||
timelineStorageIndicator<bool>(.appConfig, 'timeline.storageIndicator', true),
|
||||
|
||||
// Log
|
||||
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
|
||||
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)),
|
||||
|
||||
// Map
|
||||
mapShowFavoriteOnly<bool>(),
|
||||
mapRelativeDate<int>(),
|
||||
mapIncludeArchived<bool>(),
|
||||
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(),
|
||||
mapShowFavoriteOnly<bool>(.appConfig, 'map.showFavoriteOnly', false),
|
||||
mapRelativeDate<int>(.appConfig, 'map.relativeDate', 0),
|
||||
mapIncludeArchived<bool>(.appConfig, 'map.includeArchived', false),
|
||||
mapThemeMode<ThemeMode>(.appConfig, 'map.themeMode', .system, _EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(.appConfig, 'map.withPartners', false),
|
||||
|
||||
// Cleanup
|
||||
cleanupKeepFavorites<bool>(),
|
||||
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
|
||||
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(),
|
||||
cleanupDefaultsInitialized<bool>(),
|
||||
cleanupKeepFavorites<bool>(.appConfig, 'cleanup.keepFavorites', true),
|
||||
cleanupKeepMediaType<AssetKeepType>(
|
||||
.appConfig,
|
||||
'cleanup.keepMediaType',
|
||||
AssetKeepType.none,
|
||||
_EnumCodec(AssetKeepType.values),
|
||||
),
|
||||
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
|
||||
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false),
|
||||
|
||||
// Slideshow
|
||||
slideshowTransition<bool>(),
|
||||
slideshowRepeat<bool>(),
|
||||
slideshowDuration<int>(),
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
|
||||
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true),
|
||||
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true),
|
||||
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5),
|
||||
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(
|
||||
.appConfig,
|
||||
'slideshow.direction',
|
||||
SlideshowDirection.forward,
|
||||
_EnumCodec(SlideshowDirection.values),
|
||||
);
|
||||
|
||||
final MetadataScope scope;
|
||||
final MetadataDomain domain;
|
||||
final String name;
|
||||
final T defaultValue;
|
||||
final _MetadataCodec<T>? _codecOverride;
|
||||
|
||||
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
|
||||
const MetadataKey(this.domain, this.name, this.defaultValue, [this._codecOverride]);
|
||||
|
||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
|
||||
String get key => '${domain.prefix}.$name';
|
||||
|
||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forPrimitive(defaultValue);
|
||||
|
||||
String encode(T value) => _codec.encode(value);
|
||||
|
||||
T decode(String raw) => _codec.decode(raw);
|
||||
T decode(String raw) => _codec.decode(raw) ?? defaultValue;
|
||||
|
||||
static Map<String, MetadataKey<Object>> asKeyMap() => {for (var value in MetadataKey.values) value.key: value};
|
||||
}
|
||||
|
||||
sealed class _MetadataCodec<T extends Object> {
|
||||
const _MetadataCodec();
|
||||
|
||||
String encode(T value);
|
||||
T decode(String raw);
|
||||
T? decode(String raw);
|
||||
|
||||
static const Map<Type, _MetadataCodec<Object>> _primitives = {
|
||||
int: _PrimitiveCodec.integer,
|
||||
@@ -109,10 +146,12 @@ sealed class _MetadataCodec<T extends Object> {
|
||||
DateTime: _DateTimeCodec(),
|
||||
};
|
||||
|
||||
static _MetadataCodec<T> forType<T extends Object>(Type runtimeType) {
|
||||
final codec = _primitives[runtimeType];
|
||||
static _MetadataCodec<T> forPrimitive<T extends Object>(T sample) {
|
||||
final codec = _primitives[sample.runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the MetadataKey.');
|
||||
throw StateError(
|
||||
'No primitive codec for ${sample.runtimeType}. Provide an explicit codec when defining the MetadataKey.',
|
||||
);
|
||||
}
|
||||
return codec as _MetadataCodec<T>;
|
||||
}
|
||||
@@ -127,7 +166,7 @@ final class _EnumCodec<T extends Enum> extends _MetadataCodec<T> {
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T decode(String raw) => values.firstWhere((v) => v.name == raw);
|
||||
T? decode(String raw) => values.firstWhereOrNull((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||
@@ -137,7 +176,7 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
DateTime? decode(String raw) => DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> {
|
||||
@@ -154,26 +193,29 @@ final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec
|
||||
}
|
||||
|
||||
@override
|
||||
Map<K, V> decode(String raw) {
|
||||
Map<K, V>? decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return {};
|
||||
return null;
|
||||
}
|
||||
final result = <K, V>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final rawKey = entry.key;
|
||||
final rawValue = entry.value;
|
||||
if (rawKey is! String || rawValue is! String) {
|
||||
return {};
|
||||
return null;
|
||||
}
|
||||
final k = _keyCodec.decode(rawKey);
|
||||
final v = _valueCodec.decode(rawValue);
|
||||
if (k == null || v == null) {
|
||||
return null;
|
||||
}
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return {};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,29 +229,32 @@ final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
|
||||
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
|
||||
|
||||
@override
|
||||
List<T> decode(String raw) {
|
||||
List<T>? decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
final result = <T>[];
|
||||
for (final item in decoded) {
|
||||
if (item is! String) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
final element = _elementCodec.decode(item);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
result.add(element);
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
|
||||
final T Function(String) _parse;
|
||||
final T? Function(String) _parse;
|
||||
|
||||
const _PrimitiveCodec._(this._parse);
|
||||
|
||||
@@ -217,12 +262,12 @@ final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T decode(String raw) => _parse(raw);
|
||||
T? decode(String raw) => _parse(raw);
|
||||
|
||||
static const integer = _PrimitiveCodec<int>._(int.parse);
|
||||
static const real = _PrimitiveCodec<double>._(double.parse);
|
||||
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
|
||||
static const integer = _PrimitiveCodec<int>._(int.tryParse);
|
||||
static const real = _PrimitiveCodec<double>._(double.tryParse);
|
||||
static const boolean = _PrimitiveCodec<bool>._(bool.tryParse);
|
||||
static const string = _PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String _identity(String s) => s;
|
||||
static String? _identity(String s) => s;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ class LogService {
|
||||
}) async {
|
||||
final instance = LogService._(logRepository, metadataRepository, shouldBuffer);
|
||||
await logRepository.truncate(limit: kLogTruncateLimit);
|
||||
final level = instance._metadataRepository.appConfig.logLevel;
|
||||
final level = instance._metadataRepository.systemConfig.logLevel;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
|
||||
return instance;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class MetadataRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
final Map<MetadataKey, Object> _cache = {};
|
||||
|
||||
MetadataRepository._(this._db) : super(_db);
|
||||
|
||||
@@ -23,50 +25,153 @@ class MetadataRepository extends DriftDatabaseRepository {
|
||||
AppConfig _appConfig = const .new();
|
||||
AppConfig get appConfig => _appConfig;
|
||||
|
||||
SystemConfig _systemConfig = const .new();
|
||||
SystemConfig get systemConfig => _systemConfig;
|
||||
|
||||
static Future<MetadataRepository> ensureInitialized(Drift db) async {
|
||||
if (_instance == null) {
|
||||
final instance = MetadataRepository._(db);
|
||||
await instance.refresh();
|
||||
await instance._hydrate();
|
||||
_instance = instance;
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
|
||||
static Future<void> refresh() async {
|
||||
instance._cache.clear();
|
||||
instance._appConfig = const .new();
|
||||
instance._systemConfig = const .new();
|
||||
await instance._hydrate();
|
||||
}
|
||||
|
||||
Future<void> _hydrate() async => _hydrateCache(await _db.select(_db.metadataEntity).get());
|
||||
|
||||
T _read<T extends Object>(MetadataKey<T> key) => (_cache[key] as T?) ?? key.defaultValue;
|
||||
|
||||
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
|
||||
if (value == _appConfig.read(key)) {
|
||||
if (_read(key) == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value == defaultConfig.read(key)) {
|
||||
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
|
||||
} else {
|
||||
await _db
|
||||
.into(_db.metadataEntity)
|
||||
.insertOnConflictUpdate(
|
||||
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
}
|
||||
|
||||
_appConfig = _appConfig.write(key, value);
|
||||
await _db
|
||||
.into(_db.metadataEntity)
|
||||
.insertOnConflictUpdate(
|
||||
MetadataEntityCompanion.insert(key: key.key, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
_updateCache(key, value);
|
||||
}
|
||||
|
||||
Stream<AppConfig> watchConfig() => _db.select(_db.metadataEntity).watch().map((rows) {
|
||||
_applyOverrides(rows);
|
||||
return _appConfig;
|
||||
});
|
||||
Future<void> delete<T extends Object>(MetadataKey<T> key) async {
|
||||
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.key))).go();
|
||||
_updateCache(key, key.defaultValue);
|
||||
}
|
||||
|
||||
void _applyOverrides(List<MetadataEntityData> rows) {
|
||||
_appConfig = AppConfig.fromEntries(
|
||||
rows.fold({}, (overrides, row) {
|
||||
final metadataKey = MetadataKey.values.firstWhereOrNull((key) => key.name == row.key);
|
||||
if (metadataKey == null) {
|
||||
return overrides;
|
||||
}
|
||||
Stream<AppConfig> watchAppConfig() => _watchDomain(.appConfig).distinct();
|
||||
|
||||
return {...overrides, metadataKey: metadataKey.decode(row.value)};
|
||||
}),
|
||||
);
|
||||
Stream<SystemConfig> watchSystemConfig() => _watchDomain(.systemConfig).distinct();
|
||||
|
||||
Stream<T> _watchDomain<T extends Object>(MetadataDomain<T> domain) {
|
||||
final query = _db.select(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%'));
|
||||
return query.watch().map((rows) {
|
||||
_hydrateCache(rows);
|
||||
return domain.config(this);
|
||||
});
|
||||
}
|
||||
|
||||
void _hydrateCache(List<MetadataEntityData> rows) {
|
||||
final keyMap = MetadataKey.asKeyMap();
|
||||
for (final row in rows) {
|
||||
final key = keyMap[row.key];
|
||||
if (key == null) {
|
||||
continue;
|
||||
}
|
||||
_updateCache(key, key.decode(row.value));
|
||||
}
|
||||
}
|
||||
|
||||
void _updateCache<T extends Object>(MetadataKey<T> key, T value) {
|
||||
if (_cache[key] == value) {
|
||||
return;
|
||||
}
|
||||
_cache[key] = value;
|
||||
key.domain.rebuild(this);
|
||||
}
|
||||
}
|
||||
|
||||
extension<T extends Object> on MetadataDomain<T> {
|
||||
T config(MetadataRepository repo) => switch (this) {
|
||||
.appConfig => repo._appConfig as T,
|
||||
.systemConfig => repo._systemConfig as T,
|
||||
};
|
||||
|
||||
void rebuild(MetadataRepository repo) {
|
||||
switch (this) {
|
||||
case .appConfig:
|
||||
repo._appConfig = .new(
|
||||
theme: .new(
|
||||
mode: repo._read(.themeMode),
|
||||
primaryColor: repo._read(.themePrimaryColor),
|
||||
dynamicTheme: repo._read(.themeDynamic),
|
||||
colorfulInterface: repo._read(.themeColorfulInterface),
|
||||
),
|
||||
cleanup: .new(
|
||||
keepFavorites: repo._read(.cleanupKeepFavorites),
|
||||
keepMediaType: repo._read(.cleanupKeepMediaType),
|
||||
keepAlbumIds: repo._read(.cleanupKeepAlbumIds),
|
||||
cutoffDaysAgo: repo._read(.cleanupCutoffDaysAgo),
|
||||
defaultsInitialized: repo._read(.cleanupDefaultsInitialized),
|
||||
),
|
||||
map: .new(
|
||||
relativeDays: repo._read(.mapRelativeDate),
|
||||
favoritesOnly: repo._read(.mapShowFavoriteOnly),
|
||||
includeArchived: repo._read(.mapIncludeArchived),
|
||||
themeMode: repo._read(.mapThemeMode),
|
||||
withPartners: repo._read(.mapWithPartners),
|
||||
),
|
||||
timeline: .new(
|
||||
tilesPerRow: repo._read(.timelineTilesPerRow),
|
||||
groupAssetsBy: repo._read(.timelineGroupAssetsBy),
|
||||
storageIndicator: repo._read(.timelineStorageIndicator),
|
||||
),
|
||||
image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)),
|
||||
viewer: .new(
|
||||
loopVideo: repo._read(.viewerLoopVideo),
|
||||
loadOriginalVideo: repo._read(.viewerLoadOriginalVideo),
|
||||
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
|
||||
tapToNavigate: repo._read(.viewerTapToNavigate),
|
||||
),
|
||||
slideshow: .new(
|
||||
transition: repo._read(.slideshowTransition),
|
||||
repeat: repo._read(.slideshowRepeat),
|
||||
duration: repo._read(.slideshowDuration),
|
||||
look: repo._read(.slideshowLook),
|
||||
direction: repo._read(.slideshowDirection),
|
||||
),
|
||||
album: .new(
|
||||
sortMode: repo._read(.albumSortMode),
|
||||
isReverse: repo._read(.albumIsReverse),
|
||||
isGrid: repo._read(.albumIsGrid),
|
||||
),
|
||||
backup: .new(
|
||||
enabled: repo._read(.backupEnabled),
|
||||
useCellularForVideos: repo._read(.backupUseCellularForVideos),
|
||||
useCellularForPhotos: repo._read(.backupUseCellularForPhotos),
|
||||
requireCharging: repo._read(.backupRequireCharging),
|
||||
triggerDelay: repo._read(.backupTriggerDelay),
|
||||
syncAlbums: repo._read(.backupSyncAlbums),
|
||||
),
|
||||
);
|
||||
case .systemConfig:
|
||||
repo._systemConfig = .new(
|
||||
logLevel: repo._read(.logLevel),
|
||||
network: .new(
|
||||
autoEndpointSwitching: repo._read(.networkAutoEndpointSwitching),
|
||||
preferredWifiName: repo._read(.networkPreferredWifiName).nullIfEmpty,
|
||||
localEndpoint: repo._read(.networkLocalEndpoint).nullIfEmpty,
|
||||
externalEndpointList: repo._read(.networkExternalEndpointList),
|
||||
customHeaders: repo._read(.networkCustomHeaders),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
_searchController = TextEditingController();
|
||||
_searchFocusNode = FocusNode();
|
||||
|
||||
_enableSyncUploadAlbum.value = ref.read(appConfigProvider).backup.syncAlbums;
|
||||
_enableSyncUploadAlbum.value = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
ref.read(backupAlbumProvider.notifier).getAll();
|
||||
|
||||
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||
@@ -55,7 +55,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
return;
|
||||
}
|
||||
|
||||
final enableSyncUploadAlbum = ref.read(appConfigProvider).backup.syncAlbums;
|
||||
final enableSyncUploadAlbum = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
final selectedAlbums = ref
|
||||
.read(backupAlbumProvider)
|
||||
.where((a) => a.backupSelection == BackupSelection.selected)
|
||||
|
||||
@@ -19,7 +19,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
bool hasPopped = false;
|
||||
final previousBackup = ref.read(appConfigProvider).backup;
|
||||
final previousBackup = ref.read(metadataProvider).appConfig.backup;
|
||||
final previousCellularForVideos = previousBackup.useCellularForVideos;
|
||||
final previousCellularForPhotos = previousBackup.useCellularForPhotos;
|
||||
return PopScope(
|
||||
|
||||
@@ -22,7 +22,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
||||
final headers = useState<List<SettingsHeader>>([]);
|
||||
final setInitialHeaders = useState(false);
|
||||
|
||||
final storedHeaders = ref.read(metadataProvider).appConfig.network.customHeaders;
|
||||
final storedHeaders = ref.read(metadataProvider).systemConfig.network.customHeaders;
|
||||
if (!setInitialHeaders.value) {
|
||||
storedHeaders.forEach((k, v) {
|
||||
final header = SettingsHeader();
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
@@ -36,7 +36,7 @@ class BootstrapErrorWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext _) {
|
||||
final immichTheme = defaultConfig.theme.primaryColor.themeOfPreset;
|
||||
final immichTheme = MetadataKey.themePrimaryColor.defaultValue.themeOfPreset;
|
||||
|
||||
return EasyLocalization(
|
||||
supportedLocales: locales.values.toList(),
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
/// - Prompt to delete the asset locally
|
||||
@@ -40,8 +39,6 @@ class DeleteLocalActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.invalidate(localAlbumProvider);
|
||||
|
||||
final successMessage = 'delete_local_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -14,9 +14,7 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class _SharePreparingDialog extends StatelessWidget {
|
||||
final ValueNotifier<double?> progress;
|
||||
|
||||
const _SharePreparingDialog({required this.progress});
|
||||
const _SharePreparingDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -24,24 +22,8 @@ class _SharePreparingDialog extends StatelessWidget {
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(margin: const EdgeInsets.only(bottom: 12), child: const Text('share_dialog_preparing').tr()),
|
||||
SizedBox(
|
||||
width: 240,
|
||||
child: ValueListenableBuilder<double?>(
|
||||
valueListenable: progress,
|
||||
builder: (context, value, _) {
|
||||
final percent = value == null ? null : (value * 100).clamp(0, 100);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(value: value, minHeight: 8.0),
|
||||
if (percent != null)
|
||||
Container(margin: const EdgeInsets.only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const CircularProgressIndicator(),
|
||||
Container(margin: const EdgeInsets.only(top: 12), child: const Text('share_dialog_preparing').tr()),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -61,39 +43,32 @@ class ShareActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
final cancelCompleter = Completer<void>();
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
final preparingDialog = _SharePreparingDialog(progress: progress);
|
||||
const preparingDialog = _SharePreparingDialog();
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref
|
||||
.read(actionProvider.notifier)
|
||||
.shareAssets(
|
||||
source,
|
||||
context,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: (value) => progress.value = value,
|
||||
)
|
||||
.then((ActionResult result) {
|
||||
if (cancelCompleter.isCompleted || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then((
|
||||
ActionResult result,
|
||||
) {
|
||||
if (cancelCompleter.isCompleted || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (!result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
if (!result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
buildContext.pop();
|
||||
});
|
||||
buildContext.pop();
|
||||
});
|
||||
|
||||
// Show download progress with a "Preparing" message
|
||||
// show a loading spinner with a "Preparing" message
|
||||
return preparingDialog;
|
||||
},
|
||||
barrierDismissible: false,
|
||||
@@ -102,7 +77,6 @@ class ShareActionButton extends ConsumerWidget {
|
||||
if (!cancelCompleter.isCompleted) {
|
||||
cancelCompleter.complete();
|
||||
}
|
||||
progress.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
await _apiService.updateHeaders();
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final headerMap = _ref.read(metadataProvider).appConfig.network.customHeaders;
|
||||
final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders;
|
||||
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
|
||||
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
|
||||
|
||||
@@ -187,11 +187,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
|
||||
String? getSavedWifiName() {
|
||||
return _ref.read(metadataProvider).appConfig.network.preferredWifiName;
|
||||
return _ref.read(metadataProvider).systemConfig.network.preferredWifiName;
|
||||
}
|
||||
|
||||
String? getSavedLocalEndpoint() {
|
||||
return _ref.read(metadataProvider).appConfig.network.localEndpoint;
|
||||
return _ref.read(metadataProvider).systemConfig.network.localEndpoint;
|
||||
}
|
||||
|
||||
/// Returns the current server endpoint (with /api) URL from the store
|
||||
|
||||
@@ -465,17 +465,11 @@ class ActionNotifier extends Notifier<void> {
|
||||
ActionSource source,
|
||||
BuildContext context, {
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
final ids = _getAssets(source).toList(growable: false);
|
||||
|
||||
try {
|
||||
await _service.shareAssets(
|
||||
ids,
|
||||
context,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
await _service.shareAssets(ids, context, cancelCompleter: cancelCompleter);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to share assets', error, stack);
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
|
||||
final metadataProvider = Provider.autoDispose<MetadataRepository>((_) => MetadataRepository.instance);
|
||||
|
||||
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
|
||||
final repo = ref.watch(metadataProvider);
|
||||
final subscription = repo.watchConfig().listen((event) => ref.state = event);
|
||||
final subscription = repo.watchAppConfig().listen((event) => ref.state = event);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return repo.appConfig;
|
||||
});
|
||||
|
||||
final systemConfigProvider = Provider.autoDispose<SystemConfig>((ref) {
|
||||
final repo = ref.watch(metadataProvider);
|
||||
final subscription = repo.watchSystemConfig().listen((event) => ref.state = event);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return repo.systemConfig;
|
||||
});
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider)));
|
||||
final assetMediaRepositoryProvider = Provider(
|
||||
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
|
||||
);
|
||||
|
||||
class AssetMediaRepository {
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
static final Logger _log = Logger("AssetMediaRepository");
|
||||
|
||||
const AssetMediaRepository(this._nativeSyncApi);
|
||||
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
|
||||
|
||||
Future<bool> _androidSupportsTrash() async {
|
||||
if (Platform.isAndroid) {
|
||||
@@ -105,29 +107,10 @@ class AssetMediaRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> shareAssets(
|
||||
List<BaseAsset> assets,
|
||||
BuildContext context, {
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
// TODO: make this more efficient
|
||||
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) async {
|
||||
final downloadedXFiles = <XFile>[];
|
||||
final tempFiles = <File>[];
|
||||
final totalAssets = assets.length;
|
||||
var processedAssets = 0;
|
||||
|
||||
void updateProgress([double currentAssetProgress = 0.0]) {
|
||||
if (totalAssets <= 0) {
|
||||
onAssetDownloadProgress?.call(1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
final normalizedAssetProgress = currentAssetProgress.clamp(0.0, 1.0);
|
||||
final overallProgress = ((processedAssets + normalizedAssetProgress) / totalAssets).clamp(0.0, 1.0);
|
||||
onAssetDownloadProgress?.call(overallProgress);
|
||||
}
|
||||
|
||||
updateProgress();
|
||||
|
||||
for (var asset in assets) {
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
@@ -144,8 +127,6 @@ class AssetMediaRepository {
|
||||
if (localId != null && !asset.isEdited) {
|
||||
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
|
||||
downloadedXFiles.add(XFile(f!.path));
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
if (CurrentPlatform.isIOS) {
|
||||
tempFiles.add(f);
|
||||
}
|
||||
@@ -153,50 +134,22 @@ class AssetMediaRepository {
|
||||
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
|
||||
if (remoteId == null) {
|
||||
_log.warning("Asset has no remote ID for sharing: $asset");
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
|
||||
final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
|
||||
final task = DownloadTask(
|
||||
taskId: taskId,
|
||||
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
filename: sanitizedFilename,
|
||||
baseDirectory: BaseDirectory.temporary,
|
||||
group: kShareDownloadGroup,
|
||||
updates: Updates.statusAndProgress,
|
||||
);
|
||||
final statusUpdate = await FileDownloader().download(
|
||||
task,
|
||||
onProgress: (value) {
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
unawaited(FileDownloader().cancelTaskWithId(taskId));
|
||||
return;
|
||||
}
|
||||
updateProgress(value);
|
||||
},
|
||||
);
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final name = asset.name;
|
||||
final tempFile = await File('${tempDir.path}/$name').create();
|
||||
final res = await _assetApiRepository.downloadAsset(remoteId, edited: true);
|
||||
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
await _cleanupTempFiles(tempFiles);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (statusUpdate.status == TaskStatus.complete) {
|
||||
final filePath = await task.filePath();
|
||||
final file = File(filePath);
|
||||
tempFiles.add(file);
|
||||
downloadedXFiles.add(XFile(filePath));
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
if (res.statusCode != 200) {
|
||||
_log.severe("Download for $name failed", res.toLoggerString());
|
||||
continue;
|
||||
}
|
||||
_log.severe("Download for ${asset.name} failed with status ${statusUpdate.status}", statusUpdate.exception);
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
|
||||
await tempFile.writeAsBytes(res.bodyBytes);
|
||||
downloadedXFiles.add(XFile(tempFile.path));
|
||||
tempFiles.add(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>(
|
||||
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(appConfigProvider)),
|
||||
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)),
|
||||
);
|
||||
|
||||
class AuthRepository {
|
||||
final Drift _drift;
|
||||
final AppConfig _config;
|
||||
final MetadataRepository _metadata;
|
||||
|
||||
const AuthRepository(this._drift, this._config);
|
||||
const AuthRepository(this._drift, this._metadata);
|
||||
|
||||
Future<void> clearLocalData() async {
|
||||
await SyncStreamRepository(_drift).reset();
|
||||
}
|
||||
|
||||
bool getEndpointSwitchingFeature() {
|
||||
return _config.network.autoEndpointSwitching;
|
||||
return _metadata.systemConfig.network.autoEndpointSwitching;
|
||||
}
|
||||
|
||||
String? getPreferredWifiName() {
|
||||
return _config.network.preferredWifiName;
|
||||
return _metadata.systemConfig.network.preferredWifiName;
|
||||
}
|
||||
|
||||
String? getLocalEndpoint() {
|
||||
return _config.network.localEndpoint;
|
||||
return _metadata.systemConfig.network.localEndpoint;
|
||||
}
|
||||
|
||||
List<AuxilaryEndpoint> getExternalEndpointList() {
|
||||
return _config.network.externalEndpointList.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList();
|
||||
return _metadata.systemConfig.network.externalEndpointList
|
||||
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,18 +269,8 @@ class ActionService {
|
||||
await _assetApiRepository.unStack(stackIds);
|
||||
}
|
||||
|
||||
Future<int> shareAssets(
|
||||
List<BaseAsset> assets,
|
||||
BuildContext context, {
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) {
|
||||
return _assetMediaRepository.shareAssets(
|
||||
assets,
|
||||
context,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) {
|
||||
return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter);
|
||||
}
|
||||
|
||||
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
|
||||
|
||||
@@ -177,9 +177,9 @@ class ApiService {
|
||||
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
|
||||
urls.add(serverEndpoint);
|
||||
}
|
||||
final network = MetadataRepository.instance.appConfig.network;
|
||||
final network = MetadataRepository.instance.systemConfig.network;
|
||||
final localEndpoint = network.localEndpoint;
|
||||
if (localEndpoint.isNotEmpty) {
|
||||
if (localEndpoint != null) {
|
||||
urls.add(localEndpoint);
|
||||
}
|
||||
for (final url in network.externalEndpointList) {
|
||||
@@ -191,7 +191,7 @@ class ApiService {
|
||||
}
|
||||
|
||||
static Map<String, String> getRequestHeaders() {
|
||||
return MetadataRepository.instance.appConfig.network.customHeaders;
|
||||
return MetadataRepository.instance.systemConfig.network.customHeaders;
|
||||
}
|
||||
|
||||
ApiClient get apiClient => _apiClient;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
@@ -138,11 +136,15 @@ Future<void> _migrateTo26(Drift drift) async {
|
||||
|
||||
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
|
||||
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
|
||||
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
|
||||
if (mode == null) {
|
||||
if (raw == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final mode = AlbumSortMode.values.firstWhere(
|
||||
(e) => e.storeIndex == raw,
|
||||
orElse: () => MetadataKey.albumSortMode.defaultValue,
|
||||
);
|
||||
|
||||
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, MetadataKey.albumSortMode, mode);
|
||||
}
|
||||
|
||||
@@ -206,11 +208,7 @@ class _StoreMigrator {
|
||||
return;
|
||||
}
|
||||
|
||||
final enumValue = values.elementAtOrNull(index);
|
||||
if (enumValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final enumValue = values.elementAtOrNull(index) ?? newKey.defaultValue;
|
||||
_cache[newKey] = enumValue;
|
||||
_migratedStoreIds.add(legacyKey.id);
|
||||
}
|
||||
@@ -225,11 +223,7 @@ class _StoreMigrator {
|
||||
return;
|
||||
}
|
||||
|
||||
final enumValue = values.firstWhereOrNull((e) => e.name == name);
|
||||
if (enumValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final enumValue = values.firstWhere((e) => e.name == name, orElse: () => newKey.defaultValue);
|
||||
_cache[newKey] = enumValue;
|
||||
_migratedStoreIds.add(legacyKey.id);
|
||||
}
|
||||
@@ -273,12 +267,9 @@ class _StoreMigrator {
|
||||
Future<void> complete() async {
|
||||
await _db.batch((batch) {
|
||||
for (final entry in _cache.entries) {
|
||||
if (entry.value == defaultConfig.read(entry.key)) {
|
||||
continue;
|
||||
}
|
||||
batch.insert(
|
||||
_db.metadataEntity,
|
||||
MetadataEntityCompanion(key: Value(entry.key.name), value: Value(entry.key.encode(entry.value))),
|
||||
MetadataEntityCompanion(key: Value(entry.key.key), value: Value(entry.key.encode(entry.value))),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
final isManageMediaSupported = useState(false);
|
||||
final manageMediaAndroidPermission = useState(false);
|
||||
final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
|
||||
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
|
||||
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
|
||||
useValueChanged(
|
||||
preferRemote.value,
|
||||
|
||||
@@ -64,7 +64,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
final urls = ref.read(appConfigProvider).network.externalEndpointList;
|
||||
final urls = ref.read(metadataProvider).systemConfig.network.externalEndpointList;
|
||||
|
||||
if (urls.isEmpty) {
|
||||
return null;
|
||||
|
||||
@@ -19,7 +19,7 @@ class NetworkingSettings extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentEndpoint = getServerUrl();
|
||||
final featureEnabled = useState(ref.read(appConfigProvider).network.autoEndpointSwitching);
|
||||
final featureEnabled = useState(ref.read(systemConfigProvider).network.autoEndpointSwitching);
|
||||
useValueChanged<bool, void>(featureEnabled.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.networkAutoEndpointSwitching, featureEnabled.value);
|
||||
});
|
||||
|
||||
Generated
-3
@@ -513,9 +513,6 @@ Class | Method | HTTP request | Description
|
||||
- [RatingsUpdate](doc//RatingsUpdate.md)
|
||||
- [ReactionLevel](doc//ReactionLevel.md)
|
||||
- [ReactionType](doc//ReactionType.md)
|
||||
- [ReleaseChannel](doc//ReleaseChannel.md)
|
||||
- [ReleaseEventV1](doc//ReleaseEventV1.md)
|
||||
- [ReleaseType](doc//ReleaseType.md)
|
||||
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
|
||||
- [RotateParameters](doc//RotateParameters.md)
|
||||
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
|
||||
|
||||
Generated
-3
@@ -258,9 +258,6 @@ part 'model/ratings_response.dart';
|
||||
part 'model/ratings_update.dart';
|
||||
part 'model/reaction_level.dart';
|
||||
part 'model/reaction_type.dart';
|
||||
part 'model/release_channel.dart';
|
||||
part 'model/release_event_v1.dart';
|
||||
part 'model/release_type.dart';
|
||||
part 'model/reverse_geocoding_state_response_dto.dart';
|
||||
part 'model/rotate_parameters.dart';
|
||||
part 'model/search_album_response_dto.dart';
|
||||
|
||||
Generated
-6
@@ -562,12 +562,6 @@ class ApiClient {
|
||||
return ReactionLevelTypeTransformer().decode(value);
|
||||
case 'ReactionType':
|
||||
return ReactionTypeTypeTransformer().decode(value);
|
||||
case 'ReleaseChannel':
|
||||
return ReleaseChannelTypeTransformer().decode(value);
|
||||
case 'ReleaseEventV1':
|
||||
return ReleaseEventV1.fromJson(value);
|
||||
case 'ReleaseType':
|
||||
return ReleaseTypeTypeTransformer().decode(value);
|
||||
case 'ReverseGeocodingStateResponseDto':
|
||||
return ReverseGeocodingStateResponseDto.fromJson(value);
|
||||
case 'RotateParameters':
|
||||
|
||||
Generated
-6
@@ -157,12 +157,6 @@ String parameterToString(dynamic value) {
|
||||
if (value is ReactionType) {
|
||||
return ReactionTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is ReleaseChannel) {
|
||||
return ReleaseChannelTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is ReleaseType) {
|
||||
return ReleaseTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is SearchSuggestionType) {
|
||||
return SearchSuggestionTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
/// Release channel
|
||||
class ReleaseChannel {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const ReleaseChannel._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const stable = ReleaseChannel._(r'stable');
|
||||
static const releaseCandidate = ReleaseChannel._(r'releaseCandidate');
|
||||
|
||||
/// List of all possible values in this [enum][ReleaseChannel].
|
||||
static const values = <ReleaseChannel>[
|
||||
stable,
|
||||
releaseCandidate,
|
||||
];
|
||||
|
||||
static ReleaseChannel? fromJson(dynamic value) => ReleaseChannelTypeTransformer().decode(value);
|
||||
|
||||
static List<ReleaseChannel> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ReleaseChannel>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = ReleaseChannel.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [ReleaseChannel] to String,
|
||||
/// and [decode] dynamic data back to [ReleaseChannel].
|
||||
class ReleaseChannelTypeTransformer {
|
||||
factory ReleaseChannelTypeTransformer() => _instance ??= const ReleaseChannelTypeTransformer._();
|
||||
|
||||
const ReleaseChannelTypeTransformer._();
|
||||
|
||||
String encode(ReleaseChannel data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a ReleaseChannel.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
ReleaseChannel? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'stable': return ReleaseChannel.stable;
|
||||
case r'releaseCandidate': return ReleaseChannel.releaseCandidate;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [ReleaseChannelTypeTransformer] instance.
|
||||
static ReleaseChannelTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class ReleaseEventV1 {
|
||||
/// Returns a new [ReleaseEventV1] instance.
|
||||
ReleaseEventV1({
|
||||
required this.checkedAt,
|
||||
required this.isAvailable,
|
||||
required this.releaseVersion,
|
||||
required this.serverVersion,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
/// When the server last checked for a latest version. As an ISO timestamp
|
||||
String checkedAt;
|
||||
|
||||
/// Whether a new version is available
|
||||
bool isAvailable;
|
||||
|
||||
ServerVersionResponseDto releaseVersion;
|
||||
|
||||
ServerVersionResponseDto serverVersion;
|
||||
|
||||
ReleaseType type;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is ReleaseEventV1 &&
|
||||
other.checkedAt == checkedAt &&
|
||||
other.isAvailable == isAvailable &&
|
||||
other.releaseVersion == releaseVersion &&
|
||||
other.serverVersion == serverVersion &&
|
||||
other.type == type;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(checkedAt.hashCode) +
|
||||
(isAvailable.hashCode) +
|
||||
(releaseVersion.hashCode) +
|
||||
(serverVersion.hashCode) +
|
||||
(type.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'ReleaseEventV1[checkedAt=$checkedAt, isAvailable=$isAvailable, releaseVersion=$releaseVersion, serverVersion=$serverVersion, type=$type]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'checkedAt'] = this.checkedAt;
|
||||
json[r'isAvailable'] = this.isAvailable;
|
||||
json[r'releaseVersion'] = this.releaseVersion;
|
||||
json[r'serverVersion'] = this.serverVersion;
|
||||
json[r'type'] = this.type;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [ReleaseEventV1] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static ReleaseEventV1? fromJson(dynamic value) {
|
||||
upgradeDto(value, "ReleaseEventV1");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return ReleaseEventV1(
|
||||
checkedAt: mapValueOfType<String>(json, r'checkedAt')!,
|
||||
isAvailable: mapValueOfType<bool>(json, r'isAvailable')!,
|
||||
releaseVersion: ServerVersionResponseDto.fromJson(json[r'releaseVersion'])!,
|
||||
serverVersion: ServerVersionResponseDto.fromJson(json[r'serverVersion'])!,
|
||||
type: ReleaseType.fromJson(json[r'type'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<ReleaseEventV1> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ReleaseEventV1>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = ReleaseEventV1.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, ReleaseEventV1> mapFromJson(dynamic json) {
|
||||
final map = <String, ReleaseEventV1>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = ReleaseEventV1.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of ReleaseEventV1-objects as value to a dart map
|
||||
static Map<String, List<ReleaseEventV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<ReleaseEventV1>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = ReleaseEventV1.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'checkedAt',
|
||||
'isAvailable',
|
||||
'releaseVersion',
|
||||
'serverVersion',
|
||||
'type',
|
||||
};
|
||||
}
|
||||
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class ReleaseType {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const ReleaseType._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const major = ReleaseType._(r'major');
|
||||
static const premajor = ReleaseType._(r'premajor');
|
||||
static const minor = ReleaseType._(r'minor');
|
||||
static const preminor = ReleaseType._(r'preminor');
|
||||
static const patch_ = ReleaseType._(r'patch');
|
||||
static const prepatch = ReleaseType._(r'prepatch');
|
||||
static const prerelease = ReleaseType._(r'prerelease');
|
||||
static const release = ReleaseType._(r'release');
|
||||
|
||||
/// List of all possible values in this [enum][ReleaseType].
|
||||
static const values = <ReleaseType>[
|
||||
major,
|
||||
premajor,
|
||||
minor,
|
||||
preminor,
|
||||
patch_,
|
||||
prepatch,
|
||||
prerelease,
|
||||
release,
|
||||
];
|
||||
|
||||
static ReleaseType? fromJson(dynamic value) => ReleaseTypeTypeTransformer().decode(value);
|
||||
|
||||
static List<ReleaseType> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ReleaseType>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = ReleaseType.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [ReleaseType] to String,
|
||||
/// and [decode] dynamic data back to [ReleaseType].
|
||||
class ReleaseTypeTypeTransformer {
|
||||
factory ReleaseTypeTypeTransformer() => _instance ??= const ReleaseTypeTypeTransformer._();
|
||||
|
||||
const ReleaseTypeTypeTransformer._();
|
||||
|
||||
String encode(ReleaseType data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a ReleaseType.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
ReleaseType? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'major': return ReleaseType.major;
|
||||
case r'premajor': return ReleaseType.premajor;
|
||||
case r'minor': return ReleaseType.minor;
|
||||
case r'preminor': return ReleaseType.preminor;
|
||||
case r'patch': return ReleaseType.patch_;
|
||||
case r'prepatch': return ReleaseType.prepatch;
|
||||
case r'prerelease': return ReleaseType.prerelease;
|
||||
case r'release': return ReleaseType.release;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [ReleaseTypeTypeTransformer] instance.
|
||||
static ReleaseTypeTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
+6
-22
@@ -16,61 +16,47 @@ class ServerVersionResponseDto {
|
||||
required this.major,
|
||||
required this.minor,
|
||||
required this.patch_,
|
||||
required this.prerelease,
|
||||
});
|
||||
|
||||
/// Major version number
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int major;
|
||||
|
||||
/// Minor version number
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int minor;
|
||||
|
||||
/// Patch version number
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int patch_;
|
||||
|
||||
/// Pre-release version number
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int? prerelease;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto &&
|
||||
other.major == major &&
|
||||
other.minor == minor &&
|
||||
other.patch_ == patch_ &&
|
||||
other.prerelease == prerelease;
|
||||
other.patch_ == patch_;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(major.hashCode) +
|
||||
(minor.hashCode) +
|
||||
(patch_.hashCode) +
|
||||
(prerelease == null ? 0 : prerelease!.hashCode);
|
||||
(patch_.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_, prerelease=$prerelease]';
|
||||
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'major'] = this.major;
|
||||
json[r'minor'] = this.minor;
|
||||
json[r'patch'] = this.patch_;
|
||||
if (this.prerelease != null) {
|
||||
json[r'prerelease'] = this.prerelease;
|
||||
} else {
|
||||
// json[r'prerelease'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -86,7 +72,6 @@ class ServerVersionResponseDto {
|
||||
major: mapValueOfType<int>(json, r'major')!,
|
||||
minor: mapValueOfType<int>(json, r'minor')!,
|
||||
patch_: mapValueOfType<int>(json, r'patch')!,
|
||||
prerelease: mapValueOfType<int>(json, r'prerelease'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -137,7 +122,6 @@ class ServerVersionResponseDto {
|
||||
'major',
|
||||
'minor',
|
||||
'patch',
|
||||
'prerelease',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,32 +13,26 @@ part of openapi.api;
|
||||
class SystemConfigNewVersionCheckDto {
|
||||
/// Returns a new [SystemConfigNewVersionCheckDto] instance.
|
||||
SystemConfigNewVersionCheckDto({
|
||||
required this.channel,
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
ReleaseChannel channel;
|
||||
|
||||
/// Enabled
|
||||
bool enabled;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigNewVersionCheckDto &&
|
||||
other.channel == channel &&
|
||||
other.enabled == enabled;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(channel.hashCode) +
|
||||
(enabled.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigNewVersionCheckDto[channel=$channel, enabled=$enabled]';
|
||||
String toString() => 'SystemConfigNewVersionCheckDto[enabled=$enabled]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'channel'] = this.channel;
|
||||
json[r'enabled'] = this.enabled;
|
||||
return json;
|
||||
}
|
||||
@@ -52,7 +46,6 @@ class SystemConfigNewVersionCheckDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigNewVersionCheckDto(
|
||||
channel: ReleaseChannel.fromJson(json[r'channel'])!,
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
);
|
||||
}
|
||||
@@ -101,7 +94,6 @@ class SystemConfigNewVersionCheckDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'channel',
|
||||
'enabled',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,16 +10,9 @@ class ImmichFormController extends ChangeNotifier {
|
||||
FutureOr<void> Function()? onSubmit;
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
bool _isDisposed = false;
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> submit() async {
|
||||
if (_isLoading) {
|
||||
return;
|
||||
@@ -34,9 +27,7 @@ class ImmichFormController extends ChangeNotifier {
|
||||
await onSubmit?.call();
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
if (!_isDisposed) {
|
||||
notifyListeners();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +38,13 @@ class ImmichForm extends StatefulWidget {
|
||||
final String? submitText;
|
||||
final IconData? submitIcon;
|
||||
|
||||
const ImmichForm({super.key, this.onSubmit, this.submitText, this.submitIcon, required this.builder});
|
||||
const ImmichForm({
|
||||
super.key,
|
||||
this.onSubmit,
|
||||
this.submitText,
|
||||
this.submitIcon,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichForm> createState() => _ImmichFormState();
|
||||
|
||||
+3
-3
@@ -34,7 +34,7 @@ dependencies:
|
||||
flutter_web_auth_2: ^5.0.2
|
||||
fluttertoast: ^8.2.14
|
||||
geolocator: ^14.0.2
|
||||
home_widget: ^0.8.1
|
||||
home_widget: ^0.9.0
|
||||
hooks_riverpod: ^2.6.1
|
||||
http: ^1.6.0
|
||||
image_picker: ^1.2.1
|
||||
@@ -43,7 +43,7 @@ dependencies:
|
||||
intl: ^0.20.2
|
||||
local_auth: ^2.3.0
|
||||
logging: ^1.3.0
|
||||
maplibre_gl: ^0.22.0
|
||||
maplibre_gl: ^0.26.0
|
||||
native_video_player:
|
||||
git:
|
||||
url: https://github.com/immich-app/native_video_player
|
||||
@@ -67,7 +67,7 @@ dependencies:
|
||||
sliver_tools: ^0.2.12
|
||||
stream_transform: ^2.1.1
|
||||
thumbhash: 0.1.0+1
|
||||
timezone: ^0.9.4
|
||||
timezone: ^0.11.0
|
||||
url_launcher: ^6.3.2
|
||||
uuid: ^4.5.3
|
||||
wakelock_plus: ^1.3.3
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
@@ -39,7 +39,7 @@ void main() {
|
||||
registerFallbackValue(LogLevel.info);
|
||||
|
||||
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
|
||||
when(() => mockMetadataRepository.appConfig).thenReturn(const AppConfig(logLevel: LogLevel.fine));
|
||||
when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine));
|
||||
when(() => mockMetadataRepository.write<LogLevel, LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {});
|
||||
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
|
||||
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
|
||||
@@ -59,7 +59,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('Sets log level based on the metadata repository', () {
|
||||
verify(() => mockMetadataRepository.appConfig).called(1);
|
||||
verify(() => mockMetadataRepository.systemConfig).called(1);
|
||||
expect(Logger.root.level, Level.FINE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ void main() {
|
||||
|
||||
setUp(() async {
|
||||
await ctx.db.delete(ctx.db.metadataEntity).go();
|
||||
await MetadataRepository.instance.refresh();
|
||||
await MetadataRepository.refresh();
|
||||
});
|
||||
|
||||
group('defaults', () {
|
||||
@@ -31,8 +31,8 @@ void main() {
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
|
||||
test('appConfig returns key defaults when DB is empty', () {
|
||||
expect(sut.appConfig.logLevel, LogLevel.info);
|
||||
test('systemConfig returns key defaults when DB is empty', () {
|
||||
expect(sut.systemConfig.logLevel, LogLevel.info);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,14 +46,16 @@ void main() {
|
||||
await sut.write(.themeMode, ThemeMode.light);
|
||||
await sut.write(.logLevel, LogLevel.severe);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.light);
|
||||
expect(sut.appConfig.logLevel, LogLevel.severe);
|
||||
expect(sut.systemConfig.logLevel, LogLevel.severe);
|
||||
});
|
||||
});
|
||||
|
||||
group('delete', () {
|
||||
test('removes the row and reverts to default', () async {
|
||||
await sut.write(.themeMode, ThemeMode.dark);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
|
||||
await sut.write(.themeMode, ThemeMode.system);
|
||||
await sut.delete(.themeMode);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
|
||||
final rows = await ctx.db.select(ctx.db.metadataEntity).get();
|
||||
@@ -61,15 +63,13 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('delete', () {});
|
||||
|
||||
group('sync', () {
|
||||
group('refresh', () {
|
||||
test('picks up rows that were inserted directly into the DB', () async {
|
||||
await ctx.db
|
||||
.into(ctx.db.metadataEntity)
|
||||
.insert(
|
||||
MetadataEntityCompanion.insert(
|
||||
key: MetadataKey.themeMode.name,
|
||||
key: MetadataKey.themeMode.key,
|
||||
value: ThemeMode.dark.name,
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
@@ -78,7 +78,7 @@ void main() {
|
||||
// Cache hasn't seen this row yet — view still returns the default.
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
|
||||
await MetadataRepository.instance.refresh();
|
||||
await MetadataRepository.refresh();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ void main() {
|
||||
await ctx.db.delete(ctx.db.metadataEntity).go();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
|
||||
await MetadataRepository.instance.refresh();
|
||||
await MetadataRepository.refresh();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
|
||||
@@ -103,20 +103,32 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await MetadataRepository.instance.refresh();
|
||||
await MetadataRepository.refresh();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
});
|
||||
|
||||
group('watch', () {
|
||||
test('watchAppConfig emits the new value after a write', () async {
|
||||
final expectation = expectLater(sut.watchConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
|
||||
final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
|
||||
await sut.write(MetadataKey.themeMode, ThemeMode.dark);
|
||||
await expectation;
|
||||
});
|
||||
|
||||
test('watchConfig emits the new value after a write', () async {
|
||||
final expectation = expectLater(sut.watchConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
|
||||
test('watchAppConfig does not emit when only system-config rows change', () async {
|
||||
final emissions = <ThemeMode>[];
|
||||
// skip(1) drops the on-subscribe replay so we only capture emissions caused by the write below.
|
||||
final sub = sut.watchAppConfig().skip(1).listen((c) => emissions.add(c.theme.mode));
|
||||
|
||||
await sut.write(MetadataKey.logLevel, LogLevel.severe);
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(emissions, isEmpty);
|
||||
});
|
||||
|
||||
test('watchSystemConfig emits the new value after a write', () async {
|
||||
final expectation = expectLater(sut.watchSystemConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
|
||||
await sut.write(MetadataKey.logLevel, LogLevel.warning);
|
||||
await expectation;
|
||||
});
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
|
||||
void main() {
|
||||
group('MetadataKey', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
test('verify codec for $key', () {
|
||||
final defaultValue = defaultConfig.read(key);
|
||||
final encoded = key.encode(defaultValue);
|
||||
test('every key round-trips its default value losslessly', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
final encoded = key.encode(key.defaultValue);
|
||||
final decoded = key.decode(encoded);
|
||||
expect(decoded, defaultValue, reason: 'round-trip failed for ${key.name}');
|
||||
});
|
||||
}
|
||||
expect(decoded, key.defaultValue, reason: 'round-trip failed for ${key.name}');
|
||||
}
|
||||
});
|
||||
|
||||
test('decode falls back to the default value when the raw input is unparseable', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
// String keys can decode any string. So skip them
|
||||
if (key.defaultValue is String) {
|
||||
continue;
|
||||
}
|
||||
expect(
|
||||
key.decode('not a valid encoding for any key'),
|
||||
key.defaultValue,
|
||||
reason: 'fallback failed for ${key.name}',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20800,58 +20800,6 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"lastImportFileName": {
|
||||
@@ -21521,40 +21469,26 @@
|
||||
"major": {
|
||||
"description": "Major version number",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"minor": {
|
||||
"description": "Minor version number",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"patch": {
|
||||
"description": "Patch version number",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"prerelease": {
|
||||
"description": "Pre-release version number",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.0.0",
|
||||
"state": "Added"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"major",
|
||||
"minor",
|
||||
"patch",
|
||||
"prerelease"
|
||||
"patch"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -24575,16 +24509,12 @@
|
||||
},
|
||||
"SystemConfigNewVersionCheckDto": {
|
||||
"properties": {
|
||||
"channel": {
|
||||
"$ref": "#/components/schemas/ReleaseChannel"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Enabled",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"channel",
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
|
||||
@@ -2074,8 +2074,6 @@ export type ServerVersionResponseDto = {
|
||||
minor: number;
|
||||
/** Patch version number */
|
||||
patch: number;
|
||||
/** Pre-release version number */
|
||||
prerelease: number | null;
|
||||
};
|
||||
export type VersionCheckStateResponseDto = {
|
||||
/** Last check timestamp */
|
||||
@@ -2423,7 +2421,6 @@ export type SystemConfigMetadataDto = {
|
||||
faces: SystemConfigFacesDto;
|
||||
};
|
||||
export type SystemConfigNewVersionCheckDto = {
|
||||
channel: ReleaseChannel;
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
@@ -2769,16 +2766,6 @@ export type WorkflowShareResponseDto = {
|
||||
trigger: WorkflowTrigger;
|
||||
};
|
||||
export type LicenseResponseDto = UserLicense;
|
||||
export type ReleaseEventV1 = {
|
||||
/** When the server last checked for a latest version. As an ISO timestamp */
|
||||
checkedAt: string;
|
||||
/** Whether a new version is available */
|
||||
isAvailable: boolean;
|
||||
releaseVersion: ServerVersionResponseDto;
|
||||
serverVersion: ServerVersionResponseDto;
|
||||
/** Release type */
|
||||
"type": ReleaseType;
|
||||
};
|
||||
export type SyncAckV1 = {};
|
||||
export type SyncAlbumDeleteV1 = {
|
||||
/** Album ID */
|
||||
@@ -7318,10 +7305,6 @@ export enum LogLevel {
|
||||
Error = "error",
|
||||
Fatal = "fatal"
|
||||
}
|
||||
export enum ReleaseChannel {
|
||||
Stable = "stable",
|
||||
ReleaseCandidate = "releaseCandidate"
|
||||
}
|
||||
export enum OAuthTokenEndpointAuthMethod {
|
||||
ClientSecretPost = "client_secret_post",
|
||||
ClientSecretBasic = "client_secret_basic"
|
||||
@@ -7330,16 +7313,6 @@ export enum AssetOrderBy {
|
||||
TakenAt = "takenAt",
|
||||
CreatedAt = "createdAt"
|
||||
}
|
||||
export enum ReleaseType {
|
||||
Major = "major",
|
||||
Premajor = "premajor",
|
||||
Minor = "minor",
|
||||
Preminor = "preminor",
|
||||
Patch = "patch",
|
||||
Prepatch = "prepatch",
|
||||
Prerelease = "prerelease",
|
||||
Release = "release"
|
||||
}
|
||||
export enum UserMetadataKey {
|
||||
Preferences = "preferences",
|
||||
License = "license",
|
||||
|
||||
Generated
+20
-27
@@ -571,8 +571,8 @@ importers:
|
||||
specifier: ^1.6.3
|
||||
version: 1.6.4
|
||||
semver:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.1
|
||||
specifier: ^7.6.2
|
||||
version: 7.8.0
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
@@ -11243,11 +11243,6 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.1:
|
||||
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
send@0.19.2:
|
||||
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -16305,7 +16300,7 @@ snapshots:
|
||||
nopt: 5.0.0
|
||||
npmlog: 5.0.1
|
||||
rimraf: 3.0.2
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
tar: 6.2.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
@@ -17762,7 +17757,7 @@ snapshots:
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/runtime': 7.29.7
|
||||
'@babel/runtime': 7.29.2
|
||||
'@types/aria-query': 5.0.4
|
||||
aria-query: 5.3.0
|
||||
dom-accessibility-api: 0.5.16
|
||||
@@ -18466,7 +18461,7 @@ snapshots:
|
||||
'@typescript-eslint/visitor-keys': 8.59.4
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
tinyglobby: 0.2.16
|
||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||
typescript: 6.0.3
|
||||
@@ -19566,7 +19561,7 @@ snapshots:
|
||||
dot-prop: 10.1.0
|
||||
env-paths: 3.0.0
|
||||
json-schema-typed: 8.0.2
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
uint8array-extras: 1.5.0
|
||||
|
||||
config-chain@1.1.13:
|
||||
@@ -19738,7 +19733,7 @@ snapshots:
|
||||
postcss-modules-scope: 3.2.1(postcss@8.5.15)
|
||||
postcss-modules-values: 4.0.0(postcss@8.5.15)
|
||||
postcss-value-parser: 4.2.0
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
optionalDependencies:
|
||||
webpack: 5.107.0(postcss@8.5.15)
|
||||
|
||||
@@ -20606,7 +20601,7 @@ snapshots:
|
||||
find-up: 5.0.0
|
||||
globals: 15.15.0
|
||||
lodash.memoize: 4.1.2
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
|
||||
eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(prettier@3.8.3):
|
||||
dependencies:
|
||||
@@ -20629,7 +20624,7 @@ snapshots:
|
||||
postcss: 8.5.15
|
||||
postcss-load-config: 3.1.4(postcss@8.5.15)
|
||||
postcss-safe-parser: 7.0.1(postcss@8.5.15)
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
svelte-eslint-parser: 1.6.1(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
optionalDependencies:
|
||||
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
|
||||
@@ -21107,7 +21102,7 @@ snapshots:
|
||||
minimatch: 3.1.5
|
||||
node-abort-controller: 3.1.1
|
||||
schema-utils: 3.3.0
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
tapable: 2.3.3
|
||||
typescript: 5.9.3
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
@@ -21543,7 +21538,7 @@ snapshots:
|
||||
|
||||
history@4.10.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@babel/runtime': 7.29.2
|
||||
loose-envify: 1.4.0
|
||||
resolve-pathname: 3.0.0
|
||||
tiny-invariant: 1.3.3
|
||||
@@ -22131,7 +22126,7 @@ snapshots:
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
|
||||
just-compare@2.3.0: {}
|
||||
|
||||
@@ -22417,7 +22412,7 @@ snapshots:
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
|
||||
maplibre-gl@5.24.0:
|
||||
dependencies:
|
||||
@@ -23252,7 +23247,7 @@ snapshots:
|
||||
|
||||
node-abi@3.92.0:
|
||||
dependencies:
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
optional: true
|
||||
|
||||
node-abort-controller@3.1.1: {}
|
||||
@@ -23293,7 +23288,7 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
nopt: 9.0.0
|
||||
proc-log: 6.1.0
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
tar: 7.5.15
|
||||
tinyglobby: 0.2.16
|
||||
undici: 6.25.0
|
||||
@@ -23531,7 +23526,7 @@ snapshots:
|
||||
got: 12.6.1
|
||||
registry-auth-token: 5.1.1
|
||||
registry-url: 6.0.1
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
@@ -23919,7 +23914,7 @@ snapshots:
|
||||
cosmiconfig: 8.3.6(typescript@6.0.3)
|
||||
jiti: 1.21.7
|
||||
postcss: 8.5.15
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
webpack: 5.107.0(postcss@8.5.15)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
@@ -24974,14 +24969,12 @@ snapshots:
|
||||
|
||||
semver-diff@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.8.0: {}
|
||||
|
||||
semver@7.8.1: {}
|
||||
|
||||
send@0.19.2:
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
@@ -25516,7 +25509,7 @@ snapshots:
|
||||
postcss: 8.5.15
|
||||
postcss-scss: 4.0.9(postcss@8.5.15)
|
||||
postcss-selector-parser: 7.1.1
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
optionalDependencies:
|
||||
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
|
||||
|
||||
@@ -26224,7 +26217,7 @@ snapshots:
|
||||
is-yarn-global: 0.4.1
|
||||
latest-version: 7.0.0
|
||||
pupa: 3.3.0
|
||||
semver: 7.8.1
|
||||
semver: 7.8.0
|
||||
semver-diff: 4.0.0
|
||||
xdg-basedir: 5.1.0
|
||||
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"semver": "^7.8.1",
|
||||
"semver": "^7.6.2",
|
||||
"sharp": "^0.34.5",
|
||||
"sirv": "^3.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CronExpression } from '@nestjs/schedule';
|
||||
import { ReleaseChannel } from 'src/dtos/system-config.dto';
|
||||
import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
@@ -136,7 +135,6 @@ export type SystemConfig = {
|
||||
};
|
||||
newVersionCheck: {
|
||||
enabled: boolean;
|
||||
channel: ReleaseChannel;
|
||||
};
|
||||
nightlyTasks: {
|
||||
startTime: string;
|
||||
@@ -346,7 +344,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
},
|
||||
newVersionCheck: {
|
||||
enabled: true,
|
||||
channel: ReleaseChannel.Stable,
|
||||
},
|
||||
nightlyTasks: {
|
||||
startTime: '00:00',
|
||||
|
||||
@@ -265,13 +265,3 @@ export class HistoryBuilder {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
export const extraModels: Function[] = [];
|
||||
|
||||
export const ExtraModel = (): ClassDecorator => {
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-unsafe-function-type
|
||||
return (object: Function) => {
|
||||
extraModels.push(object);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import type { SemVer } from 'semver';
|
||||
import { ExtraModel, HistoryBuilder } from 'src/decorators';
|
||||
import { isoDatetimeToDate } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
@@ -59,15 +58,9 @@ const ServerStorageResponseSchema = z
|
||||
|
||||
const ServerVersionResponseSchema = z
|
||||
.object({
|
||||
major: z.int().min(0).describe('Major version number'),
|
||||
minor: z.int().min(0).describe('Minor version number'),
|
||||
patch: z.int().min(0).describe('Patch version number'),
|
||||
prerelease: z
|
||||
.int()
|
||||
.min(0)
|
||||
.nullable()
|
||||
.meta(HistoryBuilder.v3().getExtensions())
|
||||
.describe('Pre-release version number'),
|
||||
major: z.int().describe('Major version number'),
|
||||
minor: z.int().describe('Minor version number'),
|
||||
patch: z.int().describe('Patch version number'),
|
||||
})
|
||||
.meta({ id: 'ServerVersionResponseDto' });
|
||||
|
||||
@@ -147,27 +140,6 @@ const ServerFeaturesSchema = z
|
||||
})
|
||||
.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 ServerAboutResponseDto extends createZodDto(ServerAboutResponseSchema) {}
|
||||
export class ServerApkLinksDto extends createZodDto(ServerApkLinksSchema) {}
|
||||
@@ -175,12 +147,7 @@ export class ServerStorageResponseDto extends createZodDto(ServerStorageResponse
|
||||
|
||||
export class ServerVersionResponseDto extends createZodDto(ServerVersionResponseSchema) {
|
||||
static fromSemVer(value: SemVer): z.infer<typeof ServerVersionResponseSchema> {
|
||||
return {
|
||||
major: value.major,
|
||||
minor: value.minor,
|
||||
patch: value.patch,
|
||||
prerelease: (value.prerelease[1] as number) ?? null,
|
||||
};
|
||||
return { major: value.major, minor: value.minor, patch: value.patch };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,5 +158,10 @@ export class ServerMediaTypesResponseDto extends createZodDto(ServerMediaTypesRe
|
||||
export class ServerConfigDto extends createZodDto(ServerConfigSchema) {}
|
||||
export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {}
|
||||
|
||||
@ExtraModel()
|
||||
export class ReleaseEventV1 extends createZodDto(ReleaseEventV1Schema) {}
|
||||
export interface ReleaseNotification {
|
||||
isAvailable: boolean;
|
||||
/** 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 { ExtraModel } from 'src/decorators';
|
||||
import { AssetEditActionSchema } from 'src/dtos/editing.dto';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
@@ -17,6 +17,15 @@ import {
|
||||
import { isoDatetimeToDate } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
export const extraSyncModels: Function[] = [];
|
||||
|
||||
const ExtraModel = (): ClassDecorator => {
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
return (object: Function) => {
|
||||
extraSyncModels.push(object);
|
||||
};
|
||||
};
|
||||
|
||||
const SyncUserV1Schema = z
|
||||
.object({
|
||||
id: z.string().describe('User ID'),
|
||||
|
||||
@@ -151,15 +151,8 @@ const SystemConfigMapSchema = z
|
||||
})
|
||||
.meta({ id: 'SystemConfigMapDto' });
|
||||
|
||||
export enum ReleaseChannel {
|
||||
Stable = 'stable',
|
||||
ReleaseCandidate = 'releaseCandidate',
|
||||
}
|
||||
|
||||
const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' });
|
||||
|
||||
const SystemConfigNewVersionCheckSchema = z
|
||||
.object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema })
|
||||
.object({ enabled: configBool.describe('Enabled') })
|
||||
.meta({ id: 'SystemConfigNewVersionCheckDto' });
|
||||
|
||||
const SystemConfigNightlyTasksSchema = z
|
||||
|
||||
@@ -4,7 +4,6 @@ import { exec as execCallback } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import sharp from 'sharp';
|
||||
import { ReleaseChannel } from 'src/dtos/system-config.dto';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
|
||||
@@ -65,12 +64,10 @@ export class ServerInfoRepository {
|
||||
this.logger.setContext(ServerInfoRepository.name);
|
||||
}
|
||||
|
||||
async getLatestRelease(channel: ReleaseChannel): Promise<VersionResponse> {
|
||||
async getLatestRelease(): Promise<VersionResponse> {
|
||||
try {
|
||||
const { versionCheck } = this.configRepository.getEnv();
|
||||
const url = new URL(versionCheck.url);
|
||||
url.searchParams.append('channel', channel);
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(versionCheck.url);
|
||||
|
||||
if (!response.ok) {
|
||||
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 { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { NotificationDto } from 'src/dtos/notification.dto';
|
||||
import { ReleaseEventV1, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV2 } from 'src/dtos/sync.dto';
|
||||
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
@@ -31,7 +31,7 @@ export interface ClientEventMap {
|
||||
on_person_thumbnail: [string];
|
||||
on_server_version: [ServerVersionResponseDto];
|
||||
on_config_update: [];
|
||||
on_new_release: [ReleaseEventV1];
|
||||
on_new_release: [ReleaseNotification];
|
||||
on_notification: [NotificationDto];
|
||||
on_session_delete: [string];
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export class SearchService extends BaseService {
|
||||
|
||||
const page = dto.page ?? 1;
|
||||
const size = dto.size || 250;
|
||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
const userIds = await this.getUserIdsToSearch(auth);
|
||||
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
|
||||
{ page, size },
|
||||
{
|
||||
@@ -103,7 +103,7 @@ export class SearchService extends BaseService {
|
||||
requireElevatedPermission(auth);
|
||||
}
|
||||
|
||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
const userIds = await this.getUserIdsToSearch(auth);
|
||||
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
|
||||
return items.map((item) => mapAsset(item, { auth }));
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export class SearchService extends BaseService {
|
||||
requireElevatedPermission(auth);
|
||||
}
|
||||
|
||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
const userIds = await this.getUserIdsToSearch(auth);
|
||||
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
|
||||
return items.map((item) => mapAsset(item, { auth }));
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export class SearchService extends BaseService {
|
||||
throw new BadRequestException('Smart search is not enabled');
|
||||
}
|
||||
|
||||
const userIds = this.getUserIdsToSearch(auth, dto.visibility);
|
||||
const userIds = this.getUserIdsToSearch(auth);
|
||||
let embedding;
|
||||
if (dto.query) {
|
||||
const key = machineLearning.clip.modelName + dto.query + dto.language;
|
||||
@@ -202,11 +202,7 @@ export class SearchService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise<string[]> {
|
||||
// Locked assets are personal. Never include partner IDs, regardless of A's elevated session.
|
||||
if (visibility === AssetVisibility.Locked) {
|
||||
return [auth.user.id];
|
||||
}
|
||||
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> {
|
||||
const partnerIds = await getMyPartnerIds({
|
||||
userId: auth.user.id,
|
||||
repository: this.partnerRepository,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { defaults, SystemConfig } from 'src/config';
|
||||
import { ReleaseChannel } from 'src/dtos/system-config.dto';
|
||||
import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
@@ -185,7 +184,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
},
|
||||
newVersionCheck: {
|
||||
enabled: true,
|
||||
channel: ReleaseChannel.Stable,
|
||||
},
|
||||
trash: {
|
||||
enabled: true,
|
||||
|
||||
@@ -204,16 +204,5 @@ describe(TimelineService.name, () => {
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw an error if withPartners is true and visibility is locked', async () => {
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.adminWithElevatedPermission, {
|
||||
timeBucket: 'bucket',
|
||||
visibility: AssetVisibility.Locked,
|
||||
withPartners: true,
|
||||
userId: authStub.adminWithElevatedPermission.user.id,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,14 +71,13 @@ export class TimelineService extends BaseService {
|
||||
}
|
||||
|
||||
if (dto.withPartners) {
|
||||
const requestedLocked = dto.visibility === AssetVisibility.Locked;
|
||||
const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined;
|
||||
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
|
||||
const requestedTrash = dto.isTrashed === true;
|
||||
|
||||
if (requestedLocked || requestedArchived || requestedFavorite || requestedTrash) {
|
||||
if (requestedArchived || requestedFavorite || requestedTrash) {
|
||||
throw new BadRequestException(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { DateTime } from 'luxon';
|
||||
import { SemVer } from 'semver';
|
||||
import { defaults } from 'src/config';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { ReleaseChannel } from 'src/dtos/system-config.dto';
|
||||
import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { factory } from 'test/small.factory';
|
||||
@@ -23,17 +22,6 @@ describe(VersionService.name, () => {
|
||||
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', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
@@ -78,10 +66,9 @@ describe(VersionService.name, () => {
|
||||
describe('getVersion', () => {
|
||||
it('should respond the server version', () => {
|
||||
expect(sut.getVersion()).toEqual({
|
||||
major: 3,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: null,
|
||||
major: serverVersion.major,
|
||||
minor: serverVersion.minor,
|
||||
patch: serverVersion.patch,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -156,24 +143,24 @@ describe(VersionService.name, () => {
|
||||
describe('onConfigUpdate', () => {
|
||||
it('should queue a version check job when newVersionCheck is enabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: false } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
});
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} });
|
||||
});
|
||||
|
||||
it('should not queue a version check job when newVersionCheck is disabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } },
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: false } },
|
||||
});
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not queue a version check job when newVersionCheck was already enabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
});
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -182,36 +169,21 @@ describe(VersionService.name, () => {
|
||||
describe('onWebsocketConnection', () => {
|
||||
it('should send on_server_version client event', async () => {
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
|
||||
major: 3,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: null,
|
||||
});
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should also send a new release notification', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' });
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
|
||||
major: 3,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: null,
|
||||
});
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should not send a release notification when the version check is disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } });
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
|
||||
major: 3,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: null,
|
||||
});
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
|
||||
expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,27 +3,19 @@ import { DateTime } from 'luxon';
|
||||
import semver, { SemVer } from 'semver';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { ReleaseChannel } from 'src/dtos/system-config.dto';
|
||||
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { VersionCheckMetadata } from 'src/types';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
|
||||
const asNotification = (
|
||||
channel: ReleaseChannel,
|
||||
{ checkedAt, releaseVersion }: VersionCheckMetadata,
|
||||
): ReleaseEventV1 => {
|
||||
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
|
||||
return {
|
||||
// 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,
|
||||
}),
|
||||
isAvailable: semver.gt(releaseVersion, serverVersion),
|
||||
checkedAt,
|
||||
serverVersion: ServerVersionResponseDto.fromSemVer(serverVersion),
|
||||
releaseVersion: ServerVersionResponseDto.fromSemVer(new SemVer(releaseVersion)),
|
||||
type: semver.diff(serverVersion, releaseVersion) as ReleaseType,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -106,21 +98,14 @@ export class VersionService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease(
|
||||
newVersionCheck.channel,
|
||||
);
|
||||
const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease();
|
||||
const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion };
|
||||
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.VersionCheckState, metadata);
|
||||
|
||||
// can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483
|
||||
if (
|
||||
semver.intersects(`>${serverVersion}`, releaseVersion.toString(), {
|
||||
includePrerelease: newVersionCheck.channel === ReleaseChannel.ReleaseCandidate,
|
||||
})
|
||||
) {
|
||||
if (semver.gt(releaseVersion, serverVersion)) {
|
||||
this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`);
|
||||
this.websocketRepository.clientBroadcast('on_new_release', asNotification(newVersionCheck.channel, metadata));
|
||||
this.websocketRepository.clientBroadcast('on_new_release', asNotification(metadata));
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`);
|
||||
@@ -132,11 +117,7 @@ export class VersionService extends BaseService {
|
||||
|
||||
@OnEvent({ name: 'WebsocketConnect' })
|
||||
async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) {
|
||||
this.websocketRepository.clientSend(
|
||||
'on_server_version',
|
||||
userId,
|
||||
ServerVersionResponseDto.fromSemVer(serverVersion),
|
||||
);
|
||||
this.websocketRepository.clientSend('on_server_version', userId, serverVersion);
|
||||
|
||||
const { newVersionCheck } = await this.getConfig({ withCache: true });
|
||||
if (!newVersionCheck.enabled) {
|
||||
@@ -145,7 +126,7 @@ export class VersionService extends BaseService {
|
||||
|
||||
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState);
|
||||
if (metadata) {
|
||||
this.websocketRepository.clientSend('on_new_release', userId, asNotification(newVersionCheck.channel, metadata));
|
||||
this.websocketRepository.clientSend('on_new_release', userId, asNotification(metadata));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import picomatch from 'picomatch';
|
||||
import parse from 'picomatch/lib/parse';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { CLIP_MODEL_INFO, endpointTags, serverVersion } from 'src/constants';
|
||||
import { extraModels } from 'src/decorators';
|
||||
import { extraSyncModels } from 'src/dtos/sync.dto';
|
||||
import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
|
||||
@@ -289,7 +289,7 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean })
|
||||
|
||||
const options: SwaggerDocumentOptions = {
|
||||
operationIdFactory: (controllerKey: string, methodKey: string) => methodKey,
|
||||
extraModels,
|
||||
extraModels: extraSyncModels,
|
||||
ignoreGlobalPrefix: true,
|
||||
};
|
||||
|
||||
|
||||
Vendored
-7
@@ -41,13 +41,6 @@ export const authStub = {
|
||||
id: 'token-id',
|
||||
} as AuthSession,
|
||||
}),
|
||||
adminWithElevatedPermission: Object.freeze<AuthDto>({
|
||||
user: authUser.admin,
|
||||
session: {
|
||||
id: 'token-id-elevated',
|
||||
hasElevatedPermission: true,
|
||||
} as AuthSession,
|
||||
}),
|
||||
adminSharedLink: Object.freeze({
|
||||
user: authUser.admin,
|
||||
sharedLink: {
|
||||
|
||||
@@ -51,13 +51,13 @@ describe(TimelineService.name, () => {
|
||||
const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive });
|
||||
await expect(response1).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(response1).rejects.toThrow(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
);
|
||||
|
||||
const response2 = sut.getTimeBuckets(auth, { withPartners: true });
|
||||
await expect(response2).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(response2).rejects.toThrow(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -67,13 +67,13 @@ describe(TimelineService.name, () => {
|
||||
const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false });
|
||||
await expect(response1).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(response1).rejects.toThrow(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
);
|
||||
|
||||
const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true });
|
||||
await expect(response2).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(response2).rejects.toThrow(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ describe(TimelineService.name, () => {
|
||||
const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true });
|
||||
await expect(response).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(response).rejects.toThrow(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { websocketStore } from '$lib/stores/websocket';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import { semverToName } from '$lib/utils';
|
||||
import { requestServerInfo } from '$lib/utils/auth';
|
||||
import {
|
||||
getAboutInfo,
|
||||
getVersionHistory,
|
||||
type ReleaseEventV1,
|
||||
type ServerAboutResponseDto,
|
||||
type ServerVersionHistoryResponseDto,
|
||||
} from '@immich/sdk';
|
||||
@@ -35,9 +35,11 @@
|
||||
userInteraction.versions = versions;
|
||||
});
|
||||
let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich');
|
||||
let version = $derived($serverVersion ? semverToName($serverVersion) : null);
|
||||
let version = $derived(
|
||||
$serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
|
||||
);
|
||||
|
||||
const getReleaseInfo = (release?: ReleaseEventV1) => {
|
||||
const getReleaseInfo = (release?: ReleaseEvent) => {
|
||||
if (!release || !release?.isAvailable || !authManager.user.isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,11 +30,14 @@
|
||||
|
||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
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>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
|
||||
{#each viewerAssets as viewerAsset (viewerAsset.id)}
|
||||
{#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
<AssetLayout
|
||||
{manager}
|
||||
viewerAssets={timelineDay.activeViewerAssets}
|
||||
viewerAssets={timelineDay.viewerAssets}
|
||||
height={timelineDay.height}
|
||||
width={timelineDay.width}
|
||||
{customThumbnailLayout}
|
||||
|
||||
@@ -7,13 +7,13 @@ import type {
|
||||
LoginResponseDto,
|
||||
PersonResponseDto,
|
||||
QueueResponseDto,
|
||||
ReleaseEventV1,
|
||||
SharedLinkResponseDto,
|
||||
SystemConfigDto,
|
||||
TagResponseDto,
|
||||
UserAdminResponseDto,
|
||||
WorkflowResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||
import type { TreeNode } from '$lib/utils/tree-utils';
|
||||
|
||||
@@ -86,7 +86,7 @@ export type Events = {
|
||||
WorkflowUpdate: [WorkflowResponseDto];
|
||||
WorkflowDelete: [WorkflowResponseDto];
|
||||
|
||||
ReleaseEvent: [ReleaseEventV1];
|
||||
ReleaseEvent: [ReleaseEvent];
|
||||
|
||||
WebsocketConnect: [];
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ReleaseEventV1 } from '@immich/sdk';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { type ReleaseEvent } from '$lib/types';
|
||||
|
||||
class ReleaseManager {
|
||||
value = $state<ReleaseEventV1 | undefined>();
|
||||
value = $state<ReleaseEvent | undefined>();
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
|
||||
@@ -53,3 +53,17 @@ export function updateTimelineMonthViewportProximity(timelineManager: TimelineMa
|
||||
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,31 +1,12 @@
|
||||
import { AssetOrder, AssetOrderBy } from '@immich/sdk';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { CommonLayoutOptions, CommonPosition } from '$lib/utils/layout-utils';
|
||||
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
|
||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import type { TimelineMonth } from './timeline-month.svelte';
|
||||
import type { Direction, MoveAsset, TimelineAsset } from './types';
|
||||
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 {
|
||||
readonly timelineMonth: TimelineMonth;
|
||||
readonly index: number;
|
||||
@@ -37,15 +18,12 @@ export class TimelineDay {
|
||||
height = $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);
|
||||
#start: number = $state(0);
|
||||
#row = $state(0);
|
||||
#col = $state(0);
|
||||
#deferredLayout = false;
|
||||
#lastInOrNearViewport = -1;
|
||||
|
||||
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
|
||||
this.index = index;
|
||||
@@ -171,32 +149,18 @@ export class TimelineDay {
|
||||
for (let i = 0; i < this.viewerAssets.length; 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() {
|
||||
return this.timelineMonth.top + this.#top;
|
||||
}
|
||||
|
||||
get isInOrNearViewport() {
|
||||
if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport);
|
||||
return this.#lastInOrNearViewport !== -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,11 +214,6 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
|
||||
for (const month of this.months) {
|
||||
updateTimelineMonthViewportProximity(this, month);
|
||||
if (month.isInOrNearViewport && month.isLoaded) {
|
||||
for (const day of month.timelineDays) {
|
||||
day.updateAssetBoundaries();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const month = this.months.find((month) => month.isInViewport);
|
||||
|
||||
@@ -254,7 +254,7 @@ export class TimelineMonth {
|
||||
addContext.newTimelineDays.add(timelineDay);
|
||||
}
|
||||
|
||||
const viewerAsset = new ViewerAsset(timelineAsset);
|
||||
const viewerAsset = new ViewerAsset(timelineDay, timelineAsset);
|
||||
timelineDay.viewerAssets.push(viewerAsset);
|
||||
addContext.changedTimelineDays.add(timelineDay);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
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';
|
||||
|
||||
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();
|
||||
asset: TimelineAsset = $state() as TimelineAsset;
|
||||
id: string = $derived(this.asset.id);
|
||||
|
||||
constructor(asset: TimelineAsset) {
|
||||
constructor(group: TimelineDay, asset: TimelineAsset) {
|
||||
this.#group = group;
|
||||
this.asset = asset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type AssetResponseDto,
|
||||
type MaintenanceStatusResponseDto,
|
||||
type NotificationDto,
|
||||
type ReleaseEventV1,
|
||||
type ServerVersionResponseDto,
|
||||
type SyncAssetEditV1,
|
||||
type SyncAssetV2,
|
||||
@@ -16,6 +15,7 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { maintenanceStore } from '$lib/stores/maintenance.store';
|
||||
import { notificationManager } from '$lib/stores/notification-manager.svelte';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import { createEventEmitter } from '$lib/utils/eventemitter';
|
||||
|
||||
interface AppRestartEvent {
|
||||
@@ -34,7 +34,7 @@ export interface Events {
|
||||
on_person_thumbnail: (personId: string) => void;
|
||||
on_server_version: (serverVersion: ServerVersionResponseDto) => void;
|
||||
on_config_update: () => void;
|
||||
on_new_release: (event: ReleaseEventV1) => void;
|
||||
on_new_release: (event: ReleaseEvent) => void;
|
||||
on_session_delete: (sessionId: string) => void;
|
||||
on_notification: (notification: NotificationDto) => void;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { QueueResponseDto } from '@immich/sdk';
|
||||
import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk';
|
||||
import type { ActionItem } from '@immich/ui';
|
||||
import type { DateTime } from 'luxon';
|
||||
import type { SvelteSet } from 'svelte/reactivity';
|
||||
@@ -7,6 +7,14 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
|
||||
export type LatLng = { lng: number; lat: number };
|
||||
|
||||
export interface ReleaseEvent {
|
||||
isAvailable: boolean;
|
||||
/** ISO8601 */
|
||||
checkedAt: string;
|
||||
serverVersion: ServerVersionResponseDto;
|
||||
releaseVersion: ServerVersionResponseDto;
|
||||
}
|
||||
|
||||
export type QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] };
|
||||
|
||||
export type HeaderButtonActionItem = ActionItem & { data?: { title?: string } };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AssetTypeEnum } from '@immich/sdk';
|
||||
import { getAssetUrl } from '$lib/utils';
|
||||
import { getAssetUrl, getReleaseType } from '$lib/utils';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||
|
||||
@@ -161,4 +161,26 @@ describe('utils', () => {
|
||||
expect(url).toContain(asset.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getReleaseType.name, () => {
|
||||
it('should return "major" for major version changes', () => {
|
||||
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major');
|
||||
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 3, minor: 2, patch: 1 })).toBe('major');
|
||||
});
|
||||
|
||||
it('should return "minor" for minor version changes', () => {
|
||||
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 1, patch: 0 })).toBe('minor');
|
||||
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 2, patch: 1 })).toBe('minor');
|
||||
});
|
||||
|
||||
it('should return "patch" for patch version changes', () => {
|
||||
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 1 })).toBe('patch');
|
||||
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 5 })).toBe('patch');
|
||||
});
|
||||
|
||||
it('should return "none" for matching versions', () => {
|
||||
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 0 })).toBe('none');
|
||||
expect(getReleaseType({ major: 1, minor: 2, patch: 3 }, { major: 1, minor: 2, patch: 3 })).toBe('none');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+20
-2
@@ -411,8 +411,26 @@ export function createDateFormatter(localeCode: string | undefined): DateFormatt
|
||||
};
|
||||
}
|
||||
|
||||
export const semverToName = ({ major, minor, patch, prerelease }: ServerVersionResponseDto) =>
|
||||
`v${major}.${minor}.${patch}${prerelease ? `-rc.${prerelease}` : ''}`;
|
||||
export const getReleaseType = (
|
||||
current: ServerVersionResponseDto,
|
||||
newVersion: ServerVersionResponseDto,
|
||||
): 'major' | 'minor' | 'patch' | 'none' => {
|
||||
if (current.major !== newVersion.major) {
|
||||
return 'major';
|
||||
}
|
||||
|
||||
if (current.minor !== newVersion.minor) {
|
||||
return 'minor';
|
||||
}
|
||||
|
||||
if (current.patch !== newVersion.patch) {
|
||||
return 'patch';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
};
|
||||
|
||||
export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`;
|
||||
|
||||
export const withoutIcons = (actions: ActionItem[]): ActionItem[] =>
|
||||
actions.map((action) => ({ ...action, icon: undefined }));
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
mdiPlus,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { mount } from 'svelte';
|
||||
import { flushSync, mount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
const configEntries = $derived(
|
||||
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
|
||||
);
|
||||
let dragImage = $state<Element>();
|
||||
let isDragging = $state(false);
|
||||
let isDropTarget = $state(false);
|
||||
let hoverDrag = $state(false);
|
||||
|
||||
@@ -68,11 +68,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(index));
|
||||
|
||||
const dragElement = document.createElement('div');
|
||||
document.body.append(dragElement);
|
||||
|
||||
mount(WorkflowStepDragImage, {
|
||||
target: document.body,
|
||||
target: dragElement,
|
||||
props: {
|
||||
description: method?.description,
|
||||
isFilter: method?.uiHints?.includes('filter') ?? false,
|
||||
@@ -80,9 +84,9 @@
|
||||
stepNumber: index + 1,
|
||||
},
|
||||
});
|
||||
flushSync();
|
||||
|
||||
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
|
||||
event.dataTransfer.setDragImage(dragImage, 16, 22);
|
||||
event.dataTransfer.setDragImage(dragElement, 16, 22);
|
||||
};
|
||||
|
||||
const handleDrop = (index: number, event: DragEvent) => {
|
||||
@@ -105,8 +109,7 @@
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
dragImage?.remove();
|
||||
dragImage = undefined;
|
||||
isDragging = false;
|
||||
isDropTarget = false;
|
||||
};
|
||||
</script>
|
||||
@@ -129,8 +132,8 @@
|
||||
|
||||
<div
|
||||
class="w-full transition-all"
|
||||
class:opacity-40={!!dragImage}
|
||||
class:scale-[0.99]={!!dragImage}
|
||||
class:opacity-40={isDragging}
|
||||
class:scale-[0.99]={isDragging}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={() => (isDropTarget = false)}
|
||||
ondrop={(event) => handleDrop(index, event)}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="workflow-step-drag-image"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none fixed top-[-1000px] left-0 flex w-80 items-center gap-2.5 rounded-lg border border-light-200 bg-light px-3 py-2.5 text-sm/5 text-dark shadow-2xl"
|
||||
>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
|
||||
import { semverToName } from '$lib/utils';
|
||||
import { ReleaseType, type ReleaseEventV1 } from '@immich/sdk';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import { getReleaseType, semverToName } from '$lib/utils';
|
||||
import { modalManager } from '@immich/ui';
|
||||
|
||||
let modal = $state<{
|
||||
@@ -11,20 +11,16 @@
|
||||
close: () => Promise<void>;
|
||||
}>();
|
||||
|
||||
const onReleaseEvent = async (release: ReleaseEventV1) => {
|
||||
const onReleaseEvent = async (release: ReleaseEvent) => {
|
||||
if (!release.isAvailable || !authManager.user.isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const releaseVersion = semverToName(release.releaseVersion);
|
||||
const serverVersion = semverToName(release.serverVersion);
|
||||
const type = getReleaseType(release.serverVersion, release.releaseVersion);
|
||||
|
||||
if (
|
||||
!release.type ||
|
||||
release.type === ReleaseType.Patch ||
|
||||
release.type === ReleaseType.Prepatch ||
|
||||
localStorage.getItem('appVersion') === releaseVersion
|
||||
) {
|
||||
if (type === 'none' || type === 'patch' || localStorage.getItem('appVersion') === releaseVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import SettingSelect from './SettingSelect.svelte';
|
||||
import { ReleaseChannel } from '@immich/sdk';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
@@ -23,20 +20,6 @@
|
||||
bind:checked={configToEdit.newVersionCheck.enabled}
|
||||
{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} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user