Compare commits

..

5 Commits

Author SHA1 Message Date
ben-basten
e525aa04ab feat: tag/folder tree keyboard accessibility 2025-12-02 21:08:32 -05:00
renovate[bot]
4f93eda8d8 fix(deps): update typescript-projects (#24329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-02 23:28:12 +01:00
Alex
f5df5fa98d chore: change workflow column name (#24349)
chore-change-workflow-column-name
2025-12-02 14:40:17 -06:00
renovate[bot]
f07d1441ea chore(deps): update github-actions (#24331)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 20:13:02 +01:00
Jonathan Jogenfors
1bcf28c062 chore(server): sidecars in asset_files (#21199)
* fix: sidecar check job

* feat: move sidecars to asset_files

* feat: combine with handleSidecarCheck

* fix(server): improved method signatures for stack and sidecar copying

* fix(server): improved method signatures for stack and sidecar copying

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-12-02 13:31:43 -05:00
66 changed files with 1889 additions and 1736 deletions

View File

@@ -105,7 +105,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
flavor: |
latest=false

View File

@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:73a05fc805dfd3bd29bebc08442aedfec5c419c5ad3421ec73edc5647233891a
image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
with:
category: '/language:${{matrix.language}}'

View File

@@ -572,7 +572,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:
# python-version: 3.11

View File

@@ -1006,7 +1006,7 @@ describe('/libraries', () => {
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],

View File

@@ -3,7 +3,7 @@ experimental_monorepo_root = true
[tools]
node = "24.11.1"
flutter = "3.35.7"
pnpm = "10.22.0"
pnpm = "10.24.0"
terragrunt = "0.93.10"
opentofu = "1.10.7"
java = "25.0.1"

View File

@@ -1,7 +0,0 @@
class OAuthLoginData {
final String serverUrl;
final String state;
final String codeVerifier;
const OAuthLoginData({required this.serverUrl, required this.state, required this.codeVerifier});
}

View File

@@ -27,7 +27,7 @@ class LoginPage extends HookConsumerWidget {
});
return Scaffold(
body: const LoginForm(),
body: LoginForm(),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16.0),

View File

@@ -12,29 +12,13 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class ServerAuthSettings {
final String endpoint;
final bool isOAuthEnabled;
final bool isPasswordLoginEnabled;
final String oAuthButtonText;
const ServerAuthSettings({
required this.endpoint,
required this.isOAuthEnabled,
required this.isPasswordLoginEnabled,
required this.oAuthButtonText,
});
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(
ref.watch(authServiceProvider),
@@ -43,7 +27,6 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
ref.watch(uploadServiceProvider),
ref.watch(secureStorageServiceProvider),
ref.watch(widgetServiceProvider),
ref.watch(serverInfoServiceProvider),
);
});
@@ -54,7 +37,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
final UploadService _uploadService;
final SecureStorageService _secureStorageService;
final WidgetService _widgetService;
final ServerInfoService _serverInfoService;
final _log = Logger("AuthenticationNotifier");
static const Duration _timeoutDuration = Duration(seconds: 7);
@@ -66,7 +48,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
this._uploadService,
this._secureStorageService,
this._widgetService,
this._serverInfoService,
) : super(
const AuthState(
deviceId: "",
@@ -83,27 +64,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
return _authService.validateServerUrl(url);
}
Future<ServerAuthSettings?> getServerAuthSettings(String serverUrl) async {
final sanitizedUrl = sanitizeUrl(serverUrl);
final encodedUrl = punycodeEncodeUrl(sanitizedUrl);
final endpoint = await _authService.validateServerUrl(encodedUrl);
final features = await _serverInfoService.getServerFeatures();
final config = await _serverInfoService.getServerConfig();
if (features == null || config == null) {
return null;
}
return ServerAuthSettings(
endpoint: endpoint,
isOAuthEnabled: features.oauthEnabled,
isPasswordLoginEnabled: features.passwordLogin,
oAuthButtonText: config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth',
);
}
/// Validating the url is the alternative connecting server url without
/// saving the information to the local database
Future<bool> validateAuxilaryServerUrl(String url) async {

View File

@@ -1,27 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/oauth.service.dart';
import 'package:openapi/api.dart';
export 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
final oAuthServiceProvider = Provider((ref) => OAuthService(ref.watch(apiServiceProvider)));
final oAuthProvider = StateNotifierProvider<OAuthNotifier, AsyncValue<void>>(
(ref) => OAuthNotifier(ref.watch(oAuthServiceProvider)),
);
class OAuthNotifier extends StateNotifier<AsyncValue<void>> {
final OAuthService _oAuthService;
OAuthNotifier(this._oAuthService) : super(const AsyncValue.data(null));
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) {
return _oAuthService.getOAuthLoginData(serverUrl);
}
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
return _oAuthService.completeOAuthLogin(oAuthData);
}
}

View File

@@ -1,11 +1,5 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -17,50 +11,6 @@ class OAuthService {
final log = Logger('OAuthService');
OAuthService(this._apiService);
String _generateRandomString(int length) {
const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
final random = Random.secure();
return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
}
List<int> _randomBytes(int length) {
final random = Random.secure();
return List<int>.generate(length, (i) => random.nextInt(256));
}
/// Per specification, the code verifier must be 43-128 characters long
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
String _randomCodeVerifier() {
return base64Url.encode(_randomBytes(42));
}
String _generatePKCECodeChallenge(String codeVerifier) {
final bytes = utf8.encode(codeVerifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Initiates OAuth login flow.
/// Returns the OAuth server URL to redirect to, along with PKCE parameters.
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) async {
final state = _generateRandomString(32);
final codeVerifier = _randomCodeVerifier();
final codeChallenge = _generatePKCECodeChallenge(codeVerifier);
final oAuthServerUrl = await getOAuthServerUrl(sanitizeUrl(serverUrl), state, codeChallenge);
if (oAuthServerUrl == null) {
return null;
}
return OAuthLoginData(serverUrl: oAuthServerUrl, state: state, codeVerifier: codeVerifier);
}
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
return oAuthLogin(oAuthData.serverUrl, oAuthData.state, oAuthData.codeVerifier);
}
Future<String?> getOAuthServerUrl(String serverUrl, String state, String codeChallenge) async {
// Resolve API server endpoint from user provided serverUrl
await _apiService.resolveAndSetEndpoint(serverUrl);

View File

@@ -1,13 +1,14 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class LoginButton extends StatelessWidget {
final VoidCallback onPressed;
class LoginButton extends ConsumerWidget {
final Function() onPressed;
const LoginButton({super.key, required this.onPressed});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
onPressed: onPressed,

View File

@@ -1,95 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
import 'package:immich_mobile/widgets/forms/login/version_compatibility_warning.dart';
class LoginCredentialsForm extends StatelessWidget {
final TextEditingController emailController;
final TextEditingController passwordController;
final TextEditingController serverEndpointController;
final FocusNode emailFocusNode;
final FocusNode passwordFocusNode;
final bool isLoading;
final bool isOAuthEnabled;
final bool isPasswordLoginEnabled;
final String oAuthButtonLabel;
final String? warningMessage;
final VoidCallback onLogin;
final VoidCallback onOAuthLogin;
final VoidCallback onBack;
const LoginCredentialsForm({
super.key,
required this.emailController,
required this.passwordController,
required this.serverEndpointController,
required this.emailFocusNode,
required this.passwordFocusNode,
required this.isLoading,
required this.isOAuthEnabled,
required this.isPasswordLoginEnabled,
required this.oAuthButtonLabel,
required this.warningMessage,
required this.onLogin,
required this.onOAuthLogin,
required this.onBack,
});
@override
Widget build(BuildContext context) {
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (warningMessage != null) VersionCompatibilityWarning(message: warningMessage!),
Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
if (isPasswordLoginEnabled) ...[
const SizedBox(height: 18),
EmailInput(
controller: emailController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: onLogin),
],
isLoading
? const LoadingIcon()
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
if (isPasswordLoginEnabled) LoginButton(onPressed: onLogin),
if (isOAuthEnabled) ...[
if (isPasswordLoginEnabled)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel,
onPressed: onOAuthLogin,
),
],
],
),
if (!isOAuthEnabled && !isPasswordLoginEnabled) Center(child: const Text('login_disabled').tr()),
const SizedBox(height: 12),
TextButton.icon(icon: const Icon(Icons.arrow_back), onPressed: onBack, label: const Text('back').tr()),
],
),
);
}
}

View File

