mirror of
https://github.com/immich-app/immich.git
synced 2025-12-16 01:10:57 +03:00
Compare commits
16 Commits
refactor-l
...
chore/orig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
173904e387 | ||
|
|
42854cad56 | ||
|
|
1bcf28c062 | ||
|
|
93ec8b7ecf | ||
|
|
bf1d409be1 | ||
|
|
857816bccc | ||
|
|
e19467eddd | ||
|
|
0cb96837d0 | ||
|
|
cbdfe08344 | ||
|
|
6229f9feb4 | ||
|
|
20f5a14d03 | ||
|
|
7ade8ad69a | ||
|
|
6b91b31dbc | ||
|
|
65f63be564 | ||
|
|
430af9a145 | ||
|
|
f3b0e8a5e6 |
@@ -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`],
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -290,7 +290,7 @@ export class StorageCore {
|
||||
private savePath(pathType: PathType, id: string, newPath: string) {
|
||||
switch (pathType) {
|
||||
case AssetPathType.Original: {
|
||||
return this.assetRepository.update({ id, originalPath: newPath });
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Original, path: newPath });
|
||||
}
|
||||
case AssetPathType.FullSize: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
|
||||
@@ -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 });
|
||||
|
||||
@@ -120,9 +120,7 @@ export type Asset = {
|
||||
livePhotoVideoId: string | null;
|
||||
localDateTime: Date;
|
||||
originalFileName: string;
|
||||
originalPath: string;
|
||||
ownerId: string;
|
||||
sidecarPath: string | null;
|
||||
type: AssetType;
|
||||
};
|
||||
|
||||
@@ -156,13 +154,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;
|
||||
@@ -345,9 +336,7 @@ export const columns = {
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.localDateTime',
|
||||
'asset.originalFileName',
|
||||
'asset.originalPath',
|
||||
'asset.ownerId',
|
||||
'asset.sidecarPath',
|
||||
'asset.type',
|
||||
],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
|
||||
|
||||
@@ -121,10 +121,8 @@ export type MapAsset = {
|
||||
livePhotoVideoId: string | null;
|
||||
localDateTime: Date;
|
||||
originalFileName: string;
|
||||
originalPath: string;
|
||||
owner?: User | null;
|
||||
ownerId: string;
|
||||
sidecarPath: string | null;
|
||||
stack?: Stack | null;
|
||||
stackId: string | null;
|
||||
tags?: Tag[];
|
||||
|
||||
@@ -38,12 +38,14 @@ export enum AssetType {
|
||||
}
|
||||
|
||||
export enum AssetFileType {
|
||||
Original = 'original',
|
||||
/**
|
||||
* An full/large-size image extracted/converted from RAW photos
|
||||
*/
|
||||
FullSize = 'fullsize',
|
||||
Preview = 'preview',
|
||||
Thumbnail = 'thumbnail',
|
||||
Sidecar = 'sidecar',
|
||||
}
|
||||
|
||||
export enum AlbumUserRole {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".*
|
||||
|
||||
@@ -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,
|
||||
@@ -18,6 +17,8 @@ import {
|
||||
withFacesAndPeople,
|
||||
withFilePath,
|
||||
withFiles,
|
||||
withOriginals,
|
||||
withSidecars,
|
||||
} from 'src/utils/database';
|
||||
|
||||
@Injectable()
|
||||
@@ -40,7 +41,9 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.select(['id', 'sidecarPath', 'originalPath'])
|
||||
.select('id')
|
||||
.select(withSidecars)
|
||||
.select(withOriginals)
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
@@ -59,7 +62,9 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.select(['id', 'sidecarPath', 'originalPath'])
|
||||
.select('id')
|
||||
.select(withSidecars)
|
||||
.select(withOriginals)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
@@ -105,7 +110,6 @@ export class AssetJobRepository {
|
||||
'asset.id',
|
||||
'asset.visibility',
|
||||
'asset.originalFileName',
|
||||
'asset.originalPath',
|
||||
'asset.ownerId',
|
||||
'asset.thumbhash',
|
||||
'asset.type',
|
||||
@@ -122,6 +126,8 @@ export class AssetJobRepository {
|
||||
.selectFrom('asset')
|
||||
.select(columns.asset)
|
||||
.select(withFaces)
|
||||
.select(withOriginals)
|
||||
.select(withSidecars)
|
||||
.where('asset.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
@@ -206,14 +212,8 @@ export class AssetJobRepository {
|
||||
getForSyncAssets(ids: string[]) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select([
|
||||
'asset.id',
|
||||
'asset.isOffline',
|
||||
'asset.libraryId',
|
||||
'asset.originalPath',
|
||||
'asset.status',
|
||||
'asset.fileModifiedAt',
|
||||
])
|
||||
.select(['asset.id', 'asset.isOffline', 'asset.libraryId', 'asset.status', 'asset.fileModifiedAt'])
|
||||
.select(withOriginals)
|
||||
.where('asset.id', '=', anyUuid(ids))
|
||||
.execute();
|
||||
}
|
||||
@@ -228,9 +228,7 @@ export class AssetJobRepository {
|
||||
'asset.libraryId',
|
||||
'asset.ownerId',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.sidecarPath',
|
||||
'asset.encodedVideoPath',
|
||||
'asset.originalPath',
|
||||
])
|
||||
.$call(withExif)
|
||||
.select(withFacesAndPeople)
|
||||
@@ -273,7 +271,8 @@ export class AssetJobRepository {
|
||||
getForVideoConversion(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath'])
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath'])
|
||||
.select(withOriginals)
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.executeTakeFirst();
|
||||
@@ -304,28 +303,33 @@ export class AssetJobRepository {
|
||||
'asset.ownerId',
|
||||
'asset.type',
|
||||
'asset.checksum',
|
||||
'asset.originalPath',
|
||||
'asset.isExternal',
|
||||
'asset.sidecarPath',
|
||||
'asset.originalFileName',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.fileCreatedAt',
|
||||
'asset_exif.timeZone',
|
||||
'asset_exif.fileSizeInByte',
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.select('asset_file.path')
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', '=', AssetFileType.Sidecar)
|
||||
.limit(1)
|
||||
.as('sidecarPath'), // TODO: change to withSidecars
|
||||
])
|
||||
.select(withOriginals)
|
||||
.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 +347,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
withFacesAndPeople,
|
||||
withFiles,
|
||||
withLibrary,
|
||||
withOriginals,
|
||||
withOwner,
|
||||
withSidecars,
|
||||
withSmartSearch,
|
||||
withTagId,
|
||||
withTags,
|
||||
@@ -111,6 +113,8 @@ interface GetByIdsRelations {
|
||||
smartSearch?: boolean;
|
||||
stack?: { assets?: boolean };
|
||||
tags?: boolean;
|
||||
originals?: boolean;
|
||||
sidecars?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -251,8 +255,23 @@ export class AssetRepository {
|
||||
await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute();
|
||||
}
|
||||
|
||||
create(asset: Insertable<AssetTable>) {
|
||||
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
|
||||
create(asset: Insertable<AssetTable>, files?: Insertable<AssetFileTable>[]) {
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
const createdAsset = await trx.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
|
||||
if (files && files.length > 0) {
|
||||
const values = files.map((f) => ({ ...f, assetId: createdAsset.id }));
|
||||
|
||||
await trx.insertInto('asset_file').values(values).returningAll().execute();
|
||||
}
|
||||
|
||||
const assetWithFiles = await trx
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.select(withOriginals)
|
||||
.where('asset.id', '=', asUuid(createdAsset.id))
|
||||
.executeTakeFirstOrThrow();
|
||||
return assetWithFiles;
|
||||
});
|
||||
}
|
||||
|
||||
createAll(assets: Insertable<AssetTable>[]) {
|
||||
@@ -354,8 +373,10 @@ export class AssetRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.where('libraryId', '=', asUuid(libraryId))
|
||||
.where('originalPath', '=', originalPath)
|
||||
.innerJoin('asset_file', 'asset.id', 'asset_file.assetId')
|
||||
.where('asset.libraryId', '=', asUuid(libraryId))
|
||||
.where('asset_file.path', '=', originalPath)
|
||||
.where('asset_file.type', '=', AssetFileType.Original)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
@@ -396,8 +417,21 @@ export class AssetRepository {
|
||||
return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute();
|
||||
}
|
||||
|
||||
getForCopy(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['id', 'stackId', '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 = {}) {
|
||||
getById(
|
||||
id: string,
|
||||
{ exifInfo, faces, files, library, owner, smartSearch, stack, tags, sidecars, originals }: GetByIdsRelations = {},
|
||||
) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
@@ -433,6 +467,8 @@ export class AssetRepository {
|
||||
),
|
||||
)
|
||||
.$if(!!files, (qb) => qb.select(withFiles))
|
||||
.$if(!!sidecars, (qb) => qb.select(withSidecars))
|
||||
.$if(!!originals, (qb) => qb.select(withOriginals))
|
||||
.$if(!!tags, (qb) => qb.select(withTags))
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
@@ -476,6 +512,7 @@ export class AssetRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.select(withOriginals)
|
||||
.where('ownerId', '=', asUuid(ownerId))
|
||||
.where('checksum', '=', checksum)
|
||||
.$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null)))
|
||||
@@ -842,6 +879,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;
|
||||
@@ -868,14 +909,22 @@ export class AssetRepository {
|
||||
isOffline: true,
|
||||
deletedAt: new Date(),
|
||||
})
|
||||
.where('isOffline', '=', false)
|
||||
.where('isExternal', '=', true)
|
||||
.where('libraryId', '=', asUuid(libraryId))
|
||||
.where('asset.isOffline', '=', false)
|
||||
.where('asset.isExternal', '=', true)
|
||||
.where('asset.libraryId', '=', asUuid(libraryId))
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb.not(eb.or(paths.map((path) => eb('originalPath', 'like', path)))),
|
||||
eb.or(exclusions.map((path) => eb('originalPath', 'like', path))),
|
||||
]),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', '=', AssetFileType.Original)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb.not(eb.or(paths.map((path) => eb('asset_file.path', 'like', path)))),
|
||||
eb.or(exclusions.map((path) => eb('asset_file.path', 'like', path))),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
@@ -890,10 +939,12 @@ export class AssetRepository {
|
||||
eb.exists(
|
||||
this.db
|
||||
.selectFrom('asset')
|
||||
.select('originalPath')
|
||||
.whereRef('asset.originalPath', '=', eb.ref('path'))
|
||||
.where('libraryId', '=', asUuid(libraryId))
|
||||
.where('isExternal', '=', true),
|
||||
.innerJoin('asset_file', 'asset.id', 'asset_file.assetId')
|
||||
.select('asset_file.path')
|
||||
.whereRef('asset_file.path', '=', eb.ref('path'))
|
||||
.where('asset_file.type', '=', AssetFileType.Original)
|
||||
.where('asset.libraryId', '=', asUuid(libraryId))
|
||||
.where('asset.isExternal', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { AssetFileType, AssetVisibility } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { asUuid, withExif } from 'src/utils/database';
|
||||
|
||||
@@ -12,14 +12,18 @@ export class ViewRepository {
|
||||
async getUniqueOriginalPaths(userId: string) {
|
||||
const results = await this.db
|
||||
.selectFrom('asset')
|
||||
.select((eb) => eb.fn<string>('substring', ['asset.originalPath', eb.val('^(.*/)[^/]*$')]).as('directoryPath'))
|
||||
.innerJoin('asset_file', 'asset.id', 'asset_file.assetId')
|
||||
.select((eb) =>
|
||||
eb.fn<string>('substring', [eb.ref('asset_file.path'), eb.val('^(.*/)[^/]*$')]).as('directoryPath'),
|
||||
)
|
||||
.distinct()
|
||||
.where('ownerId', '=', asUuid(userId))
|
||||
.where('visibility', '=', AssetVisibility.Timeline)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('fileCreatedAt', 'is not', null)
|
||||
.where('fileModifiedAt', 'is not', null)
|
||||
.where('localDateTime', 'is not', null)
|
||||
.where('asset.ownerId', '=', asUuid(userId))
|
||||
.where('asset.visibility', '=', AssetVisibility.Timeline)
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.where('asset.fileCreatedAt', 'is not', null)
|
||||
.where('asset.fileModifiedAt', 'is not', null)
|
||||
.where('asset.localDateTime', 'is not', null)
|
||||
.where((eb) => eb(eb.ref('asset_file.type'), '=', AssetFileType.Original))
|
||||
.orderBy('directoryPath', 'asc')
|
||||
.execute();
|
||||
|
||||
@@ -32,20 +36,22 @@ export class ViewRepository {
|
||||
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_file', 'asset.id', 'asset_file.assetId')
|
||||
.selectAll('asset')
|
||||
.$call(withExif)
|
||||
.where('ownerId', '=', asUuid(userId))
|
||||
.where('visibility', '=', AssetVisibility.Timeline)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('fileCreatedAt', 'is not', null)
|
||||
.where('fileModifiedAt', 'is not', null)
|
||||
.where('localDateTime', 'is not', null)
|
||||
.where('originalPath', 'like', `%${normalizedPath}/%`)
|
||||
.where('originalPath', 'not like', `%${normalizedPath}/%/%`)
|
||||
.where('asset.ownerId', '=', asUuid(userId))
|
||||
.where('asset.visibility', '=', AssetVisibility.Timeline)
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.where('asset.fileCreatedAt', 'is not', null)
|
||||
.where('asset.fileModifiedAt', 'is not', null)
|
||||
.where('asset.localDateTime', 'is not', null)
|
||||
.where((eb) => eb(eb.ref('asset_file.type'), '=', AssetFileType.Original))
|
||||
.where((eb) => eb(eb.ref('asset_file.path'), 'like', `%${normalizedPath}/%`))
|
||||
.where((eb) => eb(eb.ref('asset_file.path'), 'not like', `%${normalizedPath}/%/%`))
|
||||
.orderBy(
|
||||
(eb) => eb.fn('regexp_replace', ['asset.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]),
|
||||
(eb) => eb.fn('regexp_replace', [eb.ref('asset_file.path'), eb.val('.*/(.+)'), eb.val(String.raw`\\1`)]),
|
||||
'asc',
|
||||
)
|
||||
.$call(withExif)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;`.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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -72,9 +72,6 @@ export class AssetTable {
|
||||
@Column()
|
||||
type!: AssetType;
|
||||
|
||||
@Column()
|
||||
originalPath!: string;
|
||||
|
||||
@Column({ type: 'timestamp with time zone', index: true })
|
||||
fileCreatedAt!: Timestamp;
|
||||
|
||||
@@ -105,9 +102,6 @@ export class AssetTable {
|
||||
@Column({ index: true })
|
||||
originalFileName!: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
sidecarPath!: string | null;
|
||||
|
||||
@Column({ type: 'bytea', nullable: true })
|
||||
thumbhash!: Buffer | null;
|
||||
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -167,6 +167,8 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const { originalFile } = getAssetFiles(asset.files);
|
||||
|
||||
let generated: {
|
||||
previewPath: string;
|
||||
thumbnailPath: string;
|
||||
@@ -174,11 +176,11 @@ export class MediaService extends BaseService {
|
||||
thumbhash: Buffer;
|
||||
};
|
||||
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
||||
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateVideoThumbnails(asset);
|
||||
this.logger.verbose(`Thumbnail generation for video ${id} ${originalFile.path}`);
|
||||
generated = await this.generateVideoThumbnails(asset, originalFile.path);
|
||||
} else if (asset.type === AssetType.Image) {
|
||||
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateImageThumbnails(asset);
|
||||
this.logger.verbose(`Thumbnail generation for image ${id} ${originalFile.path}`);
|
||||
generated = await this.generateImageThumbnails(asset, originalFile.path);
|
||||
} else {
|
||||
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
||||
return JobStatus.Skipped;
|
||||
@@ -423,13 +425,13 @@ export class MediaService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
|
||||
private async generateVideoThumbnails(asset: ThumbnailPathEntity, originalPath: string) {
|
||||
const { image, ffmpeg } = await this.getConfig({ withCache: true });
|
||||
const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format);
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format);
|
||||
this.storageCore.ensureFolders(previewPath);
|
||||
|
||||
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(originalPath);
|
||||
const mainVideoStream = this.getMainStream(videoStreams);
|
||||
if (!mainVideoStream) {
|
||||
throw new Error(`No video streams found for asset ${asset.id}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
@@ -222,13 +224,15 @@ export class MetadataService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
const { originalFile } = getAssetFiles(asset.files);
|
||||
|
||||
const [exifTags, stats] = await Promise.all([
|
||||
this.getExifTags(asset),
|
||||
this.storageRepository.stat(asset.originalPath),
|
||||
this.storageRepository.stat(originalFile.path),
|
||||
]);
|
||||
this.logger.verbose('Exif Tags', exifTags);
|
||||
|
||||
const dates = this.getDates(asset, exifTags, stats);
|
||||
const dates = this.getDates(asset, originalFile.path, exifTags, stats);
|
||||
|
||||
const { width, height } = this.getImageDimensions(exifTags);
|
||||
let geo: ReverseGeocodeResult = { country: null, state: null, city: null },
|
||||
@@ -299,11 +303,11 @@ export class MetadataService extends BaseService {
|
||||
];
|
||||
|
||||
if (this.isMotionPhoto(asset, exifTags)) {
|
||||
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
|
||||
promises.push(this.applyMotionPhotos(asset, originalFile.path, exifTags, dates, stats));
|
||||
}
|
||||
|
||||
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {
|
||||
promises.push(this.applyTaggedFaces(asset, exifTags));
|
||||
promises.push(this.applyTaggedFaces(asset, originalFile.path, exifTags));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
@@ -359,17 +363,21 @@ export class MetadataService extends BaseService {
|
||||
break;
|
||||
}
|
||||
|
||||
const isChanged = sidecarPath !== asset.sidecarPath;
|
||||
const { originalFile, 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}: ${originalFile.path}`,
|
||||
);
|
||||
|
||||
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 +402,9 @@ export class MetadataService extends BaseService {
|
||||
|
||||
const tagsList = (asset.tags || []).map((tag) => tag.value);
|
||||
|
||||
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
|
||||
const { originalFile, sidecarFile } = getAssetFiles(asset.files);
|
||||
|
||||
const sidecarPath = sidecarFile?.path || `${originalFile.path}.xmp`; // prefer file.jpg.xmp by default
|
||||
const exif = _.omitBy(
|
||||
<Tags>{
|
||||
Description: description,
|
||||
@@ -414,25 +424,27 @@ 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({ id, files }: { id: string; files: AssetFile[] }) {
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (sidecarPath) {
|
||||
candidates.push(sidecarPath);
|
||||
const { originalFile, sidecarFile } = getAssetFiles(files);
|
||||
|
||||
if (sidecarFile?.path) {
|
||||
candidates.push(sidecarFile.path);
|
||||
}
|
||||
|
||||
const assetPath = parse(originalPath);
|
||||
const assetPath = parse(originalFile.path);
|
||||
|
||||
candidates.push(
|
||||
// IMG_123.jpg.xmp
|
||||
`${originalPath}.xmp`,
|
||||
`${assetPath}.xmp`,
|
||||
// IMG_123.xmp
|
||||
`${join(assetPath.dir, assetPath.name)}.xmp`,
|
||||
);
|
||||
@@ -456,15 +468,13 @@ 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: { files: AssetFile[]; type: AssetType }): Promise<ImmichTags> {
|
||||
const { originalFile, sidecarFile } = getAssetFiles(asset.files);
|
||||
|
||||
const [mediaTags, sidecarTags, videoTags] = await Promise.all([
|
||||
this.metadataRepository.readTags(asset.originalPath),
|
||||
asset.sidecarPath ? this.metadataRepository.readTags(asset.sidecarPath) : null,
|
||||
asset.type === AssetType.Video ? this.getVideoTags(asset.originalPath) : null,
|
||||
this.metadataRepository.readTags(originalFile.path),
|
||||
sidecarFile ? this.metadataRepository.readTags(sidecarFile.path) : null,
|
||||
asset.type === AssetType.Video ? this.getVideoTags(originalFile.path) : null,
|
||||
]);
|
||||
|
||||
// prefer dates from sidecar tags
|
||||
@@ -528,7 +538,7 @@ export class MetadataService extends BaseService {
|
||||
return asset.type === AssetType.Image && !!(tags.MotionPhoto || tags.MicroVideo);
|
||||
}
|
||||
|
||||
private async applyMotionPhotos(asset: Asset, tags: ImmichTags, dates: Dates, stats: Stats) {
|
||||
private async applyMotionPhotos(asset: Asset, originalPath: string, tags: ImmichTags, dates: Dates, stats: Stats) {
|
||||
const isMotionPhoto = tags.MotionPhoto;
|
||||
const isMicroVideo = tags.MicroVideo;
|
||||
const videoOffset = tags.MicroVideoOffset;
|
||||
@@ -559,7 +569,7 @@ export class MetadataService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`Starting motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`);
|
||||
this.logger.debug(`Starting motion photo video extraction for asset ${asset.id}: ${originalPath}`);
|
||||
|
||||
try {
|
||||
const position = stats.size - length - padding;
|
||||
@@ -567,15 +577,15 @@ export class MetadataService extends BaseService {
|
||||
// Samsung MotionPhoto video extraction
|
||||
// HEIC-encoded
|
||||
if (hasMotionPhotoVideo) {
|
||||
video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo');
|
||||
video = await this.metadataRepository.extractBinaryTag(originalPath, 'MotionPhotoVideo');
|
||||
}
|
||||
// JPEG-encoded; HEIC also contains these tags, so this conditional must come second
|
||||
else if (hasEmbeddedVideoFile) {
|
||||
video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile');
|
||||
video = await this.metadataRepository.extractBinaryTag(originalPath, 'EmbeddedVideoFile');
|
||||
}
|
||||
// Default video extraction
|
||||
else {
|
||||
video = await this.storageRepository.readFile(asset.originalPath, {
|
||||
video = await this.storageRepository.readFile(originalPath, {
|
||||
buffer: Buffer.alloc(length),
|
||||
position,
|
||||
length,
|
||||
@@ -590,21 +600,29 @@ export class MetadataService extends BaseService {
|
||||
if (!motionAsset) {
|
||||
try {
|
||||
const motionAssetId = this.cryptoRepository.randomUUID();
|
||||
motionAsset = await this.assetRepository.create({
|
||||
id: motionAssetId,
|
||||
libraryId: asset.libraryId,
|
||||
type: AssetType.Video,
|
||||
fileCreatedAt: dates.dateTimeOriginal,
|
||||
fileModifiedAt: stats.mtime,
|
||||
localDateTime: dates.localDateTime,
|
||||
checksum,
|
||||
ownerId: asset.ownerId,
|
||||
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
|
||||
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
});
|
||||
motionAsset = await this.assetRepository.create(
|
||||
{
|
||||
id: motionAssetId,
|
||||
libraryId: asset.libraryId,
|
||||
type: AssetType.Video,
|
||||
fileCreatedAt: dates.dateTimeOriginal,
|
||||
fileModifiedAt: stats.mtime,
|
||||
localDateTime: dates.localDateTime,
|
||||
checksum,
|
||||
ownerId: asset.ownerId,
|
||||
originalFileName: `${parse(originalPath).name}.mp4`,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
},
|
||||
[
|
||||
{
|
||||
type: AssetFileType.Original,
|
||||
assetId: motionAssetId,
|
||||
path: StorageCore.getAndroidMotionPath(asset, motionAssetId),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
isNewMotionAsset = true;
|
||||
|
||||
@@ -618,16 +636,18 @@ export class MetadataService extends BaseService {
|
||||
|
||||
motionAsset = await this.assetRepository.getByChecksum(checksumQuery);
|
||||
if (!motionAsset) {
|
||||
this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${asset.originalPath}`);
|
||||
this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${originalPath}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { originalFile: originalMotionFile } = getAssetFiles(motionAsset.files);
|
||||
|
||||
if (!isNewMotionAsset) {
|
||||
this.logger.debugFn(() => {
|
||||
const base64Checksum = checksum.toString('base64');
|
||||
return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${asset.originalPath}`;
|
||||
return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${originalPath}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -657,22 +677,18 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
|
||||
// write extracted motion video to disk, especially if the encoded-video folder has been deleted
|
||||
const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath);
|
||||
const existsOnDisk = await this.storageRepository.checkFileExists(originalMotionFile.path);
|
||||
if (!existsOnDisk) {
|
||||
this.storageCore.ensureFolders(motionAsset.originalPath);
|
||||
await this.storageRepository.createFile(motionAsset.originalPath, video);
|
||||
this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`);
|
||||
|
||||
this.storageCore.ensureFolders(originalMotionFile.path);
|
||||
await this.storageRepository.createFile(originalMotionFile.path, video);
|
||||
this.logger.log(`Wrote motion photo video to ${originalMotionFile.path}`);
|
||||
await this.handleMetadataExtraction({ id: motionAsset.id });
|
||||
await this.jobRepository.queue({ name: JobName.AssetEncodeVideo, data: { id: motionAsset.id } });
|
||||
}
|
||||
|
||||
this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`);
|
||||
this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${originalPath}`);
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(
|
||||
`Failed to extract motion video for ${asset.id}: ${asset.originalPath}: ${error}`,
|
||||
error?.stack,
|
||||
);
|
||||
this.logger.error(`Failed to extract motion video for ${asset.id}: ${originalPath}: ${error}`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,7 +773,8 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
|
||||
private async applyTaggedFaces(
|
||||
asset: { id: string; ownerId: string; faces: AssetFace[]; originalPath: string },
|
||||
asset: { id: string; ownerId: string; faces: AssetFace[] },
|
||||
originalPath: string,
|
||||
tags: ImmichTags,
|
||||
) {
|
||||
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
|
||||
@@ -811,13 +828,11 @@ export class MetadataService extends BaseService {
|
||||
|
||||
const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.Exif).map((face) => face.id);
|
||||
if (facesToRemove.length > 0) {
|
||||
this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${asset.originalPath}`);
|
||||
this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${originalPath}`);
|
||||
}
|
||||
|
||||
if (facesToAdd.length > 0) {
|
||||
this.logger.debug(
|
||||
`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${asset.originalPath}`,
|
||||
);
|
||||
this.logger.debug(`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${originalPath}`);
|
||||
}
|
||||
|
||||
if (facesToRemove.length > 0 || facesToAdd.length > 0) {
|
||||
@@ -830,16 +845,15 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
|
||||
private getDates(
|
||||
asset: { id: string; originalPath: string; fileCreatedAt: Date },
|
||||
asset: { id: string; fileCreatedAt: Date },
|
||||
originalPath: string,
|
||||
exifTags: ImmichTags,
|
||||
stats: Stats,
|
||||
) {
|
||||
const result = firstDateTime(exifTags);
|
||||
const tag = result?.tag;
|
||||
const dateTime = result?.dateTime;
|
||||
this.logger.verbose(
|
||||
`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${asset.originalPath}`,
|
||||
);
|
||||
this.logger.verbose(`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${originalPath}`);
|
||||
|
||||
// timezone
|
||||
let timeZone = exifTags.tz ?? null;
|
||||
@@ -850,11 +864,9 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
|
||||
if (timeZone) {
|
||||
this.logger.verbose(
|
||||
`Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`,
|
||||
);
|
||||
this.logger.verbose(`Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${originalPath}`);
|
||||
} else {
|
||||
this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`);
|
||||
this.logger.debug(`No timezone information found for asset ${asset.id}: ${originalPath}`);
|
||||
}
|
||||
|
||||
let dateTimeOriginal = dateTime?.toDateTime();
|
||||
@@ -880,12 +892,12 @@ export class MetadataService extends BaseService {
|
||||
),
|
||||
);
|
||||
this.logger.debug(
|
||||
`No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`,
|
||||
`No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${originalPath}`,
|
||||
);
|
||||
dateTimeOriginal = localDateTime = earliestDate;
|
||||
}
|
||||
|
||||
this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${asset.originalPath}`);
|
||||
this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${originalPath}`);
|
||||
|
||||
return {
|
||||
timeZone,
|
||||
|
||||
@@ -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, getAssetFiles } from 'src/utils/asset.util';
|
||||
import { getLivePhotoMotionFilename } from 'src/utils/file';
|
||||
|
||||
const storageTokens = {
|
||||
@@ -137,7 +147,8 @@ export class StorageTemplateService extends BaseService {
|
||||
const user = await this.userRepository.get(asset.ownerId, {});
|
||||
const storageLabel = user?.storageLabel || null;
|
||||
const filename = asset.originalFileName || asset.id;
|
||||
await this.moveAsset(asset, { storageLabel, filename });
|
||||
const { originalFile } = getAssetFiles(asset.files);
|
||||
await this.moveAsset({ originalPath: originalFile.path, ...asset }, { storageLabel, filename });
|
||||
|
||||
// move motion part of live photo
|
||||
if (asset.livePhotoVideoId) {
|
||||
@@ -145,8 +156,12 @@ export class StorageTemplateService extends BaseService {
|
||||
if (!livePhotoVideo) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||
const { originalFile: livePhotoOriginalFile } = getAssetFiles(livePhotoVideo.files);
|
||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoOriginalFile.path);
|
||||
await this.moveAsset(
|
||||
{ originalPath: livePhotoOriginalFile.path, ...livePhotoVideo },
|
||||
{ storageLabel, filename: motionFilename },
|
||||
);
|
||||
}
|
||||
return JobStatus.Success;
|
||||
}
|
||||
@@ -170,7 +185,8 @@ export class StorageTemplateService extends BaseService {
|
||||
const user = users.find((user) => user.id === asset.ownerId);
|
||||
const storageLabel = user?.storageLabel || null;
|
||||
const filename = asset.originalFileName || asset.id;
|
||||
await this.moveAsset(asset, { storageLabel, filename });
|
||||
const { originalFile } = getAssetFiles(asset.files);
|
||||
await this.moveAsset({ originalPath: originalFile.path, ...asset }, { storageLabel, filename });
|
||||
}
|
||||
|
||||
this.logger.debug('Cleaning up empty directories...');
|
||||
@@ -196,7 +212,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 +229,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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -18,9 +18,17 @@ export const getAssetFile = (files: AssetFile[], type: AssetFileType | Generated
|
||||
};
|
||||
|
||||
export const getAssetFiles = (files: AssetFile[]) => ({
|
||||
originalFile: (() => {
|
||||
const file = getAssetFile(files, AssetFileType.Original);
|
||||
if (!file?.path) {
|
||||
throw new BadRequestException(`Asset has no original file`); // TODO: should we throw a specific error here that can be caught higher up?
|
||||
}
|
||||
return file;
|
||||
})(),
|
||||
fullsizeFile: getAssetFile(files, AssetFileType.FullSize),
|
||||
previewFile: getAssetFile(files, AssetFileType.Preview),
|
||||
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail),
|
||||
sidecarFile: getAssetFile(files, AssetFileType.Sidecar),
|
||||
});
|
||||
|
||||
export const addAssets = async (
|
||||
@@ -160,12 +168,18 @@ export const onBeforeUnlink = async (
|
||||
{ asset: assetRepository }: AssetHookRepositories,
|
||||
{ livePhotoVideoId }: { livePhotoVideoId: string },
|
||||
) => {
|
||||
const motion = await assetRepository.getById(livePhotoVideoId);
|
||||
const motion = await assetRepository.getById(livePhotoVideoId, { files: true });
|
||||
if (!motion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (StorageCore.isAndroidMotionPath(motion.originalPath)) {
|
||||
const motionPath = motion.files?.find((file) => file.type === AssetFileType.Original)?.path;
|
||||
|
||||
if (!motionPath) {
|
||||
throw new BadRequestException('Live photo video original file not found');
|
||||
}
|
||||
|
||||
if (StorageCore.isAndroidMotionPath(motionPath)) {
|
||||
throw new BadRequestException('Cannot unlink Android motion photos');
|
||||
}
|
||||
|
||||
|
||||
@@ -191,13 +191,23 @@ export function withFaces(eb: ExpressionBuilder<DB, 'asset'>, withDeletedFace?:
|
||||
}
|
||||
|
||||
export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) {
|
||||
return jsonArrayFrom(
|
||||
const files = jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.select(columns.assetFiles)
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.$if(!!type, (qb) => qb.where('asset_file.type', '=', type!)),
|
||||
).as('files');
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export function withSidecars(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) {
|
||||
return withFiles(eb, AssetFileType.Sidecar);
|
||||
}
|
||||
|
||||
export function withOriginals(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) {
|
||||
return withFiles(eb, AssetFileType.Original);
|
||||
}
|
||||
|
||||
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType) {
|
||||
@@ -208,6 +218,10 @@ export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFile
|
||||
.where('asset_file.type', '=', type);
|
||||
}
|
||||
|
||||
export function withOriginalPath(eb: ExpressionBuilder<DB, 'asset'>) {
|
||||
return withFilePath(eb, AssetFileType.Original).as('originalPath');
|
||||
}
|
||||
|
||||
export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'asset'>, withDeletedFace?: boolean) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
|
||||
43
server/test/fixtures/asset.stub.ts
vendored
43
server/test/fixtures/asset.stub.ts
vendored
@@ -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',
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user