Compare commits

..

13 Commits

Author SHA1 Message Date
Alex Tran 9b31ca6e75 feat: workflow step card with drag-n-drop ghost target 2026-05-29 09:40:18 -05:00
pneuly a838167f11 fix(ml): pass model_root_dir to OcrOptions for RapidOCR compatibility (#28610)
* fix(ml): pass model_root_dir to OcrOptions for RapidOCR compatibility

Fix a TypeError (Path(None)) when the OCR model is invoked, caused by an upstream change in RapidOCR v3.8.1 (RapidAI/RapidOCR@8ea9626).
RapidOCR now internally calls `Path(cfg.get("model_root_dir"))`. Since `model_root_dir` was missing from `OcrOptions`, it evaluated to `None` and triggered a `TypeError: argument should be a str or an os.PathLike`.
This fix adds the missing `model_root_dir` argument to prevent the error.
Ref: #28331

* fix(ml-test): update OCR tests for RapidOCR schema change

* chore(ml-test): remove unused `cache_dir` parameter from `TextRecognizer`

* Revert "chore(ml-test): remove unused `cache_dir` parameter from `TextRecognizer`"

This reverts commit 007ad7b3f2.

* fix(ml): use self.cache_dir for model_root_dir in OcrOptions
2026-05-28 22:54:04 -04:00
Mert b189fc571c fix: make ts a peer dependency for swagger (#28677)
make ts a peer dependency
2026-05-28 22:04:25 +00:00
Jason Rasmussen 96923f6115 refactor: plugin sdk types (#28674) 2026-05-28 22:04:15 +00:00
shenlong 0d6cce4a5b fix: api repositories using stale endpoint (#28667)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-28 16:44:11 -05:00
shenlong 55947cb227 refactor: drop metadata scope (#28668)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-28 16:42:59 -05:00
Jason Rasmussen 8783180cf3 refactor: plugin manifest (#28673) 2026-05-28 17:23:49 -04:00
Jason Rasmussen 134c0d4dfb feat: search by album name and id (#28672) 2026-05-28 17:01:47 -04:00
Alex aecf8ec88b fix: timeline scroll flicker (#28653)
* test: fix scroll flicker

* lint
2026-05-28 08:20:54 -05: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
67 changed files with 1029 additions and 495 deletions
+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" }
@@ -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);
}
+1
View File
@@ -2233,6 +2233,7 @@
"slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings",
"smart_album": "Smart album",
"sort_albums_by": "Sort albums by...",
"sort_created": "Date created",
"sort_items": "Number of items",
@@ -64,6 +64,7 @@ class TextRecognizer(InferenceModel):
rec_batch_num=max_batch_size if max_batch_size else 6,
rec_img_shape=(3, 48, 320),
lang_type=self.language,
model_root_dir=self.cache_dir,
)
)
return session
+18 -3
View File
@@ -1028,7 +1028,12 @@ class TestOcr:
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
OcrOptions(
session=ort_session.return_value,
rec_batch_num=6,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
)
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
@@ -1041,7 +1046,12 @@ class TestOcr:
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320))
OcrOptions(
session=ort_session.return_value,
rec_batch_num=4,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
)
def test_ignore_other_custom_max_batch_size(
@@ -1056,7 +1066,12 @@ class TestOcr:
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
OcrOptions(
session=ort_session.return_value,
rec_batch_num=6,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
)
+79 -2
View File
@@ -54,8 +54,8 @@ lockfile = true
[tasks.plugins]
run = [
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build",
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core build",
]
[tasks.open-api-typescript]
@@ -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 --build --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" },
]
@@ -143,13 +143,8 @@ class AppConfig {
})
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;
}
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> overrides) =>
overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value));
AppConfig write<T extends Object>(MetadataKey<T> key, T value) {
return switch (key) {
+7 -18
View File
@@ -7,13 +7,6 @@ import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum MetadataScope {
user, // keys with this scope are deleted on logout
system;
const MetadataScope();
}
enum MetadataKey<T extends Object> {
// Theme
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
@@ -32,14 +25,11 @@ enum MetadataKey<T extends Object> {
viewerTapToNavigate<bool>(),
// Network
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>>(
scope: .system,
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
),
networkAutoEndpointSwitching<bool>(),
networkPreferredWifiName<String>(),
networkLocalEndpoint<String>(),
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
// Album
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
@@ -60,7 +50,7 @@ enum MetadataKey<T extends Object> {
timelineStorageIndicator<bool>(),
// Log
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
// Map
mapShowFavoriteOnly<bool>(),
@@ -83,10 +73,9 @@ enum MetadataKey<T extends Object> {
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
final MetadataScope scope;
final _MetadataCodec<T>? _codecOverride;
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
const MetadataKey({_MetadataCodec<T>? codec}) : _codecOverride = codec;
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
@@ -234,13 +234,24 @@ class RemoteAlbumService {
final pendingAdds = <Future<void>>[];
final localById = {for (final a in localAssets) a.id: a};
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = userCallbacks;
final wrappedCallbacks = UploadCallbacks(
onProgress: onProgress,
onICloudProgress: onICloudProgress,
onError: onError,
onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback(
'Upload progress callback failed for $localId',
() => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes),
),
onICloudProgress: (localId, progress) => _runUploadCallback(
'iCloud progress callback failed for $localId',
() => userCallbacks.onICloudProgress?.call(localId, progress),
),
onError: (localId, errorMessage) => _runUploadCallback(
'Upload error callback failed for $localId',
() => userCallbacks.onError?.call(localId, errorMessage),
),
onSuccess: (localId, remoteId) {
onSuccess?.call(localId, remoteId);
_runUploadCallback(
'Upload success callback failed for $localId',
() => userCallbacks.onSuccess?.call(localId, remoteId),
);
final source = localById[localId];
if (source == null) {
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
@@ -248,22 +259,29 @@ class RemoteAlbumService {
}
pendingAdds.add(
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
.then<void>((added) => addedCount += added)
.onError(
(error, stack) =>
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack),
),
.then<void>((added) {
addedCount += added;
})
.catchError((Object error, StackTrace stack) {
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack);
}),
);
},
);
await _uploadService.uploadManual(localAssets, cancelToken: null, callbacks: wrappedCallbacks);
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
await Future.wait(pendingAdds);
return addedCount;
}
// TODO: this is a poorly designed flow; adding a "stub" just to satisfy FK constraints is hacky,
// it goes out of its way to insert one at a time, and it swallows errors that should be surfaced to the user.
void _runUploadCallback(String message, void Function() callback) {
try {
callback();
} catch (error, stack) {
_logger.warning(message, error, stack);
}
}
/// Links a freshly-uploaded asset to an album, ensuring the local DB
/// reflects the change without waiting for the next sync. We call the API
/// (server is the source of truth), then upsert a placeholder
@@ -34,21 +34,33 @@ class MetadataRepository extends DriftDatabaseRepository {
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
Future<void> clear(Iterable<MetadataKey> keys) async {
if (keys.isEmpty) {
return;
}
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.metadataEntity)..where((row) => row.key.isIn(names))).go();
for (final key in keys) {
_appConfig = _appConfig.write(key, defaultConfig.read(key));
}
}
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
if (value == _appConfig.read(key)) {
return;
}
if (value == defaultConfig.read(key)) {
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
} else {
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
return clear([key]);
}
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
_appConfig = _appConfig.write(key, value);
}
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -6,54 +5,66 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
typedef OnProgress = void Function(String id, double progress);
class StorageRepository {
static final log = Logger('StorageRepository');
final log = Logger('StorageRepository');
const StorageRepository();
StorageRepository();
Future<File?> getAssetFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
return _getFileForAsset(assetId, isMotion: false, onProgress: onProgress, cancelToken: cancelToken);
}
Future<File?> getMotionFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
return _getFileForAsset(assetId, isMotion: true, onProgress: onProgress, cancelToken: cancelToken);
}
Future<File?> _getFileForAsset(
String assetId, {
bool isMotion = false,
OnProgress? onProgress,
Completer<void>? cancelToken,
}) async {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
PMProgressHandler? progressHandler;
StreamSubscription<PMProgressState>? progressSubscription;
PMCancelToken? pmCancelToken;
if (cancelToken != null) {
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) => onProgress?.call(assetId, event.progress));
pmCancelToken = PMCancelToken();
unawaited(cancelToken.future.then((_) => pmCancelToken!.cancelRequest()));
}
Future<File?> getFileForAsset(String assetId) async {
File? file;
final log = Logger('StorageRepository');
try {
return await entity.loadFile(withSubtype: isMotion, progressHandler: progressHandler, cancelToken: pmCancelToken);
final entity = await AssetEntity.fromId(assetId);
file = await entity?.originFile;
if (file == null) {
log.warning("Cannot get file for asset $assetId");
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("File for asset $assetId does not exist");
return null;
}
} catch (error, stackTrace) {
log.warning("Error loading file for asset $assetId", error, stackTrace);
return null;
} finally {
unawaited(progressSubscription?.cancel());
log.warning("Error getting file for asset $assetId", error, stackTrace);
}
return file;
}
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
File? file;
final log = Logger('StorageRepository');
try {
final entity = await AssetEntity.fromId(asset.id);
file = await entity?.originFileWithSubtype;
if (file == null) {
log.warning(
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("Motion file for asset ${asset.id} does not exist");
return null;
}
} catch (error, stackTrace) {
log.warning(
"Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
error,
stackTrace,
);
}
return file;
}
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
final log = Logger('StorageRepository');
AssetEntity? entity;
try {
@@ -88,7 +99,39 @@ class StorageRepository {
}
}
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(withSubtype: true, progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<void> clearCache() async {
final log = Logger('StorageRepository');
try {
await PhotoManager.clearFileCache();
} catch (error, stackTrace) {
@@ -6,13 +6,13 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
@@ -108,7 +108,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await ref.read(storageRepositoryProvider).getAssetFile(id);
final file = await StorageRepository().getFileForAsset(id);
if (!mounted) {
return null;
}
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -274,7 +273,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess,
onError: _handleForegroundBackupError,
onICloudProgress: CurrentPlatform.isIOS ? _handleICloudProgress : null,
onICloudProgress: _handleICloudProgress,
),
);
}
@@ -283,7 +282,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
_cancelToken?.complete();
_cancelToken = null;
_uploadSpeedManager.clear();
state = state.copyWith(uploadItems: const {}, iCloudDownloadProgress: const {});
state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {});
}
void _handleICloudProgress(String localAssetId, double progress) {
@@ -1,4 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
final storageRepositoryProvider = Provider<StorageRepository>((ref) => const StorageRepository());
final storageRepositoryProvider = Provider<StorageRepository>((ref) => StorageRepository());
+91 -43
View File
@@ -10,6 +10,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:logging/logging.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
@@ -19,14 +20,21 @@ class UploadRepository {
void Function(TaskProgressUpdate)? onTaskProgress;
UploadRepository() {
final downloader = FileDownloader();
for (final group in const [kBackupGroup, kBackupLivePhotoGroup, kManualUploadGroup]) {
downloader.registerCallbacks(
group: group,
taskStatusCallback: onUploadStatus,
taskProgressCallback: onTaskProgress,
);
}
FileDownloader().registerCallbacks(
group: kBackupGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kBackupLivePhotoGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
}
Future<void> enqueueBackground(UploadTask task) {
@@ -58,6 +66,28 @@ class UploadRepository {
return FileDownloader().start();
}
Future<void> getUploadInfo() async {
final [enqueuedTasks, runningTasks, canceledTasks, waitingTasks, pausedTasks] = await Future.wait([
FileDownloader().database.allRecordsWithStatus(TaskStatus.enqueued, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.running, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.canceled, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.waitingToRetry, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup),
]);
dPrint(
() =>
"""
Upload Info:
Enqueued: ${enqueuedTasks.length}
Running: ${runningTasks.length}
Canceled: ${canceledTasks.length}
Waiting: ${waitingTasks.length}
Paused: ${pausedTasks.length}
""",
);
}
Future<UploadResult> uploadFile({
required File file,
required String originalFileName,
@@ -81,30 +111,41 @@ class UploadRepository {
baseRequest.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData);
final StreamedResponse(:statusCode, :stream) = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await stream.bytesToString();
final response = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await response.stream.bytesToString();
return switch ((statusCode, _tryJsonDecode(responseBodyString))) {
(200 || 201, {'id': String id}) => UploadSuccess(remoteAssetId: id),
(413, _) => const UploadError(statusCode: 413, message: 'File is too large to upload'),
(_, {'message': String message}) => UploadError(statusCode: statusCode, message: message),
_ => UploadError(statusCode: statusCode, message: 'Upload failed with status $statusCode'),
};
if (![200, 201].contains(response.statusCode)) {
String? errorMessage;
if (response.statusCode == 413) {
errorMessage = 'Error(413) File is too large to upload';
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final error = jsonDecode(responseBodyString);
errorMessage = error['message'] ?? error['error'];
} catch (_) {
errorMessage = responseBodyString.isNotEmpty
? responseBodyString
: 'Upload failed with status ${response.statusCode}';
}
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final responseBody = jsonDecode(responseBodyString);
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
} catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response');
}
} on RequestAbortedException {
logger.warning("Upload $logContext was cancelled");
return const UploadCancelled();
return UploadResult.cancelled();
} catch (error, stackTrace) {
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
return UploadError(message: error.toString());
}
}
@pragma('vm:prefer-inline')
Map? _tryJsonDecode(String s) {
try {
return (jsonDecode(s) as Map);
} catch (_) {
return null;
return UploadResult.error(errorMessage: error.toString());
}
}
}
@@ -139,23 +180,30 @@ class ProgressMultipartRequest extends MultipartRequest with Abortable {
}
}
sealed class UploadResult {
const UploadResult();
}
final class UploadSuccess extends UploadResult {
final String remoteAssetId;
const UploadSuccess({required this.remoteAssetId});
}
final class UploadError extends UploadResult {
final String message;
class UploadResult {
final bool isSuccess;
final bool isCancelled;
final String? remoteAssetId;
final String? errorMessage;
final int? statusCode;
const UploadError({required this.message, this.statusCode});
}
const UploadResult({
required this.isSuccess,
required this.isCancelled,
this.remoteAssetId,
this.errorMessage,
this.statusCode,
});
final class UploadCancelled extends UploadResult {
const UploadCancelled();
factory UploadResult.success({required String remoteAssetId}) {
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
}
factory UploadResult.error({String? errorMessage, int? statusCode}) {
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode);
}
factory UploadResult.cancelled() {
return const UploadResult(isSuccess: false, isCancelled: true);
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class ApiService {
late ApiClient _apiClient;
final ApiClient _apiClient = ApiClient(basePath: '');
late UsersApi usersApi;
late AuthenticationApi authenticationApi;
@@ -54,7 +54,7 @@ class ApiService {
}
setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint);
_apiClient.basePath = endpoint;
_apiClient.client = NetworkRepository.client;
usersApi = UsersApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient);
+7 -1
View File
@@ -110,7 +110,7 @@ class AuthService {
/// - Authentication repository data
/// - Current user information
/// - Access token
/// - Asset ETag
/// - Server-specific endpoint configuration
///
/// All deletions are executed in parallel using [Future.wait].
Future<void> clearLocalData() async {
@@ -120,6 +120,12 @@ class AuthService {
_authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
MetadataRepository.instance.clear(const [
.networkAutoEndpointSwitching,
.networkPreferredWifiName,
.networkLocalEndpoint,
.networkExternalEndpointList,
]),
]);
}
@@ -266,6 +266,8 @@ class BackgroundUploadService {
return null;
}
File? file;
/// iOS LivePhoto has two files: a photo and a video.
/// They are uploaded separately, with video file being upload first, then returned with the assetId
/// The assetId is then used as a metadata for the photo file upload task.
@@ -276,9 +278,11 @@ class BackgroundUploadService {
/// The cancel operation will only cancel the video group (normal group), the photo group will not
/// be touched, as the video file is already uploaded.
final file = await (entity.isLivePhoto
? _storageRepository.getMotionFile(asset.id)
: _storageRepository.getAssetFile(asset.id));
if (entity.isLivePhoto) {
file = await _storageRepository.getMotionFileForAsset(asset);
} else {
file = await _storageRepository.getFileForAsset(asset.id);
}
if (file == null) {
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
@@ -326,7 +330,7 @@ class BackgroundUploadService {
return null;
}
final file = await _storageRepository.getAssetFile(asset.id);
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
return null;
}
@@ -20,6 +20,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
/// Callbacks for upload progress and status updates
class UploadCallbacks {
@@ -98,7 +99,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi;
},
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
}
@@ -124,14 +125,14 @@ class ForegroundUploadService {
continue;
}
await _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks);
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
}
}
/// Manually upload picked local assets
Future<void> uploadManual(
List<LocalAsset> localAssets, {
required Completer<void>? cancelToken,
Completer<void>? cancelToken,
UploadCallbacks callbacks = const UploadCallbacks(),
}) async {
if (localAssets.isEmpty) {
@@ -141,7 +142,7 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>(
items: localAssets,
cancelToken: cancelToken,
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
@@ -169,11 +170,11 @@ class ForegroundUploadService {
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
);
return switch (result) {
UploadSuccess() => onSuccess?.call(fileId),
UploadError(:final message) => onError?.call(fileId, message),
UploadCancelled() => null,
};
if (result.isSuccess) {
onSuccess?.call(fileId);
} else if (!result.isCancelled && result.errorMessage != null) {
onError?.call(fileId, result.errorMessage!);
}
},
);
}
@@ -215,7 +216,7 @@ class ForegroundUploadService {
final item = items[index];
if (shouldSkip != null && shouldSkip(item)) {
if (shouldSkip?.call(item) ?? false) {
continue;
}
@@ -232,48 +233,78 @@ class ForegroundUploadService {
}
Future<void> _uploadSingleAsset(
LocalAsset asset, {
required Completer<void>? cancelToken,
LocalAsset asset,
Completer<void>? cancelToken, {
required UploadCallbacks callbacks,
}) async {
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = callbacks;
File? assetFile;
File? file;
File? livePhotoFile;
try {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
onError?.call(
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return;
}
File? file;
if (entity.isLivePhoto) {
file = await _storageRepository.getMotionFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
if (!isAvailableLocally && CurrentPlatform.isIOS) {
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
// Create progress handler for iCloud download
PMProgressHandler? progressHandler;
StreamSubscription? progressSubscription;
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) {
callbacks.onICloudProgress?.call(asset.localId!, event.progress);
});
try {
file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler);
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.loadMotionFileFromCloud(
asset.id,
progressHandler: progressHandler,
);
}
} finally {
await progressSubscription.cancel();
}
} else {
// Get files locally
file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
onError?.call(
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return;
}
livePhotoFile = file;
// For live photos, get the motion video file
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
if (livePhotoFile == null) {
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
}
}
}
file = await _storageRepository.getAssetFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
if (file == null) {
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}");
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t());
return;
}
assetFile = file;
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
@@ -299,9 +330,11 @@ class ForegroundUploadService {
};
// Upload live photo video first if available
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
final onProgress = callbacks.onProgress;
final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile,
originalFileName: livePhotoTitle,
@@ -313,16 +346,15 @@ class ForegroundUploadService {
logContext: 'livePhotoVideo[${asset.localId}]',
);
switch (livePhotoResult) {
case UploadSuccess(:final remoteAssetId):
fields['livePhotoVideoId'] = remoteAssetId;
case UploadError(:final message):
onError?.call(asset.localId!, "Failed to upload live photo video: $message");
return;
case UploadCancelled():
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
livePhotoVideoId = livePhotoResult.remoteAssetId;
}
}
if (livePhotoVideoId != null) {
fields['livePhotoVideoId'] = livePhotoVideoId;
}
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
if (CurrentPlatform.isIOS && asset.cloudId != null) {
fields['metadata'] = jsonEncode([
@@ -339,6 +371,7 @@ class ForegroundUploadService {
]);
}
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile(
file: file,
originalFileName: originalFileName,
@@ -350,33 +383,34 @@ class ForegroundUploadService {
logContext: 'asset[${asset.localId}]',
);
switch (result) {
case UploadSuccess(:final remoteAssetId):
onSuccess?.call(asset.localId!, remoteAssetId);
case UploadCancelled():
if (result.isSuccess && result.remoteAssetId != null) {
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
} else if (result.isCancelled) {
_logger.warning(() => "Backup was cancelled by the user");
shouldAbortUpload = true;
} else if (result.errorMessage != null) {
_logger.severe(
() =>
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
);
callbacks.onError?.call(asset.localId!, result.errorMessage!);
if (result.errorMessage == "Quota has been exceeded!") {
shouldAbortUpload = true;
_logger.warning("Upload was cancelled by the user for asset ${asset.localId}");
case UploadError(:final message, :final statusCode):
shouldAbortUpload |= message == "Quota has been exceeded!";
_logger.severe(
"Error(${statusCode ?? 'unknown'}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | $message",
);
onError?.call(asset.localId!, message);
}
}
} catch (error, stackTrace) {
_logger.severe("Asset backup failed", error, stackTrace);
onError?.call(asset.localId!, error.toString());
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
callbacks.onError?.call(asset.localId!, error.toString());
} finally {
if (Platform.isIOS) {
unawaited(
Future.wait([
if (assetFile != null) assetFile.delete(),
if (livePhotoFile != null) livePhotoFile.delete(),
]).onError((error, stackTrace) {
_logger.severe("Post-upload file cleanup failed", error, stackTrace);
return const [];
}),
);
try {
await file?.delete();
await livePhotoFile?.delete();
} catch (error, stackTrace) {
_logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace);
}
}
}
}
@@ -412,7 +446,7 @@ class ForegroundUploadService {
logContext: 'shareIntent[$deviceAssetId]',
);
} catch (e) {
return UploadError(message: e.toString());
return UploadResult.error(errorMessage: e.toString());
}
}
+21 -3
View File
@@ -508,12 +508,18 @@ class AlbumsApi {
/// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters)
///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
///
/// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? isOwned, bool? isShared, }) async {
///
/// * [String] name:
/// Album name (exact match)
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums';
@@ -527,12 +533,18 @@ class AlbumsApi {
if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (isOwned != null) {
queryParams.addAll(_queryParams('', 'isOwned', isOwned));
}
if (isShared != null) {
queryParams.addAll(_queryParams('', 'isShared', isShared));
}
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
const contentTypes = <String>[];
@@ -557,13 +569,19 @@ class AlbumsApi {
/// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters)
///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
///
/// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? isOwned, bool? isShared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, isOwned: isOwned, isShared: isShared, );
///
/// * [String] name:
/// Album name (exact match)
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, id: id, isOwned: isOwned, isShared: isShared, name: name, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+1 -1
View File
@@ -13,7 +13,7 @@ part of openapi.api;
class ApiClient {
ApiClient({this.basePath = '/api', this.authentication,});
final String basePath;
String basePath;
final Authentication? authentication;
var _client = Client();
+3 -3
View File
@@ -77,7 +77,7 @@ class JobName {
static const versionCheck = JobName._(r'VersionCheck');
static const ocrQueueAll = JobName._(r'OcrQueueAll');
static const ocr = JobName._(r'Ocr');
static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate');
static const workflowAssetTrigger = JobName._(r'WorkflowAssetTrigger');
/// List of all possible values in this [enum][JobName].
static const values = <JobName>[
@@ -135,7 +135,7 @@ class JobName {
versionCheck,
ocrQueueAll,
ocr,
workflowAssetCreate,
workflowAssetTrigger,
];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@@ -228,7 +228,7 @@ class JobNameTypeTransformer {
case r'VersionCheck': return JobName.versionCheck;
case r'OcrQueueAll': return JobName.ocrQueueAll;
case r'Ocr': return JobName.ocr;
case r'WorkflowAssetCreate': return JobName.workflowAssetCreate;
case r'WorkflowAssetTrigger': return JobName.workflowAssetTrigger;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
+14 -3
View File
@@ -18,6 +18,7 @@ class PluginTemplateResponseDto {
this.steps = const [],
required this.title,
required this.trigger,
this.uiHints = const [],
});
/// Template description
@@ -34,13 +35,17 @@ class PluginTemplateResponseDto {
WorkflowTrigger trigger;
/// Ui hints, for example \"smart-album\"
List<String> uiHints;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
other.description == description &&
other.key == key &&
_deepEquality.equals(other.steps, steps) &&
other.title == title &&
other.trigger == trigger;
other.trigger == trigger &&
_deepEquality.equals(other.uiHints, uiHints);
@override
int get hashCode =>
@@ -49,10 +54,11 @@ class PluginTemplateResponseDto {
(key.hashCode) +
(steps.hashCode) +
(title.hashCode) +
(trigger.hashCode);
(trigger.hashCode) +
(uiHints.hashCode);
@override
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger]';
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger, uiHints=$uiHints]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -61,6 +67,7 @@ class PluginTemplateResponseDto {
json[r'steps'] = this.steps;
json[r'title'] = this.title;
json[r'trigger'] = this.trigger;
json[r'uiHints'] = this.uiHints;
return json;
}
@@ -78,6 +85,9 @@ class PluginTemplateResponseDto {
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
title: mapValueOfType<String>(json, r'title')!,
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
uiHints: json[r'uiHints'] is Iterable
? (json[r'uiHints'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
@@ -130,6 +140,7 @@ class PluginTemplateResponseDto {
'steps',
'title',
'trigger',
'uiHints',
};
}
@@ -75,7 +75,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg');
final task = await sut.getUploadTask(asset);
@@ -92,7 +92,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getUploadTask(asset);
@@ -109,7 +109,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getMotionFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -130,7 +130,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -150,7 +150,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
@@ -194,7 +194,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithCloudId);
@@ -243,7 +243,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutAndroid.getUploadTask(assetWithCloudId);
@@ -281,7 +281,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
).thenAnswer((_) async => 'test.jpg');
@@ -323,7 +323,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
).thenAnswer((_) async => 'livephoto.heic');
+30 -2
View File
@@ -1627,6 +1627,17 @@
"type": "string"
}
},
{
"name": "id",
"required": false,
"in": "query",
"description": "Album ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "isOwned",
"required": false,
@@ -1644,6 +1655,15 @@
"schema": {
"type": "boolean"
}
},
{
"name": "name",
"required": false,
"in": "query",
"description": "Album name (exact match)",
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -18151,7 +18171,7 @@
"VersionCheck",
"OcrQueueAll",
"Ocr",
"WorkflowAssetCreate"
"WorkflowAssetTrigger"
],
"type": "string"
},
@@ -20199,6 +20219,13 @@
"trigger": {
"$ref": "#/components/schemas/WorkflowTrigger",
"description": "Workflow trigger"
},
"uiHints": {
"description": "Ui hints, for example \"smart-album\"",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
@@ -20206,7 +20233,8 @@
"key",
"steps",
"title",
"trigger"
"trigger",
"uiHints"
],
"type": "object"
},
+9
View File
@@ -1,3 +1,12 @@
@@ -13,7 +13,7 @@
class ApiClient {
ApiClient({this.basePath = '/api', this.authentication,});
- final String basePath;
+ String basePath;
final Authentication? authentication;
var _client = Client();
@@ -143,19 +143,19 @@
);
}
+15 -14
View File
@@ -20,19 +20,20 @@
"caseSensitive": false
}
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumIds": []
}
},
{
"method": "immich-plugin-core#assetArchive",
"config": {
"inverse": false
}
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumIds": []
}
}
]
],
"uiHints": ["SmartAlbum"]
}
],
"methods": [
@@ -65,7 +66,7 @@
},
"required": ["pattern"]
},
"uiHints": ["filter"]
"uiHints": ["Filter"]
},
{
"name": "filterFileType",
@@ -85,7 +86,7 @@
},
"required": ["fileTypes"]
},
"uiHints": ["filter"]
"uiHints": ["Filter"]
},
{
"name": "filterPerson",
@@ -99,7 +100,7 @@
"array": true,
"title": "Person IDs",
"description": "List of person to match",
"uiHint": "personI"
"uiHint": "personId"
},
"matchAny": {
"type": "boolean",
@@ -110,7 +111,7 @@
},
"required": ["personIds"]
},
"uiHints": ["filter"]
"uiHints": ["Filter"]
},
{
"name": "assetArchive",
@@ -187,7 +188,7 @@
"title": "Album IDs",
"array": true,
"description": "Target album IDs",
"uiHint": "albumId"
"uiHint": "AlbumId"
}
},
"required": ["albumIds"]
@@ -272,14 +273,14 @@
"type": "string",
"title": "Album ID",
"description": "Target album ID",
"uiHint": "albumId"
"uiHint": "AlbumId"
},
"albumIds": {
"type": "string",
"title": "Album IDs",
"description": "Target album IDs",
"array": true,
"uiHint": "albumId"
"uiHint": "AlbumId"
}
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ export const assetTrash = () => {
changes: {
asset: config.inverse
? { deletedAt: null, status: AssetStatus.Active }
: { deletedAt: new Date(), status: AssetStatus.Trashed },
: { deletedAt: new Date().toISOString(), status: AssetStatus.Trashed },
},
}));
};
+1
View File
@@ -27,6 +27,7 @@
"packageManager": "pnpm@10.33.4",
"devDependencies": {
"@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*",
"@types/node": "^24.12.4",
"esbuild": "^0.28.0",
"tsc-alias": "^1.8.16",
+8 -1
View File
@@ -1,3 +1,6 @@
import { type BulkIdResponseDto, type BulkIdsDto } from '@immich/sdk';
// keep in sync with plugin-core/src/index.d.ts';
declare module 'extism:host' {
interface user {
albumAddAssets(ptr: PTR): I64;
@@ -45,7 +48,11 @@ type AlbumsToAssets = {
export const hostFunctions = (authToken: string) => ({
albumAddAssets: (albumId: string, assetIds: string[]) =>
call('albumAddAssets', authToken, [albumId, { ids: assetIds }]),
call<[string, BulkIdsDto], BulkIdResponseDto[]>(
'albumAddAssets',
authToken,
[albumId, { ids: assetIds }],
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
});
+9 -9
View File
@@ -68,19 +68,19 @@ export type AssetV1 = {
ownerId: string;
type: AssetType;
originalPath: string;
fileCreatedAt: Date;
fileModifiedAt: Date;
fileCreatedAt: string;
fileModifiedAt: string;
isFavorite: boolean;
checksum: Buffer; // sha1 checksum
livePhotoVideoId: string | null;
updatedAt: Date;
createdAt: Date;
updatedAt: string;
createdAt: string;
originalFileName: string;
isOffline: boolean;
libraryId: string | null;
isExternal: boolean;
deletedAt: Date | null;
localDateTime: Date;
deletedAt: string | null;
localDateTime: string;
stackId: string | null;
duplicateId: string | null;
status: AssetStatus;
@@ -93,8 +93,8 @@ export type AssetV1 = {
exifImageHeight: number | null;
fileSizeInByte: number | null;
orientation: string | null;
dateTimeOriginal: Date | null;
modifyDate: Date | null;
dateTimeOriginal: string | null;
modifyDate: string | null;
lensModel: string | null;
fNumber: number | null;
focalLength: number | null;
@@ -116,7 +116,7 @@ export type AssetV1 = {
autoStackId: string | null;
rating: number | null;
tags: string[] | null;
updatedAt: Date | null;
updatedAt: string | null;
} | null;
};
};
+9 -3
View File
@@ -1535,6 +1535,8 @@ export type PluginTemplateResponseDto = {
title: string;
/** Workflow trigger */
trigger: WorkflowTrigger;
/** Ui hints, for example "smart-album" */
uiHints: string[];
};
export type QueueResponseDto = {
/** Whether the queue is paused */
@@ -3597,18 +3599,22 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/**
* List all albums
*/
export function getAllAlbums({ assetId, isOwned, isShared }: {
export function getAllAlbums({ assetId, id, isOwned, isShared, name }: {
assetId?: string;
id?: string;
isOwned?: boolean;
isShared?: boolean;
name?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({
assetId,
id,
isOwned,
isShared
isShared,
name
}))}`, {
...opts
}));
@@ -7140,7 +7146,7 @@ export enum JobName {
VersionCheck = "VersionCheck",
OcrQueueAll = "OcrQueueAll",
Ocr = "Ocr",
WorkflowAssetCreate = "WorkflowAssetCreate"
WorkflowAssetTrigger = "WorkflowAssetTrigger"
}
export enum SearchSuggestionType {
Country = "country",
+11 -6
View File
@@ -10,7 +10,7 @@ overrides:
sharp: ^0.34.5
webpackbar: ^7.0.0
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
packageExtensionsChecksum: sha256-W6pFzyf+6QXnV91iA6oob0OGVkergPXDN1afLgoF53k=
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
@@ -332,6 +332,9 @@ importers:
'@extism/js-pdk':
specifier: ^1.1.1
version: 1.1.1
'@immich/sdk':
specifier: workspace:*
version: link:../sdk
'@types/node':
specifier: ^24.12.4
version: 24.12.4
@@ -389,7 +392,7 @@ importers:
version: 6.1.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
'@nestjs/swagger':
specifier: ^11.4.2
version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)
'@nestjs/websockets':
specifier: ^11.0.4
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-socket.io@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -536,7 +539,7 @@ importers:
version: 8.0.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
nestjs-zod:
specifier: ^5.3.0
version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6)
version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6)
nodemailer:
specifier: ^8.0.0
version: 8.0.7
@@ -3795,6 +3798,7 @@ packages:
class-transformer: '*'
class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
typescript: '*'
peerDependenciesMeta:
'@fastify/static':
optional: true
@@ -16570,7 +16574,7 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)':
'@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)':
dependencies:
'@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -16581,6 +16585,7 @@ snapshots:
path-to-regexp: 8.4.2
reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.6
typescript: 6.0.3
'@nestjs/testing@11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)':
dependencies:
@@ -23229,14 +23234,14 @@ snapshots:
'@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.1)
tslib: 2.8.1
nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6):
nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6):
dependencies:
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
deepmerge: 4.3.1
rxjs: 7.8.2
zod: 4.3.6
optionalDependencies:
'@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
'@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)
next-tick@1.1.0: {}
+3
View File
@@ -60,6 +60,9 @@ packageExtensions:
dependencies:
node-addon-api: '*'
node-gyp: '*'
'@nestjs/swagger':
peerDependencies:
typescript: '*'
dedupePeerDependents: false
preferWorkspacePackages: true
injectWorkspacePackages: true
+4 -2
View File
@@ -13,14 +13,15 @@ FROM builder AS server
WORKDIR /usr/src/app
COPY ./server ./server/
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/
RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --filter @immich/plugin-sdk --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
FROM builder AS web
@@ -66,6 +67,7 @@ ENV MISE_DISABLE_TOOLS=flutter
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-core ./packages/plugin-core/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/
+1
View File
@@ -68,6 +68,7 @@ run = [
[tasks.ci-medium]
run = [
{ task = ":install" },
{ task = "//:plugins" },
{ task = "//packages/plugin-core:build" },
{ task = ":test-medium --run" },
]
+2
View File
@@ -65,6 +65,8 @@ const UpdateAlbumSchema = z
const GetAlbumsSchema = z
.object({
id: z.uuidv4().optional().describe('Album ID'),
name: z.string().optional().describe('Album name (exact match)'),
isOwned: stringToBool
.optional()
.describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
+1
View File
@@ -38,6 +38,7 @@ const PluginManifestTemplateSchema = z
description: z.string().min(1).describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).optional().default([]).describe('Ui hints, for example "smart-album"'),
})
.meta({ id: 'PluginManifestTemplateDto' });
+3
View File
@@ -58,6 +58,7 @@ const PluginTemplateResponseSchema = z
description: z.string().describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).describe('Ui hints, for example "smart-album"'),
})
.meta({ id: 'PluginTemplateResponseDto' });
@@ -91,6 +92,7 @@ export type PluginTemplate = {
config?: Record<string, unknown> | null;
enabled?: boolean;
}>;
uiHints: string[];
};
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => {
@@ -104,6 +106,7 @@ export const mapTemplate = (plugin: { name: string }, template: PluginTemplate):
config: step.config ?? null,
enabled: step.enabled,
})),
uiHints: template.uiHints ?? [],
};
};
+1 -1
View File
@@ -866,7 +866,7 @@ export enum JobName {
Ocr = 'Ocr',
// Workflow
WorkflowAssetCreate = 'WorkflowAssetCreate',
WorkflowAssetTrigger = 'WorkflowAssetTrigger',
}
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' });
+6 -1
View File
@@ -209,11 +209,16 @@ export class AlbumRepository {
}
@GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
getAll(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<MapAlbumDto[]> {
getAll(
ownerId: string,
options: { id?: string; isOwned?: boolean; isShared?: boolean; name?: string } = {},
): Promise<MapAlbumDto[]> {
return this.buildAlbumBaseQuery(ownerId, options)
.selectAll('album')
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.$if(!!options.id, (qb) => qb.where('album.id', '=', options.id!))
.$if(!!options.name, (qb) => qb.where('album.albumName', '=', options.name!))
.orderBy('album.createdAt', 'desc')
.execute();
}
@@ -45,10 +45,10 @@ export class WorkflowRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
search(dto: WorkflowSearchDto & { ownerId?: string }) {
search(dto: WorkflowSearchDto & { userId?: string }) {
return this.queryBuilder()
.$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!))
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
.$if(!!dto.userId, (qb) => qb.where('ownerId', '=', dto.userId!))
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
.orderBy('createdAt', 'desc')
+2 -5
View File
@@ -37,15 +37,12 @@ export class AlbumService extends BaseService {
};
}
async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, isOwned, isShared }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, ...rest }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails();
const albums = assetId
? await this.albumRepository.getByAssetId(ownerId, assetId)
: await this.albumRepository.getAll(ownerId, { isOwned, isShared });
: await this.albumRepository.getAll(ownerId, rest);
if (albums.length === 0) {
return [];
@@ -1,9 +1,8 @@
import { CurrentPlugin } from '@extism/extism';
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
import { HttpException, UnauthorizedException } from '@nestjs/common';
import _ from 'lodash';
import { join } from 'node:path';
import { OnEvent, OnJob } from 'src/decorators';
import { DummyValue, OnEvent, OnJob } from 'src/decorators';
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -21,6 +20,7 @@ import {
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service';
import { AssetService } from 'src/services/asset.service';
import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types';
@@ -32,9 +32,11 @@ const dummy = () => {
type ExecuteOptions<T extends WorkflowType> = {
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
write: (changes: WorkflowChanges<T>) => Promise<void>;
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
};
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string;
@@ -62,7 +64,6 @@ export class WorkflowExecutionService extends BaseService {
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
albumService.addAssets(authDto, ...args),
);
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args),
);
@@ -247,20 +248,25 @@ export class WorkflowExecutionService extends BaseService {
}
@OnEvent({ name: 'AssetCreate' })
async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate };
const items = await this.workflowRepository.search(dto);
onAssetCreate({ asset: { ownerId: userId, id: assetId } }: ArgOf<'AssetCreate'>) {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate });
}
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll(
items.map((workflow) => ({
name: JobName.WorkflowAssetCreate,
data: { workflowId: workflow.id, assetId: asset.id },
name: JobName.WorkflowAssetTrigger,
data: { workflowId: workflow.id, assetId, trigger },
})),
);
}
@OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow })
handleAssetCreate({ workflowId, assetId }: JobOf<JobName.WorkflowAssetCreate>) {
@OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow })
handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) {
return this.execute(workflowId, (type) => {
const assetService = BaseService.create(AssetService, this);
switch (type) {
case WorkflowType.AssetV1: {
return {
@@ -271,19 +277,16 @@ export class WorkflowExecutionService extends BaseService {
authUserId: asset.ownerId,
};
},
write: async (changes) => {
if (changes.asset) {
await this.assetRepository.update({
id: assetId,
..._.omitBy(
{
isFavorite: changes.asset?.isFavorite,
visibility: changes.asset?.visibility,
},
_.isUndefined,
),
});
write: async (auth, changes) => {
const asset = changes.asset;
if (!asset) {
return;
}
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
});
},
} satisfies ExecuteOptions<typeof type>;
}
@@ -301,7 +304,19 @@ export class WorkflowExecutionService extends BaseService {
}
// TODO infer from steps
const type = 'AssetV1' as T;
let type: T | undefined;
for (const targetType of Object.values(WorkflowType)) {
const missing = workflow.steps.some((step) => !step.types.includes(targetType));
if (!missing) {
type = targetType as unknown as T;
break;
}
}
if (!type) {
throw new Error('Unable to infer workflow event type from steps');
}
const handler = getHandler(type);
if (!handler) {
this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`);
@@ -337,7 +352,18 @@ export class WorkflowExecutionService extends BaseService {
payload,
);
if (result?.changes) {
await write(result.changes);
await write(
{
user: {
id: readResult.authUserId,
},
session: {
id: DummyValue.UUID,
hasElevatedPermission: true,
},
} as AuthDto,
result.changes,
);
({ data } = await read(type));
}
+1 -1
View File
@@ -23,7 +23,7 @@ export class WorkflowService extends BaseService {
}
async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id });
const workflows = await this.workflowRepository.search({ ...dto, userId: auth.user.id });
return workflows.map((workflow) => mapWorkflow(workflow));
}
+1 -1
View File
@@ -404,7 +404,7 @@ export type JobItem =
| { name: JobName.Ocr; data: IEntityJob }
// Workflow
| { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } }
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } }
// Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
@@ -137,7 +137,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive' }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Archive,
@@ -154,7 +154,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline,
@@ -173,7 +173,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock' }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Locked,
@@ -190,7 +190,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline,
@@ -209,7 +209,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite' }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
@@ -224,7 +224,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
@@ -242,7 +242,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
});
@@ -261,7 +261,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id);
await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id);
@@ -279,7 +279,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
});
@@ -0,0 +1,65 @@
import type { Attachment } from 'svelte/attachments';
const EDGE_ZONE = 72;
const MAX_SCROLL_SPEED = 22;
const findScrollContainer = (element: HTMLElement): HTMLElement | null => {
let node = element.parentElement;
while (node) {
const overflowY = getComputedStyle(node).overflowY;
if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight) {
return node;
}
node = node.parentElement;
}
return null;
};
export function dragAutoScroll(isActive: () => boolean): Attachment {
return (node) => {
const element = node as HTMLElement;
let scrollContainer: HTMLElement | null = null;
let pointerY = -1;
let frame: number | null = null;
const trackPointer = (event: DragEvent) => {
pointerY = event.clientY;
};
const tick = () => {
if (scrollContainer && pointerY >= 0) {
const { top, bottom } = scrollContainer.getBoundingClientRect();
let delta = 0;
if (pointerY < top + EDGE_ZONE) {
delta = -MAX_SCROLL_SPEED * Math.min(1, (top + EDGE_ZONE - pointerY) / EDGE_ZONE);
} else if (pointerY > bottom - EDGE_ZONE) {
delta = MAX_SCROLL_SPEED * Math.min(1, (pointerY - (bottom - EDGE_ZONE)) / EDGE_ZONE);
}
if (delta !== 0) {
scrollContainer.scrollBy(0, delta);
}
}
frame = requestAnimationFrame(tick);
};
$effect(() => {
if (!isActive()) {
return;
}
scrollContainer = findScrollContainer(element);
pointerY = -1;
globalThis.addEventListener('dragover', trackPointer);
frame = requestAnimationFrame(tick);
return () => {
globalThis.removeEventListener('dragover', trackPointer);
if (frame !== null) {
cancelAnimationFrame(frame);
frame = null;
}
scrollContainer = null;
};
});
};
}
@@ -64,7 +64,7 @@
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
{/each}
</div>
{:else if schema.uiHint === 'albumId'}
{:else if schema.uiHint === 'AlbumId'}
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
{:else if schema.enum && schema.array}
<Field {label} {description}>
@@ -205,11 +205,7 @@
</script>
<div
class={[
'group flex overflow-hidden transition-[background-color,border-radius] focus-visible:outline-none',
backgroundColorClass,
{ 'rounded-xl': selected },
]}
class={['group flex overflow-hidden focus-visible:outline-none', backgroundColorClass, { 'rounded-xl': selected }]}
style:width="{width}px"
style:height="{height}px"
onmouseenter={onMouseEnter}
@@ -249,16 +245,8 @@
]}
>
<ImageThumbnail
class={[
'absolute transition-[border-radius] group-focus-visible:rounded-lg',
{ 'rounded-xl': selected },
imageClass,
]}
brokenAssetClass={[
'z-1 absolute group-focus-visible:rounded-lg transition-[border-radius]',
{ 'rounded-xl': selected },
brokenAssetClass,
]}
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
@@ -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}
@@ -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;
}
}
+1 -1
View File
@@ -24,7 +24,7 @@
<div class="grow text-start">
<Text fontWeight="medium" class="flex items-center gap-1"
>{method.title}
{#if method.uiHints.includes('filter')}
{#if method.uiHints.includes('Filter')}
<Badge size="tiny" color="info" title={$t('plugin_method_filter_type_description')}
>{$t('plugin_method_filter_type')}</Badge
>
@@ -1,37 +0,0 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import { type WorkflowResponseDto } from '@immich/sdk';
import { FormModal, ListButton, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
workflow: WorkflowResponseDto;
onClose: () => void;
};
const { workflow, onClose }: Props = $props();
let selected = $state(pluginManager.getTrigger(workflow.trigger));
const onSubmit = async () => {
const success = await handleUpdateWorkflow(workflow.id, { trigger: selected.trigger });
if (success) {
onClose();
}
};
</script>
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
<div class="flex flex-col gap-2">
{#each pluginManager.triggers as item (item.trigger)}
<ListButton selected={item.trigger === selected.trigger} onclick={() => (selected = item)}>
<div class="grow text-start">
<Text fontWeight="medium">{getTriggerName($t, item.trigger)}</Text>
<Text size="tiny" color="muted">{getTriggerDescription($t, item.trigger)}</Text>
</div>
</ListButton>
{/each}
</div>
</FormModal>
@@ -2,7 +2,7 @@
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleCreateWorkflow } from '$lib/services/workflow.service';
import { type PluginTemplateResponseDto } from '@immich/sdk';
import { FormModal, Icon, ListButton, Text } from '@immich/ui';
import { Badge, FormModal, Icon, ListButton, Text } from '@immich/ui';
import { mdiFlashOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -59,6 +59,11 @@
<Text fontWeight="medium">{template.title}</Text>
<Text size="tiny" color="muted">{template.description}</Text>
</div>
{#if template.uiHints.includes('SmartAlbum')}
<div class="shrink-0">
<Badge size="small">{$t('smart_album')}</Badge>
</div>
{/if}
</div>
</ListButton>
{/each}
+2 -2
View File
@@ -26,7 +26,7 @@ import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte';
import WorkflowTemplatePickerModal from '$lib/modals/WorkflowTemplatePickerModal.svelte';
import { Route } from '$lib/route';
import { copyToClipboard, downloadJson } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@@ -50,7 +50,7 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
const UseTemplate: ActionItem = {
title: $t('browse_templates'),
icon: mdiFileDocumentMultipleOutline,
onAction: () => modalManager.show(WorkflowTemplatePicker, {}),
onAction: () => modalManager.show(WorkflowTemplatePickerModal, {}),
};
return { Create, UseTemplate };
+1 -1
View File
@@ -104,7 +104,7 @@ export type JSONSchemaProperty = {
array?: boolean;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
uiHint?: 'albumId' | 'assetId' | 'personId';
uiHint?: 'AlbumId' | 'AssetId' | 'PersonId';
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1,5 +1,6 @@
<script lang="ts">
import { beforeNavigate, goto, invalidate } from '$app/navigation';
import { dragAutoScroll } from '$lib/attachments/drag-auto-scroll.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
@@ -7,7 +8,7 @@
import { Route } from '$lib/route';
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk';
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
import {
ActionBar,
AppShell,
@@ -43,7 +44,10 @@
mdiPlus,
} from '@mdi/js';
import { cloneDeep, isEqual } from 'lodash-es';
import { flip } from 'svelte/animate';
import { fade } from 'svelte/transition';
import { t } from 'svelte-i18n';
import { createListReorder, GHOST_KEY, type ReorderEntry } from './list-reorder.svelte';
import type { PageData } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte';
@@ -69,6 +73,11 @@
let isSaving = $state(false);
let editMode = $state<EditMode>('visual');
const reorder = createListReorder(
() => steps,
(next) => (steps = next),
);
const workflowSummary = $derived({ name, description, trigger, steps });
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
@@ -106,19 +115,6 @@
}
};
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
return;
}
const from = Number(event.dataTransfer.getData('text/plain'));
const next = [...steps];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
steps = next;
};
const handleDeleteStep = async (index: number) => {
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
if (confirmed) {
@@ -344,17 +340,51 @@
</CardHeader>
</Card>
{#each steps as step, index (step.method + index)}
<div class="hidden" aria-hidden="true" {@attach dragAutoScroll(() => reorder.isDragging)}></div>
{#snippet stepCard(entry: ReorderEntry<WorkflowStepDto>)}
<WorkflowStepCard
{step}
{index}
step={entry.item}
index={entry.index}
position={entry.index + 1}
isGhost={entry.isGhost}
isSource={entry.isSource}
isDragging={reorder.isDragging}
onEdit={handleEditStep}
onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep}
onDrop={handleDrop}
onDragStart={reorder.start}
onDragOver={reorder.over}
onDragEnd={reorder.end}
onDrop={reorder.drop}
/>
{/snippet}
{#each reorder.entries as entry (entry.isGhost ? GHOST_KEY : entry.item)}
<div class="w-full" animate:flip={{ duration: 200 }}>
{#if entry.isGhost}
<div transition:fade={{ duration: 120 }}>{@render stepCard(entry)}</div>
{:else}
{@render stepCard(entry)}
{/if}
</div>
{/each}
{#if reorder.isDragging}
<div
class="-mt-4 min-h-12 w-full"
role="listitem"
ondragover={(event) => {
event.preventDefault();
reorder.toEnd();
}}
ondrop={(event) => {
event.preventDefault();
reorder.drop();
}}
></div>
{/if}
<Button
size="small"
fullWidth
@@ -17,23 +17,59 @@
type Props = {
step: WorkflowStepDto;
index: number;
position: number;
isGhost: boolean;
isSource: boolean;
isDragging: boolean;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onInsertBefore: (index: number) => void;
onDrop: (index: number, event: DragEvent) => void;
onDragStart: (index: number) => void;
onDragOver: (index: number, after: boolean) => void;
onDragEnd: () => void;
onDrop: () => void;
};
let { step, index, onEdit, onDelete, onInsertBefore, onDrop }: Props = $props();
let {
step,
index,
position,
isGhost,
isSource,
isDragging,
onEdit,
onDelete,
onInsertBefore,
onDragStart,
onDragOver,
onDragEnd,
onDrop,
}: Props = $props();
const method = $derived(pluginManager.getMethod(step.method));
const isFilter = $derived(method?.uiHints?.includes('filter') ?? false);
const isFilter = $derived(method?.uiHints?.includes('Filter') ?? false);
const configEntries = $derived(
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
);
let dragImage = $state<Element>();
let isDropTarget = $state(false);
let hoverDrag = $state(false);
const cardStateClass = $derived.by(() => {
if (isGhost) {
return 'pointer-events-none border-2 border-dashed border-primary bg-primary-50/40 shadow-lg';
}
if (isSource) {
return 'border-dashed border-primary-300 bg-primary-50/20';
}
if (hoverDrag) {
return 'border-dashed border-primary';
}
return '';
});
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
const formatConfigValue = (value: unknown): string => {
@@ -75,7 +111,7 @@
target: document.body,
props: {
description: method?.description,
isFilter: method?.uiHints?.includes('filter') ?? false,
isFilter: method?.uiHints?.includes('Filter') ?? false,
label: step ? pluginManager.getMethodLabel(step.method) : '',
stepNumber: index + 1,
},
@@ -83,31 +119,31 @@
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
event.dataTransfer.setDragImage(dragImage, 16, 22);
onDragStart(index);
};
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
return;
}
const handleDrop = (event: DragEvent) => {
event.preventDefault();
onDrop();
};
const from = Number(event.dataTransfer.getData('text/plain'));
if (from === index) {
const handleDragOver = (event: DragEvent & { currentTarget: HTMLElement }) => {
event.preventDefault();
if (isGhost) {
return;
}
onDrop(index, event);
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
isDropTarget = true;
const rect = event.currentTarget.getBoundingClientRect();
const after = event.clientY > rect.top + rect.height / 2;
onDragOver(index, after);
};
const handleDragEnd = () => {
dragImage?.remove();
dragImage = undefined;
isDropTarget = false;
hoverDrag = false;
onDragEnd();
};
</script>
@@ -118,6 +154,7 @@
<button
type="button"
class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50"
class:hidden={isDragging}
aria-label={$t('add_step')}
title={$t('add_step')}
onclick={() => onInsertBefore(index)}
@@ -129,20 +166,12 @@
<div
class="w-full transition-all"
class:opacity-40={!!dragImage}
class:scale-[0.99]={!!dragImage}
class:opacity-50={isSource}
ondragover={handleDragOver}
ondragleave={() => (isDropTarget = false)}
ondrop={(event) => handleDrop(index, event)}
ondrop={handleDrop}
role="listitem"
>
<Card
class="shadow-none transition-colors {isDropTarget
? 'border-primary ring-2 ring-primary-200'
: hoverDrag
? 'border-dashed border-primary'
: ''}"
>
<Card class="shadow-none transition-colors {cardStateClass}">
<CardHeader>
<div class="flex items-center gap-2">
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -171,7 +200,9 @@
</div>
<div class="flex min-w-0 flex-1 flex-col">
<CardTitle class="truncate">
<span class="mr-1 font-bold text-light-500">{index + 1}</span>
{#if !isGhost}
<span class="mr-1 font-bold text-light-500">{position}</span>
{/if}
{pluginManager.getMethodLabel(step.method)}
</CardTitle>
{#if method?.description}
@@ -67,7 +67,7 @@
for (const [i, step] of workflow.steps.entries()) {
const method = pluginManager.getMethod(step.method);
const isFilter = method?.uiHints?.includes('filter') ?? false;
const isFilter = method?.uiHints?.includes('Filter') ?? false;
const type = isFilter ? $t('filter') : $t('action');
const label = pluginManager.getMethodLabel(step.method);
lines.push(` [${i + 1}] ${type.toUpperCase()} · ${label}`);
@@ -0,0 +1,78 @@
export const GHOST_KEY = 'reorder-ghost';
export type ReorderEntry<T> = {
item: T;
index: number;
isGhost: boolean;
isSource: boolean;
};
export function createListReorder<T>(getItems: () => T[], setItems: (items: T[]) => void) {
let draggingIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
const entries = $derived.by<ReorderEntry<T>[]>(() => {
const items = getItems();
const list: ReorderEntry<T>[] = items.map((item, index) => ({
item,
index,
isGhost: false,
isSource: index === draggingIndex,
}));
if (
draggingIndex !== null &&
dropIndex !== null &&
dropIndex !== draggingIndex &&
dropIndex !== draggingIndex + 1
) {
list.splice(dropIndex, 0, { item: items[draggingIndex], index: draggingIndex, isGhost: true, isSource: false });
}
return list;
});
return {
get isDragging() {
return draggingIndex !== null;
},
get entries() {
return entries;
},
start(index: number) {
draggingIndex = index;
dropIndex = index;
},
over(index: number, after: boolean) {
if (draggingIndex === null) {
return;
}
dropIndex = Math.max(0, Math.min(index + (after ? 1 : 0), getItems().length));
},
toEnd() {
if (draggingIndex !== null) {
dropIndex = getItems().length;
}
},
end() {
draggingIndex = null;
dropIndex = null;
},
drop() {
if (draggingIndex === null || dropIndex === null) {
return;
}
const target = dropIndex > draggingIndex ? dropIndex - 1 : dropIndex;
if (target !== draggingIndex) {
const next = [...getItems()];
const [moved] = next.splice(draggingIndex, 1);
next.splice(target, 0, moved);
setItems(next);
}
draggingIndex = null;
dropIndex = null;
},
};
}