@@ -1,10 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:crypto/crypto.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -25,382 +29,492 @@ import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/forms/login/login_credentials_form.dart';
import 'package:immich_mobile/widgets/forms/login/server_selection_form.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
class LoginForm extends ConsumerStatefulWidget {
const LoginForm({super.key});
class LoginForm extends HookConsumerWidget {
LoginForm({super.key});
final log = Logger('LoginForm');
@override
ConsumerState<LoginForm> createState() => _LoginFormState();
}
Widget build(BuildContext context, WidgetRef ref) {
final emailController = useTextEditingController.fromValue(TextEditingValue.empty);
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty);
final emailFocusNode = useFocusNode();
final passwordFocusNode = useFocusNode();
final serverEndpointFocusNode = useFocusNode();
final isLoading = useState<bool>(false);
final isLoadingServer = useState<bool>(false);
final isOauthEnable = useState<bool>(false);
final isPasswordLoginEnable = useState<bool>(false);
final oAuthButtonLabel = useState<String>('OAuth');
final logoAnimationController = useAnimationController(duration: const Duration(seconds: 60))..repeat();
final serverInfo = ref.watch(serverInfoProvider);
final warningMessage = useState<String?>(null);
final loginFormKey = GlobalKey<FormState>();
final ValueNotifier<String?> serverEndpoint = useState<String?>(null);
class _LoginFormState extends ConsumerState<LoginForm> with SingleTickerProviderStateMixin {
final _log = Logger('LoginForm');
final _loginFormKey = GlobalKey<FormState>();
checkVersionMismatch() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
late final TextEditingController _emailController;
late final TextEditingController _passwordController;
late final TextEditingController _serverEndpointController;
late final FocusNode _emailFocusNode;
late final FocusNode _passwordFocusNode;
late final FocusNode _serverEndpointFocusNode;
late final AnimationController _logoAnimationController;
bool _isLoading = false;
bool _isLoadingServer = false;
bool _isOAuthEnabled = false;
bool _isPasswordLoginEnabled = false;
String _oAuthButtonLabel = 'OAuth';
String? _serverEndpoint;
String? _warningMessage;
@override
void initState() {
super.initState();
_emailController = TextEditingController();
_passwordController = TextEditingController();
_serverEndpointController = TextEditingController();
_emailFocusNode = FocusNode();
_passwordFocusNode = FocusNode();
_serverEndpointFocusNode = FocusNode();
_logoAnimationController = AnimationController(vsync: this, duration: const Duration(seconds: 60))..repeat();
// Load saved server URL if available
WidgetsBinding.instance.addPostFrameCallback((_) {
final serverUrl = getServerUrl();
if (serverUrl != null) {
_serverEndpointController.text = serverUrl;
}
});
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_serverEndpointController.dispose();
_emailFocusNode.dispose();
_passwordFocusNode.dispose();
_serverEndpointFocusNode.dispose();
_logoAnimationController.dispose();
super.dispose();
}
Future<void> _checkVersionMismatch() async {
try {
final serverInfo = ref.read(serverInfoProvider);
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
setState(() {
_warningMessage = getVersionCompatibilityMessage(
warningMessage.value = getVersionCompatibilityMessage(
appMajorVersion,
appMinorVersion,
serverMajorVersion,
serverMinorVersion,
);
});
} catch (error) {
setState(() {
_warningMessage = 'Error checking version compatibility';
});
}
}
Future<void> _getServerAuthSettings() async {
final serverUrl = _serverEndpointController.text;
if (serverUrl.isEmpty) {
ImmichToast.show(context: context, msg: "login_form_server_empty".tr(), toastType: ToastType.error);
return;
} catch (error) {
warningMessage.value = 'Error checking version compatibility';
}
}
try {
setState(() {
_isLoadingServer = true;
});
/// Fetch the server login credential and enables oAuth login if necessary
/// Returns true if successful, false otherwise
Future<void> getServerAuthSettings() async {
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
final settings = await ref.read(authProvider.notifier).getServerAuthSettings(serverUrl);
if (settings == null) {
// Guard empty URL
if (serverUrl.isEmpty) {
ImmichToast.show(context: context, msg: "login_form_server_empty".tr(), toastType: ToastType.error);
}
try {
isLoadingServer.value = true;
final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
// Fetch and load server config and features
await ref.read(serverInfoProvider.notifier).getServerInfo();
final serverInfo = ref.read(serverInfoProvider);
final features = serverInfo.serverFeatures;
final config = serverInfo.serverConfig;
isOauthEnable.value = features.oauthEnabled;
isPasswordLoginEnable.value = features.passwordLogin;
oAuthButtonLabel.value = config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth';
serverEndpoint.value = endpoint;
} on ApiException catch (e) {
ImmichToast.show(
context: context,
msg: e.message ?? 'login_form_api_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} on HandshakeException {
ImmichToast.show(
context: context,
msg: 'login_form_handshake_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} catch (e) {
ImmichToast.show(
context: context,
msg: 'login_form_server_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
_resetServerState();
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
}
isLoadingServer.value = false;
}
useEffect(() {
final serverUrl = getServerUrl();
if (serverUrl != null) {
serverEndpointController.text = serverUrl;
}
return null;
}, []);
populateTestLoginInfo() {
emailController.text = 'demo@immich.app';
passwordController.text = 'demo';
serverEndpointController.text = 'https://demo.immich.app';
}
populateTestLoginInfo1() {
emailController.text = 'testuser@email.com';
passwordController.text = 'password';
serverEndpointController.text = 'http://10.1.15.216:2283/api';
}
Future<void> handleSyncFlow() async {
final backgroundManager = ref.read(backgroundSyncProvider);
await backgroundManager.syncLocal(full: true);
await backgroundManager.syncRemote();
await backgroundManager.hashAssets();
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
}
getManageMediaPermission() async {
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
if (!hasPermission) {
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
elevation: 5,
title: Text(
'manage_media_access_title',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
const SizedBox(height: 4),
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'cancel'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
TextButton(
onPressed: () {
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
Navigator.of(context).pop();
},
child: Text(
'manage_media_access_settings'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
],
);
},
);
}
}
bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
login() async {
TextInput.finishAutofillContext();
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
try {
final result = await ref.read(authProvider.notifier).login(emailController.text, passwordController.text);
if (result.shouldChangePassword && !result.isAdmin) {
unawaited(context.pushRoute(const ChangePasswordRoute()));
} else {
final isBeta = Store.isBetaTimelineEnabled;
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error) {
ImmichToast.show(
context: context,
msg: "login_form_failed_login".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
isLoading.value = false;
}
}
String generateRandomString(int length) {
const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
final random = Random.secure();
return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
}
List<int> randomBytes(int length) {
final random = Random.secure();
return List<int>.generate(length, (i) => random.nextInt(256));
}
/// Per specification, the code verifier must be 43-128 characters long
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
String randomCodeVerifier() {
return base64Url.encode(randomBytes(42));
}
Future<String> generatePKCECodeChallenge(String codeVerifier) async {
var bytes = utf8.encode(codeVerifier);
var digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
oAuthLogin() async {
var oAuthService = ref.watch(oAuthServiceProvider);
String? oAuthServerUrl;
final state = generateRandomString(32);
final codeVerifier = randomCodeVerifier();
final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
try {
oAuthServerUrl = await oAuthService.getOAuthServerUrl(
sanitizeUrl(serverEndpointController.text),
state,
codeChallenge,
);
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
} catch (error, stack) {
log.severe('Error getting OAuth server Url: $error', stack);
ImmichToast.show(
context: context,
msg: "login_form_failed_get_oauth_server_config".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isLoading.value = false;
return;
}
setState(() {
_isOAuthEnabled = settings.isOAuthEnabled;
_isPasswordLoginEnabled = settings.isPasswordLoginEnabled;
_oAuthButtonLabel = settings.oAuthButtonText;
_serverEndpoint = settings.endpoint;
_isLoadingServer = false;
});
if (oAuthServerUrl != null) {
try {
final loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl, state, codeVerifier);
await _checkVersionMismatch();
} on ApiException catch (e) {
ImmichToast.show(
context: context,
msg: e.message ?? 'login_form_api_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
_resetServerState();
} on HandshakeException {
ImmichToast.show(
context: context,
msg: 'login_form_handshake_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
_resetServerState();
} catch (e) {
ImmichToast.show(
context: context,
msg: 'login_form_server_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
_resetServerState();
}
}
void _resetServerState() {
setState(() {
_isOAuthEnabled = false;
_isPasswordLoginEnabled = true;
_isLoadingServer = false;
});
}
void _populateTestLoginInfo() {
_emailController.text = 'demo@immich.app';
_passwordController.text = 'demo';
_serverEndpointController.text = 'https://demo.immich.app';
}
void _populateTestLoginInfo1() {
_emailController.text = 'testuser@email.com';
_passwordController.text = 'password';
_serverEndpointController.text = 'http://10.1.15.216:2283/api';
}
Future<void> _handleSyncFlow() async {
final backgroundManager = ref.read(backgroundSyncProvider);
await backgroundManager.syncLocal(full: true);
await backgroundManager.syncRemote();
await backgroundManager.hashAssets();
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
}
Future<void> _getManageMediaPermission() async {
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
if (!hasPermission) {
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
elevation: 5,
title: Text(
'manage_media_access_title',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
const SizedBox(height: 4),
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'cancel'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
TextButton(
onPressed: () {
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
Navigator.of(context).pop();
},
child: Text(
'manage_media_access_settings'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
],
);
},
);
}
}
bool _isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
Future<void> _login() async {
TextInput.finishAutofillContext();
setState(() {
_isLoading = true;
});
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
try {
final result = await ref.read(authProvider.notifier).login(_emailController.text, _passwordController.text);
if (result.shouldChangePassword && !result.isAdmin) {
unawaited(context.pushRoute(const ChangePasswordRoute()));
} else {
final isBeta = Store.isBetaTimelineEnabled;
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (_isSyncRemoteDeletionsMode()) {
await _getManageMediaPermission();
if (loginResponseDto == null) {
return;
}
unawaited(_handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(context.replaceRoute(const TabShellRoute()));
return;
log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}");
final isSuccess = await ref
.watch(authProvider.notifier)
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
if (isSuccess) {
isLoading.value = false;
final permission = ref.watch(galleryPermissionNotifier);
final isBeta = Store.isBetaTimelineEnabled;
if (!isBeta && (permission.isGranted || permission.isLimited)) {
unawaited(ref.watch(backupProvider.notifier).resumeBackup());
}
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error, stack) {
log.severe('Error logging in with OAuth: $error', stack);
ImmichToast.show(
context: context,
msg: error.toString(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
isLoading.value = false;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error) {
ImmichToast.show(
context: context,
msg: "login_form_failed_login".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
setState(() {
_isLoading = false;
});
}
}
Future<void> _oAuthLogin() async {
setState(() {
_isLoading = true;
});
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
try {
final oAuthData = await ref
.read(oAuthProvider.notifier)
.getOAuthLoginData(sanitizeUrl(_serverEndpointController.text));
if (oAuthData == null) {
} else {
ImmichToast.show(
context: context,
msg: "login_form_failed_get_oauth_server_disable".tr(),
toastType: ToastType.info,
gravity: ToastGravity.TOP,
);
setState(() {
_isLoading = false;
});
isLoading.value = false;
return;
}
final loginResponseDto = await ref.read(oAuthProvider.notifier).completeOAuthLogin(oAuthData);
if (loginResponseDto == null) {
setState(() {
_isLoading = false;
});
return;
}
_log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}");
final isSuccess = await ref.read(authProvider.notifier).saveAuthInfo(accessToken: loginResponseDto.accessToken);
if (isSuccess) {
setState(() {
_isLoading = false;
});
final permission = ref.read(galleryPermissionNotifier);
final isBeta = Store.isBetaTimelineEnabled;
if (!isBeta && (permission.isGranted || permission.isLimited)) {
unawaited(ref.read(backupProvider.notifier).resumeBackup());
}
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (_isSyncRemoteDeletionsMode()) {
await _getManageMediaPermission();
}
unawaited(_handleSyncFlow());
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error, stack) {
_log.severe('Error logging in with OAuth: $error', stack);
ImmichToast.show(context: context, msg: error.toString(), toastType: ToastType.error, gravity: ToastGravity.TOP);
} finally {
setState(() {
_isLoading = false;
});
}
}
void _goBack() {
setState(() {
_serverEndpoint = null;
});
}
buildSelectServer() {
const buttonRadius = 25.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
onSubmit: getServerAuthSettings,
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(buttonRadius),
bottomLeft: Radius.circular(buttonRadius),
),
),
),
onPressed: () => context.pushRoute(const SettingsRoute()),
icon: const Icon(Icons.settings_rounded),
label: const Text(""),
),
),
const SizedBox(width: 1),
Expanded(
flex: 3,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(buttonRadius),
bottomRight: Radius.circular(buttonRadius),
),
),
),
onPressed: isLoadingServer.value ? null : getServerAuthSettings,
icon: const Icon(Icons.arrow_forward_rounded),
label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
),
],
),
const SizedBox(height: 18),
if (isLoadingServer.value) const LoadingIcon(),
],
);
}
@override
Widget build(BuildContext context) {
final serverSelectionOrLogin = _serverEndpoint == null
? ServerSelectionForm(
serverEndpointController: _serverEndpointController,
serverEndpointFocusNode: _serverEndpointFocusNode,
isLoading: _isLoadingServer,
onSubmit: _getServerAuthSettings,
)
: LoginCredentialsForm(
emailController: _emailController,
passwordController: _passwordController,
serverEndpointController: _serverEndpointController,
emailFocusNode: _emailFocusNode,
passwordFocusNode: _passwordFocusNode,
isLoading: _isLoading,
isOAuthEnabled: _isOAuthEnabled,
isPasswordLoginEnabled: _isPasswordLoginEnabled,
oAuthButtonLabel: _oAuthButtonLabel,
warningMessage: _warningMessage,
onLogin: _login,
onOAuthLogin: _oAuthLogin,
onBack: _goBack,
);
buildVersionCompatWarning() {
checkVersionMismatch();
if (warningMessage.value == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!),
),
child: Text(warningMessage.value!, textAlign: TextAlign.center),
),
);
}
buildLogin() {
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
buildVersionCompatWarning(),
Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
if (isPasswordLoginEnable.value) ...[
const SizedBox(height: 18),
EmailInput(
controller: emailController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: login),
],
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
isLoading.value
? const LoadingIcon()
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
if (isPasswordLoginEnable.value) LoginButton(onPressed: login),
if (isOauthEnable.value) ...[
if (isPasswordLoginEnable.value)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel.value,
isLoading: isLoading,
onPressed: oAuthLogin,
),
],
],
),
if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: const Text('login_disabled').tr()),
const SizedBox(height: 12),
TextButton.icon(
icon: const Icon(Icons.arrow_back),
onPressed: () => serverEndpoint.value = null,
label: const Text('back').tr(),
),
],
),
);
}
final serverSelectionOrLogin = serverEndpoint.value == null ? buildSelectServer() : buildLogin();
return LayoutBuilder(
builder: (context, constraints) {
@@ -418,19 +532,20 @@ class _LoginFormState extends ConsumerState<LoginForm> with SingleTickerProvider
mainAxisAlignment: MainAxisAlignment.end,
children: [
GestureDetector(
onDoubleTap: _populateTestLoginInfo,
onLongPress: _populateTestLoginInfo1,
onDoubleTap: () => populateTestLoginInfo(),
onLongPress: () => populateTestLoginInfo1(),
child: RotationTransition(
turns: _logoAnimationController,
turns: logoAnimationController,
child: const ImmichLogo(heroTag: 'logo'),
),
),
const Padding(padding: EdgeInsets.only(top: 8.0, bottom: 16), child: ImmichTitleText()),
],
),
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
Form(key: _loginFormKey, child: serverSelectionOrLogin),
Form(key: loginFormKey, child: serverSelectionOrLogin),
],
),
),

