mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat(server,web): remove external path nonsense and make libraries admin-only (#7237)
* remove external path * open-api * make sql * move library settings to admin panel * Add documentation * show external libraries only * fix library list * make user library settings look good * fix test * fix tests * fix tests * can pick user for library * fix tests * fix e2e * chore: make sql * Use unauth exception * delete user library list * cleanup * fix e2e * fix await lint * chore: remove unused code * chore: cleanup * revert docs * fix: is admin stuff * table alignment --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		
							parent
							
								
									369acc7bea
								
							
						
					
					
						commit
						efa6efd200
					
				@ -44,7 +44,6 @@ export const userDto = {
 | 
			
		||||
    email: signupDto.admin.email,
 | 
			
		||||
    password: signupDto.admin.password,
 | 
			
		||||
    storageLabel: 'admin',
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
@ -63,7 +62,6 @@ export const userDto = {
 | 
			
		||||
    email: createUserDto.user1.email,
 | 
			
		||||
    password: createUserDto.user1.password,
 | 
			
		||||
    storageLabel: null,
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
 | 
			
		||||
@ -65,7 +65,6 @@ export const signupResponseDto = {
 | 
			
		||||
    name: 'Immich Admin',
 | 
			
		||||
    email: 'admin@immich.cloud',
 | 
			
		||||
    storageLabel: 'admin',
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
    // why? lol
 | 
			
		||||
    shouldChangePassword: true,
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							@ -135,8 +135,8 @@ Class | Method | HTTP request | Description
 | 
			
		||||
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} | 
 | 
			
		||||
*LibraryApi* | [**createLibrary**](doc//LibraryApi.md#createlibrary) | **POST** /library | 
 | 
			
		||||
*LibraryApi* | [**deleteLibrary**](doc//LibraryApi.md#deletelibrary) | **DELETE** /library/{id} | 
 | 
			
		||||
*LibraryApi* | [**getLibraries**](doc//LibraryApi.md#getlibraries) | **GET** /library | 
 | 
			
		||||
*LibraryApi* | [**getLibraryInfo**](doc//LibraryApi.md#getlibraryinfo) | **GET** /library/{id} | 
 | 
			
		||||
*LibraryApi* | [**getAllLibraries**](doc//LibraryApi.md#getalllibraries) | **GET** /library | 
 | 
			
		||||
*LibraryApi* | [**getLibrary**](doc//LibraryApi.md#getlibrary) | **GET** /library/{id} | 
 | 
			
		||||
*LibraryApi* | [**getLibraryStatistics**](doc//LibraryApi.md#getlibrarystatistics) | **GET** /library/{id}/statistics | 
 | 
			
		||||
*LibraryApi* | [**removeOfflineFiles**](doc//LibraryApi.md#removeofflinefiles) | **POST** /library/{id}/removeOffline | 
 | 
			
		||||
*LibraryApi* | [**scanLibrary**](doc//LibraryApi.md#scanlibrary) | **POST** /library/{id}/scan | 
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/CreateLibraryDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/CreateLibraryDto.md
									
									
									
										generated
									
									
									
								
							@ -13,6 +13,7 @@ Name | Type | Description | Notes
 | 
			
		||||
**isVisible** | **bool** |  | [optional] 
 | 
			
		||||
**isWatched** | **bool** |  | [optional] 
 | 
			
		||||
**name** | **String** |  | [optional] 
 | 
			
		||||
**ownerId** | **String** |  | [optional] 
 | 
			
		||||
**type** | [**LibraryType**](LibraryType.md) |  | 
 | 
			
		||||
 | 
			
		||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/CreateUserDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/CreateUserDto.md
									
									
									
										generated
									
									
									
								
							@ -9,7 +9,6 @@ import 'package:openapi/api.dart';
 | 
			
		||||
Name | Type | Description | Notes
 | 
			
		||||
------------ | ------------- | ------------- | -------------
 | 
			
		||||
**email** | **String** |  | 
 | 
			
		||||
**externalPath** | **String** |  | [optional] 
 | 
			
		||||
**memoriesEnabled** | **bool** |  | [optional] 
 | 
			
		||||
**name** | **String** |  | 
 | 
			
		||||
**password** | **String** |  | 
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										26
									
								
								mobile/openapi/doc/LibraryApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										26
									
								
								mobile/openapi/doc/LibraryApi.md
									
									
									
										generated
									
									
									
								
							@ -11,8 +11,8 @@ Method | HTTP request | Description
 | 
			
		||||
------------- | ------------- | -------------
 | 
			
		||||
[**createLibrary**](LibraryApi.md#createlibrary) | **POST** /library | 
 | 
			
		||||
[**deleteLibrary**](LibraryApi.md#deletelibrary) | **DELETE** /library/{id} | 
 | 
			
		||||
[**getLibraries**](LibraryApi.md#getlibraries) | **GET** /library | 
 | 
			
		||||
[**getLibraryInfo**](LibraryApi.md#getlibraryinfo) | **GET** /library/{id} | 
 | 
			
		||||
[**getAllLibraries**](LibraryApi.md#getalllibraries) | **GET** /library | 
 | 
			
		||||
[**getLibrary**](LibraryApi.md#getlibrary) | **GET** /library/{id} | 
 | 
			
		||||
[**getLibraryStatistics**](LibraryApi.md#getlibrarystatistics) | **GET** /library/{id}/statistics | 
 | 
			
		||||
[**removeOfflineFiles**](LibraryApi.md#removeofflinefiles) | **POST** /library/{id}/removeOffline | 
 | 
			
		||||
[**scanLibrary**](LibraryApi.md#scanlibrary) | **POST** /library/{id}/scan | 
 | 
			
		||||
@ -129,8 +129,8 @@ void (empty response body)
 | 
			
		||||
 | 
			
		||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
# **getLibraries**
 | 
			
		||||
> List<LibraryResponseDto> getLibraries()
 | 
			
		||||
# **getAllLibraries**
 | 
			
		||||
> List<LibraryResponseDto> getAllLibraries(type)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -153,17 +153,21 @@ import 'package:openapi/api.dart';
 | 
			
		||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 | 
			
		||||
 | 
			
		||||
final api_instance = LibraryApi();
 | 
			
		||||
final type = ; // LibraryType | 
 | 
			
		||||
 | 
			
		||||
try {
 | 
			
		||||
    final result = api_instance.getLibraries();
 | 
			
		||||
    final result = api_instance.getAllLibraries(type);
 | 
			
		||||
    print(result);
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    print('Exception when calling LibraryApi->getLibraries: $e\n');
 | 
			
		||||
    print('Exception when calling LibraryApi->getAllLibraries: $e\n');
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Parameters
 | 
			
		||||
This endpoint does not need any parameter.
 | 
			
		||||
 | 
			
		||||
Name | Type | Description  | Notes
 | 
			
		||||
------------- | ------------- | ------------- | -------------
 | 
			
		||||
 **type** | [**LibraryType**](.md)|  | [optional] 
 | 
			
		||||
 | 
			
		||||
### Return type
 | 
			
		||||
 | 
			
		||||
@ -180,8 +184,8 @@ This endpoint does not need any parameter.
 | 
			
		||||
 | 
			
		||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
# **getLibraryInfo**
 | 
			
		||||
> LibraryResponseDto getLibraryInfo(id)
 | 
			
		||||
# **getLibrary**
 | 
			
		||||
> LibraryResponseDto getLibrary(id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -207,10 +211,10 @@ final api_instance = LibraryApi();
 | 
			
		||||
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 | 
			
		||||
 | 
			
		||||
try {
 | 
			
		||||
    final result = api_instance.getLibraryInfo(id);
 | 
			
		||||
    final result = api_instance.getLibrary(id);
 | 
			
		||||
    print(result);
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    print('Exception when calling LibraryApi->getLibraryInfo: $e\n');
 | 
			
		||||
    print('Exception when calling LibraryApi->getLibrary: $e\n');
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/PartnerResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/PartnerResponseDto.md
									
									
									
										generated
									
									
									
								
							@ -12,7 +12,6 @@ Name | Type | Description | Notes
 | 
			
		||||
**createdAt** | [**DateTime**](DateTime.md) |  | 
 | 
			
		||||
**deletedAt** | [**DateTime**](DateTime.md) |  | 
 | 
			
		||||
**email** | **String** |  | 
 | 
			
		||||
**externalPath** | **String** |  | 
 | 
			
		||||
**id** | **String** |  | 
 | 
			
		||||
**inTimeline** | **bool** |  | [optional] 
 | 
			
		||||
**isAdmin** | **bool** |  | 
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/UpdateUserDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/UpdateUserDto.md
									
									
									
										generated
									
									
									
								
							@ -10,7 +10,6 @@ Name | Type | Description | Notes
 | 
			
		||||
------------ | ------------- | ------------- | -------------
 | 
			
		||||
**avatarColor** | [**UserAvatarColor**](UserAvatarColor.md) |  | [optional] 
 | 
			
		||||
**email** | **String** |  | [optional] 
 | 
			
		||||
**externalPath** | **String** |  | [optional] 
 | 
			
		||||
**id** | **String** |  | 
 | 
			
		||||
**isAdmin** | **bool** |  | [optional] 
 | 
			
		||||
**memoriesEnabled** | **bool** |  | [optional] 
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/UserResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/UserResponseDto.md
									
									
									
										generated
									
									
									
								
							@ -12,7 +12,6 @@ Name | Type | Description | Notes
 | 
			
		||||
**createdAt** | [**DateTime**](DateTime.md) |  | 
 | 
			
		||||
**deletedAt** | [**DateTime**](DateTime.md) |  | 
 | 
			
		||||
**email** | **String** |  | 
 | 
			
		||||
**externalPath** | **String** |  | 
 | 
			
		||||
**id** | **String** |  | 
 | 
			
		||||
**isAdmin** | **bool** |  | 
 | 
			
		||||
**memoriesEnabled** | **bool** |  | [optional] 
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										22
									
								
								mobile/openapi/lib/api/library_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										22
									
								
								mobile/openapi/lib/api/library_api.dart
									
									
									
										generated
									
									
									
								
							@ -104,7 +104,10 @@ class LibraryApi {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Performs an HTTP 'GET /library' operation and returns the [Response].
 | 
			
		||||
  Future<Response> getLibrariesWithHttpInfo() async {
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [LibraryType] type:
 | 
			
		||||
  Future<Response> getAllLibrariesWithHttpInfo({ LibraryType? type, }) async {
 | 
			
		||||
    // ignore: prefer_const_declarations
 | 
			
		||||
    final path = r'/library';
 | 
			
		||||
 | 
			
		||||
@ -115,6 +118,10 @@ class LibraryApi {
 | 
			
		||||
    final headerParams = <String, String>{};
 | 
			
		||||
    final formParams = <String, String>{};
 | 
			
		||||
 | 
			
		||||
    if (type != null) {
 | 
			
		||||
      queryParams.addAll(_queryParams('', 'type', type));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const contentTypes = <String>[];
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -129,8 +136,11 @@ class LibraryApi {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<LibraryResponseDto>?> getLibraries() async {
 | 
			
		||||
    final response = await getLibrariesWithHttpInfo();
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [LibraryType] type:
 | 
			
		||||
  Future<List<LibraryResponseDto>?> getAllLibraries({ LibraryType? type, }) async {
 | 
			
		||||
    final response = await getAllLibrariesWithHttpInfo( type: type, );
 | 
			
		||||
    if (response.statusCode >= HttpStatus.badRequest) {
 | 
			
		||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
			
		||||
    }
 | 
			
		||||
@ -151,7 +161,7 @@ class LibraryApi {
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [String] id (required):
 | 
			
		||||
  Future<Response> getLibraryInfoWithHttpInfo(String id,) async {
 | 
			
		||||
  Future<Response> getLibraryWithHttpInfo(String id,) async {
 | 
			
		||||
    // ignore: prefer_const_declarations
 | 
			
		||||
    final path = r'/library/{id}'
 | 
			
		||||
      .replaceAll('{id}', id);
 | 
			
		||||
@ -180,8 +190,8 @@ class LibraryApi {
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [String] id (required):
 | 
			
		||||
  Future<LibraryResponseDto?> getLibraryInfo(String id,) async {
 | 
			
		||||
    final response = await getLibraryInfoWithHttpInfo(id,);
 | 
			
		||||
  Future<LibraryResponseDto?> getLibrary(String id,) async {
 | 
			
		||||
    final response = await getLibraryWithHttpInfo(id,);
 | 
			
		||||
    if (response.statusCode >= HttpStatus.badRequest) {
 | 
			
		||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								mobile/openapi/lib/model/create_library_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								mobile/openapi/lib/model/create_library_dto.dart
									
									
									
										generated
									
									
									
								
							@ -18,6 +18,7 @@ class CreateLibraryDto {
 | 
			
		||||
    this.isVisible,
 | 
			
		||||
    this.isWatched,
 | 
			
		||||
    this.name,
 | 
			
		||||
    this.ownerId,
 | 
			
		||||
    required this.type,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -49,6 +50,14 @@ class CreateLibraryDto {
 | 
			
		||||
  ///
 | 
			
		||||
  String? name;
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// Please note: This property should have been non-nullable! Since the specification file
 | 
			
		||||
  /// does not include a default value (using the "default:" property), however, the generated
 | 
			
		||||
  /// source code must fall back to having a nullable type.
 | 
			
		||||
  /// Consider adding a "default:" property in the specification file to hide this note.
 | 
			
		||||
  ///
 | 
			
		||||
  String? ownerId;
 | 
			
		||||
 | 
			
		||||
  LibraryType type;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -58,6 +67,7 @@ class CreateLibraryDto {
 | 
			
		||||
    other.isVisible == isVisible &&
 | 
			
		||||
    other.isWatched == isWatched &&
 | 
			
		||||
    other.name == name &&
 | 
			
		||||
    other.ownerId == ownerId &&
 | 
			
		||||
    other.type == type;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -68,10 +78,11 @@ class CreateLibraryDto {
 | 
			
		||||
    (isVisible == null ? 0 : isVisible!.hashCode) +
 | 
			
		||||
    (isWatched == null ? 0 : isWatched!.hashCode) +
 | 
			
		||||
    (name == null ? 0 : name!.hashCode) +
 | 
			
		||||
    (ownerId == null ? 0 : ownerId!.hashCode) +
 | 
			
		||||
    (type.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'CreateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, isVisible=$isVisible, isWatched=$isWatched, name=$name, type=$type]';
 | 
			
		||||
  String toString() => 'CreateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, isVisible=$isVisible, isWatched=$isWatched, name=$name, ownerId=$ownerId, type=$type]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
@ -91,6 +102,11 @@ class CreateLibraryDto {
 | 
			
		||||
      json[r'name'] = this.name;
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'name'] = null;
 | 
			
		||||
    }
 | 
			
		||||
    if (this.ownerId != null) {
 | 
			
		||||
      json[r'ownerId'] = this.ownerId;
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'ownerId'] = null;
 | 
			
		||||
    }
 | 
			
		||||
      json[r'type'] = this.type;
 | 
			
		||||
    return json;
 | 
			
		||||
@ -113,6 +129,7 @@ class CreateLibraryDto {
 | 
			
		||||
        isVisible: mapValueOfType<bool>(json, r'isVisible'),
 | 
			
		||||
        isWatched: mapValueOfType<bool>(json, r'isWatched'),
 | 
			
		||||
        name: mapValueOfType<String>(json, r'name'),
 | 
			
		||||
        ownerId: mapValueOfType<String>(json, r'ownerId'),
 | 
			
		||||
        type: LibraryType.fromJson(json[r'type'])!,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										13
									
								
								mobile/openapi/lib/model/create_user_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								mobile/openapi/lib/model/create_user_dto.dart
									
									
									
										generated
									
									
									
								
							@ -14,7 +14,6 @@ class CreateUserDto {
 | 
			
		||||
  /// Returns a new [CreateUserDto] instance.
 | 
			
		||||
  CreateUserDto({
 | 
			
		||||
    required this.email,
 | 
			
		||||
    this.externalPath,
 | 
			
		||||
    this.memoriesEnabled,
 | 
			
		||||
    required this.name,
 | 
			
		||||
    required this.password,
 | 
			
		||||
@ -24,8 +23,6 @@ class CreateUserDto {
 | 
			
		||||
 | 
			
		||||
  String email;
 | 
			
		||||
 | 
			
		||||
  String? externalPath;
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// Please note: This property should have been non-nullable! Since the specification file
 | 
			
		||||
  /// does not include a default value (using the "default:" property), however, the generated
 | 
			
		||||
@ -45,7 +42,6 @@ class CreateUserDto {
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) => identical(this, other) || other is CreateUserDto &&
 | 
			
		||||
    other.email == email &&
 | 
			
		||||
    other.externalPath == externalPath &&
 | 
			
		||||
    other.memoriesEnabled == memoriesEnabled &&
 | 
			
		||||
    other.name == name &&
 | 
			
		||||
    other.password == password &&
 | 
			
		||||
@ -56,7 +52,6 @@ class CreateUserDto {
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
    // ignore: unnecessary_parenthesis
 | 
			
		||||
    (email.hashCode) +
 | 
			
		||||
    (externalPath == null ? 0 : externalPath!.hashCode) +
 | 
			
		||||
    (memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) +
 | 
			
		||||
    (name.hashCode) +
 | 
			
		||||
    (password.hashCode) +
 | 
			
		||||
@ -64,16 +59,11 @@ class CreateUserDto {
 | 
			
		||||
    (storageLabel == null ? 0 : storageLabel!.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'CreateUserDto[email=$email, externalPath=$externalPath, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, storageLabel=$storageLabel]';
 | 
			
		||||
  String toString() => 'CreateUserDto[email=$email, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, storageLabel=$storageLabel]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
      json[r'email'] = this.email;
 | 
			
		||||
    if (this.externalPath != null) {
 | 
			
		||||
      json[r'externalPath'] = this.externalPath;
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'externalPath'] = null;
 | 
			
		||||
    }
 | 
			
		||||
    if (this.memoriesEnabled != null) {
 | 
			
		||||
      json[r'memoriesEnabled'] = this.memoriesEnabled;
 | 
			
		||||
    } else {
 | 
			
		||||
@ -103,7 +93,6 @@ class CreateUserDto {
 | 
			
		||||
 | 
			
		||||
      return CreateUserDto(
 | 
			
		||||
        email: mapValueOfType<String>(json, r'email')!,
 | 
			
		||||
        externalPath: mapValueOfType<String>(json, r'externalPath'),
 | 
			
		||||
        memoriesEnabled: mapValueOfType<bool>(json, r'memoriesEnabled'),
 | 
			
		||||
        name: mapValueOfType<String>(json, r'name')!,
 | 
			
		||||
        password: mapValueOfType<String>(json, r'password')!,
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										14
									
								
								mobile/openapi/lib/model/partner_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								mobile/openapi/lib/model/partner_response_dto.dart
									
									
									
										generated
									
									
									
								
							@ -17,7 +17,6 @@ class PartnerResponseDto {
 | 
			
		||||
    required this.createdAt,
 | 
			
		||||
    required this.deletedAt,
 | 
			
		||||
    required this.email,
 | 
			
		||||
    required this.externalPath,
 | 
			
		||||
    required this.id,
 | 
			
		||||
    this.inTimeline,
 | 
			
		||||
    required this.isAdmin,
 | 
			
		||||
@ -40,8 +39,6 @@ class PartnerResponseDto {
 | 
			
		||||
 | 
			
		||||
  String email;
 | 
			
		||||
 | 
			
		||||
  String? externalPath;
 | 
			
		||||
 | 
			
		||||
  String id;
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
@ -84,7 +81,6 @@ class PartnerResponseDto {
 | 
			
		||||
    other.createdAt == createdAt &&
 | 
			
		||||
    other.deletedAt == deletedAt &&
 | 
			
		||||
    other.email == email &&
 | 
			
		||||
    other.externalPath == externalPath &&
 | 
			
		||||
    other.id == id &&
 | 
			
		||||
    other.inTimeline == inTimeline &&
 | 
			
		||||
    other.isAdmin == isAdmin &&
 | 
			
		||||
@ -105,7 +101,6 @@ class PartnerResponseDto {
 | 
			
		||||
    (createdAt.hashCode) +
 | 
			
		||||
    (deletedAt == null ? 0 : deletedAt!.hashCode) +
 | 
			
		||||
    (email.hashCode) +
 | 
			
		||||
    (externalPath == null ? 0 : externalPath!.hashCode) +
 | 
			
		||||
    (id.hashCode) +
 | 
			
		||||
    (inTimeline == null ? 0 : inTimeline!.hashCode) +
 | 
			
		||||
    (isAdmin.hashCode) +
 | 
			
		||||
@ -120,7 +115,7 @@ class PartnerResponseDto {
 | 
			
		||||
    (updatedAt.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, externalPath=$externalPath, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
 | 
			
		||||
  String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
@ -132,11 +127,6 @@ class PartnerResponseDto {
 | 
			
		||||
    //  json[r'deletedAt'] = null;
 | 
			
		||||
    }
 | 
			
		||||
      json[r'email'] = this.email;
 | 
			
		||||
    if (this.externalPath != null) {
 | 
			
		||||
      json[r'externalPath'] = this.externalPath;
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'externalPath'] = null;
 | 
			
		||||
    }
 | 
			
		||||
      json[r'id'] = this.id;
 | 
			
		||||
    if (this.inTimeline != null) {
 | 
			
		||||
      json[r'inTimeline'] = this.inTimeline;
 | 
			
		||||
@ -184,7 +174,6 @@ class PartnerResponseDto {
 | 
			
		||||
        createdAt: mapDateTime(json, r'createdAt', r'')!,
 | 
			
		||||
        deletedAt: mapDateTime(json, r'deletedAt', r''),
 | 
			
		||||
        email: mapValueOfType<String>(json, r'email')!,
 | 
			
		||||
        externalPath: mapValueOfType<String>(json, r'externalPath'),
 | 
			
		||||
        id: mapValueOfType<String>(json, r'id')!,
 | 
			
		||||
        inTimeline: mapValueOfType<bool>(json, r'inTimeline'),
 | 
			
		||||
        isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
 | 
			
		||||
@ -248,7 +237,6 @@ class PartnerResponseDto {
 | 
			
		||||
    'createdAt',
 | 
			
		||||
    'deletedAt',
 | 
			
		||||
    'email',
 | 
			
		||||
    'externalPath',
 | 
			
		||||
    'id',
 | 
			
		||||
    'isAdmin',
 | 
			
		||||
    'name',
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								mobile/openapi/lib/model/update_user_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								mobile/openapi/lib/model/update_user_dto.dart
									
									
									
										generated
									
									
									
								
							@ -15,7 +15,6 @@ class UpdateUserDto {
 | 
			
		||||
  UpdateUserDto({
 | 
			
		||||
    this.avatarColor,
 | 
			
		||||
    this.email,
 | 
			
		||||
    this.externalPath,
 | 
			
		||||
    required this.id,
 | 
			
		||||
    this.isAdmin,
 | 
			
		||||
    this.memoriesEnabled,
 | 
			
		||||
@ -42,14 +41,6 @@ class UpdateUserDto {
 | 
			
		||||
  ///
 | 
			
		||||
  String? email;
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// Please note: This property should have been non-nullable! Since the specification file
 | 
			
		||||
  /// does not include a default value (using the "default:" property), however, the generated
 | 
			
		||||
  /// source code must fall back to having a nullable type.
 | 
			
		||||
  /// Consider adding a "default:" property in the specification file to hide this note.
 | 
			
		||||
  ///
 | 
			
		||||
  String? externalPath;
 | 
			
		||||
 | 
			
		||||
  String id;
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
@ -106,7 +97,6 @@ class UpdateUserDto {
 | 
			
		||||
  bool operator ==(Object other) => identical(this, other) || other is UpdateUserDto &&
 | 
			
		||||
    other.avatarColor == avatarColor &&
 | 
			
		||||
    other.email == email &&
 | 
			
		||||
    other.externalPath == externalPath &&
 | 
			
		||||
    other.id == id &&
 | 
			
		||||
    other.isAdmin == isAdmin &&
 | 
			
		||||
    other.memoriesEnabled == memoriesEnabled &&
 | 
			
		||||
@ -121,7 +111,6 @@ class UpdateUserDto {
 | 
			
		||||
    // ignore: unnecessary_parenthesis
 | 
			
		||||
    (avatarColor == null ? 0 : avatarColor!.hashCode) +
 | 
			
		||||
    (email == null ? 0 : email!.hashCode) +
 | 
			
		||||
    (externalPath == null ? 0 : externalPath!.hashCode) +
 | 
			
		||||
    (id.hashCode) +
 | 
			
		||||
    (isAdmin == null ? 0 : isAdmin!.hashCode) +
 | 
			
		||||
    (memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) +
 | 
			
		||||
@ -132,7 +121,7 @@ class UpdateUserDto {
 | 
			
		||||
    (storageLabel == null ? 0 : storageLabel!.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'UpdateUserDto[avatarColor=$avatarColor, email=$email, externalPath=$externalPath, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
 | 
			
		||||
  String toString() => 'UpdateUserDto[avatarColor=$avatarColor, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
@ -145,11 +134,6 @@ class UpdateUserDto {
 | 
			
		||||
      json[r'email'] = this.email;
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'email'] = null;
 | 
			
		||||
    }
 | 
			
		||||
    if (this.externalPath != null) {
 | 
			
		||||
      json[r'externalPath'] = this.externalPath;
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'externalPath'] = null;
 | 
			
		||||
    }
 | 
			
		||||
      json[r'id'] = this.id;
 | 
			
		||||
    if (this.isAdmin != null) {
 | 
			
		||||
@ -200,7 +184,6 @@ class UpdateUserDto {
 | 
			
		||||
      return UpdateUserDto(
 | 
			
		||||
        avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
 | 
			
		||||
        email: mapValueOfType<String>(json, r'email'),
 | 
			
		||||
        externalPath: mapValueOfType<String>(json, r'externalPath'),
 | 
			
		||||
        id: mapValueOfType<String>(json, r'id')!,
 | 
			
		||||
        isAdmin: mapValueOfType<bool>(json, r'isAdmin'),
 | 
			
		||||
        memoriesEnabled: mapValueOfType<bool>(json, r'memoriesEnabled'),
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										14
									
								
								mobile/openapi/lib/model/user_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								mobile/openapi/lib/model/user_response_dto.dart
									
									
									
										generated
									
									
									
								
							@ -17,7 +17,6 @@ class UserResponseDto {
 | 
			
		||||
    required this.createdAt,
 | 
			
		||||
    required this.deletedAt,
 | 
			
		||||
    required this.email,
 | 
			
		||||
    required this.externalPath,
 | 
			
		||||
    required this.id,
 | 
			
		||||
    required this.isAdmin,
 | 
			
		||||
    this.memoriesEnabled,
 | 
			
		||||
@ -39,8 +38,6 @@ class UserResponseDto {
 | 
			
		||||
 | 
			
		||||
  String email;
 | 
			
		||||
 | 
			
		||||
  String? externalPath;
 | 
			
		||||
 | 
			
		||||
  String id;
 | 
			
		||||
 | 
			
		||||
  bool isAdmin;
 | 
			
		||||
@ -75,7 +72,6 @@ class UserResponseDto {
 | 
			
		||||
    other.createdAt == createdAt &&
 | 
			
		||||
    other.deletedAt == deletedAt &&
 | 
			
		||||
    other.email == email &&
 | 
			
		||||
    other.externalPath == externalPath &&
 | 
			
		||||
    other.id == id &&
 | 
			
		||||
    other.isAdmin == isAdmin &&
 | 
			
		||||
    other.memoriesEnabled == memoriesEnabled &&
 | 
			
		||||
@ -95,7 +91,6 @@ class UserResponseDto {
 | 
			
		||||
    (createdAt.hashCode) +
 | 
			
		||||
    (deletedAt == null ? 0 : deletedAt!.hashCode) +
 | 
			
		||||
    (email.hashCode) +
 | 
			
		||||
    (externalPath == null ? 0 : externalPath!.hashCode) +
 | 
			
		||||
    (id.hashCode) +
 | 
			
		||||
    (isAdmin.hashCode) +
 | 
			
		||||
    (memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) +
 | 
			
		||||
@ -109,7 +104,7 @@ class UserResponseDto {
 | 
			
		||||
    (updatedAt.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, externalPath=$externalPath, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
 | 
			
		||||
  String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
@ -121,11 +116,6 @@ class UserResponseDto {
 | 
			
		||||
    //  json[r'deletedAt'] = null;
 | 
			
		||||
    }
 | 
			
		||||
      json[r'email'] = this.email;
 | 
			
		||||
    if (this.externalPath != null) {
 | 
			
		||||
      json[r'externalPath'] = this.externalPath;
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'externalPath'] = null;
 | 
			
		||||
    }
 | 
			
		||||
      json[r'id'] = this.id;
 | 
			
		||||
      json[r'isAdmin'] = this.isAdmin;
 | 
			
		||||
    if (this.memoriesEnabled != null) {
 | 
			
		||||
@ -168,7 +158,6 @@ class UserResponseDto {
 | 
			
		||||
        createdAt: mapDateTime(json, r'createdAt', r'')!,
 | 
			
		||||
        deletedAt: mapDateTime(json, r'deletedAt', r''),
 | 
			
		||||
        email: mapValueOfType<String>(json, r'email')!,
 | 
			
		||||
        externalPath: mapValueOfType<String>(json, r'externalPath'),
 | 
			
		||||
        id: mapValueOfType<String>(json, r'id')!,
 | 
			
		||||
        isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
 | 
			
		||||
        memoriesEnabled: mapValueOfType<bool>(json, r'memoriesEnabled'),
 | 
			
		||||
@ -231,7 +220,6 @@ class UserResponseDto {
 | 
			
		||||
    'createdAt',
 | 
			
		||||
    'deletedAt',
 | 
			
		||||
    'email',
 | 
			
		||||
    'externalPath',
 | 
			
		||||
    'id',
 | 
			
		||||
    'isAdmin',
 | 
			
		||||
    'name',
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								mobile/openapi/test/create_library_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/create_library_dto_test.dart
									
									
									
										generated
									
									
									
								
							@ -41,6 +41,11 @@ void main() {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String ownerId
 | 
			
		||||
    test('to test the property `ownerId`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // LibraryType type
 | 
			
		||||
    test('to test the property `type`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								mobile/openapi/test/create_user_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/create_user_dto_test.dart
									
									
									
										generated
									
									
									
								
							@ -21,11 +21,6 @@ void main() {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String externalPath
 | 
			
		||||
    test('to test the property `externalPath`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // bool memoriesEnabled
 | 
			
		||||
    test('to test the property `memoriesEnabled`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										8
									
								
								mobile/openapi/test/library_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								mobile/openapi/test/library_api_test.dart
									
									
									
										generated
									
									
									
								
							@ -27,13 +27,13 @@ void main() {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    //Future<List<LibraryResponseDto>> getLibraries() async
 | 
			
		||||
    test('test getLibraries', () async {
 | 
			
		||||
    //Future<List<LibraryResponseDto>> getAllLibraries({ LibraryType type }) async
 | 
			
		||||
    test('test getAllLibraries', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    //Future<LibraryResponseDto> getLibraryInfo(String id) async
 | 
			
		||||
    test('test getLibraryInfo', () async {
 | 
			
		||||
    //Future<LibraryResponseDto> getLibrary(String id) async
 | 
			
		||||
    test('test getLibrary', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -36,11 +36,6 @@ void main() {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String externalPath
 | 
			
		||||
    test('to test the property `externalPath`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String id
 | 
			
		||||
    test('to test the property `id`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								mobile/openapi/test/update_user_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/update_user_dto_test.dart
									
									
									
										generated
									
									
									
								
							@ -26,11 +26,6 @@ void main() {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String externalPath
 | 
			
		||||
    test('to test the property `externalPath`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String id
 | 
			
		||||
    test('to test the property `id`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								mobile/openapi/test/user_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/user_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							@ -36,11 +36,6 @@ void main() {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String externalPath
 | 
			
		||||
    test('to test the property `externalPath`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String id
 | 
			
		||||
    test('to test the property `id`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
 | 
			
		||||
@ -3299,8 +3299,17 @@
 | 
			
		||||
    },
 | 
			
		||||
    "/library": {
 | 
			
		||||
      "get": {
 | 
			
		||||
        "operationId": "getLibraries",
 | 
			
		||||
        "parameters": [],
 | 
			
		||||
        "operationId": "getAllLibraries",
 | 
			
		||||
        "parameters": [
 | 
			
		||||
          {
 | 
			
		||||
            "name": "type",
 | 
			
		||||
            "required": false,
 | 
			
		||||
            "in": "query",
 | 
			
		||||
            "schema": {
 | 
			
		||||
              "$ref": "#/components/schemas/LibraryType"
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "responses": {
 | 
			
		||||
          "200": {
 | 
			
		||||
            "content": {
 | 
			
		||||
@ -3407,7 +3416,7 @@
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      "get": {
 | 
			
		||||
        "operationId": "getLibraryInfo",
 | 
			
		||||
        "operationId": "getLibrary",
 | 
			
		||||
        "parameters": [
 | 
			
		||||
          {
 | 
			
		||||
            "name": "id",
 | 
			
		||||
@ -7592,6 +7601,10 @@
 | 
			
		||||
          "name": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "ownerId": {
 | 
			
		||||
            "format": "uuid",
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "type": {
 | 
			
		||||
            "$ref": "#/components/schemas/LibraryType"
 | 
			
		||||
          }
 | 
			
		||||
@ -7648,10 +7661,6 @@
 | 
			
		||||
          "email": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "externalPath": {
 | 
			
		||||
            "nullable": true,
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "memoriesEnabled": {
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
@ -8549,10 +8558,6 @@
 | 
			
		||||
          "email": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "externalPath": {
 | 
			
		||||
            "nullable": true,
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "id": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
@ -8601,7 +8606,6 @@
 | 
			
		||||
          "createdAt",
 | 
			
		||||
          "deletedAt",
 | 
			
		||||
          "email",
 | 
			
		||||
          "externalPath",
 | 
			
		||||
          "id",
 | 
			
		||||
          "isAdmin",
 | 
			
		||||
          "name",
 | 
			
		||||
@ -10326,9 +10330,6 @@
 | 
			
		||||
          "email": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "externalPath": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "id": {
 | 
			
		||||
            "format": "uuid",
 | 
			
		||||
            "type": "string"
 | 
			
		||||
@ -10455,10 +10456,6 @@
 | 
			
		||||
          "email": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "externalPath": {
 | 
			
		||||
            "nullable": true,
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "id": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
@ -10504,7 +10501,6 @@
 | 
			
		||||
          "createdAt",
 | 
			
		||||
          "deletedAt",
 | 
			
		||||
          "email",
 | 
			
		||||
          "externalPath",
 | 
			
		||||
          "id",
 | 
			
		||||
          "isAdmin",
 | 
			
		||||
          "name",
 | 
			
		||||
 | 
			
		||||
@ -66,7 +66,6 @@ export type UserResponseDto = {
 | 
			
		||||
    createdAt: string;
 | 
			
		||||
    deletedAt: string | null;
 | 
			
		||||
    email: string;
 | 
			
		||||
    externalPath: string | null;
 | 
			
		||||
    id: string;
 | 
			
		||||
    isAdmin: boolean;
 | 
			
		||||
    memoriesEnabled?: boolean;
 | 
			
		||||
@ -462,6 +461,7 @@ export type CreateLibraryDto = {
 | 
			
		||||
    isVisible?: boolean;
 | 
			
		||||
    isWatched?: boolean;
 | 
			
		||||
    name?: string;
 | 
			
		||||
    ownerId?: string;
 | 
			
		||||
    "type": LibraryType;
 | 
			
		||||
};
 | 
			
		||||
export type UpdateLibraryDto = {
 | 
			
		||||
@ -506,7 +506,6 @@ export type PartnerResponseDto = {
 | 
			
		||||
    createdAt: string;
 | 
			
		||||
    deletedAt: string | null;
 | 
			
		||||
    email: string;
 | 
			
		||||
    externalPath: string | null;
 | 
			
		||||
    id: string;
 | 
			
		||||
    inTimeline?: boolean;
 | 
			
		||||
    isAdmin: boolean;
 | 
			
		||||
@ -950,7 +949,6 @@ export type UpdateTagDto = {
 | 
			
		||||
};
 | 
			
		||||
export type CreateUserDto = {
 | 
			
		||||
    email: string;
 | 
			
		||||
    externalPath?: string | null;
 | 
			
		||||
    memoriesEnabled?: boolean;
 | 
			
		||||
    name: string;
 | 
			
		||||
    password: string;
 | 
			
		||||
@ -960,7 +958,6 @@ export type CreateUserDto = {
 | 
			
		||||
export type UpdateUserDto = {
 | 
			
		||||
    avatarColor?: UserAvatarColor;
 | 
			
		||||
    email?: string;
 | 
			
		||||
    externalPath?: string;
 | 
			
		||||
    id: string;
 | 
			
		||||
    isAdmin?: boolean;
 | 
			
		||||
    memoriesEnabled?: boolean;
 | 
			
		||||
@ -1841,11 +1838,15 @@ export function sendJobCommand({ id, jobCommandDto }: {
 | 
			
		||||
        body: jobCommandDto
 | 
			
		||||
    })));
 | 
			
		||||
}
 | 
			
		||||
export function getLibraries(opts?: Oazapfts.RequestOpts) {
 | 
			
		||||
export function getAllLibraries({ $type }: {
 | 
			
		||||
    $type?: LibraryType;
 | 
			
		||||
}, opts?: Oazapfts.RequestOpts) {
 | 
			
		||||
    return oazapfts.ok(oazapfts.fetchJson<{
 | 
			
		||||
        status: 200;
 | 
			
		||||
        data: LibraryResponseDto[];
 | 
			
		||||
    }>("/library", {
 | 
			
		||||
    }>(`/library${QS.query(QS.explode({
 | 
			
		||||
        "type": $type
 | 
			
		||||
    }))}`, {
 | 
			
		||||
        ...opts
 | 
			
		||||
    }));
 | 
			
		||||
}
 | 
			
		||||
@ -1869,7 +1870,7 @@ export function deleteLibrary({ id }: {
 | 
			
		||||
        method: "DELETE"
 | 
			
		||||
    }));
 | 
			
		||||
}
 | 
			
		||||
export function getLibraryInfo({ id }: {
 | 
			
		||||
export function getLibrary({ id }: {
 | 
			
		||||
    id: string;
 | 
			
		||||
}, opts?: Oazapfts.RequestOpts) {
 | 
			
		||||
    return oazapfts.ok(oazapfts.fetchJson<{
 | 
			
		||||
 | 
			
		||||
@ -41,6 +41,7 @@ describe(`${AssetController.name} (e2e)`, () => {
 | 
			
		||||
  let app: INestApplication;
 | 
			
		||||
  let server: any;
 | 
			
		||||
  let assetRepository: IAssetRepository;
 | 
			
		||||
  let admin: LoginResponseDto;
 | 
			
		||||
  let user1: LoginResponseDto;
 | 
			
		||||
  let user2: LoginResponseDto;
 | 
			
		||||
  let userWithQuota: LoginResponseDto;
 | 
			
		||||
@ -72,7 +73,7 @@ describe(`${AssetController.name} (e2e)`, () => {
 | 
			
		||||
    await testApp.reset();
 | 
			
		||||
 | 
			
		||||
    await api.authApi.adminSignUp(server);
 | 
			
		||||
    const admin = await api.authApi.adminLogin(server);
 | 
			
		||||
    admin = await api.authApi.adminLogin(server);
 | 
			
		||||
 | 
			
		||||
    await Promise.all([
 | 
			
		||||
      api.userApi.create(server, admin.accessToken, userDto.user1),
 | 
			
		||||
@ -86,12 +87,7 @@ describe(`${AssetController.name} (e2e)`, () => {
 | 
			
		||||
      api.authApi.login(server, userDto.userWithQuota),
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    const [user1Libraries, user2Libraries] = await Promise.all([
 | 
			
		||||
      api.libraryApi.getAll(server, user1.accessToken),
 | 
			
		||||
      api.libraryApi.getAll(server, user2.accessToken),
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    libraries = [...user1Libraries, ...user2Libraries];
 | 
			
		||||
    libraries = await api.libraryApi.getAll(server, admin.accessToken);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
@ -615,7 +611,7 @@ describe(`${AssetController.name} (e2e)`, () => {
 | 
			
		||||
 | 
			
		||||
    it("should not upload to another user's library", async () => {
 | 
			
		||||
      const content = randomBytes(32);
 | 
			
		||||
      const [library] = await api.libraryApi.getAll(server, user2.accessToken);
 | 
			
		||||
      const [library] = await api.libraryApi.getAll(server, admin.accessToken);
 | 
			
		||||
      await api.assetApi.upload(server, user1.accessToken, 'example-image', { content });
 | 
			
		||||
 | 
			
		||||
      const { body, status } = await request(server)
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,7 @@ import { testApp } from '../utils';
 | 
			
		||||
describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
  let server: any;
 | 
			
		||||
  let admin: LoginResponseDto;
 | 
			
		||||
  let user: LoginResponseDto;
 | 
			
		||||
 | 
			
		||||
  beforeAll(async () => {
 | 
			
		||||
    const app = await testApp.create();
 | 
			
		||||
@ -25,6 +26,9 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
    await testApp.reset();
 | 
			
		||||
    await api.authApi.adminSignUp(server);
 | 
			
		||||
    admin = await api.authApi.adminLogin(server);
 | 
			
		||||
 | 
			
		||||
    await api.userApi.create(server, admin.accessToken, userDto.user1);
 | 
			
		||||
    user = await api.authApi.login(server, userDto.user1);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('GET /library', () => {
 | 
			
		||||
@ -39,18 +43,19 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
        .get('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${admin.accessToken}`);
 | 
			
		||||
      expect(status).toBe(200);
 | 
			
		||||
      expect(body).toHaveLength(1);
 | 
			
		||||
      expect(body).toEqual([
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          ownerId: admin.userId,
 | 
			
		||||
          type: LibraryType.UPLOAD,
 | 
			
		||||
          name: 'Default Library',
 | 
			
		||||
          refreshedAt: null,
 | 
			
		||||
          assetCount: 0,
 | 
			
		||||
          importPaths: [],
 | 
			
		||||
          exclusionPatterns: [],
 | 
			
		||||
        }),
 | 
			
		||||
      ]);
 | 
			
		||||
      expect(body).toEqual(
 | 
			
		||||
        expect.arrayContaining([
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            ownerId: admin.userId,
 | 
			
		||||
            type: LibraryType.UPLOAD,
 | 
			
		||||
            name: 'Default Library',
 | 
			
		||||
            refreshedAt: null,
 | 
			
		||||
            assetCount: 0,
 | 
			
		||||
            importPaths: [],
 | 
			
		||||
            exclusionPatterns: [],
 | 
			
		||||
          }),
 | 
			
		||||
        ]),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -61,6 +66,16 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      expect(body).toEqual(errorStub.unauthorized);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should require admin authentication', async () => {
 | 
			
		||||
      const { status, body } = await request(server)
 | 
			
		||||
        .post('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${user.accessToken}`)
 | 
			
		||||
        .send({ type: LibraryType.EXTERNAL });
 | 
			
		||||
 | 
			
		||||
      expect(status).toBe(403);
 | 
			
		||||
      expect(body).toEqual(errorStub.forbidden);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should create an external library with defaults', async () => {
 | 
			
		||||
      const { status, body } = await request(server)
 | 
			
		||||
        .post('/library')
 | 
			
		||||
@ -184,29 +199,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      expect(status).toBe(400);
 | 
			
		||||
      expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have exclusion patterns'));
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should allow a non-admin to create a library', async () => {
 | 
			
		||||
      await api.userApi.create(server, admin.accessToken, userDto.user1);
 | 
			
		||||
      const user1 = await api.authApi.login(server, userDto.user1);
 | 
			
		||||
 | 
			
		||||
      const { status, body } = await request(server)
 | 
			
		||||
        .post('/library')
 | 
			
		||||
        .set('Authorization', `Bearer ${user1.accessToken}`)
 | 
			
		||||
        .send({ type: LibraryType.EXTERNAL });
 | 
			
		||||
 | 
			
		||||
      expect(status).toBe(201);
 | 
			
		||||
      expect(body).toEqual(
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          ownerId: user1.userId,
 | 
			
		||||
          type: LibraryType.EXTERNAL,
 | 
			
		||||
          name: 'New External Library',
 | 
			
		||||
          refreshedAt: null,
 | 
			
		||||
          assetCount: 0,
 | 
			
		||||
          importPaths: [],
 | 
			
		||||
          exclusionPatterns: [],
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('PUT /library/:id', () => {
 | 
			
		||||
@ -249,7 +241,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('should change the import paths', async () => {
 | 
			
		||||
        await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, IMMICH_TEST_ASSET_TEMP_PATH);
 | 
			
		||||
        const { status, body } = await request(server)
 | 
			
		||||
          .put(`/library/${library.id}`)
 | 
			
		||||
          .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
			
		||||
@ -327,6 +318,14 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      expect(body).toEqual(errorStub.unauthorized);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should require admin access', async () => {
 | 
			
		||||
      const { status, body } = await request(server)
 | 
			
		||||
        .get(`/library/${uuidStub.notFound}`)
 | 
			
		||||
        .set('Authorization', `Bearer ${user.accessToken}`);
 | 
			
		||||
      expect(status).toBe(403);
 | 
			
		||||
      expect(body).toEqual(errorStub.forbidden);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should get library by id', async () => {
 | 
			
		||||
      const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
 | 
			
		||||
 | 
			
		||||
@ -347,27 +346,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("should not allow getting another user's library", async () => {
 | 
			
		||||
      await Promise.all([
 | 
			
		||||
        api.userApi.create(server, admin.accessToken, userDto.user1),
 | 
			
		||||
        api.userApi.create(server, admin.accessToken, userDto.user2),
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
      const [user1, user2] = await Promise.all([
 | 
			
		||||
        api.authApi.login(server, userDto.user1),
 | 
			
		||||
        api.authApi.login(server, userDto.user2),
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
      const library = await api.libraryApi.create(server, user1.accessToken, { type: LibraryType.EXTERNAL });
 | 
			
		||||
 | 
			
		||||
      const { status, body } = await request(server)
 | 
			
		||||
        .get(`/library/${library.id}`)
 | 
			
		||||
        .set('Authorization', `Bearer ${user2.accessToken}`);
 | 
			
		||||
 | 
			
		||||
      expect(status).toBe(400);
 | 
			
		||||
      expect(body).toEqual(errorStub.badRequest('Not found or no library.read access'));
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('DELETE /library/:id', () => {
 | 
			
		||||
@ -390,7 +368,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      expect(body).toEqual(errorStub.noDeleteUploadLibrary);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should delete an empty library', async () => {
 | 
			
		||||
    it('should delete an external library', async () => {
 | 
			
		||||
      const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
 | 
			
		||||
 | 
			
		||||
      const { status, body } = await request(server)
 | 
			
		||||
@ -401,7 +379,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      expect(body).toEqual({});
 | 
			
		||||
 | 
			
		||||
      const libraries = await api.libraryApi.getAll(server, admin.accessToken);
 | 
			
		||||
      expect(libraries).toHaveLength(1);
 | 
			
		||||
      expect(libraries).not.toEqual(
 | 
			
		||||
        expect.arrayContaining([
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
@ -455,74 +432,42 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
        library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('should fail with no external path set', async () => {
 | 
			
		||||
        const { status, body } = await request(server)
 | 
			
		||||
          .post(`/library/${library.id}/validate`)
 | 
			
		||||
          .set('Authorization', `Bearer ${admin.accessToken}`)
 | 
			
		||||
 | 
			
		||||
          .send({ importPaths: [] });
 | 
			
		||||
 | 
			
		||||
        expect(status).toBe(400);
 | 
			
		||||
        expect(body).toEqual(errorStub.badRequest('User has no external path set'));
 | 
			
		||||
      it('should pass with no import paths', async () => {
 | 
			
		||||
        const response = await api.libraryApi.validate(server, admin.accessToken, library.id, { importPaths: [] });
 | 
			
		||||
        expect(response.importPaths).toEqual([]);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      describe('With external path set', () => {
 | 
			
		||||
        beforeEach(async () => {
 | 
			
		||||
          await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, IMMICH_TEST_ASSET_TEMP_PATH);
 | 
			
		||||
      it('should fail if path does not exist', async () => {
 | 
			
		||||
        const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`;
 | 
			
		||||
 | 
			
		||||
        const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
 | 
			
		||||
          importPaths: [pathToTest],
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it('should pass with no import paths', async () => {
 | 
			
		||||
          const response = await api.libraryApi.validate(server, admin.accessToken, library.id, { importPaths: [] });
 | 
			
		||||
          expect(response.importPaths).toEqual([]);
 | 
			
		||||
        expect(response.importPaths?.length).toEqual(1);
 | 
			
		||||
        const pathResponse = response?.importPaths?.at(0);
 | 
			
		||||
 | 
			
		||||
        expect(pathResponse).toEqual({
 | 
			
		||||
          importPath: pathToTest,
 | 
			
		||||
          isValid: false,
 | 
			
		||||
          message: `Path does not exist (ENOENT)`,
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('should fail if path is a file', async () => {
 | 
			
		||||
        const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`;
 | 
			
		||||
 | 
			
		||||
        const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
 | 
			
		||||
          importPaths: [pathToTest],
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it('should not allow paths outside of the external path', async () => {
 | 
			
		||||
          const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/../`;
 | 
			
		||||
          const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
 | 
			
		||||
            importPaths: [pathToTest],
 | 
			
		||||
          });
 | 
			
		||||
          expect(response.importPaths?.length).toEqual(1);
 | 
			
		||||
          const pathResponse = response?.importPaths?.at(0);
 | 
			
		||||
        expect(response.importPaths?.length).toEqual(1);
 | 
			
		||||
        const pathResponse = response?.importPaths?.at(0);
 | 
			
		||||
 | 
			
		||||
          expect(pathResponse).toEqual({
 | 
			
		||||
            importPath: pathToTest,
 | 
			
		||||
            isValid: false,
 | 
			
		||||
            message: `Not contained in user's external path`,
 | 
			
		||||
          });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it('should fail if path does not exist', async () => {
 | 
			
		||||
          const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`;
 | 
			
		||||
 | 
			
		||||
          const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
 | 
			
		||||
            importPaths: [pathToTest],
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          expect(response.importPaths?.length).toEqual(1);
 | 
			
		||||
          const pathResponse = response?.importPaths?.at(0);
 | 
			
		||||
 | 
			
		||||
          expect(pathResponse).toEqual({
 | 
			
		||||
            importPath: pathToTest,
 | 
			
		||||
            isValid: false,
 | 
			
		||||
            message: `Path does not exist (ENOENT)`,
 | 
			
		||||
          });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it('should fail if path is a file', async () => {
 | 
			
		||||
          const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`;
 | 
			
		||||
 | 
			
		||||
          const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
 | 
			
		||||
            importPaths: [pathToTest],
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          expect(response.importPaths?.length).toEqual(1);
 | 
			
		||||
          const pathResponse = response?.importPaths?.at(0);
 | 
			
		||||
 | 
			
		||||
          expect(pathResponse).toEqual({
 | 
			
		||||
            importPath: pathToTest,
 | 
			
		||||
            isValid: false,
 | 
			
		||||
            message: `Path does not exist (ENOENT)`,
 | 
			
		||||
          });
 | 
			
		||||
        expect(pathResponse).toEqual({
 | 
			
		||||
          importPath: pathToTest,
 | 
			
		||||
          isValid: false,
 | 
			
		||||
          message: `Path does not exist (ENOENT)`,
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -26,7 +26,12 @@ export const userApi = {
 | 
			
		||||
 | 
			
		||||
    return body as UserResponseDto;
 | 
			
		||||
  },
 | 
			
		||||
  setExternalPath: async (server: any, accessToken: string, id: string, externalPath: string) => {
 | 
			
		||||
    return await userApi.update(server, accessToken, { id, externalPath });
 | 
			
		||||
  delete: async (server: any, accessToken: string, id: string) => {
 | 
			
		||||
    const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`);
 | 
			
		||||
 | 
			
		||||
    expect(status).toBe(200);
 | 
			
		||||
    expect(body).toMatchObject({ id, deletedAt: expect.any(String) });
 | 
			
		||||
 | 
			
		||||
    return body as UserResponseDto;
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -30,8 +30,6 @@ describe(`Library watcher (e2e)`, () => {
 | 
			
		||||
    await restoreTempFolder();
 | 
			
		||||
    await api.authApi.adminSignUp(server);
 | 
			
		||||
    admin = await api.authApi.adminLogin(server);
 | 
			
		||||
 | 
			
		||||
    await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  afterEach(async () => {
 | 
			
		||||
@ -205,8 +203,6 @@ describe(`Library watcher (e2e)`, () => {
 | 
			
		||||
        ],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
 | 
			
		||||
      await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, { recursive: true });
 | 
			
		||||
      await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir2`, { recursive: true });
 | 
			
		||||
      await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true });
 | 
			
		||||
 | 
			
		||||
@ -40,8 +40,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
 | 
			
		||||
      });
 | 
			
		||||
      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
 | 
			
		||||
      await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
 | 
			
		||||
 | 
			
		||||
      const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
 | 
			
		||||
@ -79,8 +77,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
 | 
			
		||||
      });
 | 
			
		||||
      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
 | 
			
		||||
      await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
 | 
			
		||||
 | 
			
		||||
      const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
 | 
			
		||||
@ -118,16 +114,12 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should scan external library with exclusion pattern', async () => {
 | 
			
		||||
      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/not/a/real/path');
 | 
			
		||||
 | 
			
		||||
      const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
 | 
			
		||||
        exclusionPatterns: ['**/el_corcal*'],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
 | 
			
		||||
      await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
 | 
			
		||||
 | 
			
		||||
      const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
 | 
			
		||||
@ -163,7 +155,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
      });
 | 
			
		||||
      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
 | 
			
		||||
      await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
 | 
			
		||||
 | 
			
		||||
@ -190,39 +181,11 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should offline files outside of changed external path', async () => {
 | 
			
		||||
      const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
 | 
			
		||||
      });
 | 
			
		||||
      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
      await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
 | 
			
		||||
 | 
			
		||||
      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/some/other/path');
 | 
			
		||||
      await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
 | 
			
		||||
 | 
			
		||||
      const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
 | 
			
		||||
 | 
			
		||||
      expect(assets).toEqual(
 | 
			
		||||
        expect.arrayContaining([
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            isOffline: true,
 | 
			
		||||
            originalFileName: 'el_torcal_rocks',
 | 
			
		||||
          }),
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            isOffline: true,
 | 
			
		||||
            originalFileName: 'tanners_ridge',
 | 
			
		||||
          }),
 | 
			
		||||
        ]),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should scan new files', async () => {
 | 
			
		||||
      const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
      });
 | 
			
		||||
      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
 | 
			
		||||
      await fs.promises.cp(
 | 
			
		||||
        `${IMMICH_TEST_ASSET_PATH}/albums/nature/silver_fir.jpg`,
 | 
			
		||||
@ -258,7 +221,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
          type: LibraryType.EXTERNAL,
 | 
			
		||||
          importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
        });
 | 
			
		||||
        await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
 | 
			
		||||
        await fs.promises.cp(
 | 
			
		||||
          `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
 | 
			
		||||
@ -305,7 +267,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
          type: LibraryType.EXTERNAL,
 | 
			
		||||
          importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
        });
 | 
			
		||||
        await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
 | 
			
		||||
        await fs.promises.cp(
 | 
			
		||||
          `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
 | 
			
		||||
@ -345,7 +306,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
          type: LibraryType.EXTERNAL,
 | 
			
		||||
          importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
        });
 | 
			
		||||
        await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
 | 
			
		||||
        await fs.promises.cp(
 | 
			
		||||
          `${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
 | 
			
		||||
@ -387,72 +347,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    describe('External path', () => {
 | 
			
		||||
      let library: LibraryResponseDto;
 | 
			
		||||
 | 
			
		||||
      beforeEach(async () => {
 | 
			
		||||
        library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
          type: LibraryType.EXTERNAL,
 | 
			
		||||
          importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('should not scan assets for user without external path', async () => {
 | 
			
		||||
        await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
 | 
			
		||||
        const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
 | 
			
		||||
 | 
			
		||||
        expect(assets).toEqual([]);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it("should not import assets outside of user's external path", async () => {
 | 
			
		||||
        await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/not/a/real/path');
 | 
			
		||||
        await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
 | 
			
		||||
 | 
			
		||||
        const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
 | 
			
		||||
        expect(assets).toEqual([]);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it.each([`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_PATH}/albums/nature/`])(
 | 
			
		||||
        'should scan external library with external path %s',
 | 
			
		||||
        async (externalPath: string) => {
 | 
			
		||||
          await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, externalPath);
 | 
			
		||||
 | 
			
		||||
          await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
 | 
			
		||||
 | 
			
		||||
          const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
 | 
			
		||||
 | 
			
		||||
          expect(assets).toEqual(
 | 
			
		||||
            expect.arrayContaining([
 | 
			
		||||
              expect.objectContaining({
 | 
			
		||||
                type: AssetType.IMAGE,
 | 
			
		||||
                originalFileName: 'el_torcal_rocks',
 | 
			
		||||
                libraryId: library.id,
 | 
			
		||||
                resized: true,
 | 
			
		||||
                exifInfo: expect.objectContaining({
 | 
			
		||||
                  exifImageWidth: 512,
 | 
			
		||||
                  exifImageHeight: 341,
 | 
			
		||||
                  latitude: null,
 | 
			
		||||
                  longitude: null,
 | 
			
		||||
                }),
 | 
			
		||||
              }),
 | 
			
		||||
              expect.objectContaining({
 | 
			
		||||
                type: AssetType.IMAGE,
 | 
			
		||||
                originalFileName: 'silver_fir',
 | 
			
		||||
                libraryId: library.id,
 | 
			
		||||
                resized: true,
 | 
			
		||||
                exifInfo: expect.objectContaining({
 | 
			
		||||
                  exifImageWidth: 511,
 | 
			
		||||
                  exifImageHeight: 323,
 | 
			
		||||
                  latitude: null,
 | 
			
		||||
                  longitude: null,
 | 
			
		||||
                }),
 | 
			
		||||
              }),
 | 
			
		||||
            ]),
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should not scan an upload library', async () => {
 | 
			
		||||
      const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
        type: LibraryType.UPLOAD,
 | 
			
		||||
@ -484,7 +378,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
 | 
			
		||||
      });
 | 
			
		||||
      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
 | 
			
		||||
      await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
 | 
			
		||||
 | 
			
		||||
@ -506,12 +399,11 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      expect(assets).toEqual([]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should not remvove online files', async () => {
 | 
			
		||||
    it('should not remove online files', async () => {
 | 
			
		||||
      const library = await api.libraryApi.create(server, admin.accessToken, {
 | 
			
		||||
        type: LibraryType.EXTERNAL,
 | 
			
		||||
        importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
 | 
			
		||||
      });
 | 
			
		||||
      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
 | 
			
		||||
 | 
			
		||||
      await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,59 +1,62 @@
 | 
			
		||||
import { LibraryEntity, LibraryType } from '@app/infra/entities';
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
import { ArrayMaxSize, ArrayUnique, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
 | 
			
		||||
import { ValidateUUID } from '../domain.util';
 | 
			
		||||
import { ArrayMaxSize, ArrayUnique, IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator';
 | 
			
		||||
import { Optional, ValidateUUID } from '../domain.util';
 | 
			
		||||
 | 
			
		||||
export class CreateLibraryDto {
 | 
			
		||||
  @IsEnum(LibraryType)
 | 
			
		||||
  @ApiProperty({ enumName: 'LibraryType', enum: LibraryType })
 | 
			
		||||
  type!: LibraryType;
 | 
			
		||||
 | 
			
		||||
  @ValidateUUID({ optional: true })
 | 
			
		||||
  ownerId?: string;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  name?: string;
 | 
			
		||||
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  isVisible?: boolean;
 | 
			
		||||
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsString({ each: true })
 | 
			
		||||
  @IsNotEmpty({ each: true })
 | 
			
		||||
  @ArrayUnique()
 | 
			
		||||
  @ArrayMaxSize(128)
 | 
			
		||||
  importPaths?: string[];
 | 
			
		||||
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsString({ each: true })
 | 
			
		||||
  @IsNotEmpty({ each: true })
 | 
			
		||||
  @ArrayUnique()
 | 
			
		||||
  @ArrayMaxSize(128)
 | 
			
		||||
  exclusionPatterns?: string[];
 | 
			
		||||
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  isWatched?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class UpdateLibraryDto {
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  name?: string;
 | 
			
		||||
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  isVisible?: boolean;
 | 
			
		||||
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsString({ each: true })
 | 
			
		||||
  @IsNotEmpty({ each: true })
 | 
			
		||||
  @ArrayUnique()
 | 
			
		||||
  @ArrayMaxSize(128)
 | 
			
		||||
  importPaths?: string[];
 | 
			
		||||
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsNotEmpty({ each: true })
 | 
			
		||||
  @IsString({ each: true })
 | 
			
		||||
  @ArrayUnique()
 | 
			
		||||
@ -68,14 +71,14 @@ export class CrawlOptionsDto {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class ValidateLibraryDto {
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsString({ each: true })
 | 
			
		||||
  @IsNotEmpty({ each: true })
 | 
			
		||||
  @ArrayUnique()
 | 
			
		||||
  @ArrayMaxSize(128)
 | 
			
		||||
  importPaths?: string[];
 | 
			
		||||
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsNotEmpty({ each: true })
 | 
			
		||||
  @IsString({ each: true })
 | 
			
		||||
  @ArrayUnique()
 | 
			
		||||
@ -100,14 +103,21 @@ export class LibrarySearchDto {
 | 
			
		||||
 | 
			
		||||
export class ScanLibraryDto {
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  refreshModifiedFiles?: boolean;
 | 
			
		||||
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  refreshAllFiles?: boolean = false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class SearchLibraryDto {
 | 
			
		||||
  @IsEnum(LibraryType)
 | 
			
		||||
  @ApiProperty({ enumName: 'LibraryType', enum: LibraryType })
 | 
			
		||||
  @Optional()
 | 
			
		||||
  type?: LibraryType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class LibraryResponseDto {
 | 
			
		||||
  id!: string;
 | 
			
		||||
  ownerId!: string;
 | 
			
		||||
 | 
			
		||||
@ -140,24 +140,6 @@ describe(LibraryService.name, () => {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('handleQueueAssetRefresh', () => {
 | 
			
		||||
    it("should not queue assets outside of user's external path", async () => {
 | 
			
		||||
      const mockLibraryJob: ILibraryRefreshJob = {
 | 
			
		||||
        id: libraryStub.externalLibrary1.id,
 | 
			
		||||
        refreshModifiedFiles: false,
 | 
			
		||||
        refreshAllFiles: false,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
      storageMock.crawl.mockResolvedValue(['/data/user2/photo.jpg']);
 | 
			
		||||
      assetMock.getByLibraryId.mockResolvedValue([]);
 | 
			
		||||
      libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
 | 
			
		||||
      userMock.get.mockResolvedValue(userStub.externalPath1);
 | 
			
		||||
 | 
			
		||||
      await sut.handleQueueAssetRefresh(mockLibraryJob);
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue.mock.calls).toEqual([]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should queue new assets', async () => {
 | 
			
		||||
      const mockLibraryJob: ILibraryRefreshJob = {
 | 
			
		||||
        id: libraryStub.externalLibrary1.id,
 | 
			
		||||
@ -168,8 +150,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
      storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']);
 | 
			
		||||
      assetMock.getByLibraryId.mockResolvedValue([]);
 | 
			
		||||
      libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
 | 
			
		||||
      userMock.get.mockResolvedValue(userStub.externalPath1);
 | 
			
		||||
      userMock.get.mockResolvedValue(userStub.admin);
 | 
			
		||||
 | 
			
		||||
      await sut.handleQueueAssetRefresh(mockLibraryJob);
 | 
			
		||||
 | 
			
		||||
@ -196,8 +177,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
      storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']);
 | 
			
		||||
      assetMock.getByLibraryId.mockResolvedValue([]);
 | 
			
		||||
      libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
 | 
			
		||||
      userMock.get.mockResolvedValue(userStub.externalPath1);
 | 
			
		||||
      userMock.get.mockResolvedValue(userStub.admin);
 | 
			
		||||
 | 
			
		||||
      await sut.handleQueueAssetRefresh(mockLibraryJob);
 | 
			
		||||
 | 
			
		||||
@ -214,45 +194,6 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("should mark assets outside of the user's external path as offline", async () => {
 | 
			
		||||
      const mockLibraryJob: ILibraryRefreshJob = {
 | 
			
		||||
        id: libraryStub.externalLibrary1.id,
 | 
			
		||||
        refreshModifiedFiles: false,
 | 
			
		||||
        refreshAllFiles: false,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
      storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']);
 | 
			
		||||
      assetMock.getByLibraryId.mockResolvedValue([assetStub.external]);
 | 
			
		||||
      libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
 | 
			
		||||
      userMock.get.mockResolvedValue(userStub.externalPath2);
 | 
			
		||||
 | 
			
		||||
      await sut.handleQueueAssetRefresh(mockLibraryJob);
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.updateAll.mock.calls).toEqual([
 | 
			
		||||
        [
 | 
			
		||||
          [assetStub.external.id],
 | 
			
		||||
          {
 | 
			
		||||
            isOffline: true,
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should not scan libraries owned by user without external path', async () => {
 | 
			
		||||
      const mockLibraryJob: ILibraryRefreshJob = {
 | 
			
		||||
        id: libraryStub.externalLibrary1.id,
 | 
			
		||||
        refreshModifiedFiles: false,
 | 
			
		||||
        refreshAllFiles: false,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
 | 
			
		||||
 | 
			
		||||
      userMock.get.mockResolvedValue(userStub.user1);
 | 
			
		||||
 | 
			
		||||
      await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(false);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should not scan upload libraries', async () => {
 | 
			
		||||
      const mockLibraryJob: ILibraryRefreshJob = {
 | 
			
		||||
        id: libraryStub.externalLibrary1.id,
 | 
			
		||||
@ -287,7 +228,6 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
 | 
			
		||||
      storageMock.crawl.mockResolvedValue([]);
 | 
			
		||||
      assetMock.getByLibraryId.mockResolvedValue([]);
 | 
			
		||||
      libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
 | 
			
		||||
      userMock.get.mockResolvedValue(userStub.externalPathRoot);
 | 
			
		||||
 | 
			
		||||
      await sut.handleQueueAssetRefresh(mockLibraryJob);
 | 
			
		||||
@ -303,7 +243,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
    let mockUser: UserEntity;
 | 
			
		||||
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      mockUser = userStub.externalPath1;
 | 
			
		||||
      mockUser = userStub.admin;
 | 
			
		||||
      userMock.get.mockResolvedValue(mockUser);
 | 
			
		||||
 | 
			
		||||
      storageMock.stat.mockResolvedValue({
 | 
			
		||||
@ -780,26 +720,6 @@ describe(LibraryService.name, () => {
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getAllForUser', () => {
 | 
			
		||||
    it('should return all libraries for user', async () => {
 | 
			
		||||
      libraryMock.getAllByUserId.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]);
 | 
			
		||||
      await expect(sut.getAllForUser(authStub.admin)).resolves.toEqual([
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          id: libraryStub.uploadLibrary1.id,
 | 
			
		||||
          name: libraryStub.uploadLibrary1.name,
 | 
			
		||||
          ownerId: libraryStub.uploadLibrary1.ownerId,
 | 
			
		||||
        }),
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          id: libraryStub.externalLibrary1.id,
 | 
			
		||||
          name: libraryStub.externalLibrary1.name,
 | 
			
		||||
          ownerId: libraryStub.externalLibrary1.ownerId,
 | 
			
		||||
        }),
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
      expect(libraryMock.getAllByUserId).toHaveBeenCalledWith(authStub.admin.user.id);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getStatistics', () => {
 | 
			
		||||
    it('should return library statistics', async () => {
 | 
			
		||||
      libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
 | 
			
		||||
@ -1144,12 +1064,12 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      storageMock.checkFileExists.mockResolvedValue(true);
 | 
			
		||||
 | 
			
		||||
      await expect(
 | 
			
		||||
        sut.update(authStub.external1, authStub.external1.user.id, { importPaths: ['/data/user1/foo'] }),
 | 
			
		||||
        sut.update(authStub.admin, authStub.admin.user.id, { importPaths: ['/data/user1/foo'] }),
 | 
			
		||||
      ).resolves.toEqual(mapLibrary(libraryStub.externalLibraryWithImportPaths1));
 | 
			
		||||
 | 
			
		||||
      expect(libraryMock.update).toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          id: authStub.external1.user.id,
 | 
			
		||||
          id: authStub.admin.user.id,
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
      expect(storageMock.watch).toHaveBeenCalledWith(
 | 
			
		||||
@ -1584,26 +1504,6 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should error when no external path is set', async () => {
 | 
			
		||||
      await expect(
 | 
			
		||||
        sut.validate(authStub.admin, libraryStub.externalLibrary1.id, { importPaths: ['/photos'] }),
 | 
			
		||||
      ).rejects.toBeInstanceOf(BadRequestException);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should detect when path is outside external path', async () => {
 | 
			
		||||
      const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
 | 
			
		||||
        importPaths: ['/data/user2'],
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(result.importPaths).toEqual([
 | 
			
		||||
        {
 | 
			
		||||
          importPath: '/data/user2',
 | 
			
		||||
          isValid: false,
 | 
			
		||||
          message: "Not contained in user's external path",
 | 
			
		||||
        },
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should detect when path does not exist', async () => {
 | 
			
		||||
      storageMock.stat.mockImplementation(() => {
 | 
			
		||||
        const error = { code: 'ENOENT' } as any;
 | 
			
		||||
 | 
			
		||||
@ -29,6 +29,7 @@ import {
 | 
			
		||||
  LibraryResponseDto,
 | 
			
		||||
  LibraryStatsResponseDto,
 | 
			
		||||
  ScanLibraryDto,
 | 
			
		||||
  SearchLibraryDto,
 | 
			
		||||
  UpdateLibraryDto,
 | 
			
		||||
  ValidateLibraryDto,
 | 
			
		||||
  ValidateLibraryImportPathResponseDto,
 | 
			
		||||
@ -182,6 +183,7 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
  async getStatistics(auth: AuthDto, id: string): Promise<LibraryStatsResponseDto> {
 | 
			
		||||
    await this.access.requirePermission(auth, Permission.LIBRARY_READ, id);
 | 
			
		||||
 | 
			
		||||
    return this.repository.getStatistics(id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -189,17 +191,18 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
    return this.repository.getCountForUser(auth.user.id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getAllForUser(auth: AuthDto): Promise<LibraryResponseDto[]> {
 | 
			
		||||
    const libraries = await this.repository.getAllByUserId(auth.user.id);
 | 
			
		||||
    return libraries.map((library) => mapLibrary(library));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async get(auth: AuthDto, id: string): Promise<LibraryResponseDto> {
 | 
			
		||||
    await this.access.requirePermission(auth, Permission.LIBRARY_READ, id);
 | 
			
		||||
 | 
			
		||||
    const library = await this.findOrFail(id);
 | 
			
		||||
    return mapLibrary(library);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getAll(auth: AuthDto, dto: SearchLibraryDto): Promise<LibraryResponseDto[]> {
 | 
			
		||||
    const libraries = await this.repository.getAll(false, dto.type);
 | 
			
		||||
    return libraries.map((library) => mapLibrary(library));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleQueueCleanup(): Promise<boolean> {
 | 
			
		||||
    this.logger.debug('Cleaning up any pending library deletions');
 | 
			
		||||
    const pendingDeletion = await this.repository.getAllDeleted();
 | 
			
		||||
@ -234,8 +237,14 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let ownerId = auth.user.id;
 | 
			
		||||
 | 
			
		||||
    if (dto.ownerId) {
 | 
			
		||||
      ownerId = dto.ownerId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const library = await this.repository.create({
 | 
			
		||||
      ownerId: auth.user.id,
 | 
			
		||||
      ownerId,
 | 
			
		||||
      name: dto.name,
 | 
			
		||||
      type: dto.type,
 | 
			
		||||
      importPaths: dto.importPaths ?? [],
 | 
			
		||||
@ -300,24 +309,11 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
  public async validate(auth: AuthDto, id: string, dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
 | 
			
		||||
    await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
 | 
			
		||||
 | 
			
		||||
    if (!auth.user.externalPath) {
 | 
			
		||||
      throw new BadRequestException('User has no external path set');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const response = new ValidateLibraryResponseDto();
 | 
			
		||||
 | 
			
		||||
    if (dto.importPaths) {
 | 
			
		||||
      response.importPaths = await Promise.all(
 | 
			
		||||
        dto.importPaths.map(async (importPath) => {
 | 
			
		||||
          const normalizedPath = path.normalize(importPath);
 | 
			
		||||
 | 
			
		||||
          if (!this.isInExternalPath(normalizedPath, auth.user.externalPath)) {
 | 
			
		||||
            const validation = new ValidateLibraryImportPathResponseDto();
 | 
			
		||||
            validation.importPath = importPath;
 | 
			
		||||
            validation.message = `Not contained in user's external path`;
 | 
			
		||||
            return validation;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          return await this.validateImportPath(importPath);
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
@ -328,6 +324,7 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
  async update(auth: AuthDto, id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
 | 
			
		||||
    await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
 | 
			
		||||
 | 
			
		||||
    const library = await this.repository.update({ id, ...dto });
 | 
			
		||||
 | 
			
		||||
    if (dto.importPaths) {
 | 
			
		||||
@ -404,7 +401,7 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
        return true;
 | 
			
		||||
      } else {
 | 
			
		||||
        // File can't be accessed and does not already exist in db
 | 
			
		||||
        throw new BadRequestException("Can't access file", { cause: error });
 | 
			
		||||
        throw new BadRequestException('Cannot access file', { cause: error });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -591,12 +588,6 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const user = await this.userRepository.get(library.ownerId, {});
 | 
			
		||||
    if (!user?.externalPath) {
 | 
			
		||||
      this.logger.warn('User has no external path set, cannot refresh library');
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.logger.verbose(`Refreshing library: ${job.id}`);
 | 
			
		||||
 | 
			
		||||
    const pathValidation = await Promise.all(
 | 
			
		||||
@ -618,11 +609,7 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
      exclusionPatterns: library.exclusionPatterns,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const crawledAssetPaths = rawPaths
 | 
			
		||||
      // Normalize file paths. This is important to prevent security issues like path traversal
 | 
			
		||||
      .map((filePath) => path.normalize(filePath))
 | 
			
		||||
      // Filter out paths that are not within the user's external path
 | 
			
		||||
      .filter((assetPath) => this.isInExternalPath(assetPath, user.externalPath)) as string[];
 | 
			
		||||
    const crawledAssetPaths = rawPaths.map((filePath) => path.normalize(filePath));
 | 
			
		||||
 | 
			
		||||
    this.logger.debug(`Found ${crawledAssetPaths.length} asset(s) when crawling import paths ${library.importPaths}`);
 | 
			
		||||
    const assetsInLibrary = await this.assetRepository.getByLibraryId([job.id]);
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,6 @@ const responseDto = {
 | 
			
		||||
    createdAt: new Date('2021-01-01'),
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
    updatedAt: new Date('2021-01-01'),
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    memoriesEnabled: true,
 | 
			
		||||
    avatarColor: UserAvatarColor.PRIMARY,
 | 
			
		||||
    quotaSizeInBytes: null,
 | 
			
		||||
@ -37,7 +36,6 @@ const responseDto = {
 | 
			
		||||
    createdAt: new Date('2021-01-01'),
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
    updatedAt: new Date('2021-01-01'),
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    memoriesEnabled: true,
 | 
			
		||||
    avatarColor: UserAvatarColor.PRIMARY,
 | 
			
		||||
    inTimeline: true,
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,6 @@ export const ILibraryRepository = 'ILibraryRepository';
 | 
			
		||||
 | 
			
		||||
export interface ILibraryRepository {
 | 
			
		||||
  getCountForUser(ownerId: string): Promise<number>;
 | 
			
		||||
  getAllByUserId(userId: string, type?: LibraryType): Promise<LibraryEntity[]>;
 | 
			
		||||
  getAll(withDeleted?: boolean, type?: LibraryType): Promise<LibraryEntity[]>;
 | 
			
		||||
  getAllDeleted(): Promise<LibraryEntity[]>;
 | 
			
		||||
  get(id: string, withDeleted?: boolean): Promise<LibraryEntity | null>;
 | 
			
		||||
@ -16,7 +15,5 @@ export interface ILibraryRepository {
 | 
			
		||||
  getUploadLibraryCount(ownerId: string): Promise<number>;
 | 
			
		||||
  update(library: Partial<LibraryEntity>): Promise<LibraryEntity>;
 | 
			
		||||
  getStatistics(id: string): Promise<LibraryStatsResponseDto>;
 | 
			
		||||
  getOnlineAssetPaths(id: string): Promise<string[]>;
 | 
			
		||||
  getAssetIds(id: string, withDeleted?: boolean): Promise<string[]>;
 | 
			
		||||
  existsByName(name: string, withDeleted?: boolean): Promise<boolean>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
 | 
			
		||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
 | 
			
		||||
import { Optional } from '../../domain.util';
 | 
			
		||||
 | 
			
		||||
export enum SearchSuggestionType {
 | 
			
		||||
  COUNTRY = 'country',
 | 
			
		||||
@ -16,18 +17,18 @@ export class SearchSuggestionRequestDto {
 | 
			
		||||
  type!: SearchSuggestionType;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  country?: string;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  state?: string;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  make?: string;
 | 
			
		||||
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsOptional()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  model?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -21,10 +21,6 @@ export class CreateUserDto {
 | 
			
		||||
  @Transform(toSanitized)
 | 
			
		||||
  storageLabel?: string | null;
 | 
			
		||||
 | 
			
		||||
  @Optional({ nullable: true })
 | 
			
		||||
  @IsString()
 | 
			
		||||
  externalPath?: string | null;
 | 
			
		||||
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  memoriesEnabled?: boolean;
 | 
			
		||||
 | 
			
		||||
@ -25,10 +25,6 @@ export class UpdateUserDto {
 | 
			
		||||
  @Transform(toSanitized)
 | 
			
		||||
  storageLabel?: string;
 | 
			
		||||
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsString()
 | 
			
		||||
  externalPath?: string;
 | 
			
		||||
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @IsUUID('4')
 | 
			
		||||
  @ApiProperty({ format: 'uuid' })
 | 
			
		||||
 | 
			
		||||
@ -22,7 +22,6 @@ export class UserDto {
 | 
			
		||||
 | 
			
		||||
export class UserResponseDto extends UserDto {
 | 
			
		||||
  storageLabel!: string | null;
 | 
			
		||||
  externalPath!: string | null;
 | 
			
		||||
  shouldChangePassword!: boolean;
 | 
			
		||||
  isAdmin!: boolean;
 | 
			
		||||
  createdAt!: Date;
 | 
			
		||||
@ -50,7 +49,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
 | 
			
		||||
  return {
 | 
			
		||||
    ...mapSimpleUser(entity),
 | 
			
		||||
    storageLabel: entity.storageLabel,
 | 
			
		||||
    externalPath: entity.externalPath,
 | 
			
		||||
    shouldChangePassword: entity.shouldChangePassword,
 | 
			
		||||
    isAdmin: entity.isAdmin,
 | 
			
		||||
    createdAt: entity.createdAt,
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,5 @@
 | 
			
		||||
import { LibraryType, UserEntity } from '@app/infra/entities';
 | 
			
		||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
 | 
			
		||||
import path from 'node:path';
 | 
			
		||||
import sanitize from 'sanitize-filename';
 | 
			
		||||
import { ICryptoRepository, ILibraryRepository, IUserRepository } from '../repositories';
 | 
			
		||||
import { UserResponseDto } from './response-dto';
 | 
			
		||||
@ -42,7 +41,6 @@ export class UserCore {
 | 
			
		||||
      // Users can never update the isAdmin property.
 | 
			
		||||
      delete dto.isAdmin;
 | 
			
		||||
      delete dto.storageLabel;
 | 
			
		||||
      delete dto.externalPath;
 | 
			
		||||
    } else if (dto.isAdmin && user.id !== id) {
 | 
			
		||||
      // Admin cannot create another admin.
 | 
			
		||||
      throw new BadRequestException('The server already has an admin');
 | 
			
		||||
@ -70,12 +68,6 @@ export class UserCore {
 | 
			
		||||
      dto.storageLabel = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (dto.externalPath === '') {
 | 
			
		||||
      dto.externalPath = null;
 | 
			
		||||
    } else if (dto.externalPath) {
 | 
			
		||||
      dto.externalPath = path.normalize(dto.externalPath);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return this.userRepository.update(id, dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -5,13 +5,14 @@ import {
 | 
			
		||||
  LibraryStatsResponseDto,
 | 
			
		||||
  LibraryResponseDto as ResponseDto,
 | 
			
		||||
  ScanLibraryDto,
 | 
			
		||||
  SearchLibraryDto,
 | 
			
		||||
  UpdateLibraryDto as UpdateDto,
 | 
			
		||||
  ValidateLibraryDto,
 | 
			
		||||
  ValidateLibraryResponseDto,
 | 
			
		||||
} from '@app/domain';
 | 
			
		||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
 | 
			
		||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
 | 
			
		||||
import { ApiTags } from '@nestjs/swagger';
 | 
			
		||||
import { Auth, Authenticated } from '../app.guard';
 | 
			
		||||
import { AdminRoute, Auth, Authenticated } from '../app.guard';
 | 
			
		||||
import { UseValidation } from '../app.utils';
 | 
			
		||||
import { UUIDParamDto } from './dto/uuid-param.dto';
 | 
			
		||||
 | 
			
		||||
@ -19,12 +20,13 @@ import { UUIDParamDto } from './dto/uuid-param.dto';
 | 
			
		||||
@Controller('library')
 | 
			
		||||
@Authenticated()
 | 
			
		||||
@UseValidation()
 | 
			
		||||
@AdminRoute()
 | 
			
		||||
export class LibraryController {
 | 
			
		||||
  constructor(private service: LibraryService) {}
 | 
			
		||||
 | 
			
		||||
  @Get()
 | 
			
		||||
  getLibraries(@Auth() auth: AuthDto): Promise<ResponseDto[]> {
 | 
			
		||||
    return this.service.getAllForUser(auth);
 | 
			
		||||
  getAllLibraries(@Auth() auth: AuthDto, @Query() dto: SearchLibraryDto): Promise<ResponseDto[]> {
 | 
			
		||||
    return this.service.getAll(auth, dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Post()
 | 
			
		||||
@ -38,7 +40,7 @@ export class LibraryController {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get(':id')
 | 
			
		||||
  getLibraryInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ResponseDto> {
 | 
			
		||||
  getLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ResponseDto> {
 | 
			
		||||
    return this.service.get(auth, id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -43,9 +43,6 @@ export class UserEntity {
 | 
			
		||||
  @Column({ type: 'varchar', unique: true, default: null })
 | 
			
		||||
  storageLabel!: string | null;
 | 
			
		||||
 | 
			
		||||
  @Column({ type: 'varchar', default: null })
 | 
			
		||||
  externalPath!: string | null;
 | 
			
		||||
 | 
			
		||||
  @Column({ default: '', select: false })
 | 
			
		||||
  password?: string;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,13 @@
 | 
			
		||||
import { MigrationInterface, QueryRunner } from 'typeorm';
 | 
			
		||||
 | 
			
		||||
export class RemoveExternalPath1708425975121 implements MigrationInterface {
 | 
			
		||||
  name = 'RemoveExternalPath1708425975121';
 | 
			
		||||
 | 
			
		||||
  public async up(queryRunner: QueryRunner): Promise<void> {
 | 
			
		||||
    await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "externalPath"`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async down(queryRunner: QueryRunner): Promise<void> {
 | 
			
		||||
    await queryRunner.query(`ALTER TABLE "users" ADD "externalPath" character varying`);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -21,7 +21,6 @@ FROM
 | 
			
		||||
      "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
 | 
			
		||||
@ -37,7 +36,6 @@ FROM
 | 
			
		||||
      "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
 | 
			
		||||
      "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
 | 
			
		||||
@ -97,7 +95,6 @@ SELECT
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
 | 
			
		||||
@ -113,7 +110,6 @@ SELECT
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
 | 
			
		||||
@ -155,7 +151,6 @@ SELECT
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
 | 
			
		||||
@ -171,7 +166,6 @@ SELECT
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
 | 
			
		||||
@ -285,7 +279,6 @@ SELECT
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
 | 
			
		||||
@ -313,7 +306,6 @@ SELECT
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
 | 
			
		||||
@ -358,7 +350,6 @@ SELECT
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
 | 
			
		||||
@ -386,7 +377,6 @@ SELECT
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
 | 
			
		||||
@ -468,7 +458,6 @@ SELECT
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
 | 
			
		||||
@ -496,7 +485,6 @@ SELECT
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
 | 
			
		||||
@ -559,7 +547,6 @@ SELECT
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
 | 
			
		||||
  "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
 | 
			
		||||
 | 
			
		||||
@ -15,7 +15,6 @@ FROM
 | 
			
		||||
      "APIKeyEntity__APIKeyEntity_user"."isAdmin" AS "APIKeyEntity__APIKeyEntity_user_isAdmin",
 | 
			
		||||
      "APIKeyEntity__APIKeyEntity_user"."email" AS "APIKeyEntity__APIKeyEntity_user_email",
 | 
			
		||||
      "APIKeyEntity__APIKeyEntity_user"."storageLabel" AS "APIKeyEntity__APIKeyEntity_user_storageLabel",
 | 
			
		||||
      "APIKeyEntity__APIKeyEntity_user"."externalPath" AS "APIKeyEntity__APIKeyEntity_user_externalPath",
 | 
			
		||||
      "APIKeyEntity__APIKeyEntity_user"."oauthId" AS "APIKeyEntity__APIKeyEntity_user_oauthId",
 | 
			
		||||
      "APIKeyEntity__APIKeyEntity_user"."profileImagePath" AS "APIKeyEntity__APIKeyEntity_user_profileImagePath",
 | 
			
		||||
      "APIKeyEntity__APIKeyEntity_user"."shouldChangePassword" AS "APIKeyEntity__APIKeyEntity_user_shouldChangePassword",
 | 
			
		||||
 | 
			
		||||
@ -23,7 +23,6 @@ FROM
 | 
			
		||||
      "LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin",
 | 
			
		||||
      "LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email",
 | 
			
		||||
      "LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel",
 | 
			
		||||
      "LibraryEntity__LibraryEntity_owner"."externalPath" AS "LibraryEntity__LibraryEntity_owner_externalPath",
 | 
			
		||||
      "LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId",
 | 
			
		||||
      "LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath",
 | 
			
		||||
      "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
 | 
			
		||||
@ -139,7 +138,6 @@ SELECT
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."externalPath" AS "LibraryEntity__LibraryEntity_owner_externalPath",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
 | 
			
		||||
@ -185,7 +183,6 @@ SELECT
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."externalPath" AS "LibraryEntity__LibraryEntity_owner_externalPath",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
 | 
			
		||||
@ -225,7 +222,6 @@ SELECT
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."externalPath" AS "LibraryEntity__LibraryEntity_owner_externalPath",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath",
 | 
			
		||||
  "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
 | 
			
		||||
 | 
			
		||||
@ -150,7 +150,6 @@ FROM
 | 
			
		||||
      "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."isAdmin" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_isAdmin",
 | 
			
		||||
      "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."email" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_email",
 | 
			
		||||
      "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."storageLabel" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_storageLabel",
 | 
			
		||||
      "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."externalPath" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_externalPath",
 | 
			
		||||
      "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."oauthId" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_oauthId",
 | 
			
		||||
      "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileImagePath" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileImagePath",
 | 
			
		||||
      "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."shouldChangePassword" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_shouldChangePassword",
 | 
			
		||||
@ -254,7 +253,6 @@ SELECT
 | 
			
		||||
  "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."isAdmin" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_isAdmin",
 | 
			
		||||
  "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."email" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_email",
 | 
			
		||||
  "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."storageLabel" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_storageLabel",
 | 
			
		||||
  "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."externalPath" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_externalPath",
 | 
			
		||||
  "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."oauthId" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_oauthId",
 | 
			
		||||
  "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileImagePath" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileImagePath",
 | 
			
		||||
  "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."shouldChangePassword" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_shouldChangePassword",
 | 
			
		||||
@ -308,7 +306,6 @@ FROM
 | 
			
		||||
      "SharedLinkEntity__SharedLinkEntity_user"."isAdmin" AS "SharedLinkEntity__SharedLinkEntity_user_isAdmin",
 | 
			
		||||
      "SharedLinkEntity__SharedLinkEntity_user"."email" AS "SharedLinkEntity__SharedLinkEntity_user_email",
 | 
			
		||||
      "SharedLinkEntity__SharedLinkEntity_user"."storageLabel" AS "SharedLinkEntity__SharedLinkEntity_user_storageLabel",
 | 
			
		||||
      "SharedLinkEntity__SharedLinkEntity_user"."externalPath" AS "SharedLinkEntity__SharedLinkEntity_user_externalPath",
 | 
			
		||||
      "SharedLinkEntity__SharedLinkEntity_user"."oauthId" AS "SharedLinkEntity__SharedLinkEntity_user_oauthId",
 | 
			
		||||
      "SharedLinkEntity__SharedLinkEntity_user"."profileImagePath" AS "SharedLinkEntity__SharedLinkEntity_user_profileImagePath",
 | 
			
		||||
      "SharedLinkEntity__SharedLinkEntity_user"."shouldChangePassword" AS "SharedLinkEntity__SharedLinkEntity_user_shouldChangePassword",
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,6 @@ SELECT
 | 
			
		||||
  "UserEntity"."isAdmin" AS "UserEntity_isAdmin",
 | 
			
		||||
  "UserEntity"."email" AS "UserEntity_email",
 | 
			
		||||
  "UserEntity"."storageLabel" AS "UserEntity_storageLabel",
 | 
			
		||||
  "UserEntity"."externalPath" AS "UserEntity_externalPath",
 | 
			
		||||
  "UserEntity"."oauthId" AS "UserEntity_oauthId",
 | 
			
		||||
  "UserEntity"."profileImagePath" AS "UserEntity_profileImagePath",
 | 
			
		||||
  "UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",
 | 
			
		||||
@ -55,7 +54,6 @@ SELECT
 | 
			
		||||
  "user"."isAdmin" AS "user_isAdmin",
 | 
			
		||||
  "user"."email" AS "user_email",
 | 
			
		||||
  "user"."storageLabel" AS "user_storageLabel",
 | 
			
		||||
  "user"."externalPath" AS "user_externalPath",
 | 
			
		||||
  "user"."oauthId" AS "user_oauthId",
 | 
			
		||||
  "user"."profileImagePath" AS "user_profileImagePath",
 | 
			
		||||
  "user"."shouldChangePassword" AS "user_shouldChangePassword",
 | 
			
		||||
@ -79,7 +77,6 @@ SELECT
 | 
			
		||||
  "UserEntity"."isAdmin" AS "UserEntity_isAdmin",
 | 
			
		||||
  "UserEntity"."email" AS "UserEntity_email",
 | 
			
		||||
  "UserEntity"."storageLabel" AS "UserEntity_storageLabel",
 | 
			
		||||
  "UserEntity"."externalPath" AS "UserEntity_externalPath",
 | 
			
		||||
  "UserEntity"."oauthId" AS "UserEntity_oauthId",
 | 
			
		||||
  "UserEntity"."profileImagePath" AS "UserEntity_profileImagePath",
 | 
			
		||||
  "UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",
 | 
			
		||||
@ -105,7 +102,6 @@ SELECT
 | 
			
		||||
  "UserEntity"."isAdmin" AS "UserEntity_isAdmin",
 | 
			
		||||
  "UserEntity"."email" AS "UserEntity_email",
 | 
			
		||||
  "UserEntity"."storageLabel" AS "UserEntity_storageLabel",
 | 
			
		||||
  "UserEntity"."externalPath" AS "UserEntity_externalPath",
 | 
			
		||||
  "UserEntity"."oauthId" AS "UserEntity_oauthId",
 | 
			
		||||
  "UserEntity"."profileImagePath" AS "UserEntity_profileImagePath",
 | 
			
		||||
  "UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,6 @@ FROM
 | 
			
		||||
      "UserTokenEntity__UserTokenEntity_user"."isAdmin" AS "UserTokenEntity__UserTokenEntity_user_isAdmin",
 | 
			
		||||
      "UserTokenEntity__UserTokenEntity_user"."email" AS "UserTokenEntity__UserTokenEntity_user_email",
 | 
			
		||||
      "UserTokenEntity__UserTokenEntity_user"."storageLabel" AS "UserTokenEntity__UserTokenEntity_user_storageLabel",
 | 
			
		||||
      "UserTokenEntity__UserTokenEntity_user"."externalPath" AS "UserTokenEntity__UserTokenEntity_user_externalPath",
 | 
			
		||||
      "UserTokenEntity__UserTokenEntity_user"."oauthId" AS "UserTokenEntity__UserTokenEntity_user_oauthId",
 | 
			
		||||
      "UserTokenEntity__UserTokenEntity_user"."profileImagePath" AS "UserTokenEntity__UserTokenEntity_user_profileImagePath",
 | 
			
		||||
      "UserTokenEntity__UserTokenEntity_user"."shouldChangePassword" AS "UserTokenEntity__UserTokenEntity_user_shouldChangePassword",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								server/test/fixtures/auth.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								server/test/fixtures/auth.stub.ts
									
									
									
									
										vendored
									
									
								
							@ -52,7 +52,6 @@ export const authStub = {
 | 
			
		||||
      id: 'user-id',
 | 
			
		||||
      email: 'immich@test.com',
 | 
			
		||||
      isAdmin: false,
 | 
			
		||||
      externalPath: '/data/user1',
 | 
			
		||||
    } as UserEntity,
 | 
			
		||||
    userToken: {
 | 
			
		||||
      id: 'token-id',
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										20
									
								
								server/test/fixtures/library.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								server/test/fixtures/library.stub.ts
									
									
									
									
										vendored
									
									
								
							@ -20,8 +20,8 @@ export const libraryStub = {
 | 
			
		||||
    id: 'library-id',
 | 
			
		||||
    name: 'test_library',
 | 
			
		||||
    assets: [],
 | 
			
		||||
    owner: userStub.externalPath1,
 | 
			
		||||
    ownerId: 'user-id',
 | 
			
		||||
    owner: userStub.admin,
 | 
			
		||||
    ownerId: 'admin_id',
 | 
			
		||||
    type: LibraryType.EXTERNAL,
 | 
			
		||||
    importPaths: [],
 | 
			
		||||
    createdAt: new Date('2023-01-01'),
 | 
			
		||||
@ -34,8 +34,8 @@ export const libraryStub = {
 | 
			
		||||
    id: 'library-id2',
 | 
			
		||||
    name: 'test_library2',
 | 
			
		||||
    assets: [],
 | 
			
		||||
    owner: userStub.externalPath1,
 | 
			
		||||
    ownerId: 'user-id',
 | 
			
		||||
    owner: userStub.admin,
 | 
			
		||||
    ownerId: 'admin_id',
 | 
			
		||||
    type: LibraryType.EXTERNAL,
 | 
			
		||||
    importPaths: [],
 | 
			
		||||
    createdAt: new Date('2021-01-01'),
 | 
			
		||||
@ -48,8 +48,8 @@ export const libraryStub = {
 | 
			
		||||
    id: 'library-id-with-paths1',
 | 
			
		||||
    name: 'library-with-import-paths1',
 | 
			
		||||
    assets: [],
 | 
			
		||||
    owner: userStub.externalPath1,
 | 
			
		||||
    ownerId: 'user-id',
 | 
			
		||||
    owner: userStub.admin,
 | 
			
		||||
    ownerId: 'admin_id',
 | 
			
		||||
    type: LibraryType.EXTERNAL,
 | 
			
		||||
    importPaths: ['/foo', '/bar'],
 | 
			
		||||
    createdAt: new Date('2023-01-01'),
 | 
			
		||||
@ -62,8 +62,8 @@ export const libraryStub = {
 | 
			
		||||
    id: 'library-id-with-paths2',
 | 
			
		||||
    name: 'library-with-import-paths2',
 | 
			
		||||
    assets: [],
 | 
			
		||||
    owner: userStub.externalPath1,
 | 
			
		||||
    ownerId: 'user-id',
 | 
			
		||||
    owner: userStub.admin,
 | 
			
		||||
    ownerId: 'admin_id',
 | 
			
		||||
    type: LibraryType.EXTERNAL,
 | 
			
		||||
    importPaths: ['/xyz', '/asdf'],
 | 
			
		||||
    createdAt: new Date('2023-01-01'),
 | 
			
		||||
@ -76,7 +76,7 @@ export const libraryStub = {
 | 
			
		||||
    id: 'library-id',
 | 
			
		||||
    name: 'test_library',
 | 
			
		||||
    assets: [],
 | 
			
		||||
    owner: userStub.externalPath1,
 | 
			
		||||
    owner: userStub.admin,
 | 
			
		||||
    ownerId: 'user-id',
 | 
			
		||||
    type: LibraryType.EXTERNAL,
 | 
			
		||||
    importPaths: [],
 | 
			
		||||
@ -90,7 +90,7 @@ export const libraryStub = {
 | 
			
		||||
    id: 'library-id1337',
 | 
			
		||||
    name: 'importpath-exclusion-library1',
 | 
			
		||||
    assets: [],
 | 
			
		||||
    owner: userStub.externalPath1,
 | 
			
		||||
    owner: userStub.admin,
 | 
			
		||||
    ownerId: 'user-id',
 | 
			
		||||
    type: LibraryType.EXTERNAL,
 | 
			
		||||
    importPaths: ['/xyz', '/asdf'],
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										44
									
								
								server/test/fixtures/user.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										44
									
								
								server/test/fixtures/user.stub.ts
									
									
									
									
										vendored
									
									
								
							@ -31,7 +31,6 @@ export const userStub = {
 | 
			
		||||
    password: 'admin_password',
 | 
			
		||||
    name: 'admin_name',
 | 
			
		||||
    storageLabel: 'admin',
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
@ -50,7 +49,6 @@ export const userStub = {
 | 
			
		||||
    password: 'immich_password',
 | 
			
		||||
    name: 'immich_name',
 | 
			
		||||
    storageLabel: null,
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
@ -69,7 +67,6 @@ export const userStub = {
 | 
			
		||||
    password: 'immich_password',
 | 
			
		||||
    name: 'immich_name',
 | 
			
		||||
    storageLabel: null,
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
@ -88,45 +85,6 @@ export const userStub = {
 | 
			
		||||
    password: 'immich_password',
 | 
			
		||||
    name: 'immich_name',
 | 
			
		||||
    storageLabel: 'label-1',
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
    createdAt: new Date('2021-01-01'),
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
    updatedAt: new Date('2021-01-01'),
 | 
			
		||||
    tags: [],
 | 
			
		||||
    assets: [],
 | 
			
		||||
    memoriesEnabled: true,
 | 
			
		||||
    avatarColor: UserAvatarColor.PRIMARY,
 | 
			
		||||
    quotaSizeInBytes: null,
 | 
			
		||||
    quotaUsageInBytes: 0,
 | 
			
		||||
  }),
 | 
			
		||||
  externalPath1: Object.freeze<UserEntity>({
 | 
			
		||||
    ...authStub.user1.user,
 | 
			
		||||
    password: 'immich_password',
 | 
			
		||||
    name: 'immich_name',
 | 
			
		||||
    storageLabel: 'label-1',
 | 
			
		||||
    externalPath: '/data/user1',
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
    createdAt: new Date('2021-01-01'),
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
    updatedAt: new Date('2021-01-01'),
 | 
			
		||||
    tags: [],
 | 
			
		||||
    assets: [],
 | 
			
		||||
    memoriesEnabled: true,
 | 
			
		||||
    avatarColor: UserAvatarColor.PRIMARY,
 | 
			
		||||
    quotaSizeInBytes: null,
 | 
			
		||||
    quotaUsageInBytes: 0,
 | 
			
		||||
  }),
 | 
			
		||||
  externalPath2: Object.freeze<UserEntity>({
 | 
			
		||||
    ...authStub.user1.user,
 | 
			
		||||
    password: 'immich_password',
 | 
			
		||||
    name: 'immich_name',
 | 
			
		||||
    storageLabel: 'label-1',
 | 
			
		||||
    externalPath: '/data/user2',
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
@ -145,7 +103,6 @@ export const userStub = {
 | 
			
		||||
    password: 'immich_password',
 | 
			
		||||
    name: 'immich_name',
 | 
			
		||||
    storageLabel: 'label-1',
 | 
			
		||||
    externalPath: '/',
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '',
 | 
			
		||||
@ -164,7 +121,6 @@ export const userStub = {
 | 
			
		||||
    password: 'immich_password',
 | 
			
		||||
    name: 'immich_name',
 | 
			
		||||
    storageLabel: 'label-1',
 | 
			
		||||
    externalPath: null,
 | 
			
		||||
    oauthId: '',
 | 
			
		||||
    shouldChangePassword: false,
 | 
			
		||||
    profileImagePath: '/path/to/profile.jpg',
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,6 @@ export const newLibraryRepositoryMock = (): jest.Mocked<ILibraryRepository> => {
 | 
			
		||||
  return {
 | 
			
		||||
    get: jest.fn(),
 | 
			
		||||
    getCountForUser: jest.fn(),
 | 
			
		||||
    getAllByUserId: jest.fn(),
 | 
			
		||||
    create: jest.fn(),
 | 
			
		||||
    delete: jest.fn(),
 | 
			
		||||
    softDelete: jest.fn(),
 | 
			
		||||
@ -12,9 +11,7 @@ export const newLibraryRepositoryMock = (): jest.Mocked<ILibraryRepository> => {
 | 
			
		||||
    getStatistics: jest.fn(),
 | 
			
		||||
    getDefaultUploadLibrary: jest.fn(),
 | 
			
		||||
    getUploadLibraryCount: jest.fn(),
 | 
			
		||||
    getOnlineAssetPaths: jest.fn(),
 | 
			
		||||
    getAssetIds: jest.fn(),
 | 
			
		||||
    existsByName: jest.fn(),
 | 
			
		||||
    getAllDeleted: jest.fn(),
 | 
			
		||||
    getAll: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@ -34,14 +34,13 @@
 | 
			
		||||
 | 
			
		||||
  const editUser = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      const { id, email, name, storageLabel, externalPath } = user;
 | 
			
		||||
      const { id, email, name, storageLabel } = user;
 | 
			
		||||
      await updateUser({
 | 
			
		||||
        updateUserDto: {
 | 
			
		||||
          id,
 | 
			
		||||
          email,
 | 
			
		||||
          name,
 | 
			
		||||
          storageLabel: storageLabel || '',
 | 
			
		||||
          externalPath: externalPath || '',
 | 
			
		||||
          quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), 'GiB') : null,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
@ -126,22 +125,6 @@
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="m-4 flex flex-col gap-2">
 | 
			
		||||
      <label class="immich-form-label" for="external-path">External Path</label>
 | 
			
		||||
      <input
 | 
			
		||||
        class="immich-form-input"
 | 
			
		||||
        id="external-path"
 | 
			
		||||
        name="external-path"
 | 
			
		||||
        type="text"
 | 
			
		||||
        bind:value={user.externalPath}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <p>
 | 
			
		||||
        Note: Absolute path of parent import directory. A user can only import files if they exist at or under this
 | 
			
		||||
        path.
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    {#if error}
 | 
			
		||||
      <p class="ml-4 text-sm text-red-400">{error}</p>
 | 
			
		||||
    {/if}
 | 
			
		||||
 | 
			
		||||
@ -243,8 +243,8 @@
 | 
			
		||||
              addImportPath = true;
 | 
			
		||||
            }}>Add path</Button
 | 
			
		||||
          ></td
 | 
			
		||||
        ></tr
 | 
			
		||||
      >
 | 
			
		||||
        >
 | 
			
		||||
      </tr>
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
  <div class="flex justify-between w-full">
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										54
									
								
								web/src/lib/components/forms/library-user-picker-form.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								web/src/lib/components/forms/library-user-picker-form.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { createEventDispatcher } from 'svelte';
 | 
			
		||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
			
		||||
  import Button from '../elements/buttons/button.svelte';
 | 
			
		||||
  import FullScreenModal from '../shared-components/full-screen-modal.svelte';
 | 
			
		||||
  import { mdiFolderSync } from '@mdi/js';
 | 
			
		||||
  import { onMount } from 'svelte';
 | 
			
		||||
  import { getAllUsers } from '@immich/sdk';
 | 
			
		||||
  import { user } from '$lib/stores/user.store';
 | 
			
		||||
  import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
 | 
			
		||||
 | 
			
		||||
  let ownerId: string = $user.id;
 | 
			
		||||
 | 
			
		||||
  let userOptions: { value: string; text: string }[] = [];
 | 
			
		||||
 | 
			
		||||
  onMount(async () => {
 | 
			
		||||
    const users = await getAllUsers({ isAll: true });
 | 
			
		||||
    userOptions = users.map((user) => ({ value: user.id, text: user.name }));
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const dispatch = createEventDispatcher<{
 | 
			
		||||
    cancel: void;
 | 
			
		||||
    submit: { ownerId: string | null };
 | 
			
		||||
    delete: void;
 | 
			
		||||
  }>();
 | 
			
		||||
 | 
			
		||||
  const handleCancel = () => dispatch('cancel');
 | 
			
		||||
  const handleSubmit = () => dispatch('submit', { ownerId });
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<FullScreenModal on:clickOutside={() => handleCancel()}>
 | 
			
		||||
  <div
 | 
			
		||||
    class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
 | 
			
		||||
  >
 | 
			
		||||
    <div
 | 
			
		||||
      class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
 | 
			
		||||
    >
 | 
			
		||||
      <Icon path={mdiFolderSync} size="4em" />
 | 
			
		||||
      <h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Select library owner</h1>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
 | 
			
		||||
      <p class="p-5 text-sm">NOTE: This cannot be changed later!</p>
 | 
			
		||||
 | 
			
		||||
      <SettingSelect bind:value={ownerId} options={userOptions} name="user" />
 | 
			
		||||
 | 
			
		||||
      <div class="mt-8 flex w-full gap-4 px-4">
 | 
			
		||||
        <Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
 | 
			
		||||
 | 
			
		||||
        <Button type="submit" fullwidth>Create</Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
</FullScreenModal>
 | 
			
		||||
@ -4,7 +4,7 @@
 | 
			
		||||
  import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
 | 
			
		||||
  import StatusBox from '$lib/components/shared-components/status-box.svelte';
 | 
			
		||||
  import { AppRoute } from '$lib/constants';
 | 
			
		||||
  import { mdiAccountMultipleOutline, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js';
 | 
			
		||||
  import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js';
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<SideBarSection>
 | 
			
		||||
@ -21,6 +21,13 @@
 | 
			
		||||
  <a data-sveltekit-preload-data="hover" href={AppRoute.ADMIN_SETTINGS} draggable="false">
 | 
			
		||||
    <SideBarButton title="Settings" icon={mdiCog} isSelected={$page.route.id === AppRoute.ADMIN_SETTINGS} />
 | 
			
		||||
  </a>
 | 
			
		||||
  <a data-sveltekit-preload-data="hover" href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} draggable="false">
 | 
			
		||||
    <SideBarButton
 | 
			
		||||
      title="External Libraries"
 | 
			
		||||
      icon={mdiBookshelf}
 | 
			
		||||
      isSelected={$page.route.id === AppRoute.ADMIN_LIBRARY_MANAGEMENT}
 | 
			
		||||
    />
 | 
			
		||||
  </a>
 | 
			
		||||
  <a data-sveltekit-preload-data="hover" href={AppRoute.ADMIN_STATS} draggable="false">
 | 
			
		||||
    <SideBarButton title="Server Stats" icon={mdiServer} isSelected={$page.route.id === AppRoute.ADMIN_STATS} />
 | 
			
		||||
  </a>
 | 
			
		||||
 | 
			
		||||
@ -1,416 +0,0 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
			
		||||
  import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 | 
			
		||||
  import { locale } from '$lib/stores/preferences.store';
 | 
			
		||||
  import { getBytesWithUnit } from '$lib/utils/byte-units';
 | 
			
		||||
  import { getContextMenuPosition } from '$lib/utils/context-menu';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import {
 | 
			
		||||
    LibraryType,
 | 
			
		||||
    createLibrary,
 | 
			
		||||
    deleteLibrary,
 | 
			
		||||
    getLibraries,
 | 
			
		||||
    getLibraryStatistics,
 | 
			
		||||
    removeOfflineFiles,
 | 
			
		||||
    scanLibrary,
 | 
			
		||||
    updateLibrary,
 | 
			
		||||
    type LibraryResponseDto,
 | 
			
		||||
    type LibraryStatsResponseDto,
 | 
			
		||||
  } from '@immich/sdk';
 | 
			
		||||
  import { mdiDatabase, mdiDotsVertical, mdiUpload } from '@mdi/js';
 | 
			
		||||
  import { onMount } from 'svelte';
 | 
			
		||||
  import { fade, slide } from 'svelte/transition';
 | 
			
		||||
  import Button from '../elements/buttons/button.svelte';
 | 
			
		||||
  import LibraryImportPathsForm from '../forms/library-import-paths-form.svelte';
 | 
			
		||||
  import LibraryRenameForm from '../forms/library-rename-form.svelte';
 | 
			
		||||
  import LibraryScanSettingsForm from '../forms/library-scan-settings-form.svelte';
 | 
			
		||||
  import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
 | 
			
		||||
  import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
 | 
			
		||||
  import MenuOption from '../shared-components/context-menu/menu-option.svelte';
 | 
			
		||||
  import { NotificationType, notificationController } from '../shared-components/notification/notification';
 | 
			
		||||
  import Portal from '../shared-components/portal/portal.svelte';
 | 
			
		||||
 | 
			
		||||
  let libraries: LibraryResponseDto[] = [];
 | 
			
		||||
 | 
			
		||||
  let stats: LibraryStatsResponseDto[] = [];
 | 
			
		||||
  let photos: number[] = [];
 | 
			
		||||
  let videos: number[] = [];
 | 
			
		||||
  let totalCount: number[] = [];
 | 
			
		||||
  let diskUsage: number[] = [];
 | 
			
		||||
  let diskUsageUnit: string[] = [];
 | 
			
		||||
 | 
			
		||||
  let confirmDeleteLibrary: LibraryResponseDto | null = null;
 | 
			
		||||
  let deletedLibrary: LibraryResponseDto | null = null;
 | 
			
		||||
 | 
			
		||||
  let editImportPaths: number | null;
 | 
			
		||||
  let editScanSettings: number | null;
 | 
			
		||||
  let renameLibrary: number | null;
 | 
			
		||||
 | 
			
		||||
  let updateLibraryIndex: number | null;
 | 
			
		||||
 | 
			
		||||
  let deleteAssetCount = 0;
 | 
			
		||||
 | 
			
		||||
  let dropdownOpen: boolean[] = [];
 | 
			
		||||
  let showContextMenu = false;
 | 
			
		||||
  let contextMenuPosition = { x: 0, y: 0 };
 | 
			
		||||
  let selectedLibraryIndex = 0;
 | 
			
		||||
  let selectedLibrary: LibraryResponseDto | null = null;
 | 
			
		||||
 | 
			
		||||
  onMount(async () => {
 | 
			
		||||
    await readLibraryList();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const closeAll = () => {
 | 
			
		||||
    editImportPaths = null;
 | 
			
		||||
    editScanSettings = null;
 | 
			
		||||
    renameLibrary = null;
 | 
			
		||||
    updateLibraryIndex = null;
 | 
			
		||||
    showContextMenu = false;
 | 
			
		||||
 | 
			
		||||
    for (let index = 0; index < dropdownOpen.length; index++) {
 | 
			
		||||
      dropdownOpen[index] = false;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const showMenu = (event: MouseEvent, library: LibraryResponseDto, index: number) => {
 | 
			
		||||
    contextMenuPosition = getContextMenuPosition(event);
 | 
			
		||||
    showContextMenu = !showContextMenu;
 | 
			
		||||
 | 
			
		||||
    selectedLibraryIndex = index;
 | 
			
		||||
    selectedLibrary = library;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onMenuExit = () => {
 | 
			
		||||
    showContextMenu = false;
 | 
			
		||||
  };
 | 
			
		||||
  const refreshStats = async (listIndex: number) => {
 | 
			
		||||
    stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
 | 
			
		||||
    photos[listIndex] = stats[listIndex].photos;
 | 
			
		||||
    videos[listIndex] = stats[listIndex].videos;
 | 
			
		||||
    totalCount[listIndex] = stats[listIndex].total;
 | 
			
		||||
    [diskUsage[listIndex], diskUsageUnit[listIndex]] = getBytesWithUnit(stats[listIndex].usage, 0);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  async function readLibraryList() {
 | 
			
		||||
    libraries = await getLibraries();
 | 
			
		||||
 | 
			
		||||
    dropdownOpen.length = libraries.length;
 | 
			
		||||
 | 
			
		||||
    for (let index = 0; index < libraries.length; index++) {
 | 
			
		||||
      await refreshStats(index);
 | 
			
		||||
      dropdownOpen[index] = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleCreate = async (libraryType: LibraryType) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const createdLibrary = await createLibrary({
 | 
			
		||||
        createLibraryDto: { type: libraryType },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Created library: ${createdLibrary.name}`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to create library');
 | 
			
		||||
    } finally {
 | 
			
		||||
      await readLibraryList();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleUpdate = async (event: Partial<LibraryResponseDto>) => {
 | 
			
		||||
    if (updateLibraryIndex === null) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const libraryId = libraries[updateLibraryIndex].id;
 | 
			
		||||
      await updateLibrary({ id: libraryId, updateLibraryDto: { ...event } });
 | 
			
		||||
      closeAll();
 | 
			
		||||
      await readLibraryList();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to update library');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleDelete = async () => {
 | 
			
		||||
    if (confirmDeleteLibrary) {
 | 
			
		||||
      deletedLibrary = confirmDeleteLibrary;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!deletedLibrary) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await deleteLibrary({ id: deletedLibrary.id });
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Library deleted`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to remove library');
 | 
			
		||||
    } finally {
 | 
			
		||||
      confirmDeleteLibrary = null;
 | 
			
		||||
      deletedLibrary = null;
 | 
			
		||||
      await readLibraryList();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleScanAll = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      for (const library of libraries) {
 | 
			
		||||
        if (library.type === LibraryType.External) {
 | 
			
		||||
          await scanLibrary({ id: library.id, scanLibraryDto: {} });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Refreshing all libraries`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to scan libraries');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleScan = async (libraryId: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      await scanLibrary({ id: libraryId, scanLibraryDto: {} });
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Scanning library for new files`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to scan library');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleScanChanges = async (libraryId: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      await scanLibrary({ id: libraryId, scanLibraryDto: { refreshModifiedFiles: true } });
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Scanning library for changed files`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to scan library');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleForceScan = async (libraryId: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      await scanLibrary({ id: libraryId, scanLibraryDto: { refreshAllFiles: true } });
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Forcing refresh of all library files`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to scan library');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleRemoveOffline = async (libraryId: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      await removeOfflineFiles({ id: libraryId });
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Removing Offline Files`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to remove offline files');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onRenameClicked = () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    renameLibrary = selectedLibraryIndex;
 | 
			
		||||
    updateLibraryIndex = selectedLibraryIndex;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onEditImportPathClicked = () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    editImportPaths = selectedLibraryIndex;
 | 
			
		||||
    updateLibraryIndex = selectedLibraryIndex;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onScanNewLibraryClicked = async () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
 | 
			
		||||
    if (selectedLibrary) {
 | 
			
		||||
      await handleScan(selectedLibrary.id);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onScanSettingClicked = () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    editScanSettings = selectedLibraryIndex;
 | 
			
		||||
    updateLibraryIndex = selectedLibraryIndex;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onScanAllLibraryFilesClicked = async () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    if (selectedLibrary) {
 | 
			
		||||
      await handleScanChanges(selectedLibrary.id);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onForceScanAllLibraryFilesClicked = async () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    if (selectedLibrary) {
 | 
			
		||||
      await handleForceScan(selectedLibrary.id);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onRemoveOfflineFilesClicked = async () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    if (selectedLibrary) {
 | 
			
		||||
      await handleRemoveOffline(selectedLibrary.id);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onDeleteLibraryClicked = async () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
 | 
			
		||||
    if (selectedLibrary && confirm(`Are you sure you want to delete ${selectedLibrary.name} library?`) == true) {
 | 
			
		||||
      await refreshStats(selectedLibraryIndex);
 | 
			
		||||
      if (totalCount[selectedLibraryIndex] > 0) {
 | 
			
		||||
        deleteAssetCount = totalCount[selectedLibraryIndex];
 | 
			
		||||
        confirmDeleteLibrary = selectedLibrary;
 | 
			
		||||
      } else {
 | 
			
		||||
        deletedLibrary = selectedLibrary;
 | 
			
		||||
        await handleDelete();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
{#if confirmDeleteLibrary}
 | 
			
		||||
  <ConfirmDialogue
 | 
			
		||||
    title="Warning!"
 | 
			
		||||
    prompt="Are you sure you want to delete this library? This will DELETE all {deleteAssetCount} contained assets and cannot be undone."
 | 
			
		||||
    on:confirm={handleDelete}
 | 
			
		||||
    on:cancel={() => (confirmDeleteLibrary = null)}
 | 
			
		||||
  />
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
<section class="my-4">
 | 
			
		||||
  <div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
 | 
			
		||||
    {#if libraries.length > 0}
 | 
			
		||||
      <table class="w-full text-left">
 | 
			
		||||
        <thead
 | 
			
		||||
          class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
 | 
			
		||||
        >
 | 
			
		||||
          <tr class="flex w-full place-items-center">
 | 
			
		||||
            <th class="w-1/6 text-center text-sm font-medium">Type</th>
 | 
			
		||||
            <th class="w-1/3 text-center text-sm font-medium">Name</th>
 | 
			
		||||
            <th class="w-1/5 text-center text-sm font-medium">Assets</th>
 | 
			
		||||
            <th class="w-1/6 text-center text-sm font-medium">Size</th>
 | 
			
		||||
            <th class="w-1/6 text-center text-sm font-medium" />
 | 
			
		||||
          </tr>
 | 
			
		||||
        </thead>
 | 
			
		||||
        <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
 | 
			
		||||
          {#each libraries as library, index (library.id)}
 | 
			
		||||
            <tr
 | 
			
		||||
              class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
 | 
			
		||||
                index % 2 == 0
 | 
			
		||||
                  ? 'bg-immich-gray dark:bg-immich-dark-gray/75'
 | 
			
		||||
                  : 'bg-immich-bg dark:bg-immich-dark-gray/50'
 | 
			
		||||
              }`}
 | 
			
		||||
            >
 | 
			
		||||
              <td class="w-1/6 px-10 text-sm">
 | 
			
		||||
                {#if library.type === LibraryType.External}
 | 
			
		||||
                  <Icon path={mdiDatabase} size="40" title="External library (created on {library.createdAt})" />
 | 
			
		||||
                {:else if library.type === LibraryType.Upload}
 | 
			
		||||
                  <Icon path={mdiUpload} size="40" title="Upload library (created on {library.createdAt})" />
 | 
			
		||||
                {/if}</td
 | 
			
		||||
              >
 | 
			
		||||
 | 
			
		||||
              <td class="w-1/3 text-ellipsis px-4 text-sm">{library.name}</td>
 | 
			
		||||
              {#if totalCount[index] == undefined}
 | 
			
		||||
                <td colspan="2" class="flex w-1/3 items-center justify-center text-ellipsis px-4 text-sm">
 | 
			
		||||
                  <LoadingSpinner size="40" />
 | 
			
		||||
                </td>
 | 
			
		||||
              {:else}
 | 
			
		||||
                <td class="w-1/6 text-ellipsis px-4 text-sm">
 | 
			
		||||
                  {totalCount[index].toLocaleString($locale)}
 | 
			
		||||
                </td>
 | 
			
		||||
                <td class="w-1/6 text-ellipsis px-4 text-sm">{diskUsage[index]} {diskUsageUnit[index]}</td>
 | 
			
		||||
              {/if}
 | 
			
		||||
 | 
			
		||||
              <td class="w-1/6 text-ellipsis px-4 text-sm">
 | 
			
		||||
                <button
 | 
			
		||||
                  class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
 | 
			
		||||
                  on:click|stopPropagation|preventDefault={(e) => showMenu(e, library, index)}
 | 
			
		||||
                >
 | 
			
		||||
                  <Icon path={mdiDotsVertical} size="16" />
 | 
			
		||||
                </button>
 | 
			
		||||
 | 
			
		||||
                {#if showContextMenu}
 | 
			
		||||
                  <Portal target="body">
 | 
			
		||||
                    <ContextMenu {...contextMenuPosition} on:outclick={onMenuExit}>
 | 
			
		||||
                      <MenuOption on:click={onRenameClicked} text={`Rename`} />
 | 
			
		||||
 | 
			
		||||
                      {#if selectedLibrary && selectedLibrary.type === LibraryType.External}
 | 
			
		||||
                        <MenuOption on:click={onEditImportPathClicked} text="Edit Import Paths" />
 | 
			
		||||
                        <MenuOption on:click={onScanSettingClicked} text="Scan Settings" />
 | 
			
		||||
                        <hr />
 | 
			
		||||
                        <MenuOption on:click={onScanNewLibraryClicked} text="Scan New Library Files" />
 | 
			
		||||
                        <MenuOption
 | 
			
		||||
                          on:click={onScanAllLibraryFilesClicked}
 | 
			
		||||
                          text="Re-scan All Library Files"
 | 
			
		||||
                          subtitle={'Only refreshes modified files'}
 | 
			
		||||
                        />
 | 
			
		||||
                        <MenuOption
 | 
			
		||||
                          on:click={onForceScanAllLibraryFilesClicked}
 | 
			
		||||
                          text="Force Re-scan All Library Files"
 | 
			
		||||
                          subtitle={'Refreshes every file'}
 | 
			
		||||
                        />
 | 
			
		||||
                        <hr />
 | 
			
		||||
                        <MenuOption on:click={onRemoveOfflineFilesClicked} text="Remove Offline Files" />
 | 
			
		||||
                        <MenuOption on:click={onDeleteLibraryClicked}>
 | 
			
		||||
                          <p class="text-red-600">Delete library</p>
 | 
			
		||||
                        </MenuOption>
 | 
			
		||||
                      {/if}
 | 
			
		||||
                    </ContextMenu>
 | 
			
		||||
                  </Portal>
 | 
			
		||||
                {/if}
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            {#if renameLibrary === index}
 | 
			
		||||
              <div transition:slide={{ duration: 250 }}>
 | 
			
		||||
                <LibraryRenameForm
 | 
			
		||||
                  {library}
 | 
			
		||||
                  on:submit={({ detail }) => handleUpdate(detail)}
 | 
			
		||||
                  on:cancel={() => (renameLibrary = null)}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            {/if}
 | 
			
		||||
            {#if editImportPaths === index}
 | 
			
		||||
              <div transition:slide={{ duration: 250 }}>
 | 
			
		||||
                <LibraryImportPathsForm
 | 
			
		||||
                  {library}
 | 
			
		||||
                  on:submit={({ detail }) => handleUpdate(detail)}
 | 
			
		||||
                  on:cancel={() => (editImportPaths = null)}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            {/if}
 | 
			
		||||
            {#if editScanSettings === index}
 | 
			
		||||
              <div transition:slide={{ duration: 250 }} class="mb-4 ml-4 mr-4">
 | 
			
		||||
                <LibraryScanSettingsForm
 | 
			
		||||
                  {library}
 | 
			
		||||
                  on:submit={({ detail }) => handleUpdate(detail.library)}
 | 
			
		||||
                  on:cancel={() => (editScanSettings = null)}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            {/if}
 | 
			
		||||
          {/each}
 | 
			
		||||
        </tbody>
 | 
			
		||||
      </table>
 | 
			
		||||
    {/if}
 | 
			
		||||
    <div class="my-2 flex justify-end gap-2">
 | 
			
		||||
      <Button size="sm" on:click={() => handleScanAll()}>Scan All Libraries</Button>
 | 
			
		||||
      <Button size="sm" on:click={() => handleCreate(LibraryType.External)}>Create External Library</Button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</section>
 | 
			
		||||
@ -66,14 +66,6 @@
 | 
			
		||||
          required={false}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <SettingInputField
 | 
			
		||||
          inputType={SettingInputFieldType.TEXT}
 | 
			
		||||
          label="EXTERNAL PATH"
 | 
			
		||||
          disabled={true}
 | 
			
		||||
          value={editedUser.externalPath || ''}
 | 
			
		||||
          required={false}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <div class="flex justify-end">
 | 
			
		||||
          <Button type="submit" size="sm" on:click={() => handleSaveProfile()}>Save</Button>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
@ -9,7 +9,6 @@
 | 
			
		||||
  import AppearanceSettings from './appearance-settings.svelte';
 | 
			
		||||
  import ChangePasswordSettings from './change-password-settings.svelte';
 | 
			
		||||
  import DeviceList from './device-list.svelte';
 | 
			
		||||
  import LibraryList from './library-list.svelte';
 | 
			
		||||
  import MemoriesSettings from './memories-settings.svelte';
 | 
			
		||||
  import OAuthSettings from './oauth-settings.svelte';
 | 
			
		||||
  import PartnerSettings from './partner-settings.svelte';
 | 
			
		||||
@ -43,10 +42,6 @@
 | 
			
		||||
    <DeviceList bind:devices />
 | 
			
		||||
  </SettingAccordion>
 | 
			
		||||
 | 
			
		||||
  <SettingAccordion key="libraries" title="Libraries" subtitle="Manage your asset libraries">
 | 
			
		||||
    <LibraryList />
 | 
			
		||||
  </SettingAccordion>
 | 
			
		||||
 | 
			
		||||
  <SettingAccordion key="memories" title="Memories" subtitle="Manage what you see in your memories.">
 | 
			
		||||
    <MemoriesSettings user={$user} />
 | 
			
		||||
  </SettingAccordion>
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@ export enum AssetAction {
 | 
			
		||||
 | 
			
		||||
export enum AppRoute {
 | 
			
		||||
  ADMIN_USER_MANAGEMENT = '/admin/user-management',
 | 
			
		||||
  ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
 | 
			
		||||
  ADMIN_SETTINGS = '/admin/system-settings',
 | 
			
		||||
  ADMIN_STATS = '/admin/server-status',
 | 
			
		||||
  ADMIN_JOBS = '/admin/jobs-status',
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										450
									
								
								web/src/routes/admin/library-management/+page.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										450
									
								
								web/src/routes/admin/library-management/+page.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,450 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import Button from '$lib/components/elements/buttons/button.svelte';
 | 
			
		||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
			
		||||
  import LibraryImportPathsForm from '$lib/components/forms/library-import-paths-form.svelte';
 | 
			
		||||
  import LibraryRenameForm from '$lib/components/forms/library-rename-form.svelte';
 | 
			
		||||
  import LibraryScanSettingsForm from '$lib/components/forms/library-scan-settings-form.svelte';
 | 
			
		||||
  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
 | 
			
		||||
  import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
 | 
			
		||||
  import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
 | 
			
		||||
  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
			
		||||
  import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 | 
			
		||||
  import {
 | 
			
		||||
    notificationController,
 | 
			
		||||
    NotificationType,
 | 
			
		||||
  } from '$lib/components/shared-components/notification/notification';
 | 
			
		||||
  import Portal from '$lib/components/shared-components/portal/portal.svelte';
 | 
			
		||||
  import { getBytesWithUnit } from '$lib/utils/byte-units';
 | 
			
		||||
  import { getContextMenuPosition } from '$lib/utils/context-menu';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import {
 | 
			
		||||
    LibraryType,
 | 
			
		||||
    createLibrary,
 | 
			
		||||
    deleteLibrary,
 | 
			
		||||
    getLibraryStatistics,
 | 
			
		||||
    removeOfflineFiles,
 | 
			
		||||
    scanLibrary,
 | 
			
		||||
    updateLibrary,
 | 
			
		||||
    type LibraryResponseDto,
 | 
			
		||||
    type LibraryStatsResponseDto,
 | 
			
		||||
    getAllLibraries,
 | 
			
		||||
    type UserResponseDto,
 | 
			
		||||
    getUserById,
 | 
			
		||||
    type CreateLibraryDto,
 | 
			
		||||
  } from '@immich/sdk';
 | 
			
		||||
  import { mdiDatabase, mdiDotsVertical, mdiUpload } from '@mdi/js';
 | 
			
		||||
  import { onMount } from 'svelte';
 | 
			
		||||
  import { fade, slide } from 'svelte/transition';
 | 
			
		||||
  import type { PageData } from './$types';
 | 
			
		||||
  import LibraryUserPickerForm from '$lib/components/forms/library-user-picker-form.svelte';
 | 
			
		||||
 | 
			
		||||
  export let data: PageData;
 | 
			
		||||
 | 
			
		||||
  let libraries: LibraryResponseDto[] = [];
 | 
			
		||||
 | 
			
		||||
  let stats: LibraryStatsResponseDto[] = [];
 | 
			
		||||
  let owner: UserResponseDto[] = [];
 | 
			
		||||
  let photos: number[] = [];
 | 
			
		||||
  let videos: number[] = [];
 | 
			
		||||
  let totalCount: number[] = [];
 | 
			
		||||
  let diskUsage: number[] = [];
 | 
			
		||||
  let diskUsageUnit: string[] = [];
 | 
			
		||||
 | 
			
		||||
  let confirmDeleteLibrary: LibraryResponseDto | null = null;
 | 
			
		||||
  let deletedLibrary: LibraryResponseDto | null = null;
 | 
			
		||||
 | 
			
		||||
  let editImportPaths: number | null;
 | 
			
		||||
  let editScanSettings: number | null;
 | 
			
		||||
  let renameLibrary: number | null;
 | 
			
		||||
 | 
			
		||||
  let updateLibraryIndex: number | null;
 | 
			
		||||
 | 
			
		||||
  let deleteAssetCount = 0;
 | 
			
		||||
 | 
			
		||||
  let dropdownOpen: boolean[] = [];
 | 
			
		||||
  let showContextMenu = false;
 | 
			
		||||
  let contextMenuPosition = { x: 0, y: 0 };
 | 
			
		||||
  let selectedLibraryIndex = 0;
 | 
			
		||||
  let selectedLibrary: LibraryResponseDto | null = null;
 | 
			
		||||
 | 
			
		||||
  let toCreateLibrary = false;
 | 
			
		||||
 | 
			
		||||
  onMount(async () => {
 | 
			
		||||
    await readLibraryList();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const closeAll = () => {
 | 
			
		||||
    editImportPaths = null;
 | 
			
		||||
    editScanSettings = null;
 | 
			
		||||
    renameLibrary = null;
 | 
			
		||||
    updateLibraryIndex = null;
 | 
			
		||||
    showContextMenu = false;
 | 
			
		||||
 | 
			
		||||
    for (let index = 0; index < dropdownOpen.length; index++) {
 | 
			
		||||
      dropdownOpen[index] = false;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const showMenu = (event: MouseEvent, library: LibraryResponseDto, index: number) => {
 | 
			
		||||
    contextMenuPosition = getContextMenuPosition(event);
 | 
			
		||||
    showContextMenu = !showContextMenu;
 | 
			
		||||
 | 
			
		||||
    selectedLibraryIndex = index;
 | 
			
		||||
    selectedLibrary = library;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onMenuExit = () => {
 | 
			
		||||
    showContextMenu = false;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const refreshStats = async (listIndex: number) => {
 | 
			
		||||
    stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
 | 
			
		||||
    owner[listIndex] = await getUserById({ id: libraries[listIndex].ownerId });
 | 
			
		||||
    photos[listIndex] = stats[listIndex].photos;
 | 
			
		||||
    videos[listIndex] = stats[listIndex].videos;
 | 
			
		||||
    totalCount[listIndex] = stats[listIndex].total;
 | 
			
		||||
    [diskUsage[listIndex], diskUsageUnit[listIndex]] = getBytesWithUnit(stats[listIndex].usage, 0);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  async function readLibraryList() {
 | 
			
		||||
    libraries = await getAllLibraries({ $type: LibraryType.External });
 | 
			
		||||
    dropdownOpen.length = libraries.length;
 | 
			
		||||
 | 
			
		||||
    for (let index = 0; index < libraries.length; index++) {
 | 
			
		||||
      await refreshStats(index);
 | 
			
		||||
      dropdownOpen[index] = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleCreate = async (ownerId: string | null) => {
 | 
			
		||||
    try {
 | 
			
		||||
      let createLibraryDto: CreateLibraryDto = { type: LibraryType.External };
 | 
			
		||||
      if (ownerId) {
 | 
			
		||||
        createLibraryDto = { ...createLibraryDto, ownerId };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const createdLibrary = await createLibrary({ createLibraryDto });
 | 
			
		||||
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Created library: ${createdLibrary.name}`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to create library');
 | 
			
		||||
    } finally {
 | 
			
		||||
      toCreateLibrary = false;
 | 
			
		||||
      await readLibraryList();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleUpdate = async (event: Partial<LibraryResponseDto>) => {
 | 
			
		||||
    if (updateLibraryIndex === null) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const libraryId = libraries[updateLibraryIndex].id;
 | 
			
		||||
      await updateLibrary({ id: libraryId, updateLibraryDto: { ...event } });
 | 
			
		||||
      closeAll();
 | 
			
		||||
      await readLibraryList();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to update library');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleDelete = async () => {
 | 
			
		||||
    if (confirmDeleteLibrary) {
 | 
			
		||||
      deletedLibrary = confirmDeleteLibrary;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!deletedLibrary) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await deleteLibrary({ id: deletedLibrary.id });
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Library deleted`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to remove library');
 | 
			
		||||
    } finally {
 | 
			
		||||
      confirmDeleteLibrary = null;
 | 
			
		||||
      deletedLibrary = null;
 | 
			
		||||
      await readLibraryList();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleScanAll = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      for (const library of libraries) {
 | 
			
		||||
        if (library.type === LibraryType.External) {
 | 
			
		||||
          await scanLibrary({ id: library.id, scanLibraryDto: {} });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Refreshing all libraries`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to scan libraries');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleScan = async (libraryId: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      await scanLibrary({ id: libraryId, scanLibraryDto: {} });
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Scanning library for new files`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to scan library');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleScanChanges = async (libraryId: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      await scanLibrary({ id: libraryId, scanLibraryDto: { refreshModifiedFiles: true } });
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Scanning library for changed files`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to scan library');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleForceScan = async (libraryId: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      await scanLibrary({ id: libraryId, scanLibraryDto: { refreshAllFiles: true } });
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Forcing refresh of all library files`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to scan library');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleRemoveOffline = async (libraryId: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      await removeOfflineFiles({ id: libraryId });
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: `Removing Offline Files`,
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, 'Unable to remove offline files');
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onRenameClicked = () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    renameLibrary = selectedLibraryIndex;
 | 
			
		||||
    updateLibraryIndex = selectedLibraryIndex;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onEditImportPathClicked = () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    editImportPaths = selectedLibraryIndex;
 | 
			
		||||
    updateLibraryIndex = selectedLibraryIndex;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onScanNewLibraryClicked = async () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
 | 
			
		||||
    if (selectedLibrary) {
 | 
			
		||||
      await handleScan(selectedLibrary.id);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onScanSettingClicked = () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    editScanSettings = selectedLibraryIndex;
 | 
			
		||||
    updateLibraryIndex = selectedLibraryIndex;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onScanAllLibraryFilesClicked = async () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    if (selectedLibrary) {
 | 
			
		||||
      await handleScanChanges(selectedLibrary.id);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onForceScanAllLibraryFilesClicked = async () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    if (selectedLibrary) {
 | 
			
		||||
      await handleForceScan(selectedLibrary.id);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onRemoveOfflineFilesClicked = async () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
    if (selectedLibrary) {
 | 
			
		||||
      await handleRemoveOffline(selectedLibrary.id);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onDeleteLibraryClicked = async () => {
 | 
			
		||||
    closeAll();
 | 
			
		||||
 | 
			
		||||
    if (selectedLibrary && confirm(`Are you sure you want to delete ${selectedLibrary.name} library?`) == true) {
 | 
			
		||||
      await refreshStats(selectedLibraryIndex);
 | 
			
		||||
      if (totalCount[selectedLibraryIndex] > 0) {
 | 
			
		||||
        deleteAssetCount = totalCount[selectedLibraryIndex];
 | 
			
		||||
        confirmDeleteLibrary = selectedLibrary;
 | 
			
		||||
      } else {
 | 
			
		||||
        deletedLibrary = selectedLibrary;
 | 
			
		||||
        await handleDelete();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
{#if confirmDeleteLibrary}
 | 
			
		||||
  <ConfirmDialogue
 | 
			
		||||
    title="Warning!"
 | 
			
		||||
    prompt="Are you sure you want to delete this library? This will delete all {deleteAssetCount} contained assets from Immich and cannot be undone. Files will remain on disk."
 | 
			
		||||
    on:confirm={handleDelete}
 | 
			
		||||
    on:cancel={() => (confirmDeleteLibrary = null)}
 | 
			
		||||
  />
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
{#if toCreateLibrary}
 | 
			
		||||
  <LibraryUserPickerForm
 | 
			
		||||
    on:submit={({ detail }) => handleCreate(detail.ownerId)}
 | 
			
		||||
    on:cancel={() => (toCreateLibrary = false)}
 | 
			
		||||
  />
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
<UserPageLayout title={data.meta.title} admin>
 | 
			
		||||
  <section class="my-4">
 | 
			
		||||
    <div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
 | 
			
		||||
      {#if libraries.length > 0}
 | 
			
		||||
        <table class="w-full text-left">
 | 
			
		||||
          <thead
 | 
			
		||||
            class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
 | 
			
		||||
          >
 | 
			
		||||
            <tr class="grid grid-cols-6 w-full place-items-center">
 | 
			
		||||
              <th class="text-center text-sm font-medium">Type</th>
 | 
			
		||||
              <th class="text-center text-sm font-medium">Name</th>
 | 
			
		||||
              <th class="text-center text-sm font-medium">Owner</th>
 | 
			
		||||
              <th class="text-center text-sm font-medium">Assets</th>
 | 
			
		||||
              <th class="text-center text-sm font-medium">Size</th>
 | 
			
		||||
              <th class="text-center text-sm font-medium" />
 | 
			
		||||
            </tr>
 | 
			
		||||
          </thead>
 | 
			
		||||
          <tbody class="block overflow-y-auto rounded-md border dark:border-immich-dark-gray">
 | 
			
		||||
            {#each libraries as library, index (library.id)}
 | 
			
		||||
              <tr
 | 
			
		||||
                class={`grid grid-cols-6 h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
 | 
			
		||||
                  index % 2 == 0
 | 
			
		||||
                    ? 'bg-immich-gray dark:bg-immich-dark-gray/75'
 | 
			
		||||
                    : 'bg-immich-bg dark:bg-immich-dark-gray/50'
 | 
			
		||||
                }`}
 | 
			
		||||
              >
 | 
			
		||||
                <td class=" px-10 text-sm">
 | 
			
		||||
                  {#if library.type === LibraryType.External}
 | 
			
		||||
                    <Icon path={mdiDatabase} size="40" title="External library (created on {library.createdAt})" />
 | 
			
		||||
                  {:else if library.type === LibraryType.Upload}
 | 
			
		||||
                    <Icon path={mdiUpload} size="40" title="Upload library (created on {library.createdAt})" />
 | 
			
		||||
                  {/if}</td
 | 
			
		||||
                >
 | 
			
		||||
 | 
			
		||||
                <td class=" text-ellipsis px-4 text-sm">{library.name}</td>
 | 
			
		||||
                <td class=" text-ellipsis px-4 text-sm">
 | 
			
		||||
                  {#if owner[index] == undefined}
 | 
			
		||||
                    <LoadingSpinner size="40" />
 | 
			
		||||
                  {:else}{owner[index].name}{/if}
 | 
			
		||||
                </td>
 | 
			
		||||
 | 
			
		||||
                {#if totalCount[index] == undefined}
 | 
			
		||||
                  <td colspan="2" class="flex w-1/3 items-center justify-center text-ellipsis px-4 text-sm">
 | 
			
		||||
                    <LoadingSpinner size="40" />
 | 
			
		||||
                  </td>
 | 
			
		||||
                {:else}
 | 
			
		||||
                  <td class=" text-ellipsis px-4 text-sm">
 | 
			
		||||
                    {totalCount[index]}
 | 
			
		||||
                  </td>
 | 
			
		||||
                  <td class=" text-ellipsis px-4 text-sm">{diskUsage[index]} {diskUsageUnit[index]}</td>
 | 
			
		||||
                {/if}
 | 
			
		||||
 | 
			
		||||
                <td class=" text-ellipsis px-4 text-sm">
 | 
			
		||||
                  <button
 | 
			
		||||
                    class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
 | 
			
		||||
                    on:click|stopPropagation|preventDefault={(e) => showMenu(e, library, index)}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Icon path={mdiDotsVertical} size="16" />
 | 
			
		||||
                  </button>
 | 
			
		||||
 | 
			
		||||
                  {#if showContextMenu}
 | 
			
		||||
                    <Portal target="body">
 | 
			
		||||
                      <ContextMenu {...contextMenuPosition} on:outclick={() => onMenuExit()}>
 | 
			
		||||
                        <MenuOption on:click={() => onRenameClicked()} text={`Rename`} />
 | 
			
		||||
 | 
			
		||||
                        {#if selectedLibrary && selectedLibrary.type === LibraryType.External}
 | 
			
		||||
                          <MenuOption on:click={() => onEditImportPathClicked()} text="Edit Import Paths" />
 | 
			
		||||
                          <MenuOption on:click={() => onScanSettingClicked()} text="Scan Settings" />
 | 
			
		||||
                          <hr />
 | 
			
		||||
                          <MenuOption on:click={() => onScanNewLibraryClicked()} text="Scan New Library Files" />
 | 
			
		||||
                          <MenuOption
 | 
			
		||||
                            on:click={() => onScanAllLibraryFilesClicked()}
 | 
			
		||||
                            text="Re-scan All Library Files"
 | 
			
		||||
                            subtitle={'Only refreshes modified files'}
 | 
			
		||||
                          />
 | 
			
		||||
                          <MenuOption
 | 
			
		||||
                            on:click={() => onForceScanAllLibraryFilesClicked()}
 | 
			
		||||
                            text="Force Re-scan All Library Files"
 | 
			
		||||
                            subtitle={'Refreshes every file'}
 | 
			
		||||
                          />
 | 
			
		||||
                          <hr />
 | 
			
		||||
                          <MenuOption on:click={() => onRemoveOfflineFilesClicked()} text="Remove Offline Files" />
 | 
			
		||||
                          <MenuOption on:click={() => onDeleteLibraryClicked()}>
 | 
			
		||||
                            <p class="text-red-600">Delete library</p>
 | 
			
		||||
                          </MenuOption>
 | 
			
		||||
                        {/if}
 | 
			
		||||
                      </ContextMenu>
 | 
			
		||||
                    </Portal>
 | 
			
		||||
                  {/if}
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              {#if renameLibrary === index}
 | 
			
		||||
                <div transition:slide={{ duration: 250 }}>
 | 
			
		||||
                  <LibraryRenameForm
 | 
			
		||||
                    {library}
 | 
			
		||||
                    on:submit={({ detail }) => handleUpdate(detail)}
 | 
			
		||||
                    on:cancel={() => (renameLibrary = null)}
 | 
			
		||||
                  />
 | 
			
		||||
                </div>
 | 
			
		||||
              {/if}
 | 
			
		||||
              {#if editImportPaths === index}
 | 
			
		||||
                <div transition:slide={{ duration: 250 }}>
 | 
			
		||||
                  <LibraryImportPathsForm
 | 
			
		||||
                    {library}
 | 
			
		||||
                    on:submit={({ detail }) => handleUpdate(detail)}
 | 
			
		||||
                    on:cancel={() => (editImportPaths = null)}
 | 
			
		||||
                  />
 | 
			
		||||
                </div>
 | 
			
		||||
              {/if}
 | 
			
		||||
              {#if editScanSettings === index}
 | 
			
		||||
                <div transition:slide={{ duration: 250 }} class="mb-4 ml-4 mr-4">
 | 
			
		||||
                  <LibraryScanSettingsForm
 | 
			
		||||
                    {library}
 | 
			
		||||
                    on:submit={({ detail }) => handleUpdate(detail.library)}
 | 
			
		||||
                    on:cancel={() => (editScanSettings = null)}
 | 
			
		||||
                  />
 | 
			
		||||
                </div>
 | 
			
		||||
              {/if}
 | 
			
		||||
            {/each}
 | 
			
		||||
          </tbody>
 | 
			
		||||
        </table>
 | 
			
		||||
      {/if}
 | 
			
		||||
      <div class="my-2 flex justify-end gap-2">
 | 
			
		||||
        <Button size="sm" on:click={() => handleScanAll()}>Scan All Libraries</Button>
 | 
			
		||||
        <Button size="sm" on:click={() => (toCreateLibrary = true)}>Create Library</Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </section>
 | 
			
		||||
</UserPageLayout>
 | 
			
		||||
							
								
								
									
										16
									
								
								web/src/routes/admin/library-management/+page.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								web/src/routes/admin/library-management/+page.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,16 @@
 | 
			
		||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
 | 
			
		||||
import { getAllUsers } from '@immich/sdk';
 | 
			
		||||
import type { PageLoad } from './$types';
 | 
			
		||||
 | 
			
		||||
export const load = (async () => {
 | 
			
		||||
  await authenticate({ admin: true });
 | 
			
		||||
  await requestServerInfo();
 | 
			
		||||
  const allUsers = await getAllUsers({ isAll: false });
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    allUsers,
 | 
			
		||||
    meta: {
 | 
			
		||||
      title: 'External Library Management',
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
}) satisfies PageLoad;
 | 
			
		||||
@ -12,7 +12,7 @@
 | 
			
		||||
  import { user } from '$lib/stores/user.store';
 | 
			
		||||
  import { asByteUnitString } from '$lib/utils/byte-units';
 | 
			
		||||
  import { getAllUsers, type UserResponseDto } from '@immich/sdk';
 | 
			
		||||
  import { mdiCheck, mdiClose, mdiDeleteRestore, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
 | 
			
		||||
  import { mdiClose, mdiDeleteRestore, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
 | 
			
		||||
  import { onMount } from 'svelte';
 | 
			
		||||
  import type { PageData } from './$types';
 | 
			
		||||
 | 
			
		||||
@ -175,7 +175,6 @@
 | 
			
		||||
            <th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium">Email</th>
 | 
			
		||||
            <th class="hidden sm:block w-3/12 text-center text-sm font-medium">Name</th>
 | 
			
		||||
            <th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">Has quota</th>
 | 
			
		||||
            <th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">Can import</th>
 | 
			
		||||
            <th class="w-4/12 lg:w-3/12 xl:w-2/12 text-center text-sm font-medium">Action</th>
 | 
			
		||||
          </tr>
 | 
			
		||||
        </thead>
 | 
			
		||||
@ -204,16 +203,6 @@
 | 
			
		||||
                    {/if}
 | 
			
		||||
                  </div>
 | 
			
		||||
                </td>
 | 
			
		||||
                <td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
 | 
			
		||||
                  <div class="container mx-auto flex flex-wrap justify-center">
 | 
			
		||||
                    {#if immichUser.externalPath}
 | 
			
		||||
                      <Icon path={mdiCheck} size="16" />
 | 
			
		||||
                    {:else}
 | 
			
		||||
                      <Icon path={mdiClose} size="16" />
 | 
			
		||||
                    {/if}
 | 
			
		||||
                  </div>
 | 
			
		||||
                </td>
 | 
			
		||||
 | 
			
		||||
                <td class="w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm">
 | 
			
		||||
                  {#if !isDeleted(immichUser)}
 | 
			
		||||
                    <button
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,6 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({
 | 
			
		||||
  email: Sync.each(() => faker.internet.email()),
 | 
			
		||||
  name: Sync.each(() => faker.person.fullName()),
 | 
			
		||||
  storageLabel: Sync.each(() => faker.string.alphanumeric()),
 | 
			
		||||
  externalPath: Sync.each(() => faker.string.alphanumeric()),
 | 
			
		||||
  profileImagePath: '',
 | 
			
		||||
  shouldChangePassword: Sync.each(() => faker.datatype.boolean()),
 | 
			
		||||
  isAdmin: true,
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user