Compare commits

..

12 Commits

Author SHA1 Message Date
Daniel Dietzler e35610d0a7 feat: release candidate support 2026-05-29 16:06:16 +02:00
Daniel Dietzler bcff1d42b0 chore: migrate more make targets (#28663) 2026-05-28 08:33:57 -04:00
Min Idzelis 1bd367bd51 refactor(web): replace per-asset viewport proximity with day-tier active indices (#28597) 2026-05-28 11:44:18 +02:00
Daniel Dietzler 725f266b81 chore: migrate more make targets to mise (#28651) 2026-05-28 11:31:02 +02:00
Daniel Dietzler d08e3de207 fix: e2e linting (#28659) 2026-05-28 11:12:26 +02:00
Timon 26714f6bfe fix(server): prevent locked assets from leaking to partners (#28652)
* fix(server): prevent locked assets from leaking to partners

* fix tests
2026-05-27 17:33:49 -04:00
Lauritz Tieste a5ce3fc927 fix: Refresh local album overview page after asset deletion (#28586)
fix: invalidate local album provider on asset delete
2026-05-28 01:20:32 +05:30
shenlong 3b23f71a3f refactor: cleanup metadata (#28485)
jason-ify metadata

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-28 01:19:25 +05:30
shenlong dec33cadd9 fix: verify form disposal before notifyListeners (#28578)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-27 19:45:48 +00:00
Daniel Dietzler 80c15a5e27 fix: workflow drag and drop (#28650) 2026-05-27 14:05:38 -05:00
Yaros 936c28a40b feat(mobile): improve downloading algorithm for sharing (#27312)
* feat(mobile): better downloading while sharing

* chore: separate download group

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-27 17:00:49 +00:00
Spencer Stingley 1a837a28ac fix: dev container properly builds @immich/plugin-sdk for import (#28620)
Co-authored-by: Spencer Stingley <accounts@blankcanvas.io>
2026-05-27 12:41:35 -04:00
84 changed files with 1354 additions and 690 deletions
@@ -8,6 +8,8 @@ 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"
+15 -24
View File
@@ -1,46 +1,46 @@
dev:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n" >&2 && exit 1
dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n" >&2 && exit 1
dev-update:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n" >&2 && exit 1
dev-scale:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n" >&2 && exit 1
dev-docs:
npm --prefix docs run start
.PHONY: e2e
e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n" >&2 && exit 1
e2e-dev:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n" >&2 && exit 1
e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n" >&2 && exit 1
e2e-down:
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n" >&2 && exit 1
prod:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
@printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n" >&2 && exit 1
prod-down:
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
@printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n" >&2 && exit 1
prod-scale:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
@printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n" >&2 && exit 1
.PHONY: open-api
open-api:
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n" >&2 && exit 1
sql:
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n" >&2 && exit 1
renovate:
@@ -52,16 +52,7 @@ renovate:
MODULES = e2e server web cli sdk docs .github
test-e2e:
docker compose -f ./e2e/docker-compose.yml build
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
@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
clean:
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
@printf "This command has been removed. Please use:\n\n mise clean # or mise //:clean from another directory\n\n" >&2 && exit 1
+16 -1
View File
@@ -1,11 +1,21 @@
[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"
@@ -30,7 +40,12 @@ run = "tsc --noEmit"
[tasks.ci-setup]
depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"]
depends = [
"//:sdk:install",
"//:sdk:build",
"//packages/cli:install",
"//packages/cli:build",
]
run = { task = ":install" }
@@ -95,6 +95,7 @@ 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 } });
.send({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
expect(response1.status).toBe(200);
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false } });
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
const response2 = await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: true } });
.send({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
expect(response2.status).toBe(200);
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true } });
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
});
it('should reject an invalid config entry', async () => {
@@ -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);
}
+4
View File
@@ -305,6 +305,8 @@
"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",
@@ -442,6 +444,8 @@
"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",
+77
View File
@@ -84,6 +84,72 @@ 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"
@@ -99,3 +165,14 @@ 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" },
]
+1
View File
@@ -23,6 +23,7 @@ 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;
+128 -3
View File
@@ -1,14 +1,25 @@
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;
@@ -18,8 +29,10 @@ 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(),
@@ -29,9 +42,11 @@ 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,
@@ -41,7 +56,9 @@ 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,
@@ -51,12 +68,14 @@ 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 &&
@@ -65,12 +84,118 @@ class AppConfig {
other.viewer == viewer &&
other.slideshow == slideshow &&
other.album == album &&
other.backup == backup);
other.backup == backup &&
other.network == network);
@override
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup);
int get hashCode =>
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network);
@override
String toString() =>
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)';
'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)),
};
}
}
@@ -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 {},
});
@@ -1,22 +0,0 @@
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)';
}
+72 -117
View File
@@ -1,142 +1,105 @@
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 MetadataDomain<T extends Object> {
appConfig<AppConfig>('config.app'),
systemConfig<SystemConfig>('config.system');
enum MetadataScope {
user, // keys with this scope are deleted on logout
system;
final String prefix;
const MetadataDomain(this.prefix);
const MetadataScope();
}
enum MetadataKey<T extends Object> {
// Theme
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),
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
themeDynamic<bool>(),
themeColorfulInterface<bool>(),
// Image
imagePreferRemote<bool>(.appConfig, 'image.preferRemote', false),
imageLoadOriginal<bool>(.appConfig, 'image.loadOriginal', false),
imagePreferRemote<bool>(),
imageLoadOriginal<bool>(),
// Viewer
viewerLoopVideo<bool>(.appConfig, 'viewer.loopVideo', true),
viewerLoadOriginalVideo<bool>(.appConfig, 'viewer.loadOriginalVideo', false),
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
viewerLoopVideo<bool>(),
viewerLoadOriginalVideo<bool>(),
viewerAutoPlayVideo<bool>(),
viewerTapToNavigate<bool>(),
// Network
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),
),
networkAutoEndpointSwitching<bool>(scope: .system),
networkPreferredWifiName<String>(scope: .system),
networkLocalEndpoint<String>(scope: .system),
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(
.systemConfig,
'network.customHeaders',
{},
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
scope: .system,
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
),
// Album
albumSortMode<AlbumSortMode>(
.appConfig,
'album.sortMode',
AlbumSortMode.mostRecent,
_EnumCodec(AlbumSortMode.values),
),
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
albumIsReverse<bool>(),
albumIsGrid<bool>(),
// Backup
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),
backupEnabled<bool>(),
backupUseCellularForVideos<bool>(),
backupUseCellularForPhotos<bool>(),
backupRequireCharging<bool>(),
backupTriggerDelay<int>(),
backupSyncAlbums<bool>(),
// Timeline
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
timelineGroupAssetsBy<GroupAssetsBy>(
.appConfig,
'timeline.groupAssetsBy',
GroupAssetsBy.day,
_EnumCodec(GroupAssetsBy.values),
),
timelineStorageIndicator<bool>(.appConfig, 'timeline.storageIndicator', true),
timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
timelineStorageIndicator<bool>(),
// Log
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)),
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
// Map
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),
mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(),
mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(),
// Cleanup
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),
cleanupKeepFavorites<bool>(),
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(),
cleanupDefaultsInitialized<bool>(),
// Slideshow
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),
);
slideshowTransition<bool>(),
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
final MetadataDomain domain;
final String name;
final T defaultValue;
final MetadataScope scope;
final _MetadataCodec<T>? _codecOverride;
const MetadataKey(this.domain, this.name, this.defaultValue, [this._codecOverride]);
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
String get key => '${domain.prefix}.$name';
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forPrimitive(defaultValue);
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw) ?? defaultValue;
static Map<String, MetadataKey<Object>> asKeyMap() => {for (var value in MetadataKey.values) value.key: value};
T decode(String raw) => _codec.decode(raw);
}
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,
@@ -146,12 +109,10 @@ sealed class _MetadataCodec<T extends Object> {
DateTime: _DateTimeCodec(),
};
static _MetadataCodec<T> forPrimitive<T extends Object>(T sample) {
final codec = _primitives[sample.runtimeType];
static _MetadataCodec<T> forType<T extends Object>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError(
'No primitive codec for ${sample.runtimeType}. Provide an explicit codec when defining the MetadataKey.',
);
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the MetadataKey.');
}
return codec as _MetadataCodec<T>;
}
@@ -166,7 +127,7 @@ final class _EnumCodec<T extends Enum> extends _MetadataCodec<T> {
String encode(T value) => value.name;
@override
T? decode(String raw) => values.firstWhereOrNull((v) => v.name == raw);
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class _DateTimeCodec extends _MetadataCodec<DateTime> {
@@ -176,7 +137,7 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
String encode(DateTime value) => value.toIso8601String();
@override
DateTime? decode(String raw) => DateTime.tryParse(raw);
DateTime decode(String raw) => DateTime.parse(raw);
}
final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> {
@@ -193,29 +154,26 @@ 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 null;
return {};
}
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 null;
return {};
}
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 null;
return {};
}
}
}
@@ -229,32 +187,29 @@ 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 null;
return [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return null;
return [];
}
final element = _elementCodec.decode(item);
if (element == null) {
return null;
}
result.add(element);
}
return result;
} on FormatException {
return null;
return [];
}
}
}
final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
final T? Function(String) _parse;
final T Function(String) _parse;
const _PrimitiveCodec._(this._parse);
@@ -262,12 +217,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.tryParse);
static const real = _PrimitiveCodec<double>._(double.tryParse);
static const boolean = _PrimitiveCodec<bool>._(bool.tryParse);
static const integer = _PrimitiveCodec<int>._(int.parse);
static const real = _PrimitiveCodec<double>._(double.parse);
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
static const string = _PrimitiveCodec<String>._(_identity);
static String? _identity(String s) => s;
static String _identity(String s) => s;
}
+1 -1
View File
@@ -56,7 +56,7 @@ class LogService {
}) async {
final instance = LogService._(logRepository, metadataRepository, shouldBuffer);
await logRepository.truncate(limit: kLogTruncateLimit);
final level = instance._metadataRepository.systemConfig.logLevel;
final level = instance._metadataRepository.appConfig.logLevel;
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
return instance;
}
@@ -1,14 +1,12 @@
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);
@@ -25,153 +23,50 @@ 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._hydrate();
await instance.refresh();
_instance = instance;
}
return _instance!;
}
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> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
if (_read(key) == value) {
if (value == _appConfig.read(key)) {
return;
}
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.key, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
_updateCache(key, value);
}
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);
}
Stream<AppConfig> watchAppConfig() => _watchDomain(.appConfig).distinct();
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));
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);
}
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),
),
);
}
Stream<AppConfig> watchConfig() => _db.select(_db.metadataEntity).watch().map((rows) {
_applyOverrides(rows);
return _appConfig;
});
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;
}
return {...overrides, metadataKey: metadataKey.decode(row.value)};
}),
);
}
}
@@ -43,7 +43,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
_searchController = TextEditingController();
_searchFocusNode = FocusNode();
_enableSyncUploadAlbum.value = ref.read(metadataProvider).appConfig.backup.syncAlbums;
_enableSyncUploadAlbum.value = ref.read(appConfigProvider).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(metadataProvider).appConfig.backup.syncAlbums;
final enableSyncUploadAlbum = ref.read(appConfigProvider).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(metadataProvider).appConfig.backup;
final previousBackup = ref.read(appConfigProvider).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).systemConfig.network.customHeaders;
final storedHeaders = ref.read(metadataProvider).appConfig.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/metadata_key.dart';
import 'package:immich_mobile/domain/models/config/app_config.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 = MetadataKey.themePrimaryColor.defaultValue.themeOfPreset;
final immichTheme = defaultConfig.theme.primaryColor.themeOfPreset;
return EasyLocalization(
supportedLocales: locales.values.toList(),
@@ -9,6 +9,7 @@ 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
@@ -39,6 +40,8 @@ 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,7 +14,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class _SharePreparingDialog extends StatelessWidget {
const _SharePreparingDialog();
final ValueNotifier<double?> progress;
const _SharePreparingDialog({required this.progress});
@override
Widget build(BuildContext context) {
@@ -22,8 +24,24 @@ class _SharePreparingDialog extends StatelessWidget {
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
Container(margin: const EdgeInsets.only(top: 12), child: const Text('share_dialog_preparing').tr()),
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)}%')),
],
);
},
),
),
],
),
);
@@ -43,32 +61,39 @@ class ShareActionButton extends ConsumerWidget {
}
final cancelCompleter = Completer<void>();
const preparingDialog = _SharePreparingDialog();
final progress = ValueNotifier<double?>(null);
final preparingDialog = _SharePreparingDialog(progress: progress);
await showDialog(
context: context,
builder: (BuildContext buildContext) {
ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then((
ActionResult result,
) {
if (cancelCompleter.isCompleted || !context.mounted) {
return;
}
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(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 a loading spinner with a "Preparing" message
// Show download progress with a "Preparing" message
return preparingDialog;
},
barrierDismissible: false,
@@ -77,6 +102,7 @@ class ShareActionButton extends ConsumerWidget {
if (!cancelCompleter.isCompleted) {
cancelCompleter.complete();
}
progress.dispose();
});
}
+3 -3
View File
@@ -130,7 +130,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders;
final headerMap = _ref.read(metadataProvider).appConfig.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).systemConfig.network.preferredWifiName;
return _ref.read(metadataProvider).appConfig.network.preferredWifiName;
}
String? getSavedLocalEndpoint() {
return _ref.read(metadataProvider).systemConfig.network.localEndpoint;
return _ref.read(metadataProvider).appConfig.network.localEndpoint;
}
/// Returns the current server endpoint (with /api) URL from the store
@@ -465,11 +465,17 @@ 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);
await _service.shareAssets(
ids,
context,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to share assets', error, stack);
@@ -1,20 +1,12 @@
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.watchAppConfig().listen((event) => ref.state = event);
final subscription = repo.watchConfig().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,31 +1,29 @@
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/repositories/asset_api.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.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(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
);
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider)));
class AssetMediaRepository {
final AssetApiRepository _assetApiRepository;
final NativeSyncApi _nativeSyncApi;
static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
const AssetMediaRepository(this._nativeSyncApi);
Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) {
@@ -107,10 +105,29 @@ class AssetMediaRepository {
);
}
// TODO: make this more efficient
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) async {
Future<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) 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) {
@@ -127,6 +144,8 @@ 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);
}
@@ -134,22 +153,50 @@ 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 tempDir = await getTemporaryDirectory();
final name = asset.name;
final tempFile = await File('${tempDir.path}/$name').create();
final res = await _assetApiRepository.downloadAsset(remoteId, edited: true);
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);
},
);
if (res.statusCode != 200) {
_log.severe("Download for $name failed", res.toLoggerString());
continue;
if (cancelCompleter != null && cancelCompleter.isCompleted) {
await _cleanupTempFiles(tempFiles);
return 0;
}
await tempFile.writeAsBytes(res.bodyBytes);
downloadedXFiles.add(XFile(tempFile.path));
tempFiles.add(tempFile);
if (statusUpdate.status == TaskStatus.complete) {
final filePath = await task.filePath();
final file = File(filePath);
tempFiles.add(file);
downloadedXFiles.add(XFile(filePath));
processedAssets++;
updateProgress();
continue;
}
_log.severe("Download for ${asset.name} failed with status ${statusUpdate.status}", statusUpdate.exception);
processedAssets++;
updateProgress();
}
}
+8 -10
View File
@@ -1,40 +1,38 @@
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(metadataProvider)),
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(appConfigProvider)),
);
class AuthRepository {
final Drift _drift;
final MetadataRepository _metadata;
final AppConfig _config;
const AuthRepository(this._drift, this._metadata);
const AuthRepository(this._drift, this._config);
Future<void> clearLocalData() async {
await SyncStreamRepository(_drift).reset();
}
bool getEndpointSwitchingFeature() {
return _metadata.systemConfig.network.autoEndpointSwitching;
return _config.network.autoEndpointSwitching;
}
String? getPreferredWifiName() {
return _metadata.systemConfig.network.preferredWifiName;
return _config.network.preferredWifiName;
}
String? getLocalEndpoint() {
return _metadata.systemConfig.network.localEndpoint;
return _config.network.localEndpoint;
}
List<AuxilaryEndpoint> getExternalEndpointList() {
return _metadata.systemConfig.network.externalEndpointList
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
.toList();
return _config.network.externalEndpointList.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList();
}
}
+12 -2
View File
@@ -269,8 +269,18 @@ class ActionService {
await _assetApiRepository.unStack(stackIds);
}
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) {
return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter);
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<List<bool>> downloadAll(List<RemoteAsset> assets) {
+3 -3
View File
@@ -177,9 +177,9 @@ class ApiService {
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final network = MetadataRepository.instance.systemConfig.network;
final network = MetadataRepository.instance.appConfig.network;
final localEndpoint = network.localEndpoint;
if (localEndpoint != null) {
if (localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
for (final url in network.externalEndpointList) {
@@ -191,7 +191,7 @@ class ApiService {
}
static Map<String, String> getRequestHeaders() {
return MetadataRepository.instance.systemConfig.network.customHeaders;
return MetadataRepository.instance.appConfig.network.customHeaders;
}
ApiClient get apiClient => _apiClient;
+18 -9
View File
@@ -1,10 +1,12 @@
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';
@@ -136,15 +138,11 @@ Future<void> _migrateTo26(Drift drift) async {
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
if (raw == null) {
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
if (mode == null) {
return;
}
final mode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == raw,
orElse: () => MetadataKey.albumSortMode.defaultValue,
);
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, MetadataKey.albumSortMode, mode);
}
@@ -208,7 +206,11 @@ class _StoreMigrator {
return;
}
final enumValue = values.elementAtOrNull(index) ?? newKey.defaultValue;
final enumValue = values.elementAtOrNull(index);
if (enumValue == null) {
return;
}
_cache[newKey] = enumValue;
_migratedStoreIds.add(legacyKey.id);
}
@@ -223,7 +225,11 @@ class _StoreMigrator {
return;
}
final enumValue = values.firstWhere((e) => e.name == name, orElse: () => newKey.defaultValue);
final enumValue = values.firstWhereOrNull((e) => e.name == name);
if (enumValue == null) {
return;
}
_cache[newKey] = enumValue;
_migratedStoreIds.add(legacyKey.id);
}
@@ -267,9 +273,12 @@ 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.key), value: Value(entry.key.encode(entry.value))),
MetadataEntityCompanion(key: Value(entry.key.name), 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(systemConfigProvider).logLevel.index);
final levelId = useState<int>(ref.read(appConfigProvider).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(metadataProvider).systemConfig.network.externalEndpointList;
final urls = ref.read(appConfigProvider).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(systemConfigProvider).network.autoEndpointSwitching);
final featureEnabled = useState(ref.read(appConfigProvider).network.autoEndpointSwitching);
useValueChanged<bool, void>(featureEnabled.value, (_, __) {
ref.read(metadataProvider).write(.networkAutoEndpointSwitching, featureEnabled.value);
});
+3
View File
@@ -513,6 +513,9 @@ 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)
+3
View File
@@ -258,6 +258,9 @@ 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';
+6
View File
@@ -562,6 +562,12 @@ 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':
+6
View File
@@ -157,6 +157,12 @@ 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
View File
@@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Release channel
class ReleaseChannel {
/// Instantiate a new enum with the provided [value].
const ReleaseChannel._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const stable = ReleaseChannel._(r'stable');
static const releaseCandidate = ReleaseChannel._(r'releaseCandidate');
/// List of all possible values in this [enum][ReleaseChannel].
static const values = <ReleaseChannel>[
stable,
releaseCandidate,
];
static ReleaseChannel? fromJson(dynamic value) => ReleaseChannelTypeTransformer().decode(value);
static List<ReleaseChannel> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseChannel>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseChannel.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseChannel] to String,
/// and [decode] dynamic data back to [ReleaseChannel].
class ReleaseChannelTypeTransformer {
factory ReleaseChannelTypeTransformer() => _instance ??= const ReleaseChannelTypeTransformer._();
const ReleaseChannelTypeTransformer._();
String encode(ReleaseChannel data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseChannel.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ReleaseChannel? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'stable': return ReleaseChannel.stable;
case r'releaseCandidate': return ReleaseChannel.releaseCandidate;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseChannelTypeTransformer] instance.
static ReleaseChannelTypeTransformer? _instance;
}
+133
View File
@@ -0,0 +1,133 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ReleaseEventV1 {
/// Returns a new [ReleaseEventV1] instance.
ReleaseEventV1({
required this.checkedAt,
required this.isAvailable,
required this.releaseVersion,
required this.serverVersion,
required this.type,
});
/// When the server last checked for a latest version. As an ISO timestamp
String checkedAt;
/// Whether a new version is available
bool isAvailable;
ServerVersionResponseDto releaseVersion;
ServerVersionResponseDto serverVersion;
ReleaseType type;
@override
bool operator ==(Object other) => identical(this, other) || other is ReleaseEventV1 &&
other.checkedAt == checkedAt &&
other.isAvailable == isAvailable &&
other.releaseVersion == releaseVersion &&
other.serverVersion == serverVersion &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(checkedAt.hashCode) +
(isAvailable.hashCode) +
(releaseVersion.hashCode) +
(serverVersion.hashCode) +
(type.hashCode);
@override
String toString() => 'ReleaseEventV1[checkedAt=$checkedAt, isAvailable=$isAvailable, releaseVersion=$releaseVersion, serverVersion=$serverVersion, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'checkedAt'] = this.checkedAt;
json[r'isAvailable'] = this.isAvailable;
json[r'releaseVersion'] = this.releaseVersion;
json[r'serverVersion'] = this.serverVersion;
json[r'type'] = this.type;
return json;
}
/// Returns a new [ReleaseEventV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ReleaseEventV1? fromJson(dynamic value) {
upgradeDto(value, "ReleaseEventV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return ReleaseEventV1(
checkedAt: mapValueOfType<String>(json, r'checkedAt')!,
isAvailable: mapValueOfType<bool>(json, r'isAvailable')!,
releaseVersion: ServerVersionResponseDto.fromJson(json[r'releaseVersion'])!,
serverVersion: ServerVersionResponseDto.fromJson(json[r'serverVersion'])!,
type: ReleaseType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<ReleaseEventV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseEventV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseEventV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ReleaseEventV1> mapFromJson(dynamic json) {
final map = <String, ReleaseEventV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ReleaseEventV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ReleaseEventV1-objects as value to a dart map
static Map<String, List<ReleaseEventV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ReleaseEventV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ReleaseEventV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'checkedAt',
'isAvailable',
'releaseVersion',
'serverVersion',
'type',
};
}
+103
View File
@@ -0,0 +1,103 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ReleaseType {
/// Instantiate a new enum with the provided [value].
const ReleaseType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const major = ReleaseType._(r'major');
static const premajor = ReleaseType._(r'premajor');
static const minor = ReleaseType._(r'minor');
static const preminor = ReleaseType._(r'preminor');
static const patch_ = ReleaseType._(r'patch');
static const prepatch = ReleaseType._(r'prepatch');
static const prerelease = ReleaseType._(r'prerelease');
static const release = ReleaseType._(r'release');
/// List of all possible values in this [enum][ReleaseType].
static const values = <ReleaseType>[
major,
premajor,
minor,
preminor,
patch_,
prepatch,
prerelease,
release,
];
static ReleaseType? fromJson(dynamic value) => ReleaseTypeTypeTransformer().decode(value);
static List<ReleaseType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseType] to String,
/// and [decode] dynamic data back to [ReleaseType].
class ReleaseTypeTypeTransformer {
factory ReleaseTypeTypeTransformer() => _instance ??= const ReleaseTypeTypeTransformer._();
const ReleaseTypeTypeTransformer._();
String encode(ReleaseType data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ReleaseType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'major': return ReleaseType.major;
case r'premajor': return ReleaseType.premajor;
case r'minor': return ReleaseType.minor;
case r'preminor': return ReleaseType.preminor;
case r'patch': return ReleaseType.patch_;
case r'prepatch': return ReleaseType.prepatch;
case r'prerelease': return ReleaseType.prerelease;
case r'release': return ReleaseType.release;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseTypeTypeTransformer] instance.
static ReleaseTypeTypeTransformer? _instance;
}
+22 -6
View File
@@ -16,47 +16,61 @@ class ServerVersionResponseDto {
required this.major,
required this.minor,
required this.patch_,
required this.prerelease,
});
/// Major version number
///
/// Minimum value: -9007199254740991
/// Minimum value: 0
/// Maximum value: 9007199254740991
int major;
/// Minor version number
///
/// Minimum value: -9007199254740991
/// Minimum value: 0
/// Maximum value: 9007199254740991
int minor;
/// Patch version number
///
/// Minimum value: -9007199254740991
/// Minimum value: 0
/// 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.patch_ == patch_ &&
other.prerelease == prerelease;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(major.hashCode) +
(minor.hashCode) +
(patch_.hashCode);
(patch_.hashCode) +
(prerelease == null ? 0 : prerelease!.hashCode);
@override
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]';
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_, prerelease=$prerelease]';
Map<String, dynamic> toJson() {
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;
}
@@ -72,6 +86,7 @@ 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;
@@ -122,6 +137,7 @@ class ServerVersionResponseDto {
'major',
'minor',
'patch',
'prerelease',
};
}
@@ -13,26 +13,32 @@ 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[enabled=$enabled]';
String toString() => 'SystemConfigNewVersionCheckDto[channel=$channel, enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'channel'] = this.channel;
json[r'enabled'] = this.enabled;
return json;
}
@@ -46,6 +52,7 @@ class SystemConfigNewVersionCheckDto {
final json = value.cast<String, dynamic>();
return SystemConfigNewVersionCheckDto(
channel: ReleaseChannel.fromJson(json[r'channel'])!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
);
}
@@ -94,6 +101,7 @@ class SystemConfigNewVersionCheckDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'channel',
'enabled',
};
}
@@ -10,9 +10,16 @@ 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;
@@ -27,7 +34,9 @@ class ImmichFormController extends ChangeNotifier {
await onSubmit?.call();
} finally {
_isLoading = false;
notifyListeners();
if (!_isDisposed) {
notifyListeners();
}
}
}
}
@@ -38,13 +47,7 @@ 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
View File
@@ -34,7 +34,7 @@ dependencies:
flutter_web_auth_2: ^5.0.2
fluttertoast: ^8.2.14
geolocator: ^14.0.2
home_widget: ^0.9.0
home_widget: ^0.8.1
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.26.0
maplibre_gl: ^0.22.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.11.0
timezone: ^0.9.4
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/system_config.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/services/log.service.dart';
@@ -39,7 +39,7 @@ void main() {
registerFallbackValue(LogLevel.info);
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine));
when(() => mockMetadataRepository.appConfig).thenReturn(const AppConfig(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.systemConfig).called(1);
verify(() => mockMetadataRepository.appConfig).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.refresh();
await MetadataRepository.instance.refresh();
});
group('defaults', () {
@@ -31,8 +31,8 @@ void main() {
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
test('systemConfig returns key defaults when DB is empty', () {
expect(sut.systemConfig.logLevel, LogLevel.info);
test('appConfig returns key defaults when DB is empty', () {
expect(sut.appConfig.logLevel, LogLevel.info);
});
});
@@ -46,16 +46,14 @@ void main() {
await sut.write(.themeMode, ThemeMode.light);
await sut.write(.logLevel, LogLevel.severe);
expect(sut.appConfig.theme.mode, ThemeMode.light);
expect(sut.systemConfig.logLevel, LogLevel.severe);
expect(sut.appConfig.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.delete(.themeMode);
await sut.write(.themeMode, ThemeMode.system);
expect(sut.appConfig.theme.mode, ThemeMode.system);
final rows = await ctx.db.select(ctx.db.metadataEntity).get();
@@ -63,13 +61,15 @@ void main() {
});
});
group('refresh', () {
group('delete', () {});
group('sync', () {
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.key,
key: MetadataKey.themeMode.name,
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.refresh();
await MetadataRepository.instance.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.refresh();
await MetadataRepository.instance.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
@@ -103,32 +103,20 @@ void main() {
),
);
await MetadataRepository.refresh();
await MetadataRepository.instance.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
});
group('watch', () {
test('watchAppConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
final expectation = expectLater(sut.watchConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
await sut.write(MetadataKey.themeMode, ThemeMode.dark);
await expectation;
});
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));
test('watchConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watchConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
await sut.write(MetadataKey.logLevel, LogLevel.warning);
await expectation;
});
@@ -1,28 +1,16 @@
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', () {
test('every key round-trips its default value losslessly', () {
for (final key in MetadataKey.values) {
final encoded = key.encode(key.defaultValue);
for (final key in MetadataKey.values) {
test('verify codec for $key', () {
final defaultValue = defaultConfig.read(key);
final encoded = key.encode(defaultValue);
final decoded = key.decode(encoded);
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}',
);
}
});
expect(decoded, defaultValue, reason: 'round-trip failed for ${key.name}');
});
}
});
}
+74 -4
View File
@@ -20800,6 +20800,58 @@
],
"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": {
@@ -21469,26 +21521,40 @@
"major": {
"description": "Major version number",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"minimum": 0,
"type": "integer"
},
"minor": {
"description": "Minor version number",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"minimum": 0,
"type": "integer"
},
"patch": {
"description": "Patch version number",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"minimum": 0,
"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"
"patch",
"prerelease"
],
"type": "object"
},
@@ -24509,12 +24575,16 @@
},
"SystemConfigNewVersionCheckDto": {
"properties": {
"channel": {
"$ref": "#/components/schemas/ReleaseChannel"
},
"enabled": {
"description": "Enabled",
"type": "boolean"
}
},
"required": [
"channel",
"enabled"
],
"type": "object"
+27
View File
@@ -2074,6 +2074,8 @@ export type ServerVersionResponseDto = {
minor: number;
/** Patch version number */
patch: number;
/** Pre-release version number */
prerelease: number | null;
};
export type VersionCheckStateResponseDto = {
/** Last check timestamp */
@@ -2421,6 +2423,7 @@ export type SystemConfigMetadataDto = {
faces: SystemConfigFacesDto;
};
export type SystemConfigNewVersionCheckDto = {
channel: ReleaseChannel;
/** Enabled */
enabled: boolean;
};
@@ -2766,6 +2769,16 @@ 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 */
@@ -7305,6 +7318,10 @@ 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"
@@ -7313,6 +7330,16 @@ 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",
+27 -20
View File
@@ -571,8 +571,8 @@ importers:
specifier: ^1.6.3
version: 1.6.4
semver:
specifier: ^7.6.2
version: 7.8.0
specifier: ^7.8.1
version: 7.8.1
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -11243,6 +11243,11 @@ 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'}
@@ -16300,7 +16305,7 @@ snapshots:
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.8.0
semver: 7.8.1
tar: 6.2.1
transitivePeerDependencies:
- encoding
@@ -17757,7 +17762,7 @@ snapshots:
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/runtime': 7.29.2
'@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
@@ -18461,7 +18466,7 @@ snapshots:
'@typescript-eslint/visitor-keys': 8.59.4
debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.0
semver: 7.8.1
tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
@@ -19561,7 +19566,7 @@ snapshots:
dot-prop: 10.1.0
env-paths: 3.0.0
json-schema-typed: 8.0.2
semver: 7.8.0
semver: 7.8.1
uint8array-extras: 1.5.0
config-chain@1.1.13:
@@ -19733,7 +19738,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.0
semver: 7.8.1
optionalDependencies:
webpack: 5.107.0(postcss@8.5.15)
@@ -20601,7 +20606,7 @@ snapshots:
find-up: 5.0.0
globals: 15.15.0
lodash.memoize: 4.1.2
semver: 7.8.0
semver: 7.8.1
eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(prettier@3.8.3):
dependencies:
@@ -20624,7 +20629,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.0
semver: 7.8.1
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)
@@ -21102,7 +21107,7 @@ snapshots:
minimatch: 3.1.5
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.8.0
semver: 7.8.1
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)
@@ -21538,7 +21543,7 @@ snapshots:
history@4.10.1:
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.29.7
loose-envify: 1.4.0
resolve-pathname: 3.0.0
tiny-invariant: 1.3.3
@@ -22126,7 +22131,7 @@ snapshots:
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.8.0
semver: 7.8.1
just-compare@2.3.0: {}
@@ -22412,7 +22417,7 @@ snapshots:
make-dir@4.0.0:
dependencies:
semver: 7.8.0
semver: 7.8.1
maplibre-gl@5.24.0:
dependencies:
@@ -23247,7 +23252,7 @@ snapshots:
node-abi@3.92.0:
dependencies:
semver: 7.8.0
semver: 7.8.1
optional: true
node-abort-controller@3.1.1: {}
@@ -23288,7 +23293,7 @@ snapshots:
graceful-fs: 4.2.11
nopt: 9.0.0
proc-log: 6.1.0
semver: 7.8.0
semver: 7.8.1
tar: 7.5.15
tinyglobby: 0.2.16
undici: 6.25.0
@@ -23526,7 +23531,7 @@ snapshots:
got: 12.6.1
registry-auth-token: 5.1.1
registry-url: 6.0.1
semver: 7.8.0
semver: 7.8.1
package-manager-detector@1.6.0: {}
@@ -23914,7 +23919,7 @@ snapshots:
cosmiconfig: 8.3.6(typescript@6.0.3)
jiti: 1.21.7
postcss: 8.5.15
semver: 7.8.0
semver: 7.8.1
webpack: 5.107.0(postcss@8.5.15)
transitivePeerDependencies:
- typescript
@@ -24969,12 +24974,14 @@ snapshots:
semver-diff@4.0.0:
dependencies:
semver: 7.8.0
semver: 7.8.1
semver@6.3.1: {}
semver@7.8.0: {}
semver@7.8.1: {}
send@0.19.2:
dependencies:
debug: 2.6.9
@@ -25509,7 +25516,7 @@ snapshots:
postcss: 8.5.15
postcss-scss: 4.0.9(postcss@8.5.15)
postcss-selector-parser: 7.1.1
semver: 7.8.0
semver: 7.8.1
optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -26217,7 +26224,7 @@ snapshots:
is-yarn-global: 0.4.1
latest-version: 7.0.0
pupa: 3.3.0
semver: 7.8.0
semver: 7.8.1
semver-diff: 4.0.0
xdg-basedir: 5.1.0
+1 -1
View File
@@ -106,7 +106,7 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"semver": "^7.6.2",
"semver": "^7.8.1",
"sharp": "^0.34.5",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
+3
View File
@@ -1,4 +1,5 @@
import { CronExpression } from '@nestjs/schedule';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import {
AudioCodec,
Colorspace,
@@ -135,6 +136,7 @@ export type SystemConfig = {
};
newVersionCheck: {
enabled: boolean;
channel: ReleaseChannel;
};
nightlyTasks: {
startTime: string;
@@ -344,6 +346,7 @@ export const defaults = Object.freeze<SystemConfig>({
},
newVersionCheck: {
enabled: true,
channel: ReleaseChannel.Stable,
},
nightlyTasks: {
startTime: '00:00',
+10
View File
@@ -265,3 +265,13 @@ 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);
};
};
+39 -11
View File
@@ -1,5 +1,6 @@
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';
@@ -58,9 +59,15 @@ const ServerStorageResponseSchema = z
const ServerVersionResponseSchema = z
.object({
major: z.int().describe('Major version number'),
minor: z.int().describe('Minor version number'),
patch: z.int().describe('Patch version number'),
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'),
})
.meta({ id: 'ServerVersionResponseDto' });
@@ -140,6 +147,27 @@ 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) {}
@@ -147,7 +175,12 @@ 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 };
return {
major: value.major,
minor: value.minor,
patch: value.patch,
prerelease: (value.prerelease[1] as number) ?? null,
};
}
}
@@ -158,10 +191,5 @@ export class ServerMediaTypesResponseDto extends createZodDto(ServerMediaTypesRe
export class ServerConfigDto extends createZodDto(ServerConfigSchema) {}
export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {}
export interface ReleaseNotification {
isAvailable: boolean;
/** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}
@ExtraModel()
export class ReleaseEventV1 extends createZodDto(ReleaseEventV1Schema) {}
+1 -10
View File
@@ -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,15 +17,6 @@ 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'),
+8 -1
View File
@@ -151,8 +151,15 @@ 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') })
.object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema })
.meta({ id: 'SystemConfigNewVersionCheckDto' });
const SystemConfigNightlyTasksSchema = z
@@ -4,6 +4,7 @@ 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';
@@ -64,10 +65,12 @@ export class ServerInfoRepository {
this.logger.setContext(ServerInfoRepository.name);
}
async getLatestRelease(): Promise<VersionResponse> {
async getLatestRelease(channel: ReleaseChannel): Promise<VersionResponse> {
try {
const { versionCheck } = this.configRepository.getEnv();
const response = await fetch(versionCheck.url);
const url = new URL(versionCheck.url);
url.searchParams.append('channel', channel);
const response = await fetch(url);
if (!response.ok) {
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 { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ReleaseEventV1, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV2 } from 'src/dtos/sync.dto';
import { 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: [ReleaseNotification];
on_new_release: [ReleaseEventV1];
on_notification: [NotificationDto];
on_session_delete: [string];
+9 -5
View File
@@ -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);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
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);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
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);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
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);
const userIds = this.getUserIdsToSearch(auth, dto.visibility);
let embedding;
if (dto.query) {
const key = machineLearning.clip.modelName + dto.query + dto.language;
@@ -202,7 +202,11 @@ export class SearchService extends BaseService {
}
}
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> {
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];
}
const partnerIds = await getMyPartnerIds({
userId: auth.user.id,
repository: this.partnerRepository,
@@ -1,5 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import {
AudioCodec,
Colorspace,
@@ -184,6 +185,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
},
newVersionCheck: {
enabled: true,
channel: ReleaseChannel.Stable,
},
trash: {
enabled: true,
@@ -204,5 +204,16 @@ 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);
});
});
});
+3 -2
View File
@@ -71,13 +71,14 @@ 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 (requestedArchived || requestedFavorite || requestedTrash) {
if (requestedLocked || requestedArchived || requestedFavorite || requestedTrash) {
throw new BadRequestException(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
}
}
+40 -12
View File
@@ -2,6 +2,7 @@ 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';
@@ -22,6 +23,17 @@ 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();
});
@@ -66,9 +78,10 @@ describe(VersionService.name, () => {
describe('getVersion', () => {
it('should respond the server version', () => {
expect(sut.getVersion()).toEqual({
major: serverVersion.major,
minor: serverVersion.minor,
patch: serverVersion.patch,
major: 3,
minor: 0,
patch: 0,
prerelease: null,
});
});
});
@@ -143,24 +156,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 } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
oldConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
});
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 } },
newConfig: { ...defaults, newVersionCheck: { enabled: false } },
oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } },
});
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 } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
});
expect(mocks.job.queue).not.toHaveBeenCalled();
});
@@ -169,21 +182,36 @@ 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', expect.any(SemVer));
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
major: 3,
minor: 0,
patch: 0,
prerelease: null,
});
expect(mocks.websocket.clientSend).toHaveBeenCalledTimes(1);
});
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', expect.any(SemVer));
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
major: 3,
minor: 0,
patch: 0,
prerelease: null,
});
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
});
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', expect.any(SemVer));
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
major: 3,
minor: 0,
patch: 0,
prerelease: null,
});
expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
});
});
+27 -8
View File
@@ -3,19 +3,27 @@ import { DateTime } from 'luxon';
import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { 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 = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
const asNotification = (
channel: ReleaseChannel,
{ checkedAt, releaseVersion }: VersionCheckMetadata,
): ReleaseEventV1 => {
return {
isAvailable: semver.gt(releaseVersion, serverVersion),
// can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483
isAvailable: semver.intersects(`>${serverVersion}`, releaseVersion.toString(), {
includePrerelease: channel === ReleaseChannel.ReleaseCandidate,
}),
checkedAt,
serverVersion: ServerVersionResponseDto.fromSemVer(serverVersion),
releaseVersion: ServerVersionResponseDto.fromSemVer(new SemVer(releaseVersion)),
type: semver.diff(serverVersion, releaseVersion) as ReleaseType,
};
};
@@ -98,14 +106,21 @@ export class VersionService extends BaseService {
}
}
const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease();
const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease(
newVersionCheck.channel,
);
const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion };
await this.systemMetadataRepository.set(SystemMetadataKey.VersionCheckState, metadata);
if (semver.gt(releaseVersion, serverVersion)) {
// can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483
if (
semver.intersects(`>${serverVersion}`, releaseVersion.toString(), {
includePrerelease: newVersionCheck.channel === ReleaseChannel.ReleaseCandidate,
})
) {
this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`);
this.websocketRepository.clientBroadcast('on_new_release', asNotification(metadata));
this.websocketRepository.clientBroadcast('on_new_release', asNotification(newVersionCheck.channel, metadata));
}
} catch (error: Error | any) {
this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`);
@@ -117,7 +132,11 @@ export class VersionService extends BaseService {
@OnEvent({ name: 'WebsocketConnect' })
async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) {
this.websocketRepository.clientSend('on_server_version', userId, serverVersion);
this.websocketRepository.clientSend(
'on_server_version',
userId,
ServerVersionResponseDto.fromSemVer(serverVersion),
);
const { newVersionCheck } = await this.getConfig({ withCache: true });
if (!newVersionCheck.enabled) {
@@ -126,7 +145,7 @@ export class VersionService extends BaseService {
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState);
if (metadata) {
this.websocketRepository.clientSend('on_new_release', userId, asNotification(metadata));
this.websocketRepository.clientSend('on_new_release', userId, asNotification(newVersionCheck.channel, metadata));
}
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ import picomatch from 'picomatch';
import parse from 'picomatch/lib/parse';
import { SystemConfig } from 'src/config';
import { CLIP_MODEL_INFO, endpointTags, serverVersion } from 'src/constants';
import { extraSyncModels } from 'src/dtos/sync.dto';
import { extraModels } from 'src/decorators';
import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
import { 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: extraSyncModels,
extraModels,
ignoreGlobalPrefix: true,
};
+7
View File
@@ -41,6 +41,13 @@ 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 assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked 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 assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked 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 assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked 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 assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked 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 assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked 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,11 +35,9 @@
userInteraction.versions = versions;
});
let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich');
let version = $derived(
$serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
);
let version = $derived($serverVersion ? semverToName($serverVersion) : null);
const getReleaseInfo = (release?: ReleaseEvent) => {
const getReleaseInfo = (release?: ReleaseEventV1) => {
if (!release || !release?.isAvailable || !authManager.user.isAdmin) {
return;
}
@@ -30,14 +30,11 @@
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.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)}
{#each viewerAssets as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
+1 -1
View File
@@ -100,7 +100,7 @@
<AssetLayout
{manager}
viewerAssets={timelineDay.viewerAssets}
viewerAssets={timelineDay.activeViewerAssets}
height={timelineDay.height}
width={timelineDay.width}
{customThumbnailLayout}
+2 -2
View File
@@ -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: [ReleaseEvent];
ReleaseEvent: [ReleaseEventV1];
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<ReleaseEvent | undefined>();
value = $state<ReleaseEventV1 | undefined>();
constructor() {
eventManager.on({
@@ -53,17 +53,3 @@ 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,12 +1,31 @@
import { AssetOrder, AssetOrderBy } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import type { CommonLayoutOptions, CommonPosition } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { 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;
@@ -18,12 +37,15 @@ 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;
@@ -149,18 +171,32 @@ 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,6 +214,11 @@ 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(timelineDay, timelineAsset);
const viewerAsset = new ViewerAsset(timelineAsset);
timelineDay.viewerAssets.push(viewerAsset);
addContext.changedTimelineDays.add(timelineDay);
}
@@ -1,36 +1,12 @@
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(group: TimelineDay, asset: TimelineAsset) {
this.#group = group;
constructor(asset: TimelineAsset) {
this.asset = asset;
}
}
+2 -2
View File
@@ -3,6 +3,7 @@ import {
type AssetResponseDto,
type MaintenanceStatusResponseDto,
type NotificationDto,
type ReleaseEventV1,
type ServerVersionResponseDto,
type SyncAssetEditV1,
type SyncAssetV2,
@@ -15,7 +16,6 @@ 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: ReleaseEvent) => void;
on_new_release: (event: ReleaseEventV1) => void;
on_session_delete: (sessionId: string) => void;
on_notification: (notification: NotificationDto) => void;
+1 -9
View File
@@ -1,4 +1,4 @@
import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk';
import type { QueueResponseDto } from '@immich/sdk';
import type { ActionItem } from '@immich/ui';
import type { DateTime } from 'luxon';
import type { SvelteSet } from 'svelte/reactivity';
@@ -7,14 +7,6 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
export type LatLng = { lng: number; lat: number };
export 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 -23
View File
@@ -1,5 +1,5 @@
import { AssetTypeEnum } from '@immich/sdk';
import { getAssetUrl, getReleaseType } from '$lib/utils';
import { getAssetUrl } from '$lib/utils';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
@@ -161,26 +161,4 @@ 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');
});
});
});
+2 -20
View File
@@ -411,26 +411,8 @@ export function createDateFormatter(localeCode: string | undefined): DateFormatt
};
}
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 semverToName = ({ major, minor, patch, prerelease }: ServerVersionResponseDto) =>
`v${major}.${minor}.${patch}${prerelease ? `-rc.${prerelease}` : ''}`;
export const withoutIcons = (actions: ActionItem[]): ActionItem[] =>
actions.map((action) => ({ ...action, icon: undefined }));
@@ -10,7 +10,7 @@
mdiPlus,
mdiTrashCanOutline,
} from '@mdi/js';
import { flushSync, mount } from 'svelte';
import { 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 isDragging = $state(false);
let dragImage = $state<Element>();
let isDropTarget = $state(false);
let hoverDrag = $state(false);
@@ -68,15 +68,11 @@
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: dragElement,
target: document.body,
props: {
description: method?.description,
isFilter: method?.uiHints?.includes('filter') ?? false,
@@ -84,9 +80,9 @@
stepNumber: index + 1,
},
});
flushSync();
event.dataTransfer.setDragImage(dragElement, 16, 22);
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
event.dataTransfer.setDragImage(dragImage, 16, 22);
};
const handleDrop = (index: number, event: DragEvent) => {
@@ -109,7 +105,8 @@
};
const handleDragEnd = () => {
isDragging = false;
dragImage?.remove();
dragImage = undefined;
isDropTarget = false;
};
</script>
@@ -132,8 +129,8 @@
<div
class="w-full transition-all"
class:opacity-40={isDragging}
class:scale-[0.99]={isDragging}
class:opacity-40={!!dragImage}
class:scale-[0.99]={!!dragImage}
ondragover={handleDragOver}
ondragleave={() => (isDropTarget = false)}
ondrop={(event) => handleDrop(index, event)}
@@ -13,6 +13,7 @@
</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"
>
+9 -5
View File
@@ -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 type { ReleaseEvent } from '$lib/types';
import { getReleaseType, semverToName } from '$lib/utils';
import { semverToName } from '$lib/utils';
import { ReleaseType, type ReleaseEventV1 } from '@immich/sdk';
import { modalManager } from '@immich/ui';
let modal = $state<{
@@ -11,16 +11,20 @@
close: () => Promise<void>;
}>();
const onReleaseEvent = async (release: ReleaseEvent) => {
const onReleaseEvent = async (release: ReleaseEventV1) => {
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 (type === 'none' || type === 'patch' || localStorage.getItem('appVersion') === releaseVersion) {
if (
!release.type ||
release.type === ReleaseType.Patch ||
release.type === ReleaseType.Prepatch ||
localStorage.getItem('appVersion') === releaseVersion
) {
return;
}
@@ -5,8 +5,11 @@
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>
@@ -20,6 +23,20 @@
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>