mirror of
https://github.com/immich-app/immich.git
synced 2026-06-26 00:14:27 -07:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e3d8a1225 | |||
| 697818732a | |||
| df590d276a | |||
| 37fa812904 | |||
| d473b664dc | |||
| b2d20edb26 | |||
| f243712eac | |||
| e654001d17 | |||
| b9211c62d0 | |||
| 8321be4745 | |||
| 9751530af8 | |||
| 0931a19c5c | |||
| 08b2e2c0b5 | |||
| e5b50a55a4 |
@@ -45,6 +45,8 @@ jobs:
|
||||
- 'server/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'mise.toml'
|
||||
- 'packages/plugin-core/**'
|
||||
- 'packages/plugin-sdk/**'
|
||||
cli:
|
||||
- 'packages/cli/**'
|
||||
- 'packages/sdk/**'
|
||||
|
||||
+1
-1
@@ -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=v3
|
||||
IMMICH_VERSION=v2
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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 | `v3` | server, machine learning |
|
||||
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
|
||||
| `UPLOAD_LOCATION` | Host path for uploads | | server |
|
||||
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
|
||||
|
||||
|
||||
@@ -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 `:v3`.
|
||||
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1507,6 +1507,9 @@
|
||||
"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,6 +67,9 @@ 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,
|
||||
@@ -78,6 +81,9 @@ class URLSessionManager: NSObject {
|
||||
|
||||
func recreateSession() {
|
||||
session = Self.buildSession(delegate: delegate)
|
||||
if #available(iOS 15, *) {
|
||||
VideoProxyServer.shared.session = session
|
||||
}
|
||||
}
|
||||
|
||||
static func setServerUrls(_ urls: [String]) {
|
||||
@@ -249,9 +255,6 @@ 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)
|
||||
@@ -268,9 +271,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class ServerFeatures {
|
||||
final bool passwordLogin;
|
||||
final bool ocr;
|
||||
final bool smartSearch;
|
||||
final bool realtimeTranscoding;
|
||||
|
||||
const ServerFeatures({
|
||||
required this.trash,
|
||||
@@ -15,6 +16,7 @@ class ServerFeatures {
|
||||
required this.passwordLogin,
|
||||
this.ocr = false,
|
||||
this.smartSearch = false,
|
||||
this.realtimeTranscoding = false,
|
||||
});
|
||||
|
||||
ServerFeatures copyWith({
|
||||
@@ -24,6 +26,7 @@ class ServerFeatures {
|
||||
bool? passwordLogin,
|
||||
bool? ocr,
|
||||
bool? smartSearch,
|
||||
bool? realtimeTranscoding,
|
||||
}) {
|
||||
return ServerFeatures(
|
||||
trash: trash ?? this.trash,
|
||||
@@ -32,12 +35,13 @@ 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)';
|
||||
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch, realtimeTranscoding: $realtimeTranscoding)';
|
||||
}
|
||||
|
||||
ServerFeatures.fromDto(ServerFeaturesDto dto)
|
||||
@@ -46,7 +50,8 @@ class ServerFeatures {
|
||||
oauthEnabled = dto.oauth,
|
||||
passwordLogin = dto.passwordLogin,
|
||||
ocr = dto.ocr,
|
||||
smartSearch = dto.smartSearch;
|
||||
smartSearch = dto.smartSearch,
|
||||
realtimeTranscoding = dto.realtimeTranscoding;
|
||||
|
||||
@override
|
||||
bool operator ==(covariant ServerFeatures other) {
|
||||
@@ -59,7 +64,8 @@ class ServerFeatures {
|
||||
other.oauthEnabled == oauthEnabled &&
|
||||
other.passwordLogin == passwordLogin &&
|
||||
other.ocr == ocr &&
|
||||
other.smartSearch == smartSearch;
|
||||
other.smartSearch == smartSearch &&
|
||||
other.realtimeTranscoding == realtimeTranscoding;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -69,6 +75,7 @@ class ServerFeatures {
|
||||
oauthEnabled.hashCode ^
|
||||
passwordLogin.hashCode ^
|
||||
ocr.hashCode ^
|
||||
smartSearch.hashCode;
|
||||
smartSearch.hashCode ^
|
||||
realtimeTranscoding.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +377,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
child: NativeVideoViewer(
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
||||
imageProvider: imageProvider,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
asset: asset,
|
||||
localFilePath: localFilePath,
|
||||
isCurrent: isCurrent,
|
||||
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
||||
imageProvider: imageProvider,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -14,22 +15,31 @@ 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 Widget image;
|
||||
final ImageProvider imageProvider;
|
||||
|
||||
const NativeVideoViewer({
|
||||
super.key,
|
||||
required this.asset,
|
||||
this.localFilePath,
|
||||
required this.image,
|
||||
required this.imageProvider,
|
||||
this.isCurrent = false,
|
||||
this.showControls = true,
|
||||
});
|
||||
@@ -46,6 +56,7 @@ 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);
|
||||
|
||||
@@ -67,11 +78,16 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
if (!widget.isCurrent) {
|
||||
_loadTimer?.cancel();
|
||||
_notifier.pause();
|
||||
_notifier.endHlsSession();
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent unnecessary loading when swiping between assets.
|
||||
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
|
||||
if (ref.read(serverInfoProvider).serverFeatures.realtimeTranscoding) {
|
||||
_loadVideo();
|
||||
} else {
|
||||
// Prevent unnecessary loading when swiping between assets.
|
||||
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -141,14 +157,22 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
);
|
||||
}
|
||||
|
||||
final remoteId = (videoAsset as RemoteAsset).id;
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
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';
|
||||
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;
|
||||
|
||||
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
|
||||
} catch (error) {
|
||||
@@ -209,11 +233,17 @@ 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 {
|
||||
@@ -244,6 +274,7 @@ 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;
|
||||
|
||||
@@ -252,30 +283,85 @@ 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 isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
|
||||
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 status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
|
||||
return IgnorePointer(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
// 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()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain),
|
||||
imageProvider: getFullImageProvider(asset, size: context.sizeData),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 }
|
||||
@@ -33,21 +35,25 @@ final videoPlayerProvider = StateNotifierProvider.autoDispose.family<VideoPlayer
|
||||
ref,
|
||||
name,
|
||||
) {
|
||||
return VideoPlayerNotifier();
|
||||
return VideoPlayerNotifier(ref.read(apiServiceProvider).assetsApi);
|
||||
});
|
||||
|
||||
class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
static final _log = Logger('VideoPlayerNotifier');
|
||||
|
||||
VideoPlayerNotifier() : super(_defaultState);
|
||||
VideoPlayerNotifier(this._assetsApi) : 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();
|
||||
@@ -60,6 +66,29 @@ 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 {
|
||||
|
||||
@@ -48,6 +48,14 @@ 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
@@ -1128,8 +1128,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
|
||||
resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
|
||||
ref: "7b07eb0af23de588a8c937c203bcf157397b2c5c"
|
||||
resolved-ref: "7b07eb0af23de588a8c937c203bcf157397b2c5c"
|
||||
url: "https://github.com/immich-app/native_video_player"
|
||||
source: git
|
||||
version: "1.3.1"
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ dependencies:
|
||||
native_video_player:
|
||||
git:
|
||||
url: https://github.com/immich-app/native_video_player
|
||||
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
|
||||
ref: '7b07eb0af23de588a8c937c203bcf157397b2c5c'
|
||||
network_info_plus: ^6.1.4
|
||||
octo_image: ^2.1.0
|
||||
openapi:
|
||||
|
||||
@@ -222,7 +222,16 @@
|
||||
"name": "assetLock",
|
||||
"title": "Move to locked folder",
|
||||
"description": "Change visibility to locked",
|
||||
"types": ["AssetV1"]
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"properties": {
|
||||
"inverse": {
|
||||
"title": "Inverse",
|
||||
"description": "When true will unarchive any archived assets",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assetTimeline",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "pnpm build:tsc && pnpm build:wasm",
|
||||
"build:tsc": "tsc --noEmit && node esbuild.js",
|
||||
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
|
||||
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
Vendored
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { wrapper } from '@immich/plugin-sdk';
|
||||
import { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
|
||||
import { getWrapper } from '@immich/plugin-sdk';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import type manifestType from '../dist/manifest';
|
||||
|
||||
const wrapper = getWrapper<manifestType>();
|
||||
|
||||
type AssetFileFilterConfig = {
|
||||
pattern: string;
|
||||
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
|
||||
caseSensitive?: boolean;
|
||||
};
|
||||
export const assetFileFilter = () => {
|
||||
return wrapper<WorkflowType.AssetV1, AssetFileFilterConfig>(({ data, config }) => {
|
||||
return wrapper<'assetFileFilter'>(({ data, config }) => {
|
||||
const { pattern, matchType = 'contains', caseSensitive = false } = config;
|
||||
|
||||
const { asset } = data;
|
||||
@@ -43,7 +41,7 @@ export const assetFileFilter = () => {
|
||||
};
|
||||
|
||||
export const assetMissingTimeZoneFilter = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
|
||||
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
|
||||
const needsTimeZone = config.inverse ? true : false;
|
||||
return { workflow: { continue: hasTimeZone === needsTimeZone } };
|
||||
@@ -51,13 +49,7 @@ export const assetMissingTimeZoneFilter = () => {
|
||||
};
|
||||
|
||||
export const assetLocationFilter = () => {
|
||||
return wrapper<
|
||||
WorkflowType.AssetV1,
|
||||
{
|
||||
region?: { country?: string; state?: string; city?: string };
|
||||
coordinate?: { latitude?: string; longitude?: string; radius?: number };
|
||||
}
|
||||
>(({ config, data }) => {
|
||||
return wrapper<'assetLocationFilter'>(({ config, data }) => {
|
||||
if (
|
||||
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
|
||||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
|
||||
@@ -96,13 +88,13 @@ export const assetLocationFilter = () => {
|
||||
};
|
||||
|
||||
export const assetTypeFilter = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { allowedTypes: AssetTypeEnum[] }>(({ config, data }) => {
|
||||
return wrapper<'assetTypeFilter'>(({ config, data }) => {
|
||||
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
|
||||
});
|
||||
};
|
||||
|
||||
export const assetFavorite = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
return wrapper<'assetFavorite'>(({ config, data }) => {
|
||||
const target = config.inverse ? false : true;
|
||||
if (target !== data.asset.isFavorite) {
|
||||
return {
|
||||
@@ -115,13 +107,13 @@ export const assetFavorite = () => {
|
||||
};
|
||||
|
||||
export const assetVisibility = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { visibility: AssetVisibility }>(({ config }) => ({
|
||||
changes: { asset: { visibility: config.visibility } },
|
||||
return wrapper<'assetVisibility'>(({ config }) => ({
|
||||
changes: { asset: { visibility: config.visibility as AssetVisibility } },
|
||||
}));
|
||||
};
|
||||
|
||||
export const assetArchive = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
return wrapper<'assetArchive'>(({ config, data }) => {
|
||||
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
|
||||
}
|
||||
@@ -135,7 +127,7 @@ export const assetArchive = () => {
|
||||
};
|
||||
|
||||
export const assetLock = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
return wrapper<'assetLock'>(({ config, data }) => {
|
||||
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
|
||||
}
|
||||
@@ -148,13 +140,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<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
|
||||
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
|
||||
const assetId = data.asset.id;
|
||||
|
||||
if (config.albumIds.length === 0) {
|
||||
|
||||
@@ -1,53 +1,104 @@
|
||||
import type { WorkflowType } from '@immich/sdk';
|
||||
import { hostFunctions } from 'src/host-functions.js';
|
||||
import type {
|
||||
ConfigValue,
|
||||
WorkflowEventPayload,
|
||||
WorkflowResponse,
|
||||
WorkflowStepConfig,
|
||||
} from 'src/types.js';
|
||||
|
||||
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();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(input) as WorkflowEventPayload<T, 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;
|
||||
}
|
||||
type Property = {
|
||||
type: 'string' | 'boolean' | 'number';
|
||||
array?: boolean;
|
||||
enum?: string[];
|
||||
} & {
|
||||
type: 'object';
|
||||
properties: { [K: string]: Property };
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
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>>;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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'];
|
||||
|
||||
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();
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -224,6 +224,7 @@ export class PluginRepository {
|
||||
error: (message) => logger.error(message),
|
||||
} as Console,
|
||||
logLevel: asExtismLogLevel(logger.getLogLevel()),
|
||||
enableWasiOutput: true,
|
||||
},
|
||||
),
|
||||
destroy: (plugin) => plugin.close(),
|
||||
|
||||
Reference in New Issue
Block a user