Compare commits

..

16 Commits

Author SHA1 Message Date
Jonathan Jogenfors
173904e387 Merge branch 'main' of https://github.com/immich-app/immich into chore/originals-in-asset-files 2025-12-04 11:30:35 +01:00
Jonathan Jogenfors
42854cad56 wip 2025-12-02 19:46:59 +01:00
Jonathan Jogenfors
1bcf28c062 chore(server): sidecars in asset_files (#21199)
* fix: sidecar check job

* feat: move sidecars to asset_files

* feat: combine with handleSidecarCheck

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

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

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-12-02 13:31:43 -05:00
Jonathan Jogenfors
93ec8b7ecf Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-11-28 01:22:36 +01:00
Jonathan Jogenfors
bf1d409be1 Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-11-28 00:30:42 +01:00
Jonathan Jogenfors
857816bccc Merge branch 'fix/asset-service-argument-order' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-10-31 01:00:09 +01:00
Jonathan Jogenfors
e19467eddd fix(server): improved method signatures for stack and sidecar copying 2025-10-31 00:31:03 +01:00
Jonathan Jogenfors
0cb96837d0 Merge branch 'fix/asset-service-argument-order' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-10-31 00:27:33 +01:00
Jonathan Jogenfors
cbdfe08344 fix(server): improved method signatures for stack and sidecar copying 2025-10-31 00:14:12 +01:00
Jonathan Jogenfors
6229f9feb4 Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-10-31 00:03:32 +01:00
Jonathan Jogenfors
20f5a14d03 Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-10-30 23:19:07 +01:00
Jonathan Jogenfors
7ade8ad69a Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-10-04 00:01:53 +02:00
Jonathan Jogenfors
6b91b31dbc feat: combine with handleSidecarCheck 2025-09-07 21:36:02 +02:00
Jonathan Jogenfors
65f63be564 Merge branch 'fix/sidecar-check-job' of https://github.com/immich-app/immich into feat/sidecar-asset-files 2025-08-27 13:51:14 +02:00
Jonathan Jogenfors
430af9a145 feat: move sidecars to asset_files 2025-08-27 13:02:03 +02:00
Jason Rasmussen
f3b0e8a5e6 fix: sidecar check job 2025-08-26 11:51:21 -04:00
42 changed files with 1131 additions and 960 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 });

View File

@@ -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'],

View File

@@ -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[];

View File

@@ -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 {

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import { Asset, columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { StorageAsset } from 'src/types';
import {
anyUuid,
asUuid,
@@ -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();
}

View File

@@ -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),
),
),
)

View File

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

View File

@@ -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();
}
}

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`);

View File

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

View File

@@ -8,9 +8,10 @@ import { constants } from 'node:fs/promises';
import { join, parse } from 'node:path';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { Asset, AssetFace } from 'src/database';
import { Asset, AssetFace, AssetFile } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import {
AssetFileType,
AssetType,
AssetVisibility,
DatabaseLock,
@@ -29,6 +30,7 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database';
import { isFaceImportEnabled } from 'src/utils/misc';
import { upsertTags } from 'src/utils/tag';
@@ -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,

View File

@@ -6,10 +6,20 @@ import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
import { AssetPathType, AssetType, DatabaseLock, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum';
import {
AssetFileType,
AssetPathType,
AssetType,
DatabaseLock,
JobName,
JobStatus,
QueueName,
StorageFolder,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { JobOf, StorageAsset } from 'src/types';
import { getAssetFile, 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,

View File

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

View File

@@ -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');
}

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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