View File

@@ -1,20 +1,23 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class OAuthLoginButton extends StatelessWidget {
class OAuthLoginButton extends ConsumerWidget {
final TextEditingController serverEndpointController;
final ValueNotifier<bool> isLoading;
final String buttonLabel;
final VoidCallback onPressed;
final Function() onPressed;
const OAuthLoginButton({
super.key,
required this.serverEndpointController,
required this.isLoading,
required this.buttonLabel,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: context.primaryColor.withAlpha(230),

View File

@@ -1,45 +1,36 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class PasswordInput extends StatefulWidget {
class PasswordInput extends HookConsumerWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final VoidCallback? onSubmit;
final Function()? onSubmit;
const PasswordInput({super.key, required this.controller, this.focusNode, this.onSubmit});
@override
State<PasswordInput> createState() => _PasswordInputState();
}
Widget build(BuildContext context, WidgetRef ref) {
final isPasswordVisible = useState<bool>(false);
class _PasswordInputState extends State<PasswordInput> {
bool _isPasswordVisible = false;
void _togglePasswordVisibility() {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
}
@override
Widget build(BuildContext context) {
return TextFormField(
obscureText: !_isPasswordVisible,
controller: widget.controller,
obscureText: !isPasswordVisible.value,
controller: controller,
decoration: InputDecoration(
labelText: 'password'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_password_hint'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
suffixIcon: IconButton(
onPressed: _togglePasswordVisibility,
icon: Icon(_isPasswordVisible ? Icons.visibility_off_sharp : Icons.visibility_sharp),
onPressed: () => isPasswordVisible.value = !isPasswordVisible.value,
icon: Icon(isPasswordVisible.value ? Icons.visibility_off_sharp : Icons.visibility_sharp),
),
),
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.text,
onFieldSubmitted: (_) => widget.onSubmit?.call(),
focusNode: widget.focusNode,
onFieldSubmitted: (_) => onSubmit?.call(),
focusNode: focusNode,
textInputAction: TextInputAction.go,
);
}

View File

@@ -1,78 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
class ServerSelectionForm extends StatelessWidget {
final TextEditingController serverEndpointController;
final FocusNode serverEndpointFocusNode;
final bool isLoading;
final VoidCallback onSubmit;
const ServerSelectionForm({
super.key,
required this.serverEndpointController,
required this.serverEndpointFocusNode,
required this.isLoading,
required this.onSubmit,
});
static const double _buttonRadius = 25.0;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
onSubmit: onSubmit,
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(_buttonRadius),
bottomLeft: Radius.circular(_buttonRadius),
),
),
),
onPressed: () => context.pushRoute(const SettingsRoute()),
icon: const Icon(Icons.settings_rounded),
label: const Text(""),
),
),
const SizedBox(width: 1),
Expanded(
flex: 3,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(_buttonRadius),
bottomRight: Radius.circular(_buttonRadius),
),
),
),
onPressed: isLoading ? null : onSubmit,
icon: const Icon(Icons.arrow_forward_rounded),
label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
),
],
),
const SizedBox(height: 18),
if (isLoading) const LoadingIcon(),
],
);
}
}

View File

@@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class VersionCompatibilityWarning extends StatelessWidget {
final String message;
const VersionCompatibilityWarning({super.key, required this.message});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!),
),
child: Text(message, textAlign: TextAlign.center),
),
);
}
}

View File

@@ -14,7 +14,7 @@ class WorkflowActionItemDto {
/// Returns a new [WorkflowActionItemDto] instance.
WorkflowActionItemDto({
this.actionConfig,
required this.actionId,
required this.pluginActionId,
});
///
@@ -25,21 +25,21 @@ class WorkflowActionItemDto {
///
Object? actionConfig;
String actionId;
String pluginActionId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowActionItemDto &&
other.actionConfig == actionConfig &&
other.actionId == actionId;
other.pluginActionId == pluginActionId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actionConfig == null ? 0 : actionConfig!.hashCode) +
(actionId.hashCode);
(pluginActionId.hashCode);
@override
String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, actionId=$actionId]';
String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, pluginActionId=$pluginActionId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -48,7 +48,7 @@ class WorkflowActionItemDto {
} else {
// json[r'actionConfig'] = null;
}
json[r'actionId'] = this.actionId;
json[r'pluginActionId'] = this.pluginActionId;
return json;
}
@@ -62,7 +62,7 @@ class WorkflowActionItemDto {
return WorkflowActionItemDto(
actionConfig: mapValueOfType<Object>(json, r'actionConfig'),
actionId: mapValueOfType<String>(json, r'actionId')!,
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
);
}
return null;
@@ -110,7 +110,7 @@ class WorkflowActionItemDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'actionId',
'pluginActionId',
};
}

View File

@@ -14,41 +14,41 @@ class WorkflowActionResponseDto {
/// Returns a new [WorkflowActionResponseDto] instance.
WorkflowActionResponseDto({
required this.actionConfig,
required this.actionId,
required this.id,
required this.order,
required this.pluginActionId,
required this.workflowId,
});
Object? actionConfig;
String actionId;
String id;
num order;
String pluginActionId;
String workflowId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowActionResponseDto &&
other.actionConfig == actionConfig &&
other.actionId == actionId &&
other.id == id &&
other.order == order &&
other.pluginActionId == pluginActionId &&
other.workflowId == workflowId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actionConfig == null ? 0 : actionConfig!.hashCode) +
(actionId.hashCode) +
(id.hashCode) +
(order.hashCode) +
(pluginActionId.hashCode) +
(workflowId.hashCode);
@override
String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, actionId=$actionId, id=$id, order=$order, workflowId=$workflowId]';
String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, id=$id, order=$order, pluginActionId=$pluginActionId, workflowId=$workflowId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -57,9 +57,9 @@ class WorkflowActionResponseDto {
} else {
// json[r'actionConfig'] = null;
}
json[r'actionId'] = this.actionId;
json[r'id'] = this.id;
json[r'order'] = this.order;
json[r'pluginActionId'] = this.pluginActionId;
json[r'workflowId'] = this.workflowId;
return json;
}
@@ -74,9 +74,9 @@ class WorkflowActionResponseDto {
return WorkflowActionResponseDto(
actionConfig: mapValueOfType<Object>(json, r'actionConfig'),
actionId: mapValueOfType<String>(json, r'actionId')!,
id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'),
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
}
@@ -126,9 +126,9 @@ class WorkflowActionResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'actionConfig',
'actionId',
'id',
'order',
'pluginActionId',
'workflowId',
};
}

View File

@@ -14,7 +14,7 @@ class WorkflowFilterItemDto {
/// Returns a new [WorkflowFilterItemDto] instance.
WorkflowFilterItemDto({
this.filterConfig,
required this.filterId,
required this.pluginFilterId,
});
///
@@ -25,21 +25,21 @@ class WorkflowFilterItemDto {
///
Object? filterConfig;
String filterId;
String pluginFilterId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterItemDto &&
other.filterConfig == filterConfig &&
other.filterId == filterId;
other.pluginFilterId == pluginFilterId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filterConfig == null ? 0 : filterConfig!.hashCode) +
(filterId.hashCode);
(pluginFilterId.hashCode);
@override
String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, filterId=$filterId]';
String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, pluginFilterId=$pluginFilterId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -48,7 +48,7 @@ class WorkflowFilterItemDto {
} else {
// json[r'filterConfig'] = null;
}
json[r'filterId'] = this.filterId;
json[r'pluginFilterId'] = this.pluginFilterId;
return json;
}
@@ -62,7 +62,7 @@ class WorkflowFilterItemDto {
return WorkflowFilterItemDto(
filterConfig: mapValueOfType<Object>(json, r'filterConfig'),
filterId: mapValueOfType<String>(json, r'filterId')!,
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
);
}
return null;
@@ -110,7 +110,7 @@ class WorkflowFilterItemDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'filterId',
'pluginFilterId',
};
}

View File

@@ -14,41 +14,41 @@ class WorkflowFilterResponseDto {
/// Returns a new [WorkflowFilterResponseDto] instance.
WorkflowFilterResponseDto({
required this.filterConfig,
required this.filterId,
required this.id,
required this.order,
required this.pluginFilterId,
required this.workflowId,
});
Object? filterConfig;
String filterId;
String id;
num order;
String pluginFilterId;
String workflowId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterResponseDto &&
other.filterConfig == filterConfig &&
other.filterId == filterId &&
other.id == id &&
other.order == order &&
other.pluginFilterId == pluginFilterId &&
other.workflowId == workflowId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filterConfig == null ? 0 : filterConfig!.hashCode) +
(filterId.hashCode) +
(id.hashCode) +
(order.hashCode) +
(pluginFilterId.hashCode) +
(workflowId.hashCode);
@override
String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, filterId=$filterId, id=$id, order=$order, workflowId=$workflowId]';
String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, id=$id, order=$order, pluginFilterId=$pluginFilterId, workflowId=$workflowId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -57,9 +57,9 @@ class WorkflowFilterResponseDto {
} else {
// json[r'filterConfig'] = null;
}
json[r'filterId'] = this.filterId;
json[r'id'] = this.id;
json[r'order'] = this.order;
json[r'pluginFilterId'] = this.pluginFilterId;
json[r'workflowId'] = this.workflowId;
return json;
}
@@ -74,9 +74,9 @@ class WorkflowFilterResponseDto {
return WorkflowFilterResponseDto(
filterConfig: mapValueOfType<Object>(json, r'filterConfig'),
filterId: mapValueOfType<String>(json, r'filterId')!,
id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'),
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
}
@@ -126,9 +126,9 @@ class WorkflowFilterResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'filterConfig',
'filterId',
'id',
'order',
'pluginFilterId',
'workflowId',
};
}

