Compare commits

..

1 Commits

Author SHA1 Message Date
Yaros 2db907239f fix: update ocr & faces after asset edit 2026-06-24 13:18:42 +02:00
25 changed files with 227 additions and 306 deletions
-2
View File
@@ -45,8 +45,6 @@ jobs:
- 'server/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
- 'packages/plugin-core/**'
- 'packages/plugin-sdk/**'
cli:
- 'packages/cli/**'
- 'packages/sdk/**'
+1 -1
View File
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v2
IMMICH_VERSION=v3
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
+1 -1
View File
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-----: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
+1 -1
View File
@@ -29,7 +29,7 @@ docker image prune
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
-3
View File
@@ -1507,9 +1507,6 @@
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
"notification_enabled_list_tile_content": "Immich uses notifications for background backup. Manage them in your device settings.",
"notification_enabled_list_tile_open_button": "Open settings",
"notification_enabled_list_tile_title": "Notifications enabled",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
@@ -67,9 +67,6 @@ class URLSessionManager: NSObject {
delegate = URLSessionManagerDelegate()
session = Self.buildSession(delegate: delegate)
super.init()
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? []
NotificationCenter.default.addObserver(
Self.self,
@@ -81,9 +78,6 @@ class URLSessionManager: NSObject {
func recreateSession() {
session = Self.buildSession(delegate: delegate)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
}
static func setServerUrls(_ urls: [String]) {
@@ -255,6 +249,9 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil,
persistence: .forSession)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
return completion(.useCredential, credential)
}
completion(.performDefaultHandling, nil)
@@ -271,6 +268,9 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
else {
return completion(.performDefaultHandling, nil)
}
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
let credential = URLCredential(user: user, password: password, persistence: .forSession)
completion(.useCredential, credential)
}
@@ -446,6 +446,7 @@ class SyncStreamService {
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
await _refreshAssetOcrAndFaces(asset.id);
_logger.info(
'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits',
@@ -484,6 +485,7 @@ class SyncStreamService {
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit');
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
await _refreshAssetOcrAndFaces(asset.id);
_logger.info(
'Successfully processed AssetEditReadyV2 event for asset ${asset.id} with ${assetEdits.length} edits',
@@ -493,6 +495,22 @@ class SyncStreamService {
}
}
Future<void> _refreshAssetOcrAndFaces(String assetId) async {
try {
final ocr = await _api.assetsApi.getAssetOcr(assetId);
await _syncStreamRepository.replaceAssetOcr(assetId, ocr ?? const []);
} catch (error, stackTrace) {
_logger.severe("Error refreshing OCR for asset $assetId", error, stackTrace);
}
try {
final faces = await _api.facesApi.getFaces(assetId);
await _syncStreamRepository.replaceAssetFaces(assetId, faces ?? const []);
} catch (error, stackTrace) {
_logger.severe("Error refreshing faces for asset $assetId", error, stackTrace);
}
}
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return Future.value();
@@ -896,6 +896,71 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
}
/// Replaces all OCR rows for [assetId] with [data] (e.g. after an asset edit re-runs OCR).
Future<void> replaceAssetOcr(String assetId, Iterable<AssetOcrResponseDto> data) async {
try {
await _db.batch((batch) {
batch.deleteWhere(_db.assetOcrEntity, (row) => row.assetId.equals(assetId));
for (final ocr in data) {
batch.insert(
_db.assetOcrEntity,
AssetOcrEntityCompanion(
id: Value(ocr.id),
assetId: Value(ocr.assetId),
recognizedText: Value(ocr.text),
x1: Value(ocr.x1),
y1: Value(ocr.y1),
x2: Value(ocr.x2),
y2: Value(ocr.y2),
x3: Value(ocr.x3),
y3: Value(ocr.y3),
x4: Value(ocr.x4),
y4: Value(ocr.y4),
boxScore: Value(ocr.boxScore),
textScore: Value(ocr.textScore),
isVisible: const Value(true),
),
);
}
});
} catch (error, stack) {
_logger.severe('Error: replaceAssetOcr', error, stack);
rethrow;
}
}
Future<void> replaceAssetFaces(String assetId, Iterable<AssetFaceResponseDto> data) async {
try {
await _db.batch((batch) {
batch.deleteWhere(_db.assetFaceEntity, (row) => row.assetId.equals(assetId));
for (final face in data) {
batch.insert(
_db.assetFaceEntity,
AssetFaceEntityCompanion(
id: Value(face.id),
assetId: Value(assetId),
personId: Value(face.person?.id),
imageWidth: Value(face.imageWidth),
imageHeight: Value(face.imageHeight),
boundingBoxX1: Value(face.boundingBoxX1),
boundingBoxY1: Value(face.boundingBoxY1),
boundingBoxX2: Value(face.boundingBoxX2),
boundingBoxY2: Value(face.boundingBoxY2),
sourceType: Value(face.sourceType.orElse(null)?.value ?? SourceType.machineLearning.value),
isVisible: const Value(true),
deletedAt: const Value(null),
),
);
}
});
} catch (error, stack) {
_logger.severe('Error: replaceAssetFaces', error, stack);
rethrow;
}
}
Future<void> pruneAssets() async {
try {
await _db.transaction(() async {
@@ -7,7 +7,6 @@ class ServerFeatures {
final bool passwordLogin;
final bool ocr;
final bool smartSearch;
final bool realtimeTranscoding;
const ServerFeatures({
required this.trash,
@@ -16,7 +15,6 @@ class ServerFeatures {
required this.passwordLogin,
this.ocr = false,
this.smartSearch = false,
this.realtimeTranscoding = false,
});
ServerFeatures copyWith({
@@ -26,7 +24,6 @@ class ServerFeatures {
bool? passwordLogin,
bool? ocr,
bool? smartSearch,
bool? realtimeTranscoding,
}) {
return ServerFeatures(
trash: trash ?? this.trash,
@@ -35,13 +32,12 @@ class ServerFeatures {
passwordLogin: passwordLogin ?? this.passwordLogin,
ocr: ocr ?? this.ocr,
smartSearch: smartSearch ?? this.smartSearch,
realtimeTranscoding: realtimeTranscoding ?? this.realtimeTranscoding,
);
}
@override
String toString() {
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch, realtimeTranscoding: $realtimeTranscoding)';
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)';
}
ServerFeatures.fromDto(ServerFeaturesDto dto)
@@ -50,8 +46,7 @@ class ServerFeatures {
oauthEnabled = dto.oauth,
passwordLogin = dto.passwordLogin,
ocr = dto.ocr,
smartSearch = dto.smartSearch,
realtimeTranscoding = dto.realtimeTranscoding;
smartSearch = dto.smartSearch;
@override
bool operator ==(covariant ServerFeatures other) {
@@ -64,8 +59,7 @@ class ServerFeatures {
other.oauthEnabled == oauthEnabled &&
other.passwordLogin == passwordLogin &&
other.ocr == ocr &&
other.smartSearch == smartSearch &&
other.realtimeTranscoding == realtimeTranscoding;
other.smartSearch == smartSearch;
}
@override
@@ -75,7 +69,6 @@ class ServerFeatures {
oauthEnabled.hashCode ^
passwordLogin.hashCode ^
ocr.hashCode ^
smartSearch.hashCode ^
realtimeTranscoding.hashCode;
smartSearch.hashCode;
}
}
@@ -377,7 +377,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
child: NativeVideoViewer(
asset: asset,
isCurrent: isCurrent,
imageProvider: imageProvider,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
),
);
}
@@ -388,7 +388,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
asset: asset,
localFilePath: localFilePath,
isCurrent: isCurrent,
imageProvider: imageProvider,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
),
);
}
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -15,31 +14,22 @@ 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/settings.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
final _hlsVideoSessionIdRegex = RegExp(
r'/video/stream/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/',
);
// For BC if we add an audio endpoint
final _hlsAudioSessionIdRegex = RegExp(
r'/audio/stream/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/',
);
class NativeVideoViewer extends ConsumerStatefulWidget {
final BaseAsset asset;
final String? localFilePath;
final bool isCurrent;
final bool showControls;
final ImageProvider imageProvider;
final Widget image;
const NativeVideoViewer({
super.key,
required this.asset,
this.localFilePath,
required this.imageProvider,
required this.image,
this.isCurrent = false,
this.showControls = true,
});
@@ -56,7 +46,6 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
Timer? _loadTimer;
bool _isVideoReady = false;
bool _shouldPlayOnForeground = true;
String? _remoteAssetId;
VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier);
@@ -78,16 +67,11 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
if (!widget.isCurrent) {
_loadTimer?.cancel();
_notifier.pause();
_notifier.endHlsSession();
return;
}
if (ref.read(serverInfoProvider).serverFeatures.realtimeTranscoding) {
_loadVideo();
} else {
// Prevent unnecessary loading when swiping between assets.
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
}
// Prevent unnecessary loading when swiping between assets.
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
}
@override
@@ -157,22 +141,14 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
);
}
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final realtimeTranscoding = ref.read(serverInfoProvider).serverFeatures.realtimeTranscoding;
// Motion photo clips are short, so spinning up a transcoding session for them is wasteful
final useHls = !isOriginalVideo && videoAsset.livePhotoVideoId == null && realtimeTranscoding;
final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final String videoUrl;
if (useHls) {
videoUrl = '$serverEndpoint/assets/$remoteId/video/stream/main.m3u8';
} else {
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
}
_remoteAssetId = remoteId;
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
} catch (error) {
@@ -233,17 +209,11 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
_notifier.onNativeStatusChanged();
}
void _onSourceResolved() {
final url = _controller?.onPlaybackSourceResolved.value;
_notifier.updateHlsSession(assetId: _remoteAssetId, sessionId: url == null ? null : _extractHlsSessionId(url));
}
void _removeListeners() {
_controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged);
_controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged);
_controller?.onPlaybackReady.removeListener(_onPlaybackReady);
_controller?.onPlaybackEnded.removeListener(_onPlaybackEnded);
_controller?.onPlaybackSourceResolved.removeListener(_onSourceResolved);
}
void _loadVideo() async {
@@ -274,7 +244,6 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(_onPlaybackReady);
nc.onPlaybackEnded.addListener(_onPlaybackEnded);
nc.onPlaybackSourceResolved.addListener(_onSourceResolved);
_controller = nc;
@@ -283,85 +252,30 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
}
}
/// Extracts the HLS session id from a resolved playlist or segment URL,
/// e.g. `https://host/api/assets/{id}/video/stream/{sessionId}/0/playlist.m3u8`.
String? _extractHlsSessionId(String url) =>
_hlsVideoSessionIdRegex.firstMatch(url)?.group(1) ?? _hlsAudioSessionIdRegex.firstMatch(url)?.group(1);
@override
Widget build(BuildContext context) {
final image = Image(image: widget.imageProvider, fit: BoxFit.contain, alignment: Alignment.center);
if (ref.watch(castProvider.select((c) => c.isCasting))) {
return IgnorePointer(child: Center(child: image));
}
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
return IgnorePointer(
child: Stack(
clipBehavior: Clip.none,
children: [
// The engine snaps the video platform view to the device-pixel grid; snap the
// placeholder the same way so it doesn't show a hairline past the video's edge.
_DevicePixelSnap(devicePixelRatio: MediaQuery.devicePixelRatioOf(context), child: image),
Visibility.maintain(
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
if (status == VideoPlaybackStatus.buffering) const Center(child: CircularProgressIndicator()),
Center(child: widget.image),
if (!isCasting) ...[
Visibility.maintain(
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
Center(
child: AnimatedOpacity(
opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0,
duration: const Duration(milliseconds: 400),
child: const CircularProgressIndicator(),
),
),
],
],
),
);
}
}
class _DevicePixelSnap extends SingleChildRenderObjectWidget {
final double devicePixelRatio;
const _DevicePixelSnap({required this.devicePixelRatio, required Widget super.child});
@override
_RenderDevicePixelSnap createRenderObject(BuildContext context) => _RenderDevicePixelSnap(devicePixelRatio);
@override
void updateRenderObject(BuildContext context, _RenderDevicePixelSnap renderObject) {
renderObject.devicePixelRatio = devicePixelRatio;
}
}
class _RenderDevicePixelSnap extends RenderShiftedBox {
_RenderDevicePixelSnap(this._devicePixelRatio) : super(null);
double _devicePixelRatio;
set devicePixelRatio(double value) {
if (_devicePixelRatio == value) {
return;
}
_devicePixelRatio = value;
markNeedsLayout();
}
/// The largest device-pixel-aligned extent that still tucks under the platform view.
double _snap(double extent) {
final scaled = extent * _devicePixelRatio;
final floored = scaled.floorToDouble();
if (floored == scaled) {
return extent;
}
final pixels = floored - 1;
return pixels <= 0 ? extent : pixels / _devicePixelRatio;
}
@override
Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
@override
void performLayout() {
size = constraints.biggest;
final child = this.child;
if (child == null) {
return;
}
child.layout(BoxConstraints.tight(Size(_snap(size.width), _snap(size.height))));
(child.parentData! as BoxParentData).offset = Offset.zero;
}
}
@@ -66,7 +66,7 @@ class DriftMemoryCard extends StatelessWidget {
asset: asset,
isCurrent: isCurrent,
showControls: false,
imageProvider: getFullImageProvider(asset, size: context.sizeData),
image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain),
),
),
);
@@ -1,10 +1,8 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:openapi/api.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
enum VideoPlaybackStatus { paused, playing, buffering, completed }
@@ -35,25 +33,21 @@ final videoPlayerProvider = StateNotifierProvider.autoDispose.family<VideoPlayer
ref,
name,
) {
return VideoPlayerNotifier(ref.read(apiServiceProvider).assetsApi);
return VideoPlayerNotifier();
});
class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
static final _log = Logger('VideoPlayerNotifier');
VideoPlayerNotifier(this._assetsApi) : super(_defaultState);
VideoPlayerNotifier() : super(_defaultState);
final AssetsApi _assetsApi;
NativeVideoPlayerController? _controller;
Timer? _bufferingTimer;
Timer? _seekTimer;
VideoPlaybackStatus? _holdStatus;
String? _hlsAssetId;
String? _hlsSessionId;
@override
void dispose() {
endHlsSession();
_bufferingTimer?.cancel();
_seekTimer?.cancel();
WakelockPlus.disable();
@@ -66,29 +60,6 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
_controller = controller;
}
void updateHlsSession({required String? assetId, required String? sessionId}) {
if (sessionId == null || sessionId == _hlsSessionId) {
return;
}
endHlsSession();
_hlsAssetId = assetId;
_hlsSessionId = sessionId;
}
void endHlsSession() {
final assetId = _hlsAssetId;
final sessionId = _hlsSessionId;
_hlsSessionId = null;
if (assetId == null || sessionId == null) {
return;
}
unawaited(
_assetsApi.endSession(assetId, sessionId).onError((error, stackTrace) {
_log.warning('Failed to end HLS session $sessionId for asset $assetId', error, stackTrace);
}),
);
}
Future<void> load(VideoSource source) async {
_startBufferingTimer();
try {
+26 -2
View File
@@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/debounce.dart';
@@ -181,11 +182,34 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
}
void _handleSyncAssetEditReadyV1(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data));
final assetId = _assetIdFromEditReady(data);
unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data).whenComplete(() => _onAssetEditApplied(assetId)),
);
}
void _handleSyncAssetEditReadyV2(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
final assetId = _assetIdFromEditReady(data);
unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data).whenComplete(() => _onAssetEditApplied(assetId)),
);
}
String? _assetIdFromEditReady(dynamic data) {
if (data is Map && data['asset'] is Map) {
final id = (data['asset'] as Map)['id'];
return id is String ? id : null;
}
return null;
}
/// The edit handler refreshes OCR/faces in the drift DB from a background isolate,
/// so the main-isolate UI providers must be invalidated here to re-read the new data.
void _onAssetEditApplied(String? assetId) {
if (assetId == null) {
return;
}
_ref.invalidate(ocrAssetProvider(assetId));
}
void _processBatchedAssetUploadReadyV1() {
+2
View File
@@ -36,6 +36,7 @@ class ApiService {
late MemoriesApi memoriesApi;
late SessionsApi sessionsApi;
late TagsApi tagsApi;
late FacesApi facesApi;
ApiService() {
// The below line ensures that the api clients are initialized when the service is instantiated
@@ -77,6 +78,7 @@ class ApiService {
memoriesApi = MemoriesApi(_apiClient);
sessionsApi = SessionsApi(_apiClient);
tagsApi = TagsApi(_apiClient);
facesApi = FacesApi(_apiClient);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {
@@ -48,14 +48,6 @@ class NotificationSetting extends HookConsumerWidget {
showPermissionsDialog();
}
}),
)
else
SettingsButtonListTile(
icon: Icons.notifications_active_outlined,
title: 'notification_enabled_list_tile_title'.tr(),
subtileText: 'notification_enabled_list_tile_content'.tr(),
buttonText: 'notification_enabled_list_tile_open_button'.tr(),
onButtonTap: () => openAppSettings(),
),
];
+2 -2
View File
@@ -1128,8 +1128,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "7b07eb0af23de588a8c937c203bcf157397b2c5c"
resolved-ref: "7b07eb0af23de588a8c937c203bcf157397b2c5c"
ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
url: "https://github.com/immich-app/native_video_player"
source: git
version: "1.3.1"
+1 -1
View File
@@ -48,7 +48,7 @@ dependencies:
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
ref: '7b07eb0af23de588a8c937c203bcf157397b2c5c'
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
network_info_plus: ^6.1.4
octo_image: ^2.1.0
openapi:
+1 -10
View File
@@ -222,16 +222,7 @@
"name": "assetLock",
"title": "Move to locked folder",
"description": "Change visibility to locked",
"types": ["AssetV1"],
"schema": {
"properties": {
"inverse": {
"title": "Inverse",
"description": "When true will unarchive any archived assets",
"type": "boolean"
}
}
}
"types": ["AssetV1"]
},
{
"name": "assetTimeline",
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "src/index.ts",
"scripts": {
"build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
"build:tsc": "tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
},
"keywords": [],
+1 -1
View File
@@ -22,6 +22,6 @@ declare module 'main' {
export function assetArchive(): I32;
export function assetLock(): I32;
export function assetTimeline(): I32;
// export function assetTrash(): I32;
export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
}
+27 -19
View File
@@ -1,11 +1,13 @@
import { getWrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type manifestType from '../dist/manifest';
const wrapper = getWrapper<manifestType>();
import { wrapper } from '@immich/plugin-sdk';
import { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
type AssetFileFilterConfig = {
pattern: string;
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
caseSensitive?: boolean;
};
export const assetFileFilter = () => {
return wrapper<'assetFileFilter'>(({ data, config }) => {
return wrapper<WorkflowType.AssetV1, AssetFileFilterConfig>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const { asset } = data;
@@ -41,7 +43,7 @@ export const assetFileFilter = () => {
};
export const assetMissingTimeZoneFilter = () => {
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
@@ -49,7 +51,13 @@ export const assetMissingTimeZoneFilter = () => {
};
export const assetLocationFilter = () => {
return wrapper<'assetLocationFilter'>(({ config, data }) => {
return wrapper<
WorkflowType.AssetV1,
{
region?: { country?: string; state?: string; city?: string };
coordinate?: { latitude?: string; longitude?: string; radius?: number };
}
>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
@@ -88,13 +96,13 @@ export const assetLocationFilter = () => {
};
export const assetTypeFilter = () => {
return wrapper<'assetTypeFilter'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { allowedTypes: AssetTypeEnum[] }>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
};
export const assetFavorite = () => {
return wrapper<'assetFavorite'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
@@ -107,13 +115,13 @@ export const assetFavorite = () => {
};
export const assetVisibility = () => {
return wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
return wrapper<WorkflowType.AssetV1, { visibility: AssetVisibility }>(({ config }) => ({
changes: { asset: { visibility: config.visibility } },
}));
};
export const assetArchive = () => {
return wrapper<'assetArchive'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
@@ -127,7 +135,7 @@ export const assetArchive = () => {
};
export const assetLock = () => {
return wrapper<'assetLock'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
@@ -140,13 +148,13 @@ export const assetLock = () => {
});
};
// export const assetTrash = () => {
// // TODO use trash/untrash host functions
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
// };
export const assetTrash = () => {
// TODO use trash/untrash host functions
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
};
export const assetAddToAlbums = () => {
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
+39 -90
View File
@@ -1,104 +1,53 @@
import type { WorkflowType } from '@immich/sdk';
import { hostFunctions } from 'src/host-functions.js';
import type {
ConfigValue,
WorkflowEventPayload,
WorkflowResponse,
WorkflowStepConfig,
} from 'src/types.js';
type Property = {
type: 'string' | 'boolean' | 'number';
array?: boolean;
enum?: string[];
} & {
type: 'object';
properties: { [K: string]: Property };
required?: string[];
};
export const wrapper = <
T extends WorkflowType,
TConfig extends ConfigValue = ConfigValue,
>(
fn: (
payload: WorkflowEventPayload<T, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<T> | undefined,
) => {
const input = Host.inputString();
type RequiredProperties<
Properties extends { [K: string]: unknown },
Required extends string[] | undefined,
RequiredKeys extends string = Required extends undefined
? never
: NonNullable<Required>[number],
> = {
properties: Pick<Properties, RequiredKeys> &
Partial<Omit<Properties, RequiredKeys>>;
};
try {
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
type GetConfigType<T extends Property> = 'enum' extends keyof T
? NonNullable<T['enum']>[number]
: T['type'] extends 'boolean'
? boolean
: T['type'] extends 'number'
? number
: T['type'] extends 'string'
? string
: T['type'] extends 'object'
? ConfigValue<T>
: never;
const eventConfigBefore = JSON.stringify(event.config);
type ConfigValue<
T extends { properties: { [K: string]: Property }; required?: string[] },
Properties extends { [K: string]: Property } = T['properties'],
> = T extends never
? never
: RequiredProperties<
{
[K in keyof Properties]: Properties[K]['array'] extends true
? Array<GetConfigType<Properties[K]>>
: GetConfigType<Properties[K]>;
},
'required' extends keyof T ? T['required'] : undefined
>['properties'];
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
export const getWrapper =
<T extends Record<string, any>>() =>
<
K extends T['methods'][number]['name'],
L extends WorkflowType = (T['methods'][number] & {
name: K;
})['types'][number],
TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>,
>(
fn: (
payload: WorkflowEventPayload<L, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<L> | undefined,
) => {
const input = Host.inputString();
const response = fn(event) ?? {};
try {
const payload = JSON.parse(input) as WorkflowEventPayload<K, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
};
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
}
};
@@ -224,7 +224,6 @@ export class PluginRepository {
error: (message) => logger.error(message),
} as Console,
logLevel: asExtismLogLevel(logger.getLogLevel()),
enableWasiOutput: true,
},
),
destroy: (plugin) => plugin.close(),