View File

@@ -23162,13 +23162,13 @@
"actionConfig": {
"type": "object"
},
"actionId": {
"pluginActionId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"actionId"
"pluginActionId"
],
"type": "object"
},
@@ -23178,24 +23178,24 @@
"nullable": true,
"type": "object"
},
"actionId": {
"type": "string"
},
"id": {
"type": "string"
},
"order": {
"type": "number"
},
"pluginActionId": {
"type": "string"
},
"workflowId": {
"type": "string"
}
},
"required": [
"actionConfig",
"actionId",
"id",
"order",
"pluginActionId",
"workflowId"
],
"type": "object"
@@ -23244,13 +23244,13 @@
"filterConfig": {
"type": "object"
},
"filterId": {
"pluginFilterId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"filterId"
"pluginFilterId"
],
"type": "object"
},
@@ -23260,24 +23260,24 @@
"nullable": true,
"type": "object"
},
"filterId": {
"type": "string"
},
"id": {
"type": "string"
},
"order": {
"type": "number"
},
"pluginFilterId": {
"type": "string"
},
"workflowId": {
"type": "string"
}
},
"required": [
"filterConfig",
"filterId",
"id",
"order",
"pluginFilterId",
"workflowId"
],
"type": "object"

View File

@@ -1729,16 +1729,16 @@ export type CreateProfileImageResponseDto = {
};
export type WorkflowActionResponseDto = {
actionConfig: object | null;
actionId: string;
id: string;
order: number;
pluginActionId: string;
workflowId: string;
};
export type WorkflowFilterResponseDto = {
filterConfig: object | null;
filterId: string;
id: string;
order: number;
pluginFilterId: string;
workflowId: string;
};
export type WorkflowResponseDto = {
@@ -1754,11 +1754,11 @@ export type WorkflowResponseDto = {
};
export type WorkflowActionItemDto = {
actionConfig?: object;
actionId: string;
pluginActionId: string;
};
export type WorkflowFilterItemDto = {
filterConfig?: object;
filterId: string;
pluginFilterId: string;
};
export type WorkflowCreateDto = {
actions: WorkflowActionItemDto[];

View File

@@ -3,7 +3,7 @@
"version": "0.0.1",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c",
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
"engines": {
"pnpm": ">=10.0.0"
}

1472
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,14 +45,14 @@
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.207.0",
"@opentelemetry/instrumentation-http": "^0.207.0",
"@opentelemetry/instrumentation-ioredis": "^0.55.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.54.0",
"@opentelemetry/instrumentation-pg": "^0.60.0",
"@opentelemetry/exporter-prometheus": "^0.208.0",
"@opentelemetry/instrumentation-http": "^0.208.0",
"@opentelemetry/instrumentation-ioredis": "^0.56.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.55.0",
"@opentelemetry/instrumentation-pg": "^0.61.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.207.0",
"@opentelemetry/sdk-node": "^0.208.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2",

View File

@@ -305,7 +305,7 @@ export class StorageCore {
return this.assetRepository.update({ id, encodedVideoPath: newPath });
}
case AssetPathType.Sidecar: {
return this.assetRepository.update({ id, sidecarPath: newPath });
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
}
case PersonPathType.Face: {
return this.personRepository.update({ id, thumbnailPath: newPath });

View File

@@ -122,7 +122,6 @@ export type Asset = {
originalFileName: string;
originalPath: string;
ownerId: string;
sidecarPath: string | null;
type: AssetType;
};
@@ -156,13 +155,6 @@ export type StorageAsset = {
encodedVideoPath: string | null;
};
export type SidecarWriteAsset = {
id: string;
sidecarPath: string | null;
originalPath: string;
tags: Array<{ value: string }>;
};
export type Stack = {
id: string;
primaryAssetId: string;
@@ -309,14 +301,14 @@ export type Workflow = Selectable<WorkflowTable> & {
export type WorkflowFilter = Selectable<WorkflowFilterTable> & {
workflowId: string;
filterId: string;
pluginFilterId: string;
filterConfig: FilterConfig | null;
order: number;
};
export type WorkflowAction = Selectable<WorkflowActionTable> & {
workflowId: string;
actionId: string;
pluginActionId: string;
actionConfig: ActionConfig | null;
order: number;
};
@@ -347,7 +339,6 @@ export const columns = {
'asset.originalFileName',
'asset.originalPath',
'asset.ownerId',
'asset.sidecarPath',
'asset.type',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],

View File

@@ -124,7 +124,6 @@ export type MapAsset = {
originalPath: string;
owner?: User | null;
ownerId: string;
sidecarPath: string | null;
stack?: Stack | null;
stackId: string | null;
tags?: Tag[];

View File

@@ -7,7 +7,7 @@ import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation';
export class WorkflowFilterItemDto {
@IsUUID()
filterId!: string;
pluginFilterId!: string;
@IsObject()
@Optional()
@@ -16,7 +16,7 @@ export class WorkflowFilterItemDto {
export class WorkflowActionItemDto {
@IsUUID()
actionId!: string;
pluginActionId!: string;
@IsObject()
@Optional()
@@ -86,7 +86,7 @@ export class WorkflowResponseDto {
export class WorkflowFilterResponseDto {
id!: string;
workflowId!: string;
filterId!: string;
pluginFilterId!: string;
filterConfig!: FilterConfig | null;
order!: number;
}
@@ -94,7 +94,7 @@ export class WorkflowFilterResponseDto {
export class WorkflowActionResponseDto {
id!: string;
workflowId!: string;
actionId!: string;
pluginActionId!: string;
actionConfig!: ActionConfig | null;
order!: number;
}
@@ -103,7 +103,7 @@ export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterRespons
return {
id: filter.id,
workflowId: filter.workflowId,
filterId: filter.filterId,
pluginFilterId: filter.pluginFilterId,
filterConfig: filter.filterConfig,
order: filter.order,
};
@@ -113,7 +113,7 @@ export function mapWorkflowAction(action: WorkflowAction): WorkflowActionRespons
return {
id: action.id,
workflowId: action.workflowId,
actionId: action.actionId,
pluginActionId: action.pluginActionId,
actionConfig: action.actionConfig,
order: action.order,
};

View File

@@ -44,6 +44,7 @@ export enum AssetFileType {
FullSize = 'fullsize',
Preview = 'preview',
Thumbnail = 'thumbnail',
Sidecar = 'sidecar',
}
export enum AlbumUserRole {

View File

@@ -20,8 +20,23 @@ limit
-- AssetJobRepository.getForSidecarWriteJob
select
"id",
"sidecarPath",
"originalPath",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files",
(
select
coalesce(json_agg(agg), '[]')
@@ -39,21 +54,36 @@ select
from
"asset"
where
"asset"."id" = $1::uuid
"asset"."id" = $2::uuid
limit
$2
$3
-- AssetJobRepository.getForSidecarCheckJob
select
"id",
"sidecarPath",
"originalPath"
"originalPath",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files"
from
"asset"
where
"asset"."id" = $1::uuid
"asset"."id" = $2::uuid
limit
$2
$3
-- AssetJobRepository.streamForThumbnailJob
select
@@ -158,7 +188,6 @@ select
"asset"."originalFileName",
"asset"."originalPath",
"asset"."ownerId",
"asset"."sidecarPath",
"asset"."type",
(
select
@@ -173,11 +202,27 @@ select
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
) as agg
) as "faces"
) as "faces",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files"
from
"asset"
where
"asset"."id" = $1
"asset"."id" = $2
-- AssetJobRepository.getAlbumThumbnailFiles
select
@@ -322,7 +367,6 @@ select
"asset"."libraryId",
"asset"."ownerId",
"asset"."livePhotoVideoId",
"asset"."sidecarPath",
"asset"."encodedVideoPath",
"asset"."originalPath",
to_json("asset_exif") as "exifInfo",
@@ -433,18 +477,33 @@ select
"asset"."checksum",
"asset"."originalPath",
"asset"."isExternal",
"asset"."sidecarPath",
"asset"."originalFileName",
"asset"."livePhotoVideoId",
"asset"."fileCreatedAt",
"asset_exif"."timeZone",
"asset_exif"."fileSizeInByte"
"asset_exif"."fileSizeInByte",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."deletedAt" is null
and "asset"."id" = $1
and "asset"."id" = $2
-- AssetJobRepository.streamForStorageTemplateJob
select
@@ -454,12 +513,27 @@ select
"asset"."checksum",
"asset"."originalPath",
"asset"."isExternal",
"asset"."sidecarPath",
"asset"."originalFileName",
"asset"."livePhotoVideoId",
"asset"."fileCreatedAt",
"asset_exif"."timeZone",
"asset_exif"."fileSizeInByte"
"asset_exif"."fileSizeInByte",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
@@ -481,11 +555,15 @@ select
from
"asset"
where
(
"asset"."sidecarPath" = $1
or "asset"."sidecarPath" is null
not exists (
select
"asset_file"."id"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
)
and "asset"."visibility" != $2
-- AssetJobRepository.streamForDetectFacesJob
select

View File

@@ -216,6 +216,34 @@ from
limit
3
-- AssetRepository.getForCopy
select
"id",
"stackId",
"originalPath",
"isFavorite",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
) as agg
) as "files"
from
"asset"
where
"id" = $1::uuid
limit
$2
-- AssetRepository.getById
select
"asset".*

View File

@@ -6,7 +6,6 @@ import { Asset, columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { StorageAsset } from 'src/types';
import {
anyUuid,
asUuid,
@@ -40,7 +39,8 @@ export class AssetJobRepository {
return this.db
.selectFrom('asset')
.where('asset.id', '=', asUuid(id))
.select(['id', 'sidecarPath', 'originalPath'])
.select(['id', 'originalPath'])
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.select((eb) =>
jsonArrayFrom(
eb
@@ -59,7 +59,8 @@ export class AssetJobRepository {
return this.db
.selectFrom('asset')
.where('asset.id', '=', asUuid(id))
.select(['id', 'sidecarPath', 'originalPath'])
.select(['id', 'originalPath'])
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.limit(1)
.executeTakeFirst();
}
@@ -122,6 +123,7 @@ export class AssetJobRepository {
.selectFrom('asset')
.select(columns.asset)
.select(withFaces)
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.where('asset.id', '=', id)
.executeTakeFirst();
}
@@ -228,7 +230,6 @@ export class AssetJobRepository {
'asset.libraryId',
'asset.ownerId',
'asset.livePhotoVideoId',
'asset.sidecarPath',
'asset.encodedVideoPath',
'asset.originalPath',
])
@@ -306,26 +307,24 @@ export class AssetJobRepository {
'asset.checksum',
'asset.originalPath',
'asset.isExternal',
'asset.sidecarPath',
'asset.originalFileName',
'asset.livePhotoVideoId',
'asset.fileCreatedAt',
'asset_exif.timeZone',
'asset_exif.fileSizeInByte',
])
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.where('asset.deletedAt', 'is', null);
}
@GenerateSql({ params: [DummyValue.UUID] })
getForStorageTemplateJob(id: string): Promise<StorageAsset | undefined> {
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst() as Promise<
StorageAsset | undefined
>;
getForStorageTemplateJob(id: string) {
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForStorageTemplateJob() {
return this.storageTemplateAssetQuery().stream() as AsyncIterableIterator<StorageAsset>;
return this.storageTemplateAssetQuery().stream();
}
@GenerateSql({ params: [DummyValue.DATE], stream: true })
@@ -343,9 +342,18 @@ export class AssetJobRepository {
.selectFrom('asset')
.select(['asset.id'])
.$if(!force, (qb) =>
qb.where((eb) => eb.or([eb('asset.sidecarPath', '=', ''), eb('asset.sidecarPath', 'is', null)])),
qb.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('asset_file')
.select('asset_file.id')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.Sidecar),
),
),
),
)
.where('asset.visibility', '!=', AssetVisibility.Hidden)
.stream();
}

View File

@@ -396,6 +396,17 @@ export class AssetRepository {
return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForCopy(id: string) {
return this.db
.selectFrom('asset')
.select(['id', 'stackId', 'originalPath', 'isFavorite'])
.select(withFiles)
.where('id', '=', asUuid(id))
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}) {
return this.db
@@ -842,6 +853,10 @@ export class AssetRepository {
.execute();
}
async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise<void> {
await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute();
}
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
if (files.length === 0) {
return;

View File

@@ -403,7 +403,6 @@ export class DatabaseRepository {
.set((eb) => ({
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
sidecarPath: eb.fn('REGEXP_REPLACE', ['sidecarPath', source, target]),
}))
.execute();

View File

@@ -24,9 +24,8 @@ export class OAuthRepository {
}
async authorize(config: OAuthConfig, redirectUrl: string, state?: string, codeChallenge?: string) {
const { buildAuthorizationUrl, randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } = await import(
'openid-client'
);
const { buildAuthorizationUrl, randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } =
await import('openid-client');
const client = await this.getClient(config);
state ??= randomState();

View File

@@ -0,0 +1,24 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`INSERT INTO "asset_file" ("assetId", "path", "type")
SELECT
id, "sidecarPath", 'sidecar'
FROM "asset"
WHERE "sidecarPath" IS NOT NULL AND "sidecarPath" != '';`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "sidecarPath";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" ADD "sidecarPath" character varying;`.execute(db);
await sql`
UPDATE "asset"
SET "sidecarPath" = "asset_file"."path"
FROM "asset_file"
WHERE "asset"."id" = "asset_file"."assetId" AND "asset_file"."type" = 'sidecar';
`.execute(db);
await sql`DELETE FROM "asset_file" WHERE "type" = 'sidecar';`.execute(db);
}

View File

@@ -0,0 +1,27 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "workflow_filter_filterId_idx";`.execute(db);
await sql`DROP INDEX "workflow_action_actionId_idx";`.execute(db);
await sql`ALTER TABLE "workflow_filter" DROP CONSTRAINT "workflow_filter_filterId_fkey";`.execute(db);
await sql`ALTER TABLE "workflow_action" DROP CONSTRAINT "workflow_action_actionId_fkey";`.execute(db);
await sql`ALTER TABLE "workflow_filter" RENAME COLUMN "filterId" TO "pluginFilterId";`.execute(db);
await sql`ALTER TABLE "workflow_action" RENAME COLUMN "actionId" TO "pluginActionId";`.execute(db);
await sql`ALTER TABLE "workflow_filter" ADD CONSTRAINT "workflow_filter_pluginFilterId_fkey" FOREIGN KEY ("pluginFilterId") REFERENCES "plugin_filter" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`ALTER TABLE "workflow_action" ADD CONSTRAINT "workflow_action_pluginActionId_fkey" FOREIGN KEY ("pluginActionId") REFERENCES "plugin_action" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "workflow_filter_pluginFilterId_idx" ON "workflow_filter" ("pluginFilterId");`.execute(db);
await sql`CREATE INDEX "workflow_action_pluginActionId_idx" ON "workflow_action" ("pluginActionId");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "workflow_filter_pluginFilterId_idx";`.execute(db);
await sql`DROP INDEX "workflow_action_pluginActionId_idx";`.execute(db);
await sql`ALTER TABLE "workflow_filter" DROP CONSTRAINT "workflow_filter_pluginFilterId_fkey";`.execute(db);
await sql`ALTER TABLE "workflow_action" DROP CONSTRAINT "workflow_action_pluginActionId_fkey";`.execute(db);
await sql`ALTER TABLE "workflow_filter" RENAME COLUMN "pluginFilterId" TO "filterId";`.execute(db);
await sql`ALTER TABLE "workflow_action" RENAME COLUMN "pluginActionId" TO "actionId";`.execute(db);
await sql`ALTER TABLE "workflow_filter" ADD CONSTRAINT "workflow_filter_filterId_fkey" FOREIGN KEY ("filterId") REFERENCES "plugin_filter" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`ALTER TABLE "workflow_action" ADD CONSTRAINT "workflow_action_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "plugin_action" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "workflow_filter_filterId_idx" ON "workflow_filter" ("filterId");`.execute(db);
await sql`CREATE INDEX "workflow_action_actionId_idx" ON "workflow_action" ("actionId");`.execute(db);
}

View File

@@ -105,9 +105,6 @@ export class AssetTable {
@Column({ index: true })
originalFileName!: string;
@Column({ nullable: true })
sidecarPath!: string | null;
@Column({ type: 'bytea', nullable: true })
thumbhash!: Buffer | null;

View File

@@ -38,7 +38,7 @@ export class WorkflowTable {
}
@Index({ columns: ['workflowId', 'order'] })
@Index({ columns: ['filterId'] })
@Index({ columns: ['pluginFilterId'] })
@Table('workflow_filter')
export class WorkflowFilterTable {
@PrimaryGeneratedColumn('uuid')
@@ -48,7 +48,7 @@ export class WorkflowFilterTable {
workflowId!: Generated<string>;
@ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
filterId!: string;
pluginFilterId!: string;
@Column({ type: 'jsonb', nullable: true })
filterConfig!: FilterConfig | null;
@@ -58,7 +58,7 @@ export class WorkflowFilterTable {
}
@Index({ columns: ['workflowId', 'order'] })
@Index({ columns: ['actionId'] })
@Index({ columns: ['pluginActionId'] })
@Table('workflow_action')
export class WorkflowActionTable {
@PrimaryGeneratedColumn('uuid')
@@ -68,7 +68,7 @@ export class WorkflowActionTable {
workflowId!: Generated<string>;
@ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
actionId!: string;
pluginActionId!: string;
@Column({ type: 'jsonb', nullable: true })
actionConfig!: ActionConfig | null;

View File

@@ -174,7 +174,6 @@ const assetEntity = Object.freeze({
longitude: 10.703_075,
},
livePhotoVideoId: null,
sidecarPath: null,
} as MapAsset);
const existingAsset = Object.freeze({
@@ -188,7 +187,6 @@ const existingAsset = Object.freeze({
const sidecarAsset = Object.freeze({
...existingAsset,
sidecarPath: 'sidecar-path',
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
}) as MapAsset;
@@ -721,18 +719,22 @@ describe(AssetMediaService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: existingAsset.id,
sidecarPath: null,
originalFileName: 'photo1.jpeg',
originalPath: 'fake_path/photo1.jpeg',
}),
);
expect(mocks.asset.create).toHaveBeenCalledWith(
expect.objectContaining({
sidecarPath: null,
originalFileName: 'existing-filename.jpeg',
originalPath: 'fake_path/asset_1.jpeg',
}),
);
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
expect.objectContaining({
assetId: existingAsset.id,
type: AssetFileType.Sidecar,
}),
);
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date),
@@ -769,6 +771,13 @@ describe(AssetMediaService.name, () => {
deletedAt: expect.any(Date),
status: AssetStatus.Trashed,
});
expect(mocks.asset.upsertFile).toHaveBeenCalledWith(
expect.objectContaining({
assetId: existingAsset.id,
path: sidecarFile.originalPath,
type: AssetFileType.Sidecar,
}),
);
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
@@ -798,6 +807,12 @@ describe(AssetMediaService.name, () => {
deletedAt: expect.any(Date),
status: AssetStatus.Trashed,
});
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
expect.objectContaining({
assetId: existingAsset.id,
type: AssetFileType.Sidecar,
}),
);
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
@@ -827,6 +842,9 @@ describe(AssetMediaService.name, () => {
expect(mocks.asset.create).not.toHaveBeenCalled();
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: [updatedFile.originalPath, undefined] },

View File

@@ -21,7 +21,16 @@ import {
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import {
AssetFileType,
AssetStatus,
AssetType,
AssetVisibility,
CacheControl,
JobName,
Permission,
StorageFolder,
} from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service';
import { UploadFile, UploadRequest } from 'src/types';
@@ -354,9 +363,12 @@ export class AssetMediaService extends BaseService {
duration: dto.duration || null,
livePhotoVideoId: null,
sidecarPath: sidecarPath || null,
});
await (sidecarPath
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size });
await this.jobRepository.queue({
@@ -384,7 +396,6 @@ export class AssetMediaService extends BaseService {
localDateTime: asset.localDateTime,
fileModifiedAt: asset.fileModifiedAt,
livePhotoVideoId: asset.livePhotoVideoId,
sidecarPath: asset.sidecarPath,
});
const { size } = await this.storageRepository.stat(created.originalPath);
@@ -414,7 +425,6 @@ export class AssetMediaService extends BaseService {
visibility: dto.visibility ?? AssetVisibility.Timeline,
livePhotoVideoId: dto.livePhotoVideoId,
originalFileName: dto.filename || file.originalName,
sidecarPath: sidecarFile?.originalPath,
});
if (dto.metadata) {
@@ -422,6 +432,11 @@ export class AssetMediaService extends BaseService {
}
if (sidecarFile) {
await this.assetRepository.upsertFile({
assetId: asset.id,
path: sidecarFile.originalPath,
type: AssetFileType.Sidecar,
});
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));

View File

@@ -585,8 +585,8 @@ describe(AssetService.name, () => {
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
assetWithFace.encodedVideoPath,
assetWithFace.sidecarPath,
assetWithFace.encodedVideoPath, // this value is null
undefined, // no sidecar path
assetWithFace.originalPath,
],
},

View File

@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import _ from 'lodash';
import { DateTime, Duration } from 'luxon';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { AssetFile } from 'src/database';
import { OnJob } from 'src/decorators';
import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import {
@@ -18,7 +19,16 @@ import {
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { AssetMetadataKey, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import {
AssetFileType,
AssetMetadataKey,
AssetStatus,
AssetVisibility,
JobName,
JobStatus,
Permission,
QueueName,
} from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
import { requireElevatedPermission } from 'src/utils/access';
@@ -197,8 +207,8 @@ export class AssetService extends BaseService {
}: AssetCopyDto,
) {
await this.requireAccess({ auth, permission: Permission.AssetCopy, ids: [sourceId, targetId] });
const sourceAsset = await this.assetRepository.getById(sourceId);
const targetAsset = await this.assetRepository.getById(targetId);
const sourceAsset = await this.assetRepository.getForCopy(sourceId);
const targetAsset = await this.assetRepository.getForCopy(targetId);
if (!sourceAsset || !targetAsset) {
throw new BadRequestException('Both assets must exist');
@@ -252,19 +262,25 @@ export class AssetService extends BaseService {
sourceAsset,
targetAsset,
}: {
sourceAsset: { sidecarPath: string | null };
targetAsset: { id: string; sidecarPath: string | null; originalPath: string };
sourceAsset: { files: AssetFile[] };
targetAsset: { id: string; files: AssetFile[]; originalPath: string };
}) {
if (!sourceAsset.sidecarPath) {
const { sidecarFile: sourceFile } = getAssetFiles(sourceAsset.files);
if (!sourceFile?.path) {
return;
}
if (targetAsset.sidecarPath) {
await this.storageRepository.unlink(targetAsset.sidecarPath);
const { sidecarFile: targetFile } = getAssetFiles(targetAsset.files ?? []);
if (targetFile?.path) {
await this.storageRepository.unlink(targetFile.path);
}
await this.storageRepository.copyFile(sourceAsset.sidecarPath, `${targetAsset.originalPath}.xmp`);
await this.assetRepository.update({ id: targetAsset.id, sidecarPath: `${targetAsset.originalPath}.xmp` });
await this.storageRepository.copyFile(sourceFile.path, `${targetAsset.originalPath}.xmp`);
await this.assetRepository.upsertFile({
assetId: targetAsset.id,
path: `${targetAsset.originalPath}.xmp`,
type: AssetFileType.Sidecar,
});
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: targetAsset.id } });
}
@@ -344,11 +360,11 @@ export class AssetService extends BaseService {
}
}
const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(asset.files ?? []);
const { fullsizeFile, previewFile, thumbnailFile, sidecarFile } = getAssetFiles(asset.files ?? []);
const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath];
if (deleteOnDisk) {
files.push(asset.sidecarPath, asset.originalPath);
files.push(sidecarFile?.path, asset.originalPath);
}
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files } });

View File

@@ -4,7 +4,16 @@ import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { defaults } from 'src/config';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, AssetVisibility, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
import {
AssetFileType,
AssetType,
AssetVisibility,
ExifOrientation,
ImmichWorker,
JobName,
JobStatus,
SourceType,
} from 'src/enum';
import { ImmichTags } from 'src/repositories/metadata.repository';
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -15,17 +24,24 @@ import { tagStub } from 'test/fixtures/tag.stub';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const removeNonSidecarFiles = (asset: any) => {
return {
...asset,
files: asset.files.filter((file: any) => file.type === AssetFileType.Sidecar),
};
};
const forSidecarJob = (
asset: {
id?: string;
originalPath?: string;
sidecarPath?: string | null;
files?: { id: string; type: AssetFileType; path: string }[];
} = {},
) => {
return {
id: factory.uuid(),
originalPath: '/path/to/IMG_123.jpg',
sidecarPath: null,
files: [],
...asset,
};
};
@@ -166,7 +182,7 @@ describe(MetadataService.name, () => {
it('should handle a date in a sidecar file', async () => {
const originalDate = new Date('2023-11-21T16:13:17.517Z');
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -185,7 +201,7 @@ describe(MetadataService.name, () => {
it('should take the file modification date when missing exif and earlier than creation date', async () => {
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: fileModifiedAt,
@@ -211,7 +227,7 @@ describe(MetadataService.name, () => {
it('should take the file creation date when missing exif and earlier than modification date', async () => {
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: fileModifiedAt,
@@ -234,7 +250,7 @@ describe(MetadataService.name, () => {
it('should determine dateTimeOriginal regardless of the server time zone', async () => {
process.env.TZ = 'America/Los_Angeles';
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -252,7 +268,7 @@ describe(MetadataService.name, () => {
});
it('should handle lists of numbers', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: assetStub.image.fileModifiedAt,
@@ -305,7 +321,7 @@ describe(MetadataService.name, () => {
});
it('should apply reverse geocoding', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
mocks.storage.stat.mockResolvedValue({
@@ -334,7 +350,7 @@ describe(MetadataService.name, () => {
});
it('should discard latitude and longitude on null island', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
mockReadTags({
GPSLatitude: 0,
GPSLongitude: 0,
@@ -346,7 +362,7 @@ describe(MetadataService.name, () => {
});
it('should extract tags from TagsList', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mockReadTags({ TagsList: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -356,7 +372,7 @@ describe(MetadataService.name, () => {
});
it('should extract hierarchy from TagsList', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mockReadTags({ TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
@@ -376,7 +392,7 @@ describe(MetadataService.name, () => {
});
it('should extract tags from Keywords as a string', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mockReadTags({ Keywords: 'Parent' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -386,7 +402,7 @@ describe(MetadataService.name, () => {
});
it('should extract tags from Keywords as a list', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mockReadTags({ Keywords: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -396,7 +412,7 @@ describe(MetadataService.name, () => {
});
it('should extract tags from Keywords as a list with a number', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mockReadTags({ Keywords: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -407,7 +423,7 @@ describe(MetadataService.name, () => {
});
it('should extract hierarchal tags from Keywords', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mockReadTags({ Keywords: 'Parent/Child' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -426,7 +442,7 @@ describe(MetadataService.name, () => {
});
it('should ignore Keywords when TagsList is present', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -445,7 +461,7 @@ describe(MetadataService.name, () => {
});
it('should extract hierarchy from HierarchicalSubject', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
@@ -466,7 +482,7 @@ describe(MetadataService.name, () => {
});
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -1030,8 +1046,15 @@ describe(MetadataService.name, () => {
it('should prefer Duration from exif over sidecar', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
...assetStub.image,
sidecarPath: '/path/to/something',
files: [
{
id: 'some-id',
type: AssetFileType.Sidecar,
path: '/path/to/something',
},
],
});
mockReadTags({ Duration: 123 }, { Duration: 456 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -1536,18 +1559,25 @@ describe(MetadataService.name, () => {
});
it('should detect a new sidecar at .jpg.xmp', async () => {
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', files: [] });
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: `/path/to/IMG_123.jpg.xmp` });
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
assetId: asset.id,
type: AssetFileType.Sidecar,
path: '/path/to/IMG_123.jpg.xmp',
});
});
it('should detect a new sidecar at .xmp', async () => {
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg',
files: [],
});
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
@@ -1555,33 +1585,44 @@ describe(MetadataService.name, () => {
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: '/path/to/IMG_123.xmp' });
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
assetId: asset.id,
type: AssetFileType.Sidecar,
path: '/path/to/IMG_123.xmp',
});
});
it('should unset sidecar path if file does not exist anymore', async () => {
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg.xmp' });
it('should unset sidecar path if file no longer exist', async () => {
const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
});
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValue(false);
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: null });
expect(mocks.asset.deleteFile).toHaveBeenCalledWith({ assetId: asset.id, type: AssetFileType.Sidecar });
});
it('should do nothing if the sidecar file still exists', async () => {
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg' });
const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
});
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
});
});
describe('handleSidecarWrite', () => {
it('should skip assets that do not exist anymore', async () => {
it('should skip assets that no longer exist', async () => {
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0);
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed);
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
@@ -1610,7 +1651,7 @@ describe(MetadataService.name, () => {
dateTimeOriginal: date,
}),
).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.sidecarPath, {
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, {
Description: description,
ImageDescription: description,
DateTimeOriginal: date,

View File

@@ -8,9 +8,10 @@ import { constants } from 'node:fs/promises';
import { join, parse } from 'node:path';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { Asset, AssetFace } from 'src/database';
import { Asset, AssetFace, AssetFile } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import {
AssetFileType,
AssetType,
AssetVisibility,
DatabaseLock,
@@ -29,6 +30,7 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database';
import { isFaceImportEnabled } from 'src/utils/misc';
import { upsertTags } from 'src/utils/tag';
@@ -359,17 +361,21 @@ export class MetadataService extends BaseService {
break;
}
const isChanged = sidecarPath !== asset.sidecarPath;
const { sidecarFile } = getAssetFiles(asset.files);
const isChanged = sidecarPath !== sidecarFile?.path;
this.logger.debug(
`Sidecar check found old=${asset.sidecarPath}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`,
`Sidecar check found old=${sidecarFile?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`,
);
if (!isChanged) {
return JobStatus.Skipped;
}
await this.assetRepository.update({ id: asset.id, sidecarPath });
await (sidecarPath === null
? this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar })
: this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: sidecarPath }));
return JobStatus.Success;
}
@@ -394,7 +400,9 @@ export class MetadataService extends BaseService {
const tagsList = (asset.tags || []).map((tag) => tag.value);
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
const { sidecarFile } = getAssetFiles(asset.files);
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
const exif = _.omitBy(
<Tags>{
Description: description,
@@ -414,18 +422,19 @@ export class MetadataService extends BaseService {
await this.metadataRepository.writeTags(sidecarPath, exif);
if (!asset.sidecarPath) {
await this.assetRepository.update({ id, sidecarPath });
if (asset.files.length === 0) {
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: sidecarPath });
}
return JobStatus.Success;
}
private getSidecarCandidates({ sidecarPath, originalPath }: { sidecarPath: string | null; originalPath: string }) {
private getSidecarCandidates({ files, originalPath }: { files: AssetFile[]; originalPath: string }) {
const candidates: string[] = [];
if (sidecarPath) {
candidates.push(sidecarPath);
const { sidecarFile } = getAssetFiles(files);
if (sidecarFile?.path) {
candidates.push(sidecarFile.path);
}
const assetPath = parse(originalPath);
@@ -456,14 +465,12 @@ export class MetadataService extends BaseService {
return { width, height };
}
private async getExifTags(asset: {
originalPath: string;
sidecarPath: string | null;
type: AssetType;
}): Promise<ImmichTags> {
private async getExifTags(asset: { originalPath: string; files: AssetFile[]; type: AssetType }): Promise<ImmichTags> {
const { sidecarFile } = getAssetFiles(asset.files);
const [mediaTags, sidecarTags, videoTags] = await Promise.all([
this.metadataRepository.readTags(asset.originalPath),
asset.sidecarPath ? this.metadataRepository.readTags(asset.sidecarPath) : null,
sidecarFile ? this.metadataRepository.readTags(sidecarFile.path) : null,
asset.type === AssetType.Video ? this.getVideoTags(asset.originalPath) : null,
]);

View File

@@ -247,9 +247,9 @@ export class PluginService extends BaseService {
private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise<boolean> {
for (const workflowFilter of workflowFilters) {
const filter = await this.pluginRepository.getFilter(workflowFilter.filterId);
const filter = await this.pluginRepository.getFilter(workflowFilter.pluginFilterId);
if (!filter) {
this.logger.error(`Filter ${workflowFilter.filterId} not found`);
this.logger.error(`Filter ${workflowFilter.pluginFilterId} not found`);
return false;
}
@@ -291,9 +291,9 @@ export class PluginService extends BaseService {
private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise<void> {
for (const workflowAction of workflowActions) {
const action = await this.pluginRepository.getAction(workflowAction.actionId);
const action = await this.pluginRepository.getAction(workflowAction.pluginActionId);
if (!action) {
throw new Error(`Action ${workflowAction.actionId} not found`);
throw new Error(`Action ${workflowAction.pluginActionId} not found`);
}
const pluginInstance = this.loadedPlugins.get(action.pluginId);

View File

@@ -6,10 +6,20 @@ import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
import { AssetPathType, AssetType, DatabaseLock, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum';
import {
AssetFileType,
AssetPathType,
AssetType,
DatabaseLock,
JobName,
JobStatus,
QueueName,
StorageFolder,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { JobOf, StorageAsset } from 'src/types';
import { getAssetFile } from 'src/utils/asset.util';
import { getLivePhotoMotionFilename } from 'src/utils/file';
const storageTokens = {
@@ -196,7 +206,7 @@ export class StorageTemplateService extends BaseService {
}
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
const { id, sidecarPath, originalPath, checksum, fileSizeInByte } = asset;
const { id, originalPath, checksum, fileSizeInByte } = asset;
const oldPath = originalPath;
const newPath = await this.getTemplatePath(asset, metadata);
@@ -213,6 +223,8 @@ export class StorageTemplateService extends BaseService {
newPath,
assetInfo: { sizeInBytes: fileSizeInByte, checksum },
});
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar)?.path;
if (sidecarPath) {
await this.storageCore.moveFile({
entityId: id,

View File

@@ -78,13 +78,13 @@ export class WorkflowService extends BaseService {
}
private async validateAndMapFilters(
filters: Array<{ filterId: string; filterConfig?: any }>,
filters: Array<{ pluginFilterId: string; filterConfig?: any }>,
requiredContext: PluginContext,
) {
for (const dto of filters) {
const filter = await this.pluginRepository.getFilter(dto.filterId);
const filter = await this.pluginRepository.getFilter(dto.pluginFilterId);
if (!filter) {
throw new BadRequestException(`Invalid filter ID: ${dto.filterId}`);
throw new BadRequestException(`Invalid filter ID: ${dto.pluginFilterId}`);
}
if (!filter.supportedContexts.includes(requiredContext)) {
@@ -95,20 +95,20 @@ export class WorkflowService extends BaseService {
}
return filters.map((dto, index) => ({
filterId: dto.filterId,
pluginFilterId: dto.pluginFilterId,
filterConfig: dto.filterConfig || null,
order: index,
}));
}
private async validateAndMapActions(
actions: Array<{ actionId: string; actionConfig?: any }>,
actions: Array<{ pluginActionId: string; actionConfig?: any }>,
requiredContext: PluginContext,
) {
for (const dto of actions) {
const action = await this.pluginRepository.getAction(dto.actionId);
const action = await this.pluginRepository.getAction(dto.pluginActionId);
if (!action) {
throw new BadRequestException(`Invalid action ID: ${dto.actionId}`);
throw new BadRequestException(`Invalid action ID: ${dto.pluginActionId}`);
}
if (!action.supportedContexts.includes(requiredContext)) {
throw new BadRequestException(
@@ -118,7 +118,7 @@ export class WorkflowService extends BaseService {
}
return actions.map((dto, index) => ({
actionId: dto.actionId,
pluginActionId: dto.pluginActionId,
actionConfig: dto.actionConfig || null,
order: index,
}));

View File

@@ -1,6 +1,6 @@
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
import { Asset } from 'src/database';
import { Asset, AssetFile } from 'src/database';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -475,8 +475,8 @@ export type StorageAsset = {
fileCreatedAt: Date;
originalPath: string;
originalFileName: string;
sidecarPath: string | null;
fileSizeInByte: number | null;
files: AssetFile[];
};
export type OnThisDayData = { year: number };

View File

@@ -21,6 +21,7 @@ export const getAssetFiles = (files: AssetFile[]) => ({
fullsizeFile: getAssetFile(files, AssetFileType.FullSize),
previewFile: getAssetFile(files, AssetFileType.Preview),
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail),
sidecarFile: getAssetFile(files, AssetFileType.Sidecar),
});
export const addAssets = async (

View File

@@ -24,6 +24,18 @@ const fullsizeFile: AssetFile = {
path: '/uploads/user-id/fullsize/path.webp',
};
const sidecarFileWithExt: AssetFile = {
id: 'sidecar-with-ext',
type: AssetFileType.Sidecar,
path: '/original/path.ext.xmp',
};
const sidecarFileWithoutExt: AssetFile = {
id: 'sidecar-without-ext',
type: AssetFileType.Sidecar,
path: '/original/path.xmp',
};
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
@@ -51,8 +63,8 @@ export const assetStub = {
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: '/original/path.jpg',
originalFileName: 'IMG_123.jpg',
sidecarPath: null,
fileSizeInByte: 12_345,
files: [],
...asset,
}),
noResizePath: Object.freeze({
@@ -81,7 +93,6 @@ export const assetStub = {
sharedLinks: [],
faces: [],
exifInfo: {} as Exif,
sidecarPath: null,
deletedAt: null,
isExternal: false,
duplicateId: null,
@@ -117,7 +128,6 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'IMG_456.jpg',
faces: [],
sidecarPath: null,
isExternal: false,
exifInfo: {
fileSizeInByte: 123_000,
@@ -157,7 +167,6 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
deletedAt: null,
duplicateId: null,
isOffline: false,
@@ -194,7 +203,6 @@ export const assetStub = {
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 1000,
@@ -243,7 +251,6 @@ export const assetStub = {
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
@@ -285,7 +292,6 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
@@ -328,7 +334,6 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
@@ -367,7 +372,6 @@ export const assetStub = {
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
@@ -409,7 +413,6 @@ export const assetStub = {
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
@@ -448,7 +451,6 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
@@ -490,7 +492,6 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
@@ -526,7 +527,6 @@ export const assetStub = {
livePhotoVideoId: null,
sharedLinks: [],
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 100_000,
exifImageHeight: 2160,
@@ -553,6 +553,7 @@ export const assetStub = {
fileSizeInByte: 100_000,
timeZone: `America/New_York`,
},
files: [] as AssetFile[],
libraryId: null,
visibility: AssetVisibility.Hidden,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }),
@@ -573,7 +574,7 @@ export const assetStub = {
files,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
} as MapAsset & { faces: AssetFace[] }),
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
livePhotoWithOriginalFileName: Object.freeze({
id: 'live-photo-still-asset',
@@ -589,10 +590,11 @@ export const assetStub = {
fileSizeInByte: 25_000,
timeZone: `America/New_York`,
},
files: [] as AssetFile[],
libraryId: null,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
} as MapAsset & { faces: AssetFace[] }),
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
withLocation: Object.freeze({
id: 'asset-with-favorite-id',
@@ -605,7 +607,6 @@ export const assetStub = {
deviceId: 'device-id',
checksum: Buffer.from('file hash', 'utf8'),
originalPath: '/original/path.ext',
sidecarPath: null,
type: AssetType.Image,
files: [previewFile],
thumbhash: null,
@@ -652,7 +653,7 @@ export const assetStub = {
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files: [previewFile],
files: [previewFile, sidecarFileWithExt],
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
@@ -665,7 +666,6 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: '/original/path.ext.xmp',
deletedAt: null,
duplicateId: null,
isOffline: false,
@@ -688,7 +688,7 @@ export const assetStub = {
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files: [previewFile],
files: [previewFile, sidecarFileWithoutExt],
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
@@ -701,7 +701,6 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: '/original/path.xmp',
deletedAt: null,
duplicateId: null,
isOffline: false,
@@ -734,7 +733,6 @@ export const assetStub = {
livePhotoVideoId: null,
sharedLinks: [],
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 100_000,
} as Exif,
@@ -776,7 +774,6 @@ export const assetStub = {
originalFileName: 'photo.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
@@ -812,7 +809,6 @@ export const assetStub = {
originalFileName: 'asset-id.dng',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
profileDescription: 'Adobe RGB',
@@ -853,7 +849,6 @@ export const assetStub = {
originalFileName: 'asset-id.hif',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
profileDescription: 'Adobe RGB',

View File

@@ -1,5 +1,5 @@
import { Kysely } from 'kysely';
import { JobName, SharedLinkType } from 'src/enum';
import { AssetFileType, JobName, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
@@ -184,7 +184,15 @@ describe(AssetService.name, () => {
jobRepo.queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id, sidecarPath: '/path/to/my/sidecar.xmp' });
const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newAssetFile({
assetId: oldAsset.id,
path: '/path/to/my/sidecar.xmp',
type: AssetFileType.Sidecar,
});
const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: oldAsset.id, description: 'foo' });

View File

@@ -82,7 +82,11 @@ describe(MetadataService.name, () => {
process.env.TZ = serverTimeZone ?? undefined;
const { filePath } = await createTestFile(exifData);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ id: 'asset-1', originalPath: filePath } as any);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
id: 'asset-1',
originalPath: filePath,
files: [],
} as any);
await sut.handleMetadataExtraction({ id: 'asset-1' });

View File

@@ -113,13 +113,13 @@ describe(WorkflowService.name, () => {
enabled: true,
filters: [
{
filterId: testFilterId,
pluginFilterId: testFilterId,
filterConfig: { key: 'value' },
},
],
actions: [
{
actionId: testActionId,
pluginActionId: testActionId,
actionConfig: { action: 'test' },
},
],
@@ -137,7 +137,7 @@ describe(WorkflowService.name, () => {
expect(workflow.filters[0]).toMatchObject({
id: expect.any(String),
workflowId: workflow.id,
filterId: testFilterId,
pluginFilterId: testFilterId,
filterConfig: { key: 'value' },
order: 0,
});
@@ -146,7 +146,7 @@ describe(WorkflowService.name, () => {
expect(workflow.actions[0]).toMatchObject({
id: expect.any(String),
workflowId: workflow.id,
actionId: testActionId,
pluginActionId: testActionId,
actionConfig: { action: 'test' },
order: 0,
});
@@ -163,7 +163,7 @@ describe(WorkflowService.name, () => {
name: 'invalid-workflow',
description: 'A workflow with invalid filter',
enabled: true,
filters: [{ filterId: factory.uuid(), filterConfig: { key: 'value' } }],
filters: [{ pluginFilterId: factory.uuid(), filterConfig: { key: 'value' } }],
actions: [],
}),
).rejects.toThrow('Invalid filter ID');
@@ -181,7 +181,7 @@ describe(WorkflowService.name, () => {
description: 'A workflow with invalid action',
enabled: true,
filters: [],
actions: [{ actionId: factory.uuid(), actionConfig: { action: 'test' } }],
actions: [{ pluginActionId: factory.uuid(), actionConfig: { action: 'test' } }],
}),
).rejects.toThrow('Invalid action ID');
});
@@ -220,7 +220,7 @@ describe(WorkflowService.name, () => {
name: 'invalid-context-workflow',
description: 'A workflow with context mismatch',
enabled: true,
filters: [{ filterId: result.filters[0].id }],
filters: [{ pluginFilterId: result.filters[0].id }],
actions: [],
}),
).rejects.toThrow('does not support asset context');
@@ -261,7 +261,7 @@ describe(WorkflowService.name, () => {
description: 'A workflow with context mismatch',
enabled: true,
filters: [],
actions: [{ actionId: result.actions[0].id }],
actions: [{ pluginActionId: result.actions[0].id }],
}),
).rejects.toThrow('does not support asset context');
});
@@ -277,13 +277,13 @@ describe(WorkflowService.name, () => {
description: 'A workflow with multiple filters and actions',
enabled: true,
filters: [
{ filterId: testFilterId, filterConfig: { step: 1 } },
{ filterId: testFilterId, filterConfig: { step: 2 } },
{ pluginFilterId: testFilterId, filterConfig: { step: 1 } },
{ pluginFilterId: testFilterId, filterConfig: { step: 2 } },
],
actions: [
{ actionId: testActionId, actionConfig: { step: 1 } },
{ actionId: testActionId, actionConfig: { step: 2 } },
{ actionId: testActionId, actionConfig: { step: 3 } },
{ pluginActionId: testActionId, actionConfig: { step: 1 } },
{ pluginActionId: testActionId, actionConfig: { step: 2 } },
{ pluginActionId: testActionId, actionConfig: { step: 3 } },
],
});
@@ -378,8 +378,8 @@ describe(WorkflowService.name, () => {
name: 'test-workflow',
description: 'A test workflow',
enabled: true,
filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }],
actions: [{ actionId: testActionId, actionConfig: { action: 'test' } }],
filters: [{ pluginFilterId: testFilterId, filterConfig: { key: 'value' } }],
actions: [{ pluginActionId: testActionId, actionConfig: { action: 'test' } }],
});
const workflow = await sut.get(auth, created.id);
@@ -461,14 +461,14 @@ describe(WorkflowService.name, () => {
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ filterId: testFilterId, filterConfig: { old: 'config' } }],
filters: [{ pluginFilterId: testFilterId, filterConfig: { old: 'config' } }],
actions: [],
});
const updated = await sut.update(auth, created.id, {
filters: [
{ filterId: testFilterId, filterConfig: { new: 'config' } },
{ filterId: testFilterId, filterConfig: { second: 'filter' } },
{ pluginFilterId: testFilterId, filterConfig: { new: 'config' } },
{ pluginFilterId: testFilterId, filterConfig: { second: 'filter' } },
],
});
@@ -488,13 +488,13 @@ describe(WorkflowService.name, () => {
description: 'Test',
enabled: true,
filters: [],
actions: [{ actionId: testActionId, actionConfig: { old: 'config' } }],
actions: [{ pluginActionId: testActionId, actionConfig: { old: 'config' } }],
});
const updated = await sut.update(auth, created.id, {
actions: [
{ actionId: testActionId, actionConfig: { new: 'config' } },
{ actionId: testActionId, actionConfig: { second: 'action' } },
{ pluginActionId: testActionId, actionConfig: { new: 'config' } },
{ pluginActionId: testActionId, actionConfig: { second: 'action' } },
],
});
@@ -513,7 +513,7 @@ describe(WorkflowService.name, () => {
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }],
filters: [{ pluginFilterId: testFilterId, filterConfig: { key: 'value' } }],
actions: [],
});
@@ -588,7 +588,7 @@ describe(WorkflowService.name, () => {
await expect(
sut.update(auth, created.id, {
filters: [{ filterId: factory.uuid(), filterConfig: {} }],
filters: [{ pluginFilterId: factory.uuid(), filterConfig: {} }],
}),
).rejects.toThrow();
});
@@ -608,7 +608,7 @@ describe(WorkflowService.name, () => {
});
await expect(
sut.update(auth, created.id, { actions: [{ actionId: factory.uuid(), actionConfig: {} }] }),
sut.update(auth, created.id, { actions: [{ pluginActionId: factory.uuid(), actionConfig: {} }] }),
).rejects.toThrow();
});
});
@@ -643,8 +643,8 @@ describe(WorkflowService.name, () => {
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ filterId: testFilterId, filterConfig: {} }],
actions: [{ actionId: testActionId, actionConfig: {} }],
filters: [{ pluginFilterId: testFilterId, filterConfig: {} }],
actions: [{ pluginActionId: testActionId, actionConfig: {} }],
});
await sut.delete(auth, workflow.id);

View File

@@ -10,6 +10,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
updateAllExif: vitest.fn(),
updateDateTimeOriginal: vitest.fn().mockResolvedValue([]),
upsertJobStatus: vitest.fn(),
getForCopy: vitest.fn(),
getByDayOfYear: vitest.fn(),
getByIds: vitest.fn().mockResolvedValue([]),
getByIdsWithAllRelationsButStacks: vitest.fn().mockResolvedValue([]),
@@ -36,6 +37,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
getChangedDeltaSync: vitest.fn(),
upsertFile: vitest.fn(),
upsertFiles: vitest.fn(),
deleteFile: vitest.fn(),
deleteFiles: vitest.fn(),
detectOfflineExternalAssets: vitest.fn(),
filterNewExternalAssetPaths: vitest.fn(),

View File

@@ -8,14 +8,22 @@ import {
Memory,
Partner,
Session,
SidecarWriteAsset,
User,
UserAdmin,
} from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserMetadataKey, UserStatus } from 'src/enum';
import {
AssetFileType,
AssetStatus,
AssetType,
AssetVisibility,
MemoryType,
Permission,
UserMetadataKey,
UserStatus,
} from 'src/enum';
import { OnThisDayData, UserMetadataItem } from 'src/types';
import { v4, v7 } from 'uuid';
@@ -237,7 +245,6 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
originalFileName: 'IMG_123.jpg',
originalPath: `/data/12/34/IMG_123.jpg`,
ownerId: newUuid(),
sidecarPath: null,
stackId: null,
thumbhash: null,
type: AssetType.Image,
@@ -312,12 +319,17 @@ const versionHistoryFactory = () => ({
version: '1.123.45',
});
const assetSidecarWriteFactory = (asset: Partial<SidecarWriteAsset> = {}) => ({
const assetSidecarWriteFactory = () => ({
id: newUuid(),
sidecarPath: '/path/to/original-path.jpg.xmp',
originalPath: '/path/to/original-path.jpg.xmp',
tags: [],
...asset,
files: [
{
id: newUuid(),
path: '/path/to/original-path.jpg.xmp',
type: AssetFileType.Sidecar,
},
],
});
const assetOcrFactory = (

View File

@@ -69,7 +69,7 @@
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.8.0",
"@sveltejs/enhanced-img": "^0.9.0",
"@sveltejs/kit": "^2.27.1",
"@sveltejs/vite-plugin-svelte": "6.2.1",
"@tailwindcss/vite": "^4.1.7",

View File

@@ -1,7 +1,3 @@
<script lang="ts" module>
export const headerId = 'user-page-header';
</script>
<script lang="ts">
import { useActions, type ActionArray } from '$lib/actions/use-actions';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
@@ -68,7 +64,7 @@
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark">
<div class="flex gap-2 items-center">
{#if title}
<div class="font-medium outline-none pe-8" tabindex="-1" id={headerId}>{title}</div>
<div class="font-medium outline-none pe-8" tabindex="-1">{title}</div>
{/if}
{#if description}
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>

View File

@@ -376,7 +376,7 @@
}
}}
>
{#snippet children({ feature }: { feature: Feature<Geometry, GeoJsonProperties> })}
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
{:else}

View File

@@ -7,15 +7,14 @@
active: string;
icons: { default: string; active: string };
getLink: (path: string) => string;
isNested?: boolean;
}
let { tree, active, icons, getLink }: Props = $props();
let { tree, active, icons, getLink, isNested = false }: Props = $props();
</script>
<ul class="list-none ms-2">
<ul role={isNested ? 'group' : 'tree'} class="list-none ms-2">
{#each tree.children as node (node.color ? node.path + node.color : node.path)}
<li>
<Tree {node} {icons} {active} {getLink} />
</li>
<Tree {node} {icons} {active} {getLink} />
{/each}
</ul>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { TreeNode } from '$lib/utils/tree-utils';
import { Icon } from '@immich/ui';
@@ -21,30 +22,108 @@
event.preventDefault();
isOpen = !isOpen;
};
const handleSelect = (event: MouseEvent | KeyboardEvent, path: string) => {
event.preventDefault();
event.stopPropagation();
navigateTo(path);
};
const handleKeydown = (event: KeyboardEvent, node: TreeNode) => {
switch (event.key) {
case 'Enter':
case ' ': {
handleSelect(event, node.path);
break;
}
case 'ArrowRight': {
event.preventDefault();
event.stopPropagation();
const hasChildren = node.children.length > 0;
if (isOpen && hasChildren) {
const target = event.target as HTMLElement;
const child = target.querySelector<HTMLLIElement>('ul[role="group"] > li[role="treeitem"]');
child?.focus();
} else if (!isOpen && hasChildren) {
isOpen = true;
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
event.stopPropagation();
const hasChildren = node.children.length > 0;
if (isOpen && hasChildren) {
isOpen = false;
} else if (node.parents.length > 0) {
const target = event.target as HTMLElement;
const parent = target.parentElement?.closest<HTMLLIElement>('li[role="treeitem"]');
parent?.focus();
}
break;
}
case 'ArrowUp': {
event.preventDefault();
event.stopPropagation();
console.log('focus previous node');
break;
}
case 'ArrowDown': {
event.preventDefault();
event.stopPropagation();
console.log('focus next node');
break;
}
}
};
const navigateTo = (path: string) => {
const link = getLink(path);
void goto(link, { keepFocus: true });
};
</script>
<a
href={getLink(node.path)}
title={node.value}
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-primary' : 'dark:text-gray-200'}`}
data-sveltekit-keepfocus
<!-- href={getLink(node.path)} -->
<li
role="treeitem"
aria-selected={false}
tabindex="0"
class="outline-none"
onkeydown={(event) => handleKeydown(event, node)}
onclick={(event) => handleSelect(event, node.path)}
>
{#if node.size > 0}
<button type="button" {onclick}>
<Icon icon={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size="20" />
</button>
{/if}
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
<Icon
icon={isActive ? icons.active : icons.default}
class={isActive ? 'text-primary' : 'text-gray-400'}
color={node.color}
size="20"
/>
<div
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-primary' : 'dark:text-gray-200'}`}
>
{#if node.size > 0}
<button tabindex={-1} aria-hidden="true" type="button" {onclick}>
<Icon icon={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size="20" />
</button>
{/if}
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
<Icon
icon={isActive ? icons.active : icons.default}
class={isActive ? 'text-primary' : 'text-gray-400'}
color={node.color}
size="20"
/>
</div>
<span class="text-nowrap overflow-hidden text-ellipsis font-mono ps-1 pt-1 whitespace-pre-wrap">{node.value}</span>
</div>
<span class="text-nowrap overflow-hidden text-ellipsis font-mono ps-1 pt-1 whitespace-pre-wrap">{node.value}</span>
</a>
{#if isOpen}
<TreeItems tree={node} {icons} {active} {getLink} />
{/if}
{#if isOpen}
<TreeItems tree={node} {icons} {active} {getLink} isNested />
{/if}
</li>
<style>
li[role='treeitem']:focus-visible > div {
outline-style: var(--tw-outline-style);
outline-width: 2px;
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
@@ -20,7 +20,6 @@
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
@@ -79,7 +78,6 @@
<UserPageLayout title={data.meta.title}>
{#snippet sidebar()}
<Sidebar>
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} breakpoint="md" />
<section>
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div>
<div class="h-full">

View File

@@ -1,13 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import TagCreateModal from '$lib/modals/TagCreateModal.svelte';
import TagEditModal from '$lib/modals/TagEditModal.svelte';
@@ -84,7 +83,6 @@
<UserPageLayout title={data.meta.title}>
{#snippet sidebar()}
<Sidebar>
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" />
<section>
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div>
<div class="h